import asyncio import logging from datetime import datetime from zoneinfo import ZoneInfo import httpx from sqlalchemy import select from app.config import get_settings from app.daily.recap import LLM_TIMEOUT_SEC, RecapService from app.daily.schedule import resolve_evening_schedule from app.db.base import SessionLocal from app.db.models import User from app.homelab.comfyui import ComfyUIClient from app.homelab.context import resolve_timezone from app.homelab.digest import build_morning_digest from app.homelab.monitoring import check_netdata_alerts from app.homelab_scoped.notices import post_chat_notice from app.homelab.state import get_state, set_state logger = logging.getLogger(__name__) WATCH_INTERVAL_SEC = 60 _netdata_tick = 0 async def homelab_watcher_loop() -> None: global _netdata_tick while True: try: await asyncio.sleep(WATCH_INTERVAL_SEC) await _tick_morning_digest() await _tick_evening_summary() await _tick_evening_summary_retries() settings = get_settings() _netdata_tick += WATCH_INTERVAL_SEC if _netdata_tick >= settings.netdata_poll_interval_sec: _netdata_tick = 0 await _tick_netdata() except asyncio.CancelledError: raise except Exception: logger.exception("Homelab watcher error") async def _tick_morning_digest() -> None: settings = get_settings() if not settings.morning_digest_enabled: return db = SessionLocal() try: users = db.scalars(select(User).where(User.is_active.is_(True))).all() digest = build_morning_digest(db, include_news=True) for user in users: tz_name = resolve_timezone(db, user.id) try: tz = ZoneInfo(tz_name) except Exception: tz = ZoneInfo("Europe/Moscow") now = datetime.now(tz) target_min = settings.morning_digest_hour * 60 + settings.morning_digest_minute current_min = now.hour * 60 + now.minute if current_min < target_min or current_min >= target_min + 3: continue today = now.date().isoformat() state_key = f"last_morning_digest_date:{user.id}" if get_state(db, state_key) == today: continue post_chat_notice(digest, user.id) set_state(db, state_key, today) finally: db.close() async def _tick_netdata() -> None: db = SessionLocal() try: notices = check_netdata_alerts(db) if not notices: return users = db.scalars(select(User).where(User.is_active.is_(True))).all() for user in users: for notice in notices: post_chat_notice(notice, user.id) finally: db.close() async def _comfyui_reachable(base_url: str) -> bool: try: async with httpx.AsyncClient(timeout=httpx.Timeout(3.0, connect=2.0)) as client: response = await client.get(f"{base_url.rstrip('/')}/system_stats") return response.status_code < 500 except (httpx.TimeoutException, httpx.ConnectError, httpx.NetworkError): return False async def _run_evening_recap(db, user: User, day, tz: ZoneInfo, *, scheduled: bool) -> None: settings = get_settings() schedule = resolve_evening_schedule(db, user.id) if not schedule.enabled: return image_enabled = settings.evening_summary_image_enabled and settings.comfyui_enabled if image_enabled: client = ComfyUIClient() if not await _comfyui_reachable(client.base_url): logger.warning("evening_recap ComfyUI unreachable user=%s text_only", user.id) image_enabled = False recap = RecapService(db, user.id) try: row = await asyncio.wait_for( recap.generate_and_post(day, tz, image_enabled=image_enabled), timeout=settings.comfyui_timeout_sec + LLM_TIMEOUT_SEC, ) except asyncio.TimeoutError: logger.warning("evening_recap timed out user=%s day=%s scheduled=%s", user.id, day, scheduled) return if row and row.status in ("posted", "text_only"): set_state(db, f"last_evening_summary_date:{user.id}", day.isoformat()) async def _tick_evening_summary() -> None: settings = get_settings() if not settings.evening_summary_enabled: return db = SessionLocal() try: users = db.scalars(select(User).where(User.is_active.is_(True))).all() for user in users: tz_name = resolve_timezone(db, user.id) try: tz = ZoneInfo(tz_name) except Exception: tz = ZoneInfo("Europe/Moscow") schedule = resolve_evening_schedule(db, user.id) if not schedule.enabled: continue now = datetime.now(tz) target_min = schedule.hour * 60 + schedule.minute current_min = now.hour * 60 + now.minute if current_min < target_min or current_min >= target_min + 3: continue today = now.date() state_key = f"last_evening_summary_date:{user.id}" if get_state(db, state_key) == today.isoformat(): continue await _run_evening_recap(db, user, today, tz, scheduled=True) finally: db.close() async def _tick_evening_summary_retries() -> None: db = SessionLocal() try: users = db.scalars(select(User).where(User.is_active.is_(True))).all() for user in users: tz_name = resolve_timezone(db, user.id) try: tz = ZoneInfo(tz_name) except Exception: tz = ZoneInfo("Europe/Moscow") recap = RecapService(db, user.id) for row in recap.list_failed_for_retry(): await _run_evening_recap(db, user, row.day, tz, scheduled=False) finally: db.close()