From f6fa3b65183dc398c4fcdc42d4c5991b2e025628 Mon Sep 17 00:00:00 2001 From: Divyanshu Shopra Date: Fri, 3 Apr 2026 09:42:57 +0530 Subject: [PATCH 1/6] Add weather location controls to admin settings UI --- backend/admin_api.py | 16 +++++- backend/requirements.txt | 4 +- backend/server.py | 27 +++++++++- backend/tasks.py | 107 +++++++++++++++++++++++++++++++++++++++ backend/users.py | 43 ++++++++++++++++ intra_admin/admin.py | 75 +++++++++++++++++++++++++++ 6 files changed, 268 insertions(+), 4 deletions(-) create mode 100644 backend/tasks.py diff --git a/backend/admin_api.py b/backend/admin_api.py index 120ecc8..fd5d9f0 100644 --- a/backend/admin_api.py +++ b/backend/admin_api.py @@ -3,7 +3,7 @@ import time from fastapi import APIRouter, Depends from server import verify_admin -from users import get_all_users, delete_user_data, reset_user_password, set_require_approval, get_require_approval, approve_user_db, get_ai_config, set_ai_config +from users import get_all_users, delete_user_data, reset_user_password, set_require_approval, get_require_approval, approve_user_db, get_ai_config, set_ai_config, get_default_location, set_default_location from messages import cleanup_old_messages from chat import connected_clients # 👈 SOURCE OF TRUTH @@ -103,6 +103,20 @@ def admin_cleanup_files(days: int, admin=Depends(verify_admin)): "message": f"Deleted {deleted_count} files. Freed {freed_mb} MB space." } + + +# ================= WEATHER LOCATION SETTINGS ================= +@router.get("/weather_location") +def admin_get_weather_location(admin=Depends(verify_admin)): + lat, lon = get_default_location() + return {"success": True, "default_lat": lat, "default_lon": lon} + + +@router.post("/weather_location") +def admin_set_weather_location(lat: float, lon: float, admin=Depends(verify_admin)): + set_default_location(lat, lon) + return {"success": True, "message": "Weather location updated", "default_lat": lat, "default_lon": lon} + # ================= AI SETTINGS ================= @router.get("/ai_settings") def admin_get_ai_settings(admin=Depends(verify_admin)): diff --git a/backend/requirements.txt b/backend/requirements.txt index 69c31d9..41d1201 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -9,4 +9,6 @@ pytesseract requests PyPDF2 chromadb -psutil \ No newline at end of file +psutil +apscheduler +httpx diff --git a/backend/server.py b/backend/server.py index 23f6480..3449de4 100644 --- a/backend/server.py +++ b/backend/server.py @@ -3,7 +3,9 @@ # lan_server/server.py import os import secrets +from contextlib import asynccontextmanager from fastapi import FastAPI +from apscheduler.schedulers.asyncio import AsyncIOScheduler from fastapi import Header, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse @@ -12,13 +14,13 @@ from jose import jwt from datetime import datetime, timedelta, timezone import chat, files, calls, messages +from tasks import run_proactive_weather_sentinel import profiles # 👈 1. Import profiles module from users import init_db, register_user, verify_user, get_admin_key_db, set_admin_key_db from messages import init_msg_db from users import get_all_users from messages import get_recent_messages from users import delete_user_data # 👈 Import the new function -from fastapi.staticfiles import StaticFiles # ================= JWT CONFIG ================= SECRET_KEY = os.getenv("JWT_SECRET_KEY", "CHANGE_THIS_TO_SOMETHING_RANDOM_AND_LONG") @@ -69,8 +71,29 @@ def verify_admin(x_admin_key: str = Header(None)): print(f"🚫 Admin Access Denied: Key mismatch. Received length: {len(clean_key)} (Expected: {len(clean_secret)}), Masked input: {masked_key}") print("💡 TIP: Copy the EXACT random secret from the server logs above.") raise HTTPException(status_code=403, detail="Admin access denied") +# ================= BACKGROUND TASKS & SCHEDULER ================= +@asynccontextmanager +async def lifespan(app: FastAPI): + scheduler = AsyncIOScheduler(timezone="Asia/Kolkata") + scheduler.add_job( + run_proactive_weather_sentinel, + trigger="cron", + hour=7, + minute=0, + id="proactive_weather_sentinel", + replace_existing=True, + ) + scheduler.start() + print("🌅 Weather Sentinel scheduler started (Runs at 07:00 AM IST).") + try: + yield + finally: + scheduler.shutdown(wait=False) + print("🌅 Weather Sentinel scheduler stopped.") + + # ================= FASTAPI APP ================= -app = FastAPI(title="LAN Chat Server (modular)") +app = FastAPI(title="LAN Chat Server (modular)", lifespan=lifespan) # CORS: allow origins (disabled credentials for security) app.add_middleware( diff --git a/backend/tasks.py b/backend/tasks.py new file mode 100644 index 0000000..f084a78 --- /dev/null +++ b/backend/tasks.py @@ -0,0 +1,107 @@ +import asyncio +import json +import logging +from datetime import datetime, timedelta, timezone + +import httpx + +from chat import connected_clients, send_to_user +from lumir.ai_engine import ask_ai +from messages import create_delivery_entries, save_message +from users import get_ai_config, get_all_users, get_default_location + +LOGGER = logging.getLogger(__name__) + +IST = timezone(timedelta(hours=5, minutes=30)) +DEFAULT_LAT = 26.2183 +DEFAULT_LON = 78.1828 + + + +def _get_default_coordinates() -> tuple[float, float]: + try: + return get_default_location() + except Exception: + LOGGER.debug("Weather sentinel could not read configured coordinates.", exc_info=True) + return DEFAULT_LAT, DEFAULT_LON + + +async def _broadcast_family_warning(message_text: str) -> None: + timestamp = int(datetime.now(IST).timestamp() * 1000) + payload = { + "type": "text", + "text": message_text, + "sender": "Lumir", + "receiver": "Family Group", + "timestamp": timestamp, + } + + msg_id = save_message( + text=message_text, + sender="Lumir", + receiver="Family Group", + msg_type="text", + ) + + recipients = [u["username"] for u in get_all_users() if u.get("username") != "Lumir"] + if recipients: + create_delivery_entries(msg_id, recipients) + + payload_json = json.dumps(payload) + for username in list(connected_clients.keys()): + await send_to_user(username, payload_json) + + +async def run_proactive_weather_sentinel() -> None: + try: + lat, lon = _get_default_coordinates() + location = f"{lat:.4f}, {lon:.4f}" + + async with httpx.AsyncClient(timeout=15.0) as client: + response = await client.get( + "https://api.open-meteo.com/v1/forecast", + params={ + "latitude": lat, + "longitude": lon, + "daily": "precipitation_sum,temperature_2m_max", + "timezone": "Asia/Kolkata", + "forecast_days": 1, + }, + ) + response.raise_for_status() + forecast = response.json().get("daily", {}) + + precipitation_values = forecast.get("precipitation_sum") or [] + max_temp_values = forecast.get("temperature_2m_max") or [] + + precipitation_sum = float(precipitation_values[0]) if precipitation_values else 0.0 + temperature_2m_max = float(max_temp_values[0]) if max_temp_values else 0.0 + + alert_reason = None + if precipitation_sum > 2.0: + alert_reason = f"{precipitation_sum:.1f}mm rain" + elif temperature_2m_max > 40.0: + alert_reason = f"{temperature_2m_max:.1f}°C heat" + + if not alert_reason: + return + + hidden_prompt = ( + "System Context: Aaj {location} mein {alert_reason} hone wali hai. " + "Act like a caring family member and write a warning message for the Family Group. " + "Keep it under 15 words. Do NOT use markdown. Say good morning." + ).format(location=location, alert_reason=alert_reason) + + ai_config = get_ai_config() + generated_message = await asyncio.to_thread( + ask_ai, + prompt=hidden_prompt, + config=ai_config, + history=[], + sender="Family Group", + ) + + await _broadcast_family_warning((generated_message or "Good morning. Please stay safe today.").strip()) + + except Exception: + LOGGER.debug("Weather sentinel failed during scheduled execution.", exc_info=True) diff --git a/backend/users.py b/backend/users.py index c4535f9..aa47817 100644 --- a/backend/users.py +++ b/backend/users.py @@ -44,6 +44,49 @@ def init_db(): # --- Config Helpers --- + + +# --- Weather Location Config Helpers --- +def get_default_location(): + """ + Fetch weather default location from config table. + Falls back to Maheshpura coordinates when missing/invalid. + """ + lat = 26.2183 + lon = 78.1828 + + conn = sqlite3.connect(DATABASE_NAME) + cursor = conn.cursor() + cursor.execute("SELECT key, value FROM config WHERE key IN ('default_lat', 'default_lon')") + rows = cursor.fetchall() + conn.close() + + for key, value in rows: + try: + if key == "default_lat": + lat = float(value) + elif key == "default_lon": + lon = float(value) + except (TypeError, ValueError): + continue + + return lat, lon + + +def set_default_location(lat: float, lon: float): + conn = sqlite3.connect(DATABASE_NAME) + cursor = conn.cursor() + cursor.execute( + "INSERT OR REPLACE INTO config (key, value) VALUES ('default_lat', ?)", + (str(lat),), + ) + cursor.execute( + "INSERT OR REPLACE INTO config (key, value) VALUES ('default_lon', ?)", + (str(lon),), + ) + conn.commit() + conn.close() + def set_require_approval(enabled: bool): conn = sqlite3.connect(DATABASE_NAME) cursor = conn.cursor() diff --git a/intra_admin/admin.py b/intra_admin/admin.py index 462651f..db1065d 100644 --- a/intra_admin/admin.py +++ b/intra_admin/admin.py @@ -488,8 +488,36 @@ def create_settings_page(self): card_layout.addWidget(self.settings_status) card_layout.addWidget(save_btn) + # Weather Location Card + weather_card = QFrame() + weather_card.setStyleSheet("background-color: #181825; border: 1px solid #313244; border-radius: 12px;") + weather_layout = QFormLayout(weather_card) + weather_layout.setContentsMargins(30, 30, 30, 30) + weather_layout.setSpacing(15) + + self.weather_lat_input = QLineEdit() + self.weather_lat_input.setPlaceholderText("e.g. 26.2183") + self.weather_lon_input = QLineEdit() + self.weather_lon_input.setPlaceholderText("e.g. 78.1828") + + self.weather_status = QLabel("") + self.weather_status.setStyleSheet("font-weight: bold; font-size: 14px;") + + weather_save_btn = QPushButton("💾 SAVE WEATHER LOCATION") + weather_save_btn.setObjectName("SaveBtn") + weather_save_btn.setMinimumHeight(50) + weather_save_btn.setCursor(Qt.PointingHandCursor) + weather_save_btn.clicked.connect(self.save_weather_location) + + weather_layout.addRow("Default Latitude:", self.weather_lat_input) + weather_layout.addRow("Default Longitude:", self.weather_lon_input) + weather_layout.addRow(self.weather_status) + weather_layout.addRow(weather_save_btn) + layout.addWidget(lbl) layout.addWidget(card) + layout.addSpacing(15) + layout.addWidget(weather_card) layout.addStretch() return page @@ -502,6 +530,7 @@ def load_settings(self): self.chk_approval.blockSignals(True) self.chk_approval.setChecked(bool(data["require_approval"])) self.chk_approval.blockSignals(False) + self.load_weather_location() except Exception as e: print("load_settings error:", e) @@ -525,6 +554,52 @@ def save_settings(self): self.settings_status.setText(f"❌ Error: {str(e)}") self.settings_status.setStyleSheet("color: #f38ba8;") + def load_weather_location(self): + try: + self.weather_status.setText("") + res = requests.get(f"{self.server_url}/admin/weather_location", headers=self.headers, timeout=3) + data = res.json() + if data.get("success"): + self.weather_lat_input.setText(str(data.get("default_lat", ""))) + self.weather_lon_input.setText(str(data.get("default_lon", ""))) + except Exception as e: + self.weather_status.setText(f"❌ Load failed: {str(e)}") + self.weather_status.setStyleSheet("color: #f38ba8;") + + def save_weather_location(self): + try: + lat = float(self.weather_lat_input.text().strip()) + lon = float(self.weather_lon_input.text().strip()) + except ValueError: + QMessageBox.warning(self, "Invalid Input", "Latitude/Longitude must be valid numbers.") + return + + if not (-90 <= lat <= 90): + QMessageBox.warning(self, "Invalid Latitude", "Latitude must be between -90 and 90.") + return + + if not (-180 <= lon <= 180): + QMessageBox.warning(self, "Invalid Longitude", "Longitude must be between -180 and 180.") + return + + try: + self.weather_status.setText("Saving weather location...") + self.weather_status.setStyleSheet("color: #89b4fa;") + res = requests.post( + f"{self.server_url}/admin/weather_location", + headers=self.headers, + params={"lat": lat, "lon": lon}, + timeout=3, + ) + if res.status_code == 200: + self.weather_status.setText("✅ Weather location saved successfully!") + self.weather_status.setStyleSheet("color: #a6e3a1;") + else: + raise Exception(f"Server error: {res.status_code}") + except Exception as e: + self.weather_status.setText(f"❌ Error: {str(e)}") + self.weather_status.setStyleSheet("color: #f38ba8;") + # ================= PAGE: AI SETTINGS ================= def create_ai_page(self): From 250f7889b055ed6002016b0eb9943c9a4282b5bc Mon Sep 17 00:00:00 2001 From: Divyanshu Shopra Date: Wed, 8 Apr 2026 09:48:29 +0530 Subject: [PATCH 2/6] Fix Python 3.8-compatible tuple annotation in weather task --- backend/tasks.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/tasks.py b/backend/tasks.py index f084a78..c56377f 100644 --- a/backend/tasks.py +++ b/backend/tasks.py @@ -2,6 +2,7 @@ import json import logging from datetime import datetime, timedelta, timezone +from typing import Tuple import httpx @@ -18,7 +19,7 @@ -def _get_default_coordinates() -> tuple[float, float]: +def _get_default_coordinates() -> Tuple[float, float]: try: return get_default_location() except Exception: From da2fd4f87b307230106e206e5b1131587cd57b7a Mon Sep 17 00:00:00 2001 From: Divyanshu Shopra Date: Sun, 12 Apr 2026 17:10:17 +0530 Subject: [PATCH 3/6] Polish weather location save button styling in admin panel --- intra_admin/admin.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/intra_admin/admin.py b/intra_admin/admin.py index db1065d..eafce9d 100644 --- a/intra_admin/admin.py +++ b/intra_admin/admin.py @@ -507,6 +507,18 @@ def create_settings_page(self): weather_save_btn.setObjectName("SaveBtn") weather_save_btn.setMinimumHeight(50) weather_save_btn.setCursor(Qt.PointingHandCursor) + weather_save_btn.setStyleSheet(""" + QPushButton { + background-color: #89b4fa; + color: #11111b; + font-size: 16px; + font-weight: bold; + border-radius: 8px; + } + QPushButton:hover { + background-color: #b4befe; + } + """) weather_save_btn.clicked.connect(self.save_weather_location) weather_layout.addRow("Default Latitude:", self.weather_lat_input) From 829bef68bd2b23b6e49aad82136fe0d2dfe7571c Mon Sep 17 00:00:00 2001 From: Divyanshu Shopra Date: Sun, 12 Apr 2026 17:10:22 +0530 Subject: [PATCH 4/6] Add place-name lookup for admin weather coordinates --- intra_admin/admin.py | 62 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/intra_admin/admin.py b/intra_admin/admin.py index eafce9d..09233c8 100644 --- a/intra_admin/admin.py +++ b/intra_admin/admin.py @@ -499,6 +499,8 @@ def create_settings_page(self): self.weather_lat_input.setPlaceholderText("e.g. 26.2183") self.weather_lon_input = QLineEdit() self.weather_lon_input.setPlaceholderText("e.g. 78.1828") + self.weather_location_input = QLineEdit() + self.weather_location_input.setPlaceholderText("e.g. Gwalior, Guna, Chachora") self.weather_status = QLabel("") self.weather_status.setStyleSheet("font-weight: bold; font-size: 14px;") @@ -520,7 +522,31 @@ def create_settings_page(self): } """) weather_save_btn.clicked.connect(self.save_weather_location) + weather_resolve_btn = QPushButton("📍 FIND COORDINATES") + weather_resolve_btn.setMinimumHeight(42) + weather_resolve_btn.setCursor(Qt.PointingHandCursor) + weather_resolve_btn.setStyleSheet(""" + QPushButton { + background-color: #a6e3a1; + color: #11111b; + font-size: 14px; + font-weight: bold; + border-radius: 8px; + } + QPushButton:hover { + background-color: #94e2d5; + } + """) + weather_resolve_btn.clicked.connect(self.resolve_weather_location) + + weather_location_row = QWidget() + weather_location_row_layout = QHBoxLayout(weather_location_row) + weather_location_row_layout.setContentsMargins(0, 0, 0, 0) + weather_location_row_layout.setSpacing(10) + weather_location_row_layout.addWidget(self.weather_location_input) + weather_location_row_layout.addWidget(weather_resolve_btn) + weather_layout.addRow("Find by Place Name:", weather_location_row) weather_layout.addRow("Default Latitude:", self.weather_lat_input) weather_layout.addRow("Default Longitude:", self.weather_lon_input) weather_layout.addRow(self.weather_status) @@ -578,6 +604,42 @@ def load_weather_location(self): self.weather_status.setText(f"❌ Load failed: {str(e)}") self.weather_status.setStyleSheet("color: #f38ba8;") + def resolve_weather_location(self): + place_name = self.weather_location_input.text().strip() + if not place_name: + QMessageBox.warning(self, "Missing Place", "Please enter a place/city name first.") + return + + try: + self.weather_status.setText("Finding coordinates...") + self.weather_status.setStyleSheet("color: #89b4fa;") + res = requests.get( + "https://geocoding-api.open-meteo.com/v1/search", + params={"name": place_name, "count": 1, "language": "en", "format": "json"}, + timeout=6, + ) + data = res.json() + results = data.get("results") or [] + if not results: + raise Exception("No matching place found.") + + top = results[0] + lat = top.get("latitude") + lon = top.get("longitude") + if lat is None or lon is None: + raise Exception("Coordinates missing in geocoding response.") + + self.weather_lat_input.setText(str(lat)) + self.weather_lon_input.setText(str(lon)) + + location_name_parts = [top.get("name"), top.get("admin1"), top.get("country")] + location_name = ", ".join([part for part in location_name_parts if part]) + self.weather_status.setText(f"✅ Found: {location_name}") + self.weather_status.setStyleSheet("color: #a6e3a1;") + except Exception as e: + self.weather_status.setText(f"❌ Lookup failed: {str(e)}") + self.weather_status.setStyleSheet("color: #f38ba8;") + def save_weather_location(self): try: lat = float(self.weather_lat_input.text().strip()) From 9e456ca57a4c623a0c7668ec86fdb59fcb7c38db Mon Sep 17 00:00:00 2001 From: Divyanshu Shopra Date: Tue, 14 Apr 2026 13:47:27 +0530 Subject: [PATCH 5/6] Upgrade sentinel to cached hourly guard with smart refresh jobs --- backend/server.py | 23 +++++- backend/tasks.py | 198 +++++++++++++++++++++++++++++++++++++--------- 2 files changed, 181 insertions(+), 40 deletions(-) diff --git a/backend/server.py b/backend/server.py index 3449de4..2c737bd 100644 --- a/backend/server.py +++ b/backend/server.py @@ -14,7 +14,11 @@ from jose import jwt from datetime import datetime, timedelta, timezone import chat, files, calls, messages -from tasks import run_proactive_weather_sentinel +from tasks import ( + run_hourly_weather_guard, + run_intraday_weather_refresh, + run_proactive_weather_sentinel, +) import profiles # 👈 1. Import profiles module from users import init_db, register_user, verify_user, get_admin_key_db, set_admin_key_db from messages import init_msg_db @@ -83,8 +87,23 @@ async def lifespan(app: FastAPI): id="proactive_weather_sentinel", replace_existing=True, ) + scheduler.add_job( + run_hourly_weather_guard, + trigger="cron", + minute=5, + id="hourly_weather_guard", + replace_existing=True, + ) + scheduler.add_job( + run_intraday_weather_refresh, + trigger="cron", + hour="12,15,18,21", + minute=0, + id="intraday_weather_refresh", + replace_existing=True, + ) scheduler.start() - print("🌅 Weather Sentinel scheduler started (Runs at 07:00 AM IST).") + print("🌅 Weather Sentinel scheduler started (07:00 daily + hourly guard + smart intraday refresh).") try: yield finally: diff --git a/backend/tasks.py b/backend/tasks.py index c56377f..8106cc2 100644 --- a/backend/tasks.py +++ b/backend/tasks.py @@ -1,6 +1,7 @@ import asyncio import json import logging +import os from datetime import datetime, timedelta, timezone from typing import Tuple @@ -16,6 +17,12 @@ IST = timezone(timedelta(hours=5, minutes=30)) DEFAULT_LAT = 26.2183 DEFAULT_LON = 78.1828 +WEATHER_CACHE_FILE = os.path.join(os.path.dirname(__file__), "weather_cache.json") + +# Default thresholds (future-ready for admin-config driven values) +TEMP_ALERT_THRESHOLD_C = 42.0 +WIND_ALERT_THRESHOLD_KMH = 40.0 +RAIN_ALERT_THRESHOLD_MM = 10.0 @@ -53,56 +60,171 @@ async def _broadcast_family_warning(message_text: str) -> None: await send_to_user(username, payload_json) -async def run_proactive_weather_sentinel() -> None: +def _load_weather_cache() -> dict: + try: + if not os.path.exists(WEATHER_CACHE_FILE): + return {} + with open(WEATHER_CACHE_FILE, "r", encoding="utf-8") as fp: + return json.load(fp) + except Exception: + LOGGER.debug("Could not read weather cache.", exc_info=True) + return {} + + +def _save_weather_cache(cache: dict) -> None: + try: + with open(WEATHER_CACHE_FILE, "w", encoding="utf-8") as fp: + json.dump(cache, fp, ensure_ascii=False) + except Exception: + LOGGER.debug("Could not write weather cache.", exc_info=True) + + +def _find_hourly_index_for_now(hourly_times: list, now_ist: datetime) -> int: + now_hour = now_ist.replace(minute=0, second=0, microsecond=0).isoformat() + for idx, ts in enumerate(hourly_times): + if ts == now_hour: + return idx + return -1 + + +def _compose_contextual_prompt(location: str, reasons: list, start_hour: int, end_hour: int) -> str: + reasons_text = ", ".join(reasons) + return ( + "System Context: Aaj {location} mein {start_hour}:00 se {end_hour}:00 ke beech " + "{reasons} expected hai. Act like a caring family member and write a concise warning " + "for the Family Group in natural Hinglish. Keep it under 20 words. Do NOT use markdown." + ).format( + location=location, + start_hour=start_hour, + end_hour=end_hour, + reasons=reasons_text, + ) + + +async def refresh_weather_cache(force: bool = False) -> None: + lat, lon = _get_default_coordinates() + location = f"{lat:.4f}, {lon:.4f}" + now_ist = datetime.now(IST) + + cache = _load_weather_cache() + cache_date = cache.get("cache_date") + today = now_ist.date().isoformat() + if not force and cache_date == today and cache.get("hourly"): + return + + async with httpx.AsyncClient(timeout=15.0) as client: + response = await client.get( + "https://api.open-meteo.com/v1/forecast", + params={ + "latitude": lat, + "longitude": lon, + "hourly": "temperature_2m,precipitation,wind_speed_10m,uv_index", + "daily": "precipitation_sum,temperature_2m_max,wind_speed_10m_max", + "timezone": "Asia/Kolkata", + "forecast_days": 1, + }, + ) + response.raise_for_status() + payload = response.json() + + daily = payload.get("daily", {}) + precipitation_sum = float((daily.get("precipitation_sum") or [0.0])[0] or 0.0) + max_temp = float((daily.get("temperature_2m_max") or [0.0])[0] or 0.0) + max_wind = float((daily.get("wind_speed_10m_max") or [0.0])[0] or 0.0) + danger_day = ( + precipitation_sum >= RAIN_ALERT_THRESHOLD_MM + or max_temp >= TEMP_ALERT_THRESHOLD_C + or max_wind >= WIND_ALERT_THRESHOLD_KMH + ) + + _save_weather_cache( + { + "cache_date": today, + "cached_at": now_ist.isoformat(), + "location": location, + "danger_day": danger_day, + "hourly": payload.get("hourly", {}), + "daily": daily, + } + ) + + +async def run_intraday_weather_refresh() -> None: + try: + cache = _load_weather_cache() + if not cache.get("danger_day"): + return + await refresh_weather_cache(force=True) + except Exception: + LOGGER.debug("Intraday weather cache refresh failed.", exc_info=True) + + +async def run_hourly_weather_guard() -> None: try: - lat, lon = _get_default_coordinates() - location = f"{lat:.4f}, {lon:.4f}" - - async with httpx.AsyncClient(timeout=15.0) as client: - response = await client.get( - "https://api.open-meteo.com/v1/forecast", - params={ - "latitude": lat, - "longitude": lon, - "daily": "precipitation_sum,temperature_2m_max", - "timezone": "Asia/Kolkata", - "forecast_days": 1, - }, - ) - response.raise_for_status() - forecast = response.json().get("daily", {}) - - precipitation_values = forecast.get("precipitation_sum") or [] - max_temp_values = forecast.get("temperature_2m_max") or [] - - precipitation_sum = float(precipitation_values[0]) if precipitation_values else 0.0 - temperature_2m_max = float(max_temp_values[0]) if max_temp_values else 0.0 - - alert_reason = None - if precipitation_sum > 2.0: - alert_reason = f"{precipitation_sum:.1f}mm rain" - elif temperature_2m_max > 40.0: - alert_reason = f"{temperature_2m_max:.1f}°C heat" - - if not alert_reason: + await refresh_weather_cache(force=False) + cache = _load_weather_cache() + hourly = cache.get("hourly") or {} + times = hourly.get("time") or [] + if not times: + return + + now_ist = datetime.now(IST) + start_idx = _find_hourly_index_for_now(times, now_ist) + if start_idx < 0: + return + + end_idx = min(start_idx + 3, len(times)) + temperatures = hourly.get("temperature_2m") or [] + precipitations = hourly.get("precipitation") or [] + wind_speeds = hourly.get("wind_speed_10m") or [] + + peak_temp = max([float(v or 0.0) for v in temperatures[start_idx:end_idx]] or [0.0]) + peak_rain = max([float(v or 0.0) for v in precipitations[start_idx:end_idx]] or [0.0]) + peak_wind = max([float(v or 0.0) for v in wind_speeds[start_idx:end_idx]] or [0.0]) + + reasons = [] + if peak_temp >= TEMP_ALERT_THRESHOLD_C: + reasons.append(f"temperature {peak_temp:.1f}°C") + if peak_wind >= WIND_ALERT_THRESHOLD_KMH: + reasons.append(f"wind {peak_wind:.1f} km/h") + if peak_rain >= RAIN_ALERT_THRESHOLD_MM: + reasons.append(f"rain {peak_rain:.1f} mm") + + if not reasons: return - hidden_prompt = ( - "System Context: Aaj {location} mein {alert_reason} hone wali hai. " - "Act like a caring family member and write a warning message for the Family Group. " - "Keep it under 15 words. Do NOT use markdown. Say good morning." - ).format(location=location, alert_reason=alert_reason) + # Anti-spam: one alert per 3-hour window with same reason signature. + reason_key = "|".join(sorted(reasons)) + window_key = f"{now_ist.date().isoformat()}-{now_ist.hour}-{reason_key}" + if cache.get("last_alert_window") == window_key: + return + prompt = _compose_contextual_prompt( + location=cache.get("location", "your area"), + reasons=reasons, + start_hour=now_ist.hour, + end_hour=(now_ist + timedelta(hours=3)).hour, + ) ai_config = get_ai_config() generated_message = await asyncio.to_thread( ask_ai, - prompt=hidden_prompt, + prompt=prompt, config=ai_config, history=[], sender="Family Group", ) + await _broadcast_family_warning((generated_message or "Please stay safe. Weather may worsen soon.").strip()) - await _broadcast_family_warning((generated_message or "Good morning. Please stay safe today.").strip()) + cache["last_alert_window"] = window_key + _save_weather_cache(cache) + except Exception: + LOGGER.debug("Hourly weather guard failed.", exc_info=True) + + +async def run_proactive_weather_sentinel() -> None: + try: + await refresh_weather_cache(force=True) + await run_hourly_weather_guard() except Exception: LOGGER.debug("Weather sentinel failed during scheduled execution.", exc_info=True) From 97f2a87a76582d88c8f2e9e6a690756871aedd52 Mon Sep 17 00:00:00 2001 From: Divyanshu Shopra Date: Fri, 17 Apr 2026 07:32:47 +0530 Subject: [PATCH 6/6] Use DB-driven environment thresholds in weather guard --- backend/tasks.py | 36 ++++++++++++++++++++++-------------- backend/users.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 14 deletions(-) diff --git a/backend/tasks.py b/backend/tasks.py index 8106cc2..ba0d085 100644 --- a/backend/tasks.py +++ b/backend/tasks.py @@ -10,7 +10,7 @@ from chat import connected_clients, send_to_user from lumir.ai_engine import ask_ai from messages import create_delivery_entries, save_message -from users import get_ai_config, get_all_users, get_default_location +from users import get_ai_config, get_all_users, get_default_location, get_environment_settings LOGGER = logging.getLogger(__name__) @@ -19,13 +19,6 @@ DEFAULT_LON = 78.1828 WEATHER_CACHE_FILE = os.path.join(os.path.dirname(__file__), "weather_cache.json") -# Default thresholds (future-ready for admin-config driven values) -TEMP_ALERT_THRESHOLD_C = 42.0 -WIND_ALERT_THRESHOLD_KMH = 40.0 -RAIN_ALERT_THRESHOLD_MM = 10.0 - - - def _get_default_coordinates() -> Tuple[float, float]: try: return get_default_location() @@ -101,6 +94,19 @@ def _compose_contextual_prompt(location: str, reasons: list, start_hour: int, en ) +def _get_environment_thresholds() -> Tuple[float, float, float]: + try: + env_settings = get_environment_settings() + return ( + float(env_settings.get("alert_temp", 40.0)), + float(env_settings.get("alert_wind", 40.0)), + float(env_settings.get("alert_rain", 5.0)), + ) + except Exception: + LOGGER.debug("Could not load environment settings; using default thresholds.", exc_info=True) + return 40.0, 40.0, 5.0 + + async def refresh_weather_cache(force: bool = False) -> None: lat, lon = _get_default_coordinates() location = f"{lat:.4f}, {lon:.4f}" @@ -128,13 +134,14 @@ async def refresh_weather_cache(force: bool = False) -> None: payload = response.json() daily = payload.get("daily", {}) + alert_temp, alert_wind, alert_rain = _get_environment_thresholds() precipitation_sum = float((daily.get("precipitation_sum") or [0.0])[0] or 0.0) max_temp = float((daily.get("temperature_2m_max") or [0.0])[0] or 0.0) max_wind = float((daily.get("wind_speed_10m_max") or [0.0])[0] or 0.0) danger_day = ( - precipitation_sum >= RAIN_ALERT_THRESHOLD_MM - or max_temp >= TEMP_ALERT_THRESHOLD_C - or max_wind >= WIND_ALERT_THRESHOLD_KMH + precipitation_sum >= alert_rain + or max_temp >= alert_temp + or max_wind >= alert_wind ) _save_weather_cache( @@ -181,13 +188,14 @@ async def run_hourly_weather_guard() -> None: peak_temp = max([float(v or 0.0) for v in temperatures[start_idx:end_idx]] or [0.0]) peak_rain = max([float(v or 0.0) for v in precipitations[start_idx:end_idx]] or [0.0]) peak_wind = max([float(v or 0.0) for v in wind_speeds[start_idx:end_idx]] or [0.0]) + alert_temp, alert_wind, alert_rain = _get_environment_thresholds() reasons = [] - if peak_temp >= TEMP_ALERT_THRESHOLD_C: + if peak_temp >= alert_temp: reasons.append(f"temperature {peak_temp:.1f}°C") - if peak_wind >= WIND_ALERT_THRESHOLD_KMH: + if peak_wind >= alert_wind: reasons.append(f"wind {peak_wind:.1f} km/h") - if peak_rain >= RAIN_ALERT_THRESHOLD_MM: + if peak_rain >= alert_rain: reasons.append(f"rain {peak_rain:.1f} mm") if not reasons: diff --git a/backend/users.py b/backend/users.py index aa47817..9ad261e 100644 --- a/backend/users.py +++ b/backend/users.py @@ -87,6 +87,42 @@ def set_default_location(lat: float, lon: float): conn.commit() conn.close() + +def get_environment_settings(): + """ + Fetch Smart Environment Monitor settings from config. + Defaults: + - env_mode: auto + - alert_temp: 40.0 + - alert_wind: 40.0 + - alert_rain: 5.0 + """ + settings = { + "env_mode": "auto", + "alert_temp": 40.0, + "alert_wind": 40.0, + "alert_rain": 5.0, + } + + conn = sqlite3.connect(DATABASE_NAME) + cursor = conn.cursor() + cursor.execute( + "SELECT key, value FROM config WHERE key IN ('env_mode', 'alert_temp', 'alert_wind', 'alert_rain')" + ) + rows = cursor.fetchall() + conn.close() + + for key, value in rows: + try: + if key == "env_mode": + settings[key] = value + else: + settings[key] = float(value) + except (TypeError, ValueError): + continue + + return settings + def set_require_approval(enabled: bool): conn = sqlite3.connect(DATABASE_NAME) cursor = conn.cursor()