diff --git a/community/proactive-weather/README.md b/community/proactive-weather/README.md new file mode 100644 index 00000000..de97d510 --- /dev/null +++ b/community/proactive-weather/README.md @@ -0,0 +1,28 @@ +# Proactive Weather + +A weather ability for OpenHome with two components: a reactive flow for on-demand +weather queries, and a background daemon that monitors conditions and proactively +alerts you to severe weather. + +No API key required. Uses Open-Meteo (weather data) and Nominatim (geocoding), +both free with no authentication needed. + +## Installation + +1. Upload the ability folder to OpenHome +2. Set trigger words in the dashboard (e.g. "what's the weather", "weather") +3. Select **Skill** as the ability category + +## Usage + +**Reactive:** Say a trigger phrase to get current conditions and today's forecast. +On first use the ability asks for your city and saves it for future sessions. + +**Proactive:** The background daemon starts automatically on session connect. If +severe weather is detected (thunderstorms, heavy rain, hail, etc.), it interrupts +the conversation with an alert. Each alert type fires once per session. + +**Personality awareness:** After fetching weather, `local_weather.md` is written +to persistent storage. The Memory Watcher picks it up within ~60-90 seconds and +injects it into the Personality's system prompt so it can naturally reference +weather conditions without being asked. diff --git a/community/proactive-weather/__init__.py b/community/proactive-weather/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/community/proactive-weather/__init__.py @@ -0,0 +1 @@ + diff --git a/community/proactive-weather/background.py b/community/proactive-weather/background.py new file mode 100644 index 00000000..94f7b5b2 --- /dev/null +++ b/community/proactive-weather/background.py @@ -0,0 +1,197 @@ +import json +import requests +from time import time +from src.agent.capability import MatchingCapability +from src.main import AgentWorker +from src.agent.capability_worker import CapabilityWorker + +GEOCODE_URL = "https://nominatim.openstreetmap.org/search" +WEATHER_URL = "https://api.open-meteo.com/v1/forecast" +LOCATION_KEY = "weather_user_location" +WEATHER_JSON = "weather_data.json" +WEATHER_MD = "local_weather.md" +ALERTS_JSON = "weather_alerts_state.json" +POLL_INTERVAL = 600.0 # 10 minutes + +SEVERE_CODES = { + 55, 56, 57, + 65, 66, 67, + 75, 77, + 82, + 85, 86, + 95, 96, 99, +} + +WMO_DESCRIPTIONS = { + 55: "heavy drizzle", 56: "light freezing drizzle", 57: "dense freezing drizzle", + 65: "heavy rain", 66: "light freezing rain", 67: "heavy freezing rain", + 75: "heavy snowfall", 77: "snow grains", + 82: "violent rain showers", + 85: "heavy snow showers", 86: "heavy snow showers", + 95: "thunderstorm", + 96: "thunderstorm with slight hail", 99: "thunderstorm with heavy hail", +} + +ALERT_PROMPT = """ +Severe weather detected: {description} (WMO code {code}). +Current conditions: {current}. +Write a short 1-2 sentence calm but urgent voice alert to warn the user. +""" + +WEATHER_MD_PROMPT = """ +Here is current weather data: {weather_data} for {location}. +Write a concise markdown summary (under 200 words) for a voice assistant's background context. +Use bullet points under a ## header. Include: current conditions, today's high/low, +precipitation outlook, and any active severe conditions. Write current state only, not history. +""" + + +class WeatheralertCapabilityBackground(MatchingCapability): + worker: AgentWorker = None + capability_worker: CapabilityWorker = None + background_daemon_mode: bool = False + + # Do not change following tag of register capability + # {{register capability}} + + def call(self, worker: AgentWorker, background_daemon_mode: bool): + self.worker = worker + self.background_daemon_mode = background_daemon_mode + self.capability_worker = CapabilityWorker(self) + self.worker.session_tasks.create(self.watch_weather()) + + async def watch_weather(self): + self.worker.editor_logging_handler.info(f"[WeatherDaemon] Started at {time()}") + + # Clear stale files from previous session before first cycle + await self.clear_stale_files() + + while True: + try: + saved = self.capability_worker.get_single_key(LOCATION_KEY) + saved_value = saved.get("value") if saved else None + if saved_value and saved_value.get("city"): + lat, lon = self.geocode(saved_value["city"]) + if lat is not None: + weather_data = self.fetch_weather(lat, lon) + if weather_data: + await self.check_for_alerts(weather_data) + await self.write_weather_md(saved["city"], weather_data) + else: + self.worker.editor_logging_handler.info( + "[WeatherDaemon] No saved location yet, skipping poll." + ) + except Exception as e: + self.worker.editor_logging_handler.error(f"[WeatherDaemon] Loop error: {e}") + + await self.worker.session_tasks.sleep(POLL_INTERVAL) + + async def clear_stale_files(self): + """Clear .md and alerts state from previous session on startup.""" + for filename in [WEATHER_MD, ALERTS_JSON]: + try: + exists = await self.capability_worker.check_if_file_exists(filename, in_ability_directory=False) + if exists: + await self.capability_worker.delete_file(filename, in_ability_directory=False) + self.worker.editor_logging_handler.info( + f"[WeatherDaemon] Cleared stale file: {filename}" + ) + except Exception as e: + self.worker.editor_logging_handler.error( + f"[WeatherDaemon] Failed to clear {filename}: {e}" + ) + + def geocode(self, location: str): + try: + resp = requests.get( + GEOCODE_URL, + params={"q": location, "format": "json", "limit": 1}, + headers={"User-Agent": "OpenHome-Weather-Ability"}, + timeout=10, + ) + data = resp.json() + if data: + return float(data[0]["lat"]), float(data[0]["lon"]) + except Exception as e: + self.worker.editor_logging_handler.error(f"[WeatherDaemon] Geocode failed: {e}") + return None, None + + def fetch_weather(self, lat: float, lon: float) -> dict: + try: + resp = requests.get( + WEATHER_URL, + params={ + "latitude": lat, + "longitude": lon, + "current": "temperature_2m,weathercode,windspeed_10m,precipitation", + "daily": "temperature_2m_max,temperature_2m_min,precipitation_sum,weathercode", + "temperature_unit": "fahrenheit", + "forecast_days": 1, + "timezone": "auto", + }, + timeout=10, + ) + return resp.json() + except Exception as e: + self.worker.editor_logging_handler.error(f"[WeatherDaemon] Fetch failed: {e}") + return None + + async def check_for_alerts(self, weather_data: dict): + try: + current = weather_data.get("current", {}) + code = current.get("weathercode") + if code not in SEVERE_CODES: + return + + # Load dedup state + fired_codes = [] + exists = await self.capability_worker.check_if_file_exists(ALERTS_JSON, False) + if exists: + raw = await self.capability_worker.read_file(ALERTS_JSON, False) + try: + fired_codes = json.loads(raw).get("fired_codes", []) + except Exception: + fired_codes = [] + + if code in fired_codes: + return + + # Generate and speak alert + description = WMO_DESCRIPTIONS.get(code, "severe weather") + alert_text = self.capability_worker.text_to_text_response( + ALERT_PROMPT.format( + code=code, + description=description, + current=json.dumps(current), + ) + ) + await self.capability_worker.send_interrupt_signal() + await self.capability_worker.speak(alert_text) + + # Save fired code to dedup state + fired_codes.append(code) + await self.capability_worker.write_file(ALERTS_JSON, json.dumps({"fired_codes": fired_codes}), False) + self.worker.editor_logging_handler.info(f"[WeatherDaemon] Fired alert for code {code}") + except Exception as e: + self.worker.editor_logging_handler.error(f"[WeatherDaemon] Alert error: {e}") + + async def write_weather_md(self, location: str, weather_data: dict): + """Write local_weather.md for Memory Watcher injection into Personality prompt.""" + try: + content = self.capability_worker.text_to_text_response( + WEATHER_MD_PROMPT.format( + weather_data=json.dumps(weather_data), + location=location + ) + ) + + # required write pattern for context files + exists = await self.capability_worker.check_if_file_exists(WEATHER_MD, in_ability_directory=False) + if exists: + await self.capability_worker.delete_file(WEATHER_MD, in_ability_directory=False) + await self.capability_worker.write_file(WEATHER_MD, content, in_ability_directory=False) + + self.worker.editor_logging_handler.info("[Weather] Wrote local_weather.md") + + except Exception as e: + self.worker.editor_logging_handler.error(f"[Weather] Failed to write md: {e}") diff --git a/community/proactive-weather/main.py b/community/proactive-weather/main.py new file mode 100644 index 00000000..d3bf8267 --- /dev/null +++ b/community/proactive-weather/main.py @@ -0,0 +1,172 @@ +import json +import requests +from src.agent.capability import MatchingCapability +from src.main import AgentWorker +from src.agent.capability_worker import CapabilityWorker + +GEOCODE_URL = "https://nominatim.openstreetmap.org/search" +WEATHER_URL = "https://api.open-meteo.com/v1/forecast" +LOCATION_KEY = "weather_user_location" +WEATHER_JSON = "weather_data.json" +WEATHER_MD = "local_weather.md" + +SUMMARIZE_PROMPT = """ +Here is current weather data: {weather_data}. +Summarize in 2-3 sentences, voice-friendly. Only mention what affects the user's day: +temperature, precipitation, wind if notable, and anything severe. +Do not read out every field. Keep it natural and conversational. +""" + +WEATHER_MD_PROMPT = """ +Here is current weather data: {weather_data} for {location}. +Write a concise markdown summary (under 200 words) for a voice assistant's background context. +Use bullet points under a ## header. Include: current conditions, today's high/low, +precipitation outlook, and any active severe conditions. Write current state only, not history. +""" + + +class WeatherCapability(MatchingCapability): + worker: AgentWorker = None + capability_worker: CapabilityWorker = None + + # {{register_capability}} + + def parse_location(self, raw: str) -> str: + result = self.capability_worker.text_to_text_response( + f"Extract ONLY the city name from this text: '{raw}'. " + "Reply with the city name only — no other words, no punctuation, no explanation. " + "Examples: 'Los Angeles' or 'London' or 'Chicago, IL'. " + "If there is absolutely no city mentioned, reply with exactly: NONE" + ) + result = result.strip() + if result.upper() == "NONE" or not result: + return "" + return result + + def call(self, worker: AgentWorker): + self.worker = worker + self.capability_worker = CapabilityWorker(self.worker) + self.worker.session_tasks.create(self.run()) + + async def run(self): + try: + # confirm location is persistently saved + trigger = await self.capability_worker.wait_for_complete_transcription() + saved = self.capability_worker.get_single_key(LOCATION_KEY) + self.worker.editor_logging_handler.info(f"[Weather] get_single_key returned: {saved}") + + # Check if we have a saved location + saved = self.capability_worker.get_single_key(LOCATION_KEY) + saved_value = saved.get("value") if saved else None + if saved_value and saved_value.get("city"): + location = saved_value["city"] + await self.capability_worker.speak(f"Checking the weather in {location}.") + else: + # Try to extract location from the trigger phrase first + extracted = self.parse_location(trigger) + if extracted: + location = extracted + self.capability_worker.create_key(LOCATION_KEY, {"city": location}) + await self.capability_worker.speak(f"Checking the weather in {location}.") + else: + # Nothing in trigger, ask explicitly + raw = await self.capability_worker.run_io_loop( + "Which city would you like the weather for?" + ) + location = self.parse_location(raw) + if not location: + await self.capability_worker.speak("I didn't catch that. Try again later.") + self.capability_worker.resume_normal_flow() + return + self.capability_worker.create_key(LOCATION_KEY, {"city": location}) + self.worker.editor_logging_handler.info(f"[Weather] Saved location: {location}") # confirm location is saved + await self.capability_worker.speak(f"Got it, checking {location}.") + + # Geocode + lat, lon = self.geocode(location) + if lat is None: + await self.capability_worker.speak( + "I couldn't find that location. Try a different city name." + ) + self.capability_worker.resume_normal_flow() + return + + # Fetch weather + weather_data = self.fetch_weather(lat, lon) + if not weather_data: + await self.capability_worker.speak( + "Sorry, I couldn't get the weather right now." + ) + self.capability_worker.resume_normal_flow() + return + + # Speak a concise summary + summary = self.capability_worker.text_to_text_response( + SUMMARIZE_PROMPT.format(weather_data=json.dumps(weather_data)) + ) + await self.capability_worker.speak(summary) + + # Write local_weather.md for the Memory Watcher + await self.write_weather_md(location, weather_data) + + except Exception as e: + self.worker.editor_logging_handler.error(f"[Weather] Error: {e}") + await self.capability_worker.speak("Sorry, something went wrong.") + finally: + self.capability_worker.resume_normal_flow() + + def geocode(self, location: str): + try: + resp = requests.get( + GEOCODE_URL, + params={"q": location, "format": "json", "limit": 1}, + headers={"User-Agent": "OpenHome-Weather-Ability"}, + timeout=10, + ) + data = resp.json() + if data: + return float(data[0]["lat"]), float(data[0]["lon"]) + except Exception as e: + self.worker.editor_logging_handler.error(f"[Weather] Geocode failed: {e}") + return None, None + + def fetch_weather(self, lat: float, lon: float) -> dict: + try: + resp = requests.get( + WEATHER_URL, + params={ + "latitude": lat, + "longitude": lon, + "current": "temperature_2m,weathercode,windspeed_10m,precipitation", + "daily": "temperature_2m_max,temperature_2m_min,precipitation_sum,weathercode", + "temperature_unit": "fahrenheit", + "forecast_days": 1, + "timezone": "auto", + }, + timeout=10, + ) + return resp.json() + except Exception as e: + self.worker.editor_logging_handler.error(f"[Weather] Fetch failed: {e}") + return None + + async def write_weather_md(self, location: str, weather_data: dict): + """Write local_weather.md for Memory Watcher injection into Personality prompt.""" + try: + content = self.capability_worker.text_to_text_response( + WEATHER_MD_PROMPT.format( + weather_data=json.dumps(weather_data), + location=location + ) + ) + + # required write pattern for context files + exists = await self.capability_worker.check_if_file_exists(WEATHER_MD, in_ability_directory=False) + if exists: + await self.capability_worker.delete_file(WEATHER_MD, in_ability_directory=False) + await self.capability_worker.write_file(WEATHER_MD, content, in_ability_directory=False) + + self.worker.editor_logging_handler.info("[Weather] Wrote local_weather.md") + + except Exception as e: + self.worker.editor_logging_handler.error(f"[Weather] Failed to write md: {e}")