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