From f998b2a07d79e0a6382edad3ab767d9e7e38b3c0 Mon Sep 17 00:00:00 2001 From: hbrain Date: Thu, 25 Jun 2026 11:26:13 +0000 Subject: [PATCH] Add weather forecast and trim analysis context --- .env.example | 12 ++- .gitignore | 2 + ha_observer.py | 204 +++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 194 insertions(+), 24 deletions(-) diff --git a/.env.example b/.env.example index f707c43..6e2ca9c 100644 --- a/.env.example +++ b/.env.example @@ -16,8 +16,10 @@ SITE_URL="https://hapi.novosel.dk" PROMPT_FILE="./llm_instructions.md" # Collection/history settings -HISTORY_HOURS="24" -MAX_HISTORY_PER_ENTITY="20" +HISTORY_HOURS="2" +MAX_HISTORY_PER_ENTITY="10" +MAX_STATES_PER_SNAPSHOT="160" +MAX_HISTORY_ENTITIES_PER_SNAPSHOT="70" CALENDAR_LOOKAHEAD_DAYS="7" MAX_CALENDAR_EVENTS_PER_CALENDAR="8" KEEP_SNAPSHOT_DAYS="14" @@ -26,7 +28,13 @@ KEEP_SNAPSHOT_DAYS="14" ANALYZE_SNAPSHOT_HOURS="24" ARTICLE_CONTEXT_DAYS="7" MAX_ANALYZE_CHARS="80000" +MAX_PREVIOUS_ARTICLE_CHARS="20000" +MAX_PREVIOUS_ARTICLE_CHARS_PER_REPORT="3000" DISPLAY_TIMEZONE="Europe/Copenhagen" +# Optional: override Home Assistant coordinates for direct Open-Meteo forecast. +# If blank, ha-observer uses /api/config latitude/longitude from Home Assistant. +WEATHER_LATITUDE="" +WEATHER_LONGITUDE="" # Domains to include RELEVANT_DOMAINS="sensor,binary_sensor,person,device_tracker,climate,light,switch,lock,cover,alarm_control_panel,media_player,calendar,weather" diff --git a/.gitignore b/.gitignore index e7ae51d..7c77e82 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ cron.log __pycache__/ *.pyc llm_instructions.md + +AGENTS.md diff --git a/ha_observer.py b/ha_observer.py index 8e4b15e..693d291 100755 --- a/ha_observer.py +++ b/ha_observer.py @@ -38,12 +38,18 @@ SITE_URL = os.environ.get("SITE_URL", "http://localhost").rstrip("/") PROMPT_FILE = Path(os.environ.get("PROMPT_FILE", "./llm_instructions.md")) HISTORY_HOURS = int(os.environ.get("HISTORY_HOURS", "24")) MAX_HISTORY_PER_ENTITY = int(os.environ.get("MAX_HISTORY_PER_ENTITY", "20")) +MAX_STATES_PER_SNAPSHOT = int(os.environ.get("MAX_STATES_PER_SNAPSHOT", "160")) +MAX_HISTORY_ENTITIES_PER_SNAPSHOT = int(os.environ.get("MAX_HISTORY_ENTITIES_PER_SNAPSHOT", "70")) CALENDAR_LOOKAHEAD_DAYS = int(os.environ.get("CALENDAR_LOOKAHEAD_DAYS", "7")) MAX_CALENDAR_EVENTS_PER_CALENDAR = int(os.environ.get("MAX_CALENDAR_EVENTS_PER_CALENDAR", "8")) ANALYZE_SNAPSHOT_HOURS = int(os.environ.get("ANALYZE_SNAPSHOT_HOURS", "24")) ARTICLE_CONTEXT_DAYS = int(os.environ.get("ARTICLE_CONTEXT_DAYS", "7")) MAX_ANALYZE_CHARS = int(os.environ.get("MAX_ANALYZE_CHARS", "80000")) +MAX_PREVIOUS_ARTICLE_CHARS = int(os.environ.get("MAX_PREVIOUS_ARTICLE_CHARS", "20000")) +MAX_PREVIOUS_ARTICLE_CHARS_PER_REPORT = int(os.environ.get("MAX_PREVIOUS_ARTICLE_CHARS_PER_REPORT", "3000")) DISPLAY_TIMEZONE = os.environ.get("DISPLAY_TIMEZONE", "Europe/Copenhagen") +WEATHER_LATITUDE = os.environ.get("WEATHER_LATITUDE", "").strip() +WEATHER_LONGITUDE = os.environ.get("WEATHER_LONGITUDE", "").strip() KEEP_SNAPSHOT_DAYS = int(os.environ.get("KEEP_SNAPSHOT_DAYS", "14")) # LLM_MODE: none | pi | ollama | openai @@ -144,6 +150,130 @@ def ha_get(path: str, params: dict[str, str] | None = None) -> Any: return response.json() +WEATHER_CODES = { + 0: "clear sky", + 1: "mainly clear", + 2: "partly cloudy", + 3: "overcast", + 45: "fog", + 48: "depositing rime fog", + 51: "light drizzle", + 53: "moderate drizzle", + 55: "dense drizzle", + 56: "light freezing drizzle", + 57: "dense freezing drizzle", + 61: "slight rain", + 63: "moderate rain", + 65: "heavy rain", + 66: "light freezing rain", + 67: "heavy freezing rain", + 71: "slight snow", + 73: "moderate snow", + 75: "heavy snow", + 77: "snow grains", + 80: "slight rain showers", + 81: "moderate rain showers", + 82: "violent rain showers", + 85: "slight snow showers", + 86: "heavy snow showers", + 95: "thunderstorm", + 96: "thunderstorm with slight hail", + 99: "thunderstorm with heavy hail", +} + + +def weather_code_text(value: Any) -> str: + try: + return WEATHER_CODES.get(int(value), f"weather code {value}") + except Exception: + return str(value) + + +def get_weather_coordinates() -> tuple[float, float] | None: + if WEATHER_LATITUDE and WEATHER_LONGITUDE: + try: + return float(WEATHER_LATITUDE), float(WEATHER_LONGITUDE) + except ValueError: + print("Ignoring invalid WEATHER_LATITUDE/WEATHER_LONGITUDE", file=sys.stderr) + try: + config = ha_get("/api/config") + return float(config["latitude"]), float(config["longitude"]) + except Exception as exc: + print(f"Skipping direct weather forecast; could not get coordinates: {exc}", file=sys.stderr) + return None + + +def get_direct_weather_forecast() -> dict[str, Any]: + """Fetch current + today/tomorrow forecast from Open-Meteo. No API key required.""" + coords = get_weather_coordinates() + if not coords: + return {} + latitude, longitude = coords + params = { + "latitude": f"{latitude:.5f}", + "longitude": f"{longitude:.5f}", + "timezone": DISPLAY_TIMEZONE, + "current": "temperature_2m,relative_humidity_2m,apparent_temperature,precipitation,rain,showers,snowfall,weather_code,cloud_cover,wind_speed_10m,wind_gusts_10m", + "daily": "weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,rain_sum,showers_sum,snowfall_sum,precipitation_probability_max,wind_speed_10m_max,wind_gusts_10m_max,sunrise,sunset", + "forecast_days": "2", + } + try: + response = requests.get("https://api.open-meteo.com/v1/forecast", params=params, timeout=30) + response.raise_for_status() + data = response.json() + except Exception as exc: + print(f"Skipping direct weather forecast; Open-Meteo request failed: {exc}", file=sys.stderr) + return {} + + current = data.get("current") or {} + daily = data.get("daily") or {} + days: list[dict[str, Any]] = [] + dates = daily.get("time") or [] + for i, date_value in enumerate(dates[:2]): + def daily_value(name: str) -> Any: + values = daily.get(name) or [] + return values[i] if i < len(values) else None + + days.append( + { + "date": date_value, + "condition": weather_code_text(daily_value("weather_code")), + "temperature_min_c": daily_value("temperature_2m_min"), + "temperature_max_c": daily_value("temperature_2m_max"), + "precipitation_mm": daily_value("precipitation_sum"), + "rain_mm": daily_value("rain_sum"), + "showers_mm": daily_value("showers_sum"), + "snowfall_cm": daily_value("snowfall_sum"), + "precipitation_probability_max_percent": daily_value("precipitation_probability_max"), + "wind_speed_max_kmh": daily_value("wind_speed_10m_max"), + "wind_gusts_max_kmh": daily_value("wind_gusts_10m_max"), + "sunrise": daily_value("sunrise"), + "sunset": daily_value("sunset"), + } + ) + + return { + "source": "Open-Meteo direct net forecast", + "latitude": latitude, + "longitude": longitude, + "current": { + "time": current.get("time"), + "condition": weather_code_text(current.get("weather_code")), + "temperature_c": current.get("temperature_2m"), + "apparent_temperature_c": current.get("apparent_temperature"), + "humidity_percent": current.get("relative_humidity_2m"), + "precipitation_mm": current.get("precipitation"), + "rain_mm": current.get("rain"), + "showers_mm": current.get("showers"), + "snowfall_cm": current.get("snowfall"), + "cloud_cover_percent": current.get("cloud_cover"), + "wind_speed_kmh": current.get("wind_speed_10m"), + "wind_gusts_kmh": current.get("wind_gusts_10m"), + }, + "days": days, + } + + def is_relevant_entity(entity_id: str) -> bool: return entity_id not in EXCLUDED_ENTITIES and entity_id.split(".", 1)[0] in RELEVANT_DOMAINS @@ -278,20 +408,34 @@ def get_history(hours: int, entity_ids: list[str]) -> list[dict[str, Any]]: if len(set(x["state"] for x in compact)) > 1: changes.append({"entity_id": entity_id, "recent_states": compact}) + changes = sorted(changes, key=lambda x: (-entity_importance(x.get("entity_id", "")), x.get("entity_id", ""))) + if MAX_HISTORY_ENTITIES_PER_SNAPSHOT > 0: + changes = changes[:MAX_HISTORY_ENTITIES_PER_SNAPSHOT] return sorted(changes, key=lambda x: x["entity_id"]) def make_snapshot() -> dict[str, Any]: - states = get_states() + all_states = get_states() + states = sorted( + all_states, + key=lambda state: (-entity_importance(state.get("entity_id", ""), state.get("attributes", {})), state.get("entity_id", "")), + ) + if MAX_STATES_PER_SNAPSHOT > 0: + states = states[:MAX_STATES_PER_SNAPSHOT] entity_ids = [state["entity_id"] for state in states] calendar_entity_ids = [entity_id for entity_id in entity_ids if entity_id.startswith("calendar.")] return { "generated_at": datetime.now().isoformat(timespec="seconds"), "history_hours": HISTORY_HOURS, "calendar_lookahead_days": CALENDAR_LOOKAHEAD_DAYS, + "state_count_before_limit": len(all_states), + "state_count_after_limit": len(states), + "max_states_per_snapshot": MAX_STATES_PER_SNAPSHOT, + "max_history_entities_per_snapshot": MAX_HISTORY_ENTITIES_PER_SNAPSHOT, "states": states, "history": get_history(HISTORY_HOURS, entity_ids), "calendar_events": get_calendar_events(calendar_entity_ids), + "weather_forecast": get_direct_weather_forecast(), } @@ -392,6 +536,27 @@ def summarize_snapshot(snapshot: dict[str, Any]) -> str: value = f"{state.get('state')} {unit}".strip() score = entity_importance(state.get("entity_id", ""), attrs) lines.append(f"- importance={score} {name} ({state.get('entity_id')}): {value}; last_changed={display_time(state.get('last_changed'))}") + forecast = snapshot.get("weather_forecast") or {} + if forecast: + lines.append("Direct weather forecast:") + lines.append(f"- source={forecast.get('source')}; location={forecast.get('latitude')},{forecast.get('longitude')}") + current = forecast.get("current") or {} + if current: + lines.append( + "- current: " + f"{current.get('condition')}; temp={current.get('temperature_c')}°C; " + f"feels_like={current.get('apparent_temperature_c')}°C; humidity={current.get('humidity_percent')}%; " + f"precipitation={current.get('precipitation_mm')}mm; cloud_cover={current.get('cloud_cover_percent')}%; " + f"wind={current.get('wind_speed_kmh')}km/h; gusts={current.get('wind_gusts_kmh')}km/h" + ) + for day in forecast.get("days", []): + lines.append( + f"- forecast {day.get('date')}: {day.get('condition')}; " + f"temp={day.get('temperature_min_c')}–{day.get('temperature_max_c')}°C; " + f"precipitation={day.get('precipitation_mm')}mm; probability={day.get('precipitation_probability_max_percent')}%; " + f"wind_max={day.get('wind_speed_max_kmh')}km/h; gusts={day.get('wind_gusts_max_kmh')}km/h; " + f"sunrise={day.get('sunrise')}; sunset={day.get('sunset')}" + ) lines.append("Upcoming calendar events:") for calendar in snapshot.get("calendar_events", []): lines.append(f"- {calendar.get('entity_id')}:") @@ -457,8 +622,18 @@ def load_recent_article_context(days: int) -> str: print(f"Skipping unreadable previous report {path}: {exc}", file=sys.stderr) continue conclusions = text.split("\n## Data bundle\n", 1)[0].strip() - articles.append(f"PREVIOUS ARTICLE {path.name}:\n{conclusions[:8000]}") - return "\n\n---\n\n".join(articles[-7:]) + articles.append(f"PREVIOUS ARTICLE {path.name}:\n{conclusions[:MAX_PREVIOUS_ARTICLE_CHARS_PER_REPORT]}") + + selected: list[str] = [] + total = 0 + separator_len = len("\n\n---\n\n") + for article in reversed(articles[-7:]): + extra = len(article) + (separator_len if selected else 0) + if selected and total + extra > MAX_PREVIOUS_ARTICLE_CHARS: + break + selected.append(article) + total += extra + return "\n\n---\n\n".join(reversed(selected)) def analysis_prompt(input_summary: str, previous_articles: str = "") -> str: @@ -743,7 +918,6 @@ BLOG_CSS = """ article li { margin:.35rem 0; } article p, article li { font-size:1.04rem; color:#e6fbff; } article h1 { font-size:clamp(1.8rem,4vw,3.5rem); text-align:left; } - .title-image { display:block; width:100%; height:auto; margin:0 0 1.4rem; border:1px solid #22d3ee66; box-shadow:0 0 28px #00d9ff22; } article h2 { margin-top:1.8rem; padding-top:1rem; border-top:1px solid #22d3ee33; } article h1 + p, article h2 + p, article h3 + p { margin-top:.3rem; } strong { color:#ffffff; font-weight:750; } @@ -913,17 +1087,13 @@ def write_rss_feed() -> Path: description = article_text[:600] pub_dt = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc) url = site_url(f"articles/{path.name}") - image_path = WEB_DIR / "images" / path.name.replace(".html", ".svg") - enclosure = "" - if image_path.exists(): - enclosure = f'\n ' items.append(f""" {html.escape(title)} {html.escape(url)} {html.escape(url)} {format_datetime(pub_dt, usegmt=True)} - {html.escape(description)}{enclosure} + {html.escape(description)} """) now = format_datetime(datetime.now(timezone.utc), usegmt=True) feed_url = site_url("rss.xml") @@ -945,10 +1115,7 @@ def write_rss_feed() -> Path: return path -def blog_shell(title: str, subtitle: str, main_content: str, archive_links: str, image_href: str = "") -> str: - image_meta = "" - if image_href: - image_meta = f'\n' +def blog_shell(title: str, subtitle: str, main_content: str, archive_links: str) -> str: return f""" @@ -956,7 +1123,7 @@ def blog_shell(title: str, subtitle: str, main_content: str, archive_links: str, {html.escape(title)} -{image_meta} + @@ -991,13 +1158,8 @@ def publish_webpage(conclusions: str, raw_summary: str) -> Path: article_name = f"{now_dt:%Y-%m-%d}.html" body = markdownish_to_html(conclusions) raw = html.escape(raw_summary[:60000]) - article_title, _ = clean_rss_text(f"
{body}
") - title_image_path = write_title_image(article_name, article_title, now) - title_image_href = site_href(f"images/{title_image_path.name}") - title_image_html = f'{html.escape(article_title)}' article_content = f"""
- {title_image_html}
{body}
@@ -1015,7 +1177,6 @@ def publish_webpage(conclusions: str, raw_summary: str) -> Path: f"Daily home intelligence briefing · Generated {now}", article_content, article_links(), - image_href=f"images/{title_image_path.name}", ), encoding="utf-8", ) @@ -1023,7 +1184,6 @@ def publish_webpage(conclusions: str, raw_summary: str) -> Path: featured = f"""

Latest article · {html.escape(now)}

- {title_image_html}
{body}
@@ -1032,7 +1192,7 @@ def publish_webpage(conclusions: str, raw_summary: str) -> Path: """ index_path = WEB_DIR / "index.html" index_path.write_text( - blog_shell("Smart Home Gossip Gazette", "A daily blog of your Home Assistant household signals", featured, article_links(), image_href=f"images/{title_image_path.name}"), + blog_shell("Smart Home Gossip Gazette", "A daily blog of your Home Assistant household signals", featured, article_links()), encoding="utf-8", ) write_favicon()