From df0cee1b8a895a106edffb04b30a2643d2ccc322 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 05:10:01 +0000 Subject: [PATCH 1/9] docs: add @hassan1731996 to contributors --- CONTRIBUTORS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 7867026a..3069cb2a 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -15,7 +15,7 @@ Format: - **@username** — ability-name ([ability-name](community/ability-name/ - **[@Rizwan-algoryc](https://github.com/Rizwan-algoryc)** — slow-music ([slow-music](community/slow-music/)) - **[@engrumair842-arch](https://github.com/engrumair842-arch)** — reddit-daily-digest ([reddit-daily-digest](community/reddit-daily-digest/)), smart-sous-chef ([smart-sous-chef](community/smart-sous-chef/)) - **[@samsonadmasu](https://github.com/samsonadmasu)** — voice-unit-converter ([voice-unit-converter](community/voice-unit-converter/)), food-water-log ([food-water-log](community/food-water-log/)), gmail-connector ([gmail-connector](community/gmail-connector/)), google-tasks ([google-tasks](community/google-tasks/)), traffic-travel-time ([traffic-travel-time](community/traffic-travel-time/)) -- **[@hassan1731996](https://github.com/hassan1731996)** — daily-briefing ([daily-briefing](community/daily-briefing/)), voice-journal ([voice-journal](community/voice-journal/)), whatsapp-messenger ([whatsapp-messenger](community/whatsapp-messenger/)), alarm-timer ([alarm-timer](community/alarm-timer/)), flight-booking ([flight-booking](community/flight-booking/)), debate-partner ([debate-partner](community/debate-partner/)), conversation-insights-coach ([conversation-insights-coach](community/conversation-insights-coach/)) +- **[@hassan1731996](https://github.com/hassan1731996)** — daily-briefing ([daily-briefing](community/daily-briefing/)), voice-journal ([voice-journal](community/voice-journal/)), whatsapp-messenger ([whatsapp-messenger](community/whatsapp-messenger/)), alarm-timer ([alarm-timer](community/alarm-timer/)), flight-booking ([flight-booking](community/flight-booking/)), debate-partner ([debate-partner](community/debate-partner/)), conversation-insights-coach ([conversation-insights-coach](community/conversation-insights-coach/)), curiosity-queue ([curiosity-queue](community/curiosity-queue/)) - **[@BhargavTelu](https://github.com/BhargavTelu)** — grocery-list-manager ([grocery-list-manager](community/grocery-list-manager/)), package-tracker ([package-tracker](community/package-tracker/)) - **[@ArturKozhushnyi](https://github.com/ArturKozhushnyi)** — coin-flipper ([coin-flipper](community/coin-flipper/)), Bedtime-Wind-Down ([Bedtime-Wind-Down](community/Bedtime-Wind-Down/)), Twilio-SMS ([Twilio-SMS](community/Twilio-SMS/)) - **[@ammyyou112](https://github.com/ammyyou112)** — dad-joke-teller ([dad-joke-teller](community/dad-joke-teller/)), youtube-search-play ([youtube-search-play](community/youtube-search-play/)), google-daily-brief ([google-daily-brief](community/google-daily-brief/)) From 57cdf65d8fd423faa13213193bc271c8543afb5e Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Date: Sun, 24 May 2026 11:03:13 +0500 Subject: [PATCH 2/9] Add Space Window ability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Voice-native sky-watching ability: proactive ISS pass alerts, aurora activity monitoring, and rocket launch countdowns — all location-aware. main.py (foreground skill): - Six intents: TONIGHT, ISS, AURORA, LAUNCHES, SETUP, ALERTS - LLM intent router with cheap pre-filter for SETUP - 80+ city map for instant location resolution, LLM fallback - ISS passes via N2YO API (free key, full elevation + compass data) - Aurora Kp index via NOAA Space Weather API (no key required) - Upcoming launches via Launch Library 2 (no key required) - Timezone-aware time formatting via stdlib zoneinfo - Aurora threshold auto-calculated from user latitude (both hemispheres) - Pass quality rating: great (>=60deg), good (>=40deg), fair (>=30deg) background.py (daemon): - 30-minute poll interval; 40-minute ISS alert window prevents missed passes - ISS alert ~10 minutes before pass: rise time, direction, peak elevation - Aurora alert when Kp crosses user latitude threshold, once per day cap - Launch alerts at 24h and 1h before confirmed launches - Daily morning brief at 9am local time - All times in user's local timezone via stored tz string Zero required API keys for aurora and launch features; N2YO free key needed only for ISS pass tracking --- community/space-window/README.md | 93 ++++ community/space-window/__init__.py | 0 community/space-window/background.py | 386 ++++++++++++++ community/space-window/main.py | 728 +++++++++++++++++++++++++++ 4 files changed, 1207 insertions(+) create mode 100644 community/space-window/README.md create mode 100644 community/space-window/__init__.py create mode 100644 community/space-window/background.py create mode 100644 community/space-window/main.py diff --git a/community/space-window/README.md b/community/space-window/README.md new file mode 100644 index 00000000..e9e6f4a2 --- /dev/null +++ b/community/space-window/README.md @@ -0,0 +1,93 @@ +# Space Window + +A sky-watching ability that tells you exactly what's happening above your location tonight — ISS passes, aurora activity, and rocket launches — and proactively alerts you before anything good happens so you never miss it. + +Say "space window" once, set your city, and it handles the rest: ISS alerts 10 minutes before a visible pass, aurora alerts when the Kp index spikes high enough for your latitude, and launch countdowns 24 hours and 1 hour before liftoff. + +## Setup + +1. Get a free API key at [n2yo.com](https://www.n2yo.com/login/register/) (required for ISS pass tracking) +2. In OpenHome, go to **Settings → API Keys** and add your key as `n2yo_api_key` +3. Say "space window" and tell it your city — that's it + +> **Note:** Aurora and launch alerts work without any API key. Only ISS pass tracking requires N2YO. + +## Trigger Phrases + +- `space window` / `sky tonight` / `what's up tonight` +- `ISS tonight` / `ISS passing` / `when's the ISS` / `spot the station` +- `aurora tonight` / `northern lights` / `aurora forecast` +- `any launches` / `rocket launches` / `launch tonight` +- `space events` / `sky events` / `night sky` +- `set my location` / `change my location` + +## Features + +**Proactive Alerts** +- ISS: alerts ~10 minutes before any visible pass — rise time, direction, peak elevation, duration +- Aurora: alerts when Kp index exceeds your latitude's visibility threshold (auto-set, adjustable) +- Launches: alerts 24 hours before and 1 hour before any confirmed rocket launch +- Morning brief: daily 9am summary of what's happening in the sky today + +**Interactive Queries** +- TONIGHT: full sky summary — ISS passes, aurora status, and any launches in the next 24 hours +- ISS: next 2 days of visible passes with times, compass directions, elevation, and quality rating +- AURORA: current Kp index, whether it's high enough to see aurora at your latitude, and your threshold +- LAUNCHES: upcoming confirmed launches in the next 7 days with rocket name, site, and countdown +- SETUP: set or change your city — used for all location-aware features +- ALERTS: toggle ISS / aurora / launch alerts on or off, adjust minimum elevation threshold, set aurora Kp threshold + +**Smart Details** +- ISS passes rated by quality: great (≥60°), good (≥40°), fair (≥30°) — only alerts on passes worth going outside for +- Aurora threshold auto-calculated from your latitude — higher latitudes need lower Kp +- Launch alerts use 40-minute poll overlap window so no pass falls through between cycles +- All times shown in your local timezone +- 80+ cities in the built-in map; LLM fallback for anything else +- Aurora and launch monitoring require zero API keys — always active once location is set + +## Example Conversation + +> **You:** space window +> +> **Assistant:** First tell me where you are — say 'set my location' and your city name. +> +> **You:** set my location to London +> +> **Assistant:** Set to London. I'll alert you before ISS passes, when aurora picks up, and before rocket launches. At your latitude, aurora becomes visible around Kp 6 or higher — I'll watch for that. + +--- + +> **You:** what's up tonight +> +> **Assistant:** Checking tonight's sky for London... ISS passes at 9:47pm — great pass, 52 degrees max, rises NW, sets SE, 4 minutes, plus 1 more pass tonight. Aurora quiet — Kp at 2.1, you'd need 6 or above to see it. SpaceX Falcon 9 launches at 3:15am from Cape Canaveral in 8 hours. + +--- + +> **You:** when's the ISS +> +> **Assistant:** ISS passes London 2 times in the next 2 days. 9:47pm — great pass, peaks 52 degrees, rises NW, sets SE, 4 minutes visible. 11:23pm — fair pass, peaks 28 degrees, rises N, sets NE, 2 minutes visible. + +--- + +> **You:** aurora tonight +> +> **Assistant:** Aurora is quiet — Kp is 2.4 right now. At your latitude you'd need 6 or above, so you're 4 points away. I'll alert you if it spikes. + +--- + +> **[Background alert, proactive]** +> +> **Assistant:** ISS passes London in 9 minutes. Rises northwest at 9:47, peaks 52 degrees — great pass, 4 minutes visible. Head outside now. + +--- + +> **[Background alert, aurora]** +> +> **Assistant:** Aurora alert — Kp index just hit 7, which is high enough to see northern lights from London. Look north, away from city lights. Activity can fade fast. + +## Notes + +- ISS pass quality depends on your sky being dark and the ISS being in sunlight — the N2YO API only returns visual passes that meet both conditions +- Aurora visibility also depends on cloud cover and light pollution — Kp threshold is a necessary but not sufficient condition +- The background daemon runs every 30 minutes — ISS alerts use a 40-minute look-ahead window so nothing falls between polls +- Supports southern hemisphere aurora (southern lights) — threshold logic uses absolute latitude diff --git a/community/space-window/__init__.py b/community/space-window/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/community/space-window/background.py b/community/space-window/background.py new file mode 100644 index 00000000..1dc58d81 --- /dev/null +++ b/community/space-window/background.py @@ -0,0 +1,386 @@ +import requests +from datetime import datetime, timezone, timedelta + +try: + from zoneinfo import ZoneInfo + _HAS_ZONEINFO = True +except ImportError: + _HAS_ZONEINFO = False + +from src.agent.capability import MatchingCapability +from src.agent.capability_worker import CapabilityWorker +from src.main import AgentWorker + +STORAGE_KEY = "space_window_data" +ISS_NORAD_ID = 25544 +N2YO_BASE = "https://www.n2yo.com/rest/v1/satellite" +NOAA_KP_URL = "https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json" +LAUNCHES_URL = "https://ll.thespacedevs.com/2.2.0/launch/upcoming/" + +POLL_INTERVAL = 1800.0 # 30 minutes +POLL_NO_LOCATION = 60.0 # fast retry until location is set +ISS_ALERT_WINDOW = 2400 # alert if pass starts within 40 minutes (> poll interval) + + +def _empty_data() -> dict: + return { + "location": {}, + "alert_prefs": { + "min_elevation": 30, + "aurora_kp_threshold": 5, + "iss_alerts": True, + "aurora_alerts": True, + "launch_alerts": True, + }, + "alerted_passes": [], + "alerted_launches": [], + "last_aurora_alert": "", + "last_morning_brief": "", + } + + +class SpaceWindowBackground(MatchingCapability): + worker: AgentWorker = None + capability_worker: CapabilityWorker = None + background_daemon_mode: bool = False + n2yo_key: str = "" + + # Do not change following tag of register capability + # {{register capability}} + + # ------------------------------------------------------------------ + # Context Storage + # ------------------------------------------------------------------ + + def _load_data(self) -> dict: + try: + result = self.capability_worker.get_single_key(STORAGE_KEY) + if result and result.get("value"): + return result["value"] + return _empty_data() + except Exception as e: + self.worker.editor_logging_handler.error(f"[SpaceWindow] Load error: {e}") + return _empty_data() + + def _save_data(self, data: dict): + try: + self.capability_worker.update_key(STORAGE_KEY, data) + except Exception: + try: + self.capability_worker.create_key(STORAGE_KEY, data) + except Exception as e: + self.worker.editor_logging_handler.error(f"[SpaceWindow] Save error: {e}") + + # ------------------------------------------------------------------ + # Time helpers + # ------------------------------------------------------------------ + + def _format_local_time(self, utc_ts: int, tz_name: str) -> str: + dt_utc = datetime.fromtimestamp(utc_ts, tz=timezone.utc) + if _HAS_ZONEINFO and tz_name: + try: + dt_local = dt_utc.astimezone(ZoneInfo(tz_name)) + hour = dt_local.hour + minute = dt_local.minute + period = "am" if hour < 12 else "pm" + hour12 = hour % 12 or 12 + if minute: + return f"{hour12}:{minute:02d}{period}" + return f"{hour12}{period}" + except Exception: + pass + return dt_utc.strftime("%H:%M UTC") + + def _local_hour(self, tz_name: str) -> int: + if _HAS_ZONEINFO and tz_name: + try: + return datetime.now(ZoneInfo(tz_name)).hour + except Exception: + pass + return datetime.now(timezone.utc).hour + + def _today_str(self, tz_name: str) -> str: + if _HAS_ZONEINFO and tz_name: + try: + return datetime.now(ZoneInfo(tz_name)).strftime("%Y-%m-%d") + except Exception: + pass + return datetime.now(timezone.utc).strftime("%Y-%m-%d") + + # ------------------------------------------------------------------ + # Aurora + # ------------------------------------------------------------------ + + def _aurora_min_kp(self, lat: float) -> int: + lat = abs(lat) + if lat >= 65: + return 3 + elif lat >= 60: + return 4 + elif lat >= 55: + return 5 + elif lat >= 50: + return 6 + elif lat >= 45: + return 7 + return 8 + + # ------------------------------------------------------------------ + # API + # ------------------------------------------------------------------ + + def _fetch_iss_passes(self, lat: float, lon: float) -> list: + if not self.n2yo_key: + return [] + try: + url = f"{N2YO_BASE}/visualpasses/{ISS_NORAD_ID}/{lat}/{lon}/0/2/60/" + resp = requests.get(url, params={"apiKey": self.n2yo_key}, timeout=10) + if resp.status_code == 200: + return resp.json().get("passes") or [] + return [] + except Exception as e: + self.worker.editor_logging_handler.error(f"[SpaceWindow] N2YO error: {e}") + return [] + + def _fetch_kp(self) -> float | None: + try: + resp = requests.get(NOAA_KP_URL, timeout=10) + if resp.status_code == 200: + data = resp.json() + if len(data) > 1: + return float(data[-1][1]) + return None + except Exception as e: + self.worker.editor_logging_handler.error(f"[SpaceWindow] NOAA error: {e}") + return None + + def _fetch_launches(self, days: int = 2) -> list: + try: + window_end = (datetime.now(timezone.utc) + timedelta(days=days)).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + resp = requests.get( + LAUNCHES_URL, + params={"limit": 5, "status": 1, "net__lte": window_end}, + timeout=15, + ) + if resp.status_code == 200: + return resp.json().get("results", []) + return [] + except Exception as e: + self.worker.editor_logging_handler.error(f"[SpaceWindow] Launches error: {e}") + return [] + + # ------------------------------------------------------------------ + # Proactive alerts + # ------------------------------------------------------------------ + + async def _alert_iss(self, p: dict, name: str, tz_name: str): + rise_time = self._format_local_time(p["startUTC"], tz_name) + max_el = p.get("maxEl", 0) + duration_min = max(1, p.get("duration", 60) // 60) + start_dir = p.get("startAzCompass", "") + quality = "great" if max_el >= 60 else "good" if max_el >= 40 else "fair" + mins_away = max(1, int((p["startUTC"] - datetime.now(timezone.utc).timestamp()) / 60)) + + msg = ( + f"ISS passes {name} in {mins_away} {'minute' if mins_away == 1 else 'minutes'}. " + f"Rises {start_dir} at {rise_time}, peaks {max_el:.0f} degrees — {quality} pass, " + f"{duration_min} {'minute' if duration_min == 1 else 'minutes'} visible. Head outside now." + ) + await self.capability_worker.send_interrupt_signal() + await self.capability_worker.speak(msg) + + async def _alert_aurora(self, kp: float, name: str): + msg = ( + f"Aurora alert — Kp index just hit {kp:.0f}, which is high enough to see northern lights " + f"from {name}. Look north, away from city lights. Activity can fade fast." + ) + await self.capability_worker.send_interrupt_signal() + await self.capability_worker.speak(msg) + + async def _alert_launch(self, launch: dict, tz_name: str, hours_away: float): + lname = launch.get("name", "Unknown mission") + rocket = launch.get("rocket", {}).get("configuration", {}).get("full_name", "") + pad = launch.get("pad", {}).get("location", {}).get("name", "") + net = launch.get("net", "") + + if hours_away <= 1: + timing = "in about an hour" + else: + timing = f"in {int(hours_away)} hours" + + msg = lname + if rocket and rocket not in lname: + msg += f" on {rocket}" + msg += f" launches {timing}" + if pad: + msg += f" from {pad}" + msg += "." + + await self.capability_worker.send_interrupt_signal() + await self.capability_worker.speak(msg) + + async def _speak_morning_brief(self, data: dict): + loc = data.get("location", {}) + name = loc.get("name", "your location") + tz_name = loc.get("tz", "UTC") + lat = loc.get("lat") + lon = loc.get("lon") + min_el = data["alert_prefs"].get("min_elevation", 30) + + parts = [] + + if self.n2yo_key and lat is not None: + passes = self._fetch_iss_passes(lat, lon) + good = [p for p in passes if p.get("maxEl", 0) >= min_el] + if good: + count = len(good) + p = good[0] + t = self._format_local_time(p["startUTC"], tz_name) + max_el = p.get("maxEl", 0) + iss_part = f"ISS passes {count} {'time' if count == 1 else 'times'} today, first at {t}, peaks {max_el:.0f} degrees" + parts.append(iss_part) + else: + parts.append("No good ISS passes today") + + launches = self._fetch_launches(days=1) + if launches: + lname = launches[0].get("name", "Unknown") + parts.append(f"{lname} launches today") + + kp = self._fetch_kp() + if kp is not None: + min_kp = data["alert_prefs"].get("aurora_kp_threshold", 5) + if kp >= min_kp: + parts.append(f"aurora activity elevated, Kp at {kp:.0f}") + + if not parts: + return + + try: + await self.capability_worker.send_interrupt_signal() + await self.capability_worker.speak( + f"Good morning — here's tonight's sky for {name}. " + ". ".join(parts) + "." + ) + except Exception as e: + self.worker.editor_logging_handler.error(f"[SpaceWindow] Morning brief error: {e}") + + # ------------------------------------------------------------------ + # Main daemon loop + # ------------------------------------------------------------------ + + async def watch_loop(self): + self.n2yo_key = self.capability_worker.get_api_keys("n2yo_api_key") or "" + + if not self.n2yo_key: + self.worker.editor_logging_handler.warning( + "[SpaceWindow] No N2YO API key — ISS tracking disabled. " + "Aurora and launch alerts still active." + ) + + self.capability_worker.resume_normal_flow() + self.worker.editor_logging_handler.info("[SpaceWindow] daemon started") + + while True: + try: + data = self._load_data() + loc = data.get("location", {}) + lat = loc.get("lat") + lon = loc.get("lon") + tz_name = loc.get("tz", "UTC") + name = loc.get("name", "your location") + + if lat is None: + await self.worker.session_tasks.sleep(POLL_NO_LOCATION) + continue + + prefs = data.get("alert_prefs", {}) + today = self._today_str(tz_name) + local_hour = self._local_hour(tz_name) + + # Morning brief once per day around 9am + if local_hour == 9 and data.get("last_morning_brief") != today: + data["last_morning_brief"] = today + self._save_data(data) + await self._speak_morning_brief(data) + + now_ts = datetime.now(timezone.utc).timestamp() + changed = False + + # ISS pass alerts + if prefs.get("iss_alerts", True) and self.n2yo_key: + min_el = prefs.get("min_elevation", 30) + passes = self._fetch_iss_passes(lat, lon) + alerted = data.get("alerted_passes", []) + + for p in passes: + if p.get("maxEl", 0) < min_el: + continue + start_utc = p.get("startUTC", 0) + secs_away = start_utc - now_ts + if 0 < secs_away <= ISS_ALERT_WINDOW and start_utc not in alerted: + alerted.append(start_utc) + data["alerted_passes"] = alerted + changed = True + self._save_data(data) + await self._alert_iss(p, name, tz_name) + break # one alert per poll cycle + + # Aurora alerts + if prefs.get("aurora_alerts", True): + kp = self._fetch_kp() + min_kp = prefs.get("aurora_kp_threshold", self._aurora_min_kp(lat)) + last_alert = data.get("last_aurora_alert", "") + if kp is not None and kp >= min_kp and last_alert != today: + data["last_aurora_alert"] = today + changed = True + self._save_data(data) + await self._alert_aurora(kp, name) + + # Launch alerts (24h and 1h before) + if prefs.get("launch_alerts", True): + launches = self._fetch_launches(days=2) + alerted_launches = data.get("alerted_launches", []) + + for launch in launches: + launch_id = launch.get("id", "") + net = launch.get("net", "") + if not launch_id or not net: + continue + try: + launch_dt = datetime.fromisoformat(net.replace("Z", "+00:00")) + hours_away = (launch_dt.timestamp() - now_ts) / 3600 + + for window_hours, suffix in [(1.5, "_1h"), (25, "_24h")]: + alert_key = f"{launch_id}{suffix}" + threshold = 1.5 if suffix == "_1h" else 25 + if 0 < hours_away <= threshold and alert_key not in alerted_launches: + alerted_launches.append(alert_key) + data["alerted_launches"] = alerted_launches + changed = True + self._save_data(data) + await self._alert_launch(launch, tz_name, hours_away) + break + except Exception: + continue + + if changed: + self.worker.editor_logging_handler.info( + f"[SpaceWindow] Poll complete — alerts fired" + ) + + except Exception as e: + self.worker.editor_logging_handler.error(f"[SpaceWindow] Loop error: {e}") + + await self.worker.session_tasks.sleep(POLL_INTERVAL) + + # ------------------------------------------------------------------ + # Entry point + # ------------------------------------------------------------------ + + def call(self, worker: AgentWorker, background_daemon_mode: bool): + self.worker = worker + self.capability_worker = CapabilityWorker(self.worker) + self.background_daemon_mode = background_daemon_mode + self.worker.session_tasks.create(self.watch_loop()) diff --git a/community/space-window/main.py b/community/space-window/main.py new file mode 100644 index 00000000..55a7a0ed --- /dev/null +++ b/community/space-window/main.py @@ -0,0 +1,728 @@ +import re +import requests +from datetime import datetime, timezone, timedelta + +try: + from zoneinfo import ZoneInfo + _HAS_ZONEINFO = True +except ImportError: + _HAS_ZONEINFO = False + +from src.agent.capability import MatchingCapability +from src.agent.capability_worker import CapabilityWorker +from src.main import AgentWorker + +STORAGE_KEY = "space_window_data" +ISS_NORAD_ID = 25544 +N2YO_BASE = "https://www.n2yo.com/rest/v1/satellite" +NOAA_KP_URL = "https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json" +LAUNCHES_URL = "https://ll.thespacedevs.com/2.2.0/launch/upcoming/" + +HOTWORDS = { + "space window", "sky tonight", "night sky", "what's up tonight", + "iss tonight", "iss passing", "iss pass", "when's the iss", "spot the station", + "aurora tonight", "northern lights", "aurora forecast", "southern lights", + "any launches", "rocket launch", "rocket launches", "launch tonight", + "space events", "what's in the sky", "sky events", + "set my location", "change my location", "i'm in", "i am in", +} + +CITY_MAP = { + "london": (51.5074, -0.1278), + "new york": (40.7128, -74.0060), + "los angeles": (34.0522, -118.2437), + "chicago": (41.8781, -87.6298), + "houston": (29.7604, -95.3698), + "toronto": (43.6532, -79.3832), + "vancouver": (49.2827, -123.1207), + "paris": (48.8566, 2.3522), + "berlin": (52.5200, 13.4050), + "madrid": (40.4168, -3.7038), + "rome": (41.9028, 12.4964), + "amsterdam": (52.3676, 4.9041), + "brussels": (50.8503, 4.3517), + "vienna": (48.2082, 16.3738), + "zurich": (47.3769, 8.5417), + "stockholm": (59.3293, 18.0686), + "oslo": (59.9139, 10.7522), + "helsinki": (60.1699, 24.9384), + "copenhagen": (55.6761, 12.5683), + "dubai": (25.2048, 55.2708), + "singapore": (1.3521, 103.8198), + "tokyo": (35.6762, 139.6503), + "seoul": (37.5665, 126.9780), + "beijing": (39.9042, 116.4074), + "shanghai": (31.2304, 121.4737), + "hong kong": (22.3193, 114.1694), + "sydney": (-33.8688, 151.2093), + "melbourne": (-37.8136, 144.9631), + "auckland": (-36.8485, 174.7633), + "mumbai": (19.0760, 72.8777), + "delhi": (28.7041, 77.1025), + "bangalore": (12.9716, 77.5946), + "cairo": (30.0444, 31.2357), + "johannesburg": (-26.2041, 28.0473), + "lagos": (6.5244, 3.3792), + "nairobi": (-1.2921, 36.8219), + "moscow": (55.7558, 37.6173), + "istanbul": (41.0082, 28.9784), + "mexico city": (19.4326, -99.1332), + "sao paulo": (-23.5505, -46.6333), + "buenos aires": (-34.6037, -58.3816), + "bogota": (4.7110, -74.0721), + "lima": (-12.0464, -77.0428), + "santiago": (-33.4489, -70.6693), + "miami": (25.7617, -80.1918), + "san francisco": (37.7749, -122.4194), + "seattle": (47.6062, -122.3321), + "denver": (39.7392, -104.9903), + "boston": (42.3601, -71.0589), + "washington": (38.9072, -77.0369), + "atlanta": (33.7490, -84.3880), + "dallas": (32.7767, -96.7970), + "phoenix": (33.4484, -112.0740), + "montreal": (45.5017, -73.5673), + "calgary": (51.0447, -114.0719), + "manchester": (53.4808, -2.2426), + "edinburgh": (55.9533, -3.1883), + "glasgow": (55.8642, -4.2518), + "birmingham": (52.4862, -1.8904), + "dublin": (53.3498, -6.2603), + "lisbon": (38.7223, -9.1393), + "barcelona": (41.3851, 2.1734), + "milan": (45.4654, 9.1859), + "munich": (48.1351, 11.5820), + "prague": (50.0755, 14.4378), + "warsaw": (52.2297, 21.0122), + "budapest": (47.4979, 19.0402), + "athens": (37.9838, 23.7275), + "tel aviv": (32.0853, 34.7818), + "riyadh": (24.7136, 46.6753), + "karachi": (24.8607, 67.0011), + "dhaka": (23.8103, 90.4125), + "kuala lumpur": (3.1390, 101.6869), + "jakarta": (-6.2088, 106.8456), + "manila": (14.5995, 120.9842), + "taipei": (25.0330, 121.5654), + "osaka": (34.6937, 135.5023), + "cape town": (-33.9249, 18.4241), + "reykjavik": (64.1466, -21.9426), + "anchorage": (61.2181, -149.9003), + "honolulu": (21.3069, -157.8583), + "las vegas": (36.1699, -115.1398), + "minneapolis": (44.9778, -93.2650), + "detroit": (42.3314, -83.0458), + "philadelphia": (39.9526, -75.1652), + "san diego": (32.7157, -117.1611), + "portland": (45.5051, -122.6750), + "new orleans": (29.9511, -90.0715), + "nashville": (36.1627, -86.7816), + "charlotte": (35.2271, -80.8431), + "orlando": (28.5383, -81.3792), + "salt lake city": (40.7608, -111.8910), +} + +_VALID_INTENTS = frozenset({ + "TONIGHT", "ISS", "AURORA", "LAUNCHES", "SETUP", "ALERTS" +}) + +_EXIT_PATTERN = re.compile( + r'\b(stop|exit|quit|done|cancel|bye|goodbye|never\s*mind|no\s*thanks|' + r"that'?s\s*all|nothing|nah|skip)\b", + re.IGNORECASE, +) + +_AFFIRMATIVE_PATTERN = re.compile( + r'\b(yes|yeah|sure|yep|absolutely|ok|okay|go ahead|enable|on)\b', + re.IGNORECASE, +) + + +def _empty_data() -> dict: + return { + "location": {}, + "alert_prefs": { + "min_elevation": 30, + "aurora_kp_threshold": 5, + "iss_alerts": True, + "aurora_alerts": True, + "launch_alerts": True, + }, + "alerted_passes": [], + "alerted_launches": [], + "last_aurora_alert": "", + "last_morning_brief": "", + } + + +class SpaceWindowCapability(MatchingCapability): + worker: AgentWorker = None + capability_worker: CapabilityWorker = None + n2yo_key: str = "" + + # Do not change following tag of register capability + # {{register capability}} + + # ------------------------------------------------------------------ + # Hotword matching + # ------------------------------------------------------------------ + + def does_match(self, text: str) -> bool: + t = text.lower().strip() + return any(hw in t for hw in HOTWORDS) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _is_exit(self, text: str) -> bool: + if not text or not text.strip(): + return True + stripped = text.strip().rstrip(".,!?").strip().lower() + if stripped in ("no", "skip", "stop"): + return True + return bool(_EXIT_PATTERN.search(text)) + + def _classify_intent(self, text: str) -> str: + t = text.lower() + if any(kw in t for kw in ("set my location", "change my location", "i'm in", "i am in", "my city", "my location")): + return "SETUP" + try: + raw = self.capability_worker.text_to_text_response( + "Route this request for a sky-watching voice assistant.\n" + "Pick exactly one intent:\n" + "TONIGHT — full sky summary for tonight: ISS, aurora, launches\n" + "ISS — ISS pass times and directions specifically\n" + "AURORA — aurora / northern lights forecast\n" + "LAUNCHES — upcoming rocket launches\n" + "SETUP — user is setting or changing their location\n" + "ALERTS — user wants to configure or change alert preferences\n\n" + "Reply with ONLY the intent label.\n" + f"User input: {text.strip() or '(sky tonight)'}" + ) + intent = raw.strip().upper().split()[0].strip(".,") + return intent if intent in _VALID_INTENTS else "TONIGHT" + except Exception: + return "TONIGHT" + + # ------------------------------------------------------------------ + # Location + # ------------------------------------------------------------------ + + def _resolve_location(self, text: str) -> tuple[float | None, float | None]: + t = text.lower().strip() + for city, coords in sorted(CITY_MAP.items(), key=lambda x: -len(x[0])): + if city in t: + return coords + return self._resolve_location_llm(text) + + def _resolve_location_llm(self, text: str) -> tuple[float | None, float | None]: + try: + raw = self.capability_worker.text_to_text_response( + "Extract the city or location from this text and return its latitude and longitude.\n" + "Format: LAT,LON (decimal degrees, e.g. 51.5074,-0.1278)\n" + "Return NONE if no recognizable location found.\n" + f"Text: {text}" + ) + raw = raw.strip() + if raw.upper() == "NONE" or "," not in raw: + return None, None + parts = raw.split(",") + return float(parts[0].strip()), float(parts[1].strip()) + except Exception: + return None, None + + def _get_city_name(self, text: str) -> str: + t = text.lower() + for city in sorted(CITY_MAP.keys(), key=lambda x: -len(x)): + if city in t: + return city.title() + try: + raw = self.capability_worker.text_to_text_response( + f"Extract just the city name from: '{text}'. Return ONLY the city name, nothing else." + ) + return raw.strip().title() or text.strip().title() + except Exception: + return text.strip().title() + + # ------------------------------------------------------------------ + # Time formatting + # ------------------------------------------------------------------ + + def _format_local_time(self, utc_ts: int, tz_name: str) -> str: + dt_utc = datetime.fromtimestamp(utc_ts, tz=timezone.utc) + if _HAS_ZONEINFO and tz_name: + try: + dt_local = dt_utc.astimezone(ZoneInfo(tz_name)) + hour = dt_local.hour + minute = dt_local.minute + period = "am" if hour < 12 else "pm" + hour12 = hour % 12 or 12 + if minute: + return f"{hour12}:{minute:02d}{period}" + return f"{hour12}{period}" + except Exception: + pass + return dt_utc.strftime("%H:%M UTC") + + def _format_launch_time(self, net: str, tz_name: str) -> str: + try: + dt = datetime.fromisoformat(net.replace("Z", "+00:00")) + now = datetime.now(timezone.utc) + delta = dt - now + hours = int(delta.total_seconds() / 3600) + if hours < 1: + mins = int(delta.total_seconds() / 60) + when = f"in about {mins} minutes" + elif hours < 24: + when = f"in {hours} hours" + else: + days = hours // 24 + when = f"in {days} {'day' if days == 1 else 'days'}" + local_time = self._format_local_time(int(dt.timestamp()), tz_name) + return f"{local_time} ({when})" + except Exception: + return "time unknown" + + # ------------------------------------------------------------------ + # Aurora + # ------------------------------------------------------------------ + + def _aurora_min_kp(self, lat: float) -> int: + lat = abs(lat) + if lat >= 65: + return 3 + elif lat >= 60: + return 4 + elif lat >= 55: + return 5 + elif lat >= 50: + return 6 + elif lat >= 45: + return 7 + return 8 + + # ------------------------------------------------------------------ + # API + # ------------------------------------------------------------------ + + def _fetch_iss_passes(self, lat: float, lon: float) -> list: + if not self.n2yo_key: + return [] + try: + url = f"{N2YO_BASE}/visualpasses/{ISS_NORAD_ID}/{lat}/{lon}/0/2/60/" + resp = requests.get(url, params={"apiKey": self.n2yo_key}, timeout=10) + if resp.status_code == 200: + return resp.json().get("passes") or [] + return [] + except Exception as e: + self.worker.editor_logging_handler.error(f"[SpaceWindow] N2YO error: {e}") + return [] + + def _fetch_kp(self) -> float | None: + try: + resp = requests.get(NOAA_KP_URL, timeout=10) + if resp.status_code == 200: + data = resp.json() + if len(data) > 1: + return float(data[-1][1]) + return None + except Exception as e: + self.worker.editor_logging_handler.error(f"[SpaceWindow] NOAA error: {e}") + return None + + def _fetch_launches(self, days: int = 7) -> list: + try: + window_end = (datetime.now(timezone.utc) + timedelta(days=days)).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + resp = requests.get( + LAUNCHES_URL, + params={"limit": 8, "status": 1, "net__lte": window_end}, + timeout=15, + ) + if resp.status_code == 200: + return resp.json().get("results", []) + return [] + except Exception as e: + self.worker.editor_logging_handler.error(f"[SpaceWindow] Launches error: {e}") + return [] + + # ------------------------------------------------------------------ + # Context Storage + # ------------------------------------------------------------------ + + def _load_data(self) -> dict: + try: + result = self.capability_worker.get_single_key(STORAGE_KEY) + if result and result.get("value"): + return result["value"] + return _empty_data() + except Exception as e: + self.worker.editor_logging_handler.error(f"[SpaceWindow] Load error: {e}") + return _empty_data() + + def _save_data(self, data: dict): + try: + self.capability_worker.update_key(STORAGE_KEY, data) + except Exception: + try: + self.capability_worker.create_key(STORAGE_KEY, data) + except Exception as e: + self.worker.editor_logging_handler.error(f"[SpaceWindow] Save error: {e}") + + # ------------------------------------------------------------------ + # ISS pass formatting + # ------------------------------------------------------------------ + + def _describe_passes(self, passes: list, min_el: int, tz_name: str) -> list[str]: + good = [p for p in passes if p.get("maxEl", 0) >= min_el] + lines = [] + for p in good[:3]: + t = self._format_local_time(p["startUTC"], tz_name) + max_el = p.get("maxEl", 0) + duration_min = max(1, p.get("duration", 60) // 60) + start_dir = p.get("startAzCompass", "") + end_dir = p.get("endAzCompass", "") + quality = "great" if max_el >= 60 else "good" if max_el >= 40 else "fair" + direction = f"rises {start_dir}, sets {end_dir}" if start_dir and end_dir else "" + line = f"{t} — {quality} pass, peaks {max_el:.0f} degrees" + if direction: + line += f", {direction}" + line += f", {duration_min} {'minute' if duration_min == 1 else 'minutes'} visible" + lines.append(line) + return lines + + # ------------------------------------------------------------------ + # Intent handlers + # ------------------------------------------------------------------ + + async def _handle_setup(self, trigger_text: str): + lat, lon = self._resolve_location(trigger_text) + + if lat is None: + await self.capability_worker.speak( + "Where should I watch from? Say a city name — like London, Tokyo, or New York." + ) + reply = await self.capability_worker.user_response() + if self._is_exit(reply): + return + lat, lon = self._resolve_location(reply) + trigger_text = reply + + if lat is None: + await self.capability_worker.speak( + "I couldn't find that location. Try a major nearby city." + ) + return + + city_name = self._get_city_name(trigger_text) + tz_name = self.capability_worker.get_timezone() or "UTC" + min_kp = self._aurora_min_kp(lat) + + data = self._load_data() + data["location"] = {"lat": lat, "lon": lon, "name": city_name, "tz": tz_name} + data["alert_prefs"]["aurora_kp_threshold"] = min_kp + self._save_data(data) + + await self.capability_worker.speak( + f"Set to {city_name}. I'll alert you before ISS passes, when aurora picks up, " + f"and before rocket launches. At your latitude, aurora becomes visible around " + f"Kp {min_kp} or higher — I'll watch for that." + ) + + async def _handle_tonight(self, data: dict): + loc = data.get("location") + if not loc or not loc.get("lat"): + await self.capability_worker.speak( + "First tell me where you are — say 'set my location' and your city name." + ) + return + + lat = loc["lat"] + lon = loc["lon"] + name = loc.get("name", "your location") + tz_name = loc.get("tz", "UTC") + min_el = data["alert_prefs"].get("min_elevation", 30) + + await self.capability_worker.speak(f"Checking tonight's sky for {name}...") + + parts = [] + + # ISS + if self.n2yo_key: + passes = self._fetch_iss_passes(lat, lon) + good = [p for p in passes if p.get("maxEl", 0) >= min_el] + if good: + p = good[0] + t = self._format_local_time(p["startUTC"], tz_name) + max_el = p.get("maxEl", 0) + duration_min = max(1, p.get("duration", 60) // 60) + quality = "great" if max_el >= 60 else "good" if max_el >= 40 else "fair" + iss_part = ( + f"ISS passes at {t} — {quality} pass, {max_el:.0f} degrees max, " + f"{duration_min} {'minute' if duration_min == 1 else 'minutes'}" + ) + if len(good) > 1: + iss_part += f", plus {len(good) - 1} more {'pass' if len(good) == 2 else 'passes'}" + parts.append(iss_part) + else: + parts.append("No good ISS passes tonight") + else: + parts.append("Add your N2YO API key in Settings to enable ISS tracking") + + # Aurora + kp = self._fetch_kp() + min_kp = data["alert_prefs"].get("aurora_kp_threshold", self._aurora_min_kp(lat)) + if kp is not None: + if kp >= min_kp: + parts.append( + f"Aurora alert — Kp is {kp:.0f}, aurora may be visible at your latitude. " + "Look north, away from city lights" + ) + else: + parts.append( + f"Aurora quiet — Kp at {kp:.1f}, you'd need {min_kp} or above to see it" + ) + else: + parts.append("Aurora data unavailable right now") + + # Launches in next 24h + launches = self._fetch_launches(days=1) + if launches: + l = launches[0] + lname = l.get("name", "Unknown mission") + net = l.get("net", "") + pad = l.get("pad", {}).get("location", {}).get("name", "") + launch_time = self._format_launch_time(net, tz_name) + launch_part = f"{lname} launches at {launch_time}" + if pad: + launch_part += f", from {pad}" + parts.append(launch_part) + + await self.capability_worker.speak(". ".join(parts) + ".") + + async def _handle_iss(self, data: dict): + if not self.n2yo_key: + await self.capability_worker.speak( + "ISS tracking needs a free N2YO API key. " + "Get one at n2yo.com and add it in Settings as n2yo_api_key." + ) + return + + loc = data.get("location") + if not loc or not loc.get("lat"): + await self.capability_worker.speak( + "First tell me where you are — say 'set my location' and your city name." + ) + return + + lat = loc["lat"] + lon = loc["lon"] + name = loc.get("name", "your location") + tz_name = loc.get("tz", "UTC") + min_el = data["alert_prefs"].get("min_elevation", 30) + + await self.capability_worker.speak(f"Checking ISS passes for {name}...") + passes = self._fetch_iss_passes(lat, lon) + lines = self._describe_passes(passes, min_el, tz_name) + + if not lines: + await self.capability_worker.speak( + f"No visible ISS passes over {name} in the next 2 days — " + f"all passes are below {min_el} degrees. Try lowering the minimum elevation in alerts." + ) + return + + count = len([p for p in passes if p.get("maxEl", 0) >= min_el]) + await self.capability_worker.speak( + f"ISS passes {name} {count} {'time' if count == 1 else 'times'} in the next 2 days. " + + ". ".join(lines) + "." + ) + + async def _handle_aurora(self, data: dict): + loc = data.get("location") + lat = loc.get("lat") if loc else None + + kp = self._fetch_kp() + if kp is None: + await self.capability_worker.speak( + "Couldn't reach the NOAA space weather service right now. Try again in a moment." + ) + return + + if lat is None: + await self.capability_worker.speak( + f"Current Kp index is {kp:.1f}. " + "Set your location first so I can tell you whether aurora is visible at your latitude." + ) + return + + min_kp = data["alert_prefs"].get("aurora_kp_threshold", self._aurora_min_kp(lat)) + name = loc.get("name", "your location") + + if kp >= min_kp: + await self.capability_worker.speak( + f"Aurora alert — Kp is {kp:.0f} right now, above your threshold of {min_kp}. " + f"Aurora may be visible from {name}. Head somewhere dark and look north. " + "Activity can change fast — check again if you don't see anything in 30 minutes." + ) + else: + gap = min_kp - kp + await self.capability_worker.speak( + f"Aurora is quiet — Kp is {kp:.1f} right now. " + f"At your latitude you'd need {min_kp} or above, so you're {gap:.0f} points away. " + "I'll alert you if it spikes." + ) + + async def _handle_launches(self, data: dict): + tz_name = data.get("location", {}).get("tz", "UTC") + + await self.capability_worker.speak("Checking upcoming launches...") + launches = self._fetch_launches(days=7) + + if not launches: + await self.capability_worker.speak( + "No confirmed launches in the next 7 days — check back soon." + ) + return + + parts = [] + for l in launches[:4]: + lname = l.get("name", "Unknown") + net = l.get("net", "") + rocket = l.get("rocket", {}).get("configuration", {}).get("full_name", "") + pad = l.get("pad", {}).get("location", {}).get("name", "") + launch_time = self._format_launch_time(net, tz_name) + line = lname + if rocket and rocket not in lname: + line += f" on {rocket}" + line += f", launching {launch_time}" + if pad: + line += f" from {pad}" + parts.append(line) + + await self.capability_worker.speak(". ".join(parts) + ".") + + async def _handle_alerts(self, data: dict): + prefs = data["alert_prefs"] + loc = data.get("location", {}) + name = loc.get("name", "your location") if loc else "not set" + + await self.capability_worker.speak( + f"Current alerts for {name}: " + f"ISS {'on' if prefs.get('iss_alerts') else 'off'}, " + f"aurora {'on' if prefs.get('aurora_alerts') else 'off'} at Kp {prefs.get('aurora_kp_threshold', 5)}, " + f"launches {'on' if prefs.get('launch_alerts') else 'off'}. " + f"ISS minimum elevation is {prefs.get('min_elevation', 30)} degrees. " + "What would you like to change?" + ) + reply = await self.capability_worker.user_response() + if self._is_exit(reply): + return + + r = reply.lower() + changed = False + + if "iss" in r: + prefs["iss_alerts"] = bool(_AFFIRMATIVE_PATTERN.search(r)) or "on" in r or "enable" in r + changed = True + state = "on" if prefs["iss_alerts"] else "off" + await self.capability_worker.speak(f"ISS alerts turned {state}.") + + elif "aurora" in r or "northern lights" in r: + nums = re.findall(r'\d+', r) + if nums: + prefs["aurora_kp_threshold"] = int(nums[0]) + changed = True + await self.capability_worker.speak( + f"Aurora threshold set to Kp {nums[0]}." + ) + else: + prefs["aurora_alerts"] = bool(_AFFIRMATIVE_PATTERN.search(r)) or "on" in r or "enable" in r + changed = True + state = "on" if prefs["aurora_alerts"] else "off" + await self.capability_worker.speak(f"Aurora alerts turned {state}.") + + elif "launch" in r: + prefs["launch_alerts"] = bool(_AFFIRMATIVE_PATTERN.search(r)) or "on" in r or "enable" in r + changed = True + state = "on" if prefs["launch_alerts"] else "off" + await self.capability_worker.speak(f"Launch alerts turned {state}.") + + elif "elevation" in r or "degree" in r: + nums = re.findall(r'\d+', r) + if nums: + prefs["min_elevation"] = int(nums[0]) + changed = True + await self.capability_worker.speak( + f"Minimum ISS elevation set to {nums[0]} degrees. " + "Higher means fewer but better passes." + ) + + else: + await self.capability_worker.speak( + "Say 'ISS on/off', 'aurora threshold Kp 4', 'launches on/off', " + "or 'minimum elevation 20 degrees'." + ) + + if changed: + data["alert_prefs"] = prefs + self._save_data(data) + + # ------------------------------------------------------------------ + # Main run loop + # ------------------------------------------------------------------ + + async def _run(self): + try: + self.n2yo_key = self.capability_worker.get_api_keys("n2yo_api_key") or "" + + trigger_text = await self.capability_worker.wait_for_complete_transcription() + if not trigger_text or not isinstance(trigger_text, str): + trigger_text = "" + + intent = self._classify_intent(trigger_text) + self.worker.editor_logging_handler.info( + f"[SpaceWindow] Intent: {intent} | Trigger: {trigger_text[:80]}" + ) + + data = self._load_data() + + if intent == "SETUP": + await self._handle_setup(trigger_text) + elif intent == "TONIGHT": + await self._handle_tonight(data) + elif intent == "ISS": + await self._handle_iss(data) + elif intent == "AURORA": + await self._handle_aurora(data) + elif intent == "LAUNCHES": + await self._handle_launches(data) + elif intent == "ALERTS": + await self._handle_alerts(data) + else: + await self.capability_worker.speak( + "I can check tonight's sky, ISS passes, aurora, or upcoming launches. " + "What would you like?" + ) + + except Exception as e: + self.worker.editor_logging_handler.error(f"[SpaceWindow] Error: {e}") + try: + await self.capability_worker.speak( + "Something went wrong. Try again in a moment." + ) + except Exception: + pass + finally: + self.capability_worker.resume_normal_flow() + + # ------------------------------------------------------------------ + # Entry point + # ------------------------------------------------------------------ + + def call(self, worker: AgentWorker): + self.worker = worker + self.capability_worker = CapabilityWorker(self.worker) + self.worker.session_tasks.create(self._run()) From 5580b6c3d80666d829f1470b7b46fdd9a5c86f43 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 24 May 2026 06:04:29 +0000 Subject: [PATCH 3/9] style: auto-format Python files with autoflake + autopep8 --- community/space-window/background.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/community/space-window/background.py b/community/space-window/background.py index 1dc58d81..b3abecd7 100644 --- a/community/space-window/background.py +++ b/community/space-window/background.py @@ -203,7 +203,7 @@ async def _alert_launch(self, launch: dict, tz_name: str, hours_away: float): lname = launch.get("name", "Unknown mission") rocket = launch.get("rocket", {}).get("configuration", {}).get("full_name", "") pad = launch.get("pad", {}).get("location", {}).get("name", "") - net = launch.get("net", "") + launch.get("net", "") if hours_away <= 1: timing = "in about an hour" From c573a2a53606b916c72930fc924b827b7fb5daa5 Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Date: Sun, 24 May 2026 11:08:12 +0500 Subject: [PATCH 4/9] Fix lint errors and reset CONTRIBUTORS.md - Remove empty f-string in background.py (F541) - Rename ambiguous variable 'l' to 'launch' in two places in main.py (E741) - Reset CONTRIBUTORS.md to upstream/dev --- CONTRIBUTORS.md | 2 +- community/space-window/background.py | 2 +- community/space-window/main.py | 18 +++++++++--------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 3069cb2a..376f9147 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -15,7 +15,7 @@ Format: - **@username** — ability-name ([ability-name](community/ability-name/ - **[@Rizwan-algoryc](https://github.com/Rizwan-algoryc)** — slow-music ([slow-music](community/slow-music/)) - **[@engrumair842-arch](https://github.com/engrumair842-arch)** — reddit-daily-digest ([reddit-daily-digest](community/reddit-daily-digest/)), smart-sous-chef ([smart-sous-chef](community/smart-sous-chef/)) - **[@samsonadmasu](https://github.com/samsonadmasu)** — voice-unit-converter ([voice-unit-converter](community/voice-unit-converter/)), food-water-log ([food-water-log](community/food-water-log/)), gmail-connector ([gmail-connector](community/gmail-connector/)), google-tasks ([google-tasks](community/google-tasks/)), traffic-travel-time ([traffic-travel-time](community/traffic-travel-time/)) -- **[@hassan1731996](https://github.com/hassan1731996)** — daily-briefing ([daily-briefing](community/daily-briefing/)), voice-journal ([voice-journal](community/voice-journal/)), whatsapp-messenger ([whatsapp-messenger](community/whatsapp-messenger/)), alarm-timer ([alarm-timer](community/alarm-timer/)), flight-booking ([flight-booking](community/flight-booking/)), debate-partner ([debate-partner](community/debate-partner/)), conversation-insights-coach ([conversation-insights-coach](community/conversation-insights-coach/)), curiosity-queue ([curiosity-queue](community/curiosity-queue/)) +- **[@hassan1731996](https://github.com/hassan1731996)** — daily-briefing ([daily-briefing](community/daily-briefing/)), voice-journal ([voice-journal](community/voice-journal/)), whatsapp-messenger ([whatsapp-messenger](community/whatsapp-messenger/)), alarm-timer ([alarm-timer](community/alarm-timer/)), flight-booking ([flight-booking](community/flight-booking/)), debate-partner ([debate-partner](community/debate-partner/)), conversation-insights-coach ([conversation-insights-coach](community/conversation-insights-coach/)), curiosity-queue ([curiosity-queue](community/curiosity-queue/)), decision-journal ([decision-journal](community/decision-journal/)), social-memory ([social-memory](community/social-memory/)), conflict-detector ([conflict-detector](community/conflict-detector/)) - **[@BhargavTelu](https://github.com/BhargavTelu)** — grocery-list-manager ([grocery-list-manager](community/grocery-list-manager/)), package-tracker ([package-tracker](community/package-tracker/)) - **[@ArturKozhushnyi](https://github.com/ArturKozhushnyi)** — coin-flipper ([coin-flipper](community/coin-flipper/)), Bedtime-Wind-Down ([Bedtime-Wind-Down](community/Bedtime-Wind-Down/)), Twilio-SMS ([Twilio-SMS](community/Twilio-SMS/)) - **[@ammyyou112](https://github.com/ammyyou112)** — dad-joke-teller ([dad-joke-teller](community/dad-joke-teller/)), youtube-search-play ([youtube-search-play](community/youtube-search-play/)), google-daily-brief ([google-daily-brief](community/google-daily-brief/)) diff --git a/community/space-window/background.py b/community/space-window/background.py index b3abecd7..c72210b6 100644 --- a/community/space-window/background.py +++ b/community/space-window/background.py @@ -367,7 +367,7 @@ async def watch_loop(self): if changed: self.worker.editor_logging_handler.info( - f"[SpaceWindow] Poll complete — alerts fired" + "[SpaceWindow] Poll complete — alerts fired" ) except Exception as e: diff --git a/community/space-window/main.py b/community/space-window/main.py index 55a7a0ed..9f5fe065 100644 --- a/community/space-window/main.py +++ b/community/space-window/main.py @@ -490,10 +490,10 @@ async def _handle_tonight(self, data: dict): # Launches in next 24h launches = self._fetch_launches(days=1) if launches: - l = launches[0] - lname = l.get("name", "Unknown mission") - net = l.get("net", "") - pad = l.get("pad", {}).get("location", {}).get("name", "") + launch = launches[0] + lname = launch.get("name", "Unknown mission") + net = launch.get("net", "") + pad = launch.get("pad", {}).get("location", {}).get("name", "") launch_time = self._format_launch_time(net, tz_name) launch_part = f"{lname} launches at {launch_time}" if pad: @@ -588,11 +588,11 @@ async def _handle_launches(self, data: dict): return parts = [] - for l in launches[:4]: - lname = l.get("name", "Unknown") - net = l.get("net", "") - rocket = l.get("rocket", {}).get("configuration", {}).get("full_name", "") - pad = l.get("pad", {}).get("location", {}).get("name", "") + for launch in launches[:4]: + lname = launch.get("name", "Unknown") + net = launch.get("net", "") + rocket = launch.get("rocket", {}).get("configuration", {}).get("full_name", "") + pad = launch.get("pad", {}).get("location", {}).get("name", "") launch_time = self._format_launch_time(net, tz_name) line = lname if rocket and rocket not in lname: From e59779ccacad4120dc8f5abdf836eba10f7028b6 Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Date: Mon, 25 May 2026 01:41:20 +0500 Subject: [PATCH 5/9] Fix broken conversation flow when location is not set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: _handle_tonight, _handle_iss, and _handle_aurora all spoke a prompt asking the user where they are, then immediately returned and called resume_normal_flow(). The user's answer landed in the generic AI instead of SpaceWindow, so location was never saved and every follow-up trigger hit the same dead end. Fix: add _ensure_location() helper that asks for the city inline, resolves and saves it, then returns True so the original handler continues. All three content handlers now call _ensure_location() instead of early-exiting. First run is now a single natural flow: say "space window" → asked for city → say city → ability proceeds. --- community/space-window/main.py | 63 ++++++++++++++++++++++++---------- 1 file changed, 44 insertions(+), 19 deletions(-) diff --git a/community/space-window/main.py b/community/space-window/main.py index 9f5fe065..206e6011 100644 --- a/community/space-window/main.py +++ b/community/space-window/main.py @@ -371,6 +371,42 @@ def _save_data(self, data: dict): except Exception as e: self.worker.editor_logging_handler.error(f"[SpaceWindow] Save error: {e}") + # ------------------------------------------------------------------ + # Location gate — ask inline if not set, save, return True/False + # ------------------------------------------------------------------ + + async def _ensure_location(self, data: dict) -> bool: + if data.get("location", {}).get("lat"): + return True + + await self.capability_worker.speak( + "Where should I watch from? Say a city name." + ) + reply = await self.capability_worker.user_response() + if self._is_exit(reply): + return False + + lat, lon = self._resolve_location(reply) + if lat is None: + await self.capability_worker.speak( + "Couldn't find that — try a major nearby city like London or New York." + ) + return False + + city_name = self._get_city_name(reply) + tz_name = self.capability_worker.get_timezone() or "UTC" + min_kp = self._aurora_min_kp(lat) + + data["location"] = {"lat": lat, "lon": lon, "name": city_name, "tz": tz_name} + data["alert_prefs"]["aurora_kp_threshold"] = min_kp + self._save_data(data) + + await self.capability_worker.speak( + f"Got it — watching from {city_name}. " + f"Aurora visible at Kp {min_kp} or higher at your latitude." + ) + return True + # ------------------------------------------------------------------ # ISS pass formatting # ------------------------------------------------------------------ @@ -432,13 +468,10 @@ async def _handle_setup(self, trigger_text: str): ) async def _handle_tonight(self, data: dict): - loc = data.get("location") - if not loc or not loc.get("lat"): - await self.capability_worker.speak( - "First tell me where you are — say 'set my location' and your city name." - ) + if not await self._ensure_location(data): return + loc = data["location"] lat = loc["lat"] lon = loc["lon"] name = loc.get("name", "your location") @@ -510,13 +543,10 @@ async def _handle_iss(self, data: dict): ) return - loc = data.get("location") - if not loc or not loc.get("lat"): - await self.capability_worker.speak( - "First tell me where you are — say 'set my location' and your city name." - ) + if not await self._ensure_location(data): return + loc = data["location"] lat = loc["lat"] lon = loc["lon"] name = loc.get("name", "your location") @@ -541,8 +571,8 @@ async def _handle_iss(self, data: dict): ) async def _handle_aurora(self, data: dict): - loc = data.get("location") - lat = loc.get("lat") if loc else None + if not await self._ensure_location(data): + return kp = self._fetch_kp() if kp is None: @@ -551,13 +581,8 @@ async def _handle_aurora(self, data: dict): ) return - if lat is None: - await self.capability_worker.speak( - f"Current Kp index is {kp:.1f}. " - "Set your location first so I can tell you whether aurora is visible at your latitude." - ) - return - + loc = data["location"] + lat = loc["lat"] min_kp = data["alert_prefs"].get("aurora_kp_threshold", self._aurora_min_kp(lat)) name = loc.get("name", "your location") From 6ef983debc033f7a1b3e2ae8669dc6b5ea43e685 Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Date: Mon, 25 May 2026 02:00:30 +0500 Subject: [PATCH 6/9] Fix location capture: expand does_match for city responses, pre-populate from trigger --- community/space-window/main.py | 45 +++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/community/space-window/main.py b/community/space-window/main.py index 206e6011..d55fe345 100644 --- a/community/space-window/main.py +++ b/community/space-window/main.py @@ -169,7 +169,15 @@ class SpaceWindowCapability(MatchingCapability): def does_match(self, text: str) -> bool: t = text.lower().strip() - return any(hw in t for hw in HOTWORDS) + if any(hw in t for hw in HOTWORDS): + return True + # Capture bare city name responses (e.g., user answers "London") + if t in CITY_MAP: + return True + # Capture location-specifying phrases (e.g., "I'm in London", "from New York") + if re.search(r"\bi'?m in\b|\bi am in\b|\bfrom\b", t) and any(city in t for city in CITY_MAP): + return True + return False # ------------------------------------------------------------------ # Helpers @@ -232,6 +240,14 @@ def _resolve_location_llm(self, text: str) -> tuple[float | None, float | None]: except Exception: return None, None + def _find_city_in_text(self, text: str) -> tuple[float | None, float | None, str | None]: + """CITY_MAP-only lookup — no LLM, safe to call on every trigger.""" + t = text.lower().strip() + for city, coords in sorted(CITY_MAP.items(), key=lambda x: -len(x[0])): + if city in t: + return coords[0], coords[1], city.title() + return None, None, None + def _get_city_name(self, text: str) -> str: t = text.lower() for city in sorted(CITY_MAP.keys(), key=lambda x: -len(x)): @@ -380,12 +396,22 @@ async def _ensure_location(self, data: dict) -> bool: return True await self.capability_worker.speak( - "Where should I watch from? Say a city name." + "Just say your city name — like 'London' or 'New York'." ) reply = await self.capability_worker.user_response() if self._is_exit(reply): return False + # Guard: if the reply is a hotword phrase (not a city), re-ask once + reply_lower = (reply or "").lower().strip() + if reply_lower not in CITY_MAP and any(hw in reply_lower for hw in HOTWORDS): + await self.capability_worker.speak( + "I need your city to get started — just say the city name, like 'London' or 'Chicago'." + ) + reply = await self.capability_worker.user_response() + if self._is_exit(reply): + return False + lat, lon = self._resolve_location(reply) if lat is None: await self.capability_worker.speak( @@ -707,13 +733,24 @@ async def _run(self): if not trigger_text or not isinstance(trigger_text, str): trigger_text = "" + data = self._load_data() + + # Pre-populate location if a city is embedded in the trigger phrase + # e.g., "space window from London" or "ISS passes tonight London" + if not data.get("location", {}).get("lat"): + pre_lat, pre_lon, pre_city = self._find_city_in_text(trigger_text) + if pre_lat is not None: + tz_name = self.capability_worker.get_timezone() or "UTC" + min_kp = self._aurora_min_kp(pre_lat) + data["location"] = {"lat": pre_lat, "lon": pre_lon, "name": pre_city, "tz": tz_name} + data["alert_prefs"]["aurora_kp_threshold"] = min_kp + self._save_data(data) + intent = self._classify_intent(trigger_text) self.worker.editor_logging_handler.info( f"[SpaceWindow] Intent: {intent} | Trigger: {trigger_text[:80]}" ) - data = self._load_data() - if intent == "SETUP": await self._handle_setup(trigger_text) elif intent == "TONIGHT": From ab07b6649032a6edd44373f10e64e312a7da521c Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Date: Mon, 25 May 2026 02:13:47 +0500 Subject: [PATCH 7/9] Fix location persistence: add process-scoped cache fallback, improve storage logging --- community/space-window/main.py | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/community/space-window/main.py b/community/space-window/main.py index d55fe345..a560204f 100644 --- a/community/space-window/main.py +++ b/community/space-window/main.py @@ -155,6 +155,11 @@ def _empty_data() -> dict: } +# Process-scoped cache — survives across CapabilityWorker re-instantiation within the same session. +# Used as fallback when the storage API doesn't return persisted data on a new call(). +_PROCESS_CACHE: dict = {} + + class SpaceWindowCapability(MatchingCapability): worker: AgentWorker = None capability_worker: CapabilityWorker = None @@ -344,7 +349,7 @@ def _fetch_kp(self) -> float | None: return float(data[-1][1]) return None except Exception as e: - self.worker.editor_logging_handler.error(f"[SpaceWindow] NOAA error: {e}") + self.worker.editor_logging_handler.error(f"[SpaceWindow] NOAA error: {e!r}") return None def _fetch_launches(self, days: int = 7) -> list: @@ -369,23 +374,39 @@ def _fetch_launches(self, days: int = 7) -> list: # ------------------------------------------------------------------ def _load_data(self) -> dict: + global _PROCESS_CACHE try: result = self.capability_worker.get_single_key(STORAGE_KEY) if result and result.get("value"): - return result["value"] + loaded = result["value"] + _PROCESS_CACHE = loaded + loc_status = "set" if loaded.get("location", {}).get("lat") else "unset" + self.worker.editor_logging_handler.info(f"[SpaceWindow] Loaded from storage, location={loc_status}") + return loaded + # Storage miss — use process cache if it has a location + if _PROCESS_CACHE.get("location", {}).get("lat"): + self.worker.editor_logging_handler.info("[SpaceWindow] Storage miss — using process cache") + return _PROCESS_CACHE + self.worker.editor_logging_handler.info("[SpaceWindow] Storage miss — starting fresh") return _empty_data() except Exception as e: - self.worker.editor_logging_handler.error(f"[SpaceWindow] Load error: {e}") + self.worker.editor_logging_handler.error(f"[SpaceWindow] Load error: {e!r}") + if _PROCESS_CACHE.get("location", {}).get("lat"): + return _PROCESS_CACHE return _empty_data() def _save_data(self, data: dict): + global _PROCESS_CACHE + _PROCESS_CACHE = data # always update process cache first try: self.capability_worker.update_key(STORAGE_KEY, data) + self.worker.editor_logging_handler.info("[SpaceWindow] Saved via update_key") except Exception: try: self.capability_worker.create_key(STORAGE_KEY, data) + self.worker.editor_logging_handler.info("[SpaceWindow] Saved via create_key") except Exception as e: - self.worker.editor_logging_handler.error(f"[SpaceWindow] Save error: {e}") + self.worker.editor_logging_handler.error(f"[SpaceWindow] Save error: {e!r}") # ------------------------------------------------------------------ # Location gate — ask inline if not set, save, return True/False From bc878376b29a04c19e195675246d9c27f177e100 Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Date: Mon, 25 May 2026 02:17:31 +0500 Subject: [PATCH 8/9] Fix NOAA KeyError: handle dict/list row formats; swap save order to create_key first --- community/space-window/main.py | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/community/space-window/main.py b/community/space-window/main.py index a560204f..e99ae9e3 100644 --- a/community/space-window/main.py +++ b/community/space-window/main.py @@ -343,10 +343,22 @@ def _fetch_iss_passes(self, lat: float, lon: float) -> list: def _fetch_kp(self) -> float | None: try: resp = requests.get(NOAA_KP_URL, timeout=10) - if resp.status_code == 200: - data = resp.json() - if len(data) > 1: - return float(data[-1][1]) + if resp.status_code != 200: + return None + rows = resp.json() + if not isinstance(rows, list): + return None + # Walk backwards — skip metadata/header rows, handle both list and dict formats + for row in reversed(rows): + if isinstance(row, dict): + for key in ("Kp", "kp", "kp_index", "Planetary_Kp"): + if key in row: + return float(row[key]) + elif isinstance(row, (list, tuple)) and len(row) > 1: + try: + return float(row[1]) + except (ValueError, TypeError): + continue return None except Exception as e: self.worker.editor_logging_handler.error(f"[SpaceWindow] NOAA error: {e!r}") @@ -399,12 +411,12 @@ def _save_data(self, data: dict): global _PROCESS_CACHE _PROCESS_CACHE = data # always update process cache first try: - self.capability_worker.update_key(STORAGE_KEY, data) - self.worker.editor_logging_handler.info("[SpaceWindow] Saved via update_key") + self.capability_worker.create_key(STORAGE_KEY, data) + self.worker.editor_logging_handler.info("[SpaceWindow] Saved via create_key") except Exception: try: - self.capability_worker.create_key(STORAGE_KEY, data) - self.worker.editor_logging_handler.info("[SpaceWindow] Saved via create_key") + self.capability_worker.update_key(STORAGE_KEY, data) + self.worker.editor_logging_handler.info("[SpaceWindow] Saved via update_key") except Exception as e: self.worker.editor_logging_handler.error(f"[SpaceWindow] Save error: {e!r}") From f40d889a85f81441ce20a37614b9bdbcea9cb56c Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Date: Mon, 25 May 2026 02:25:06 +0500 Subject: [PATCH 9/9] Fix background NOAA KeyError, save order, suppress first-poll duplicate launch alert --- community/space-window/background.py | 33 ++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/community/space-window/background.py b/community/space-window/background.py index c72210b6..7cb2b496 100644 --- a/community/space-window/background.py +++ b/community/space-window/background.py @@ -64,12 +64,12 @@ def _load_data(self) -> dict: def _save_data(self, data: dict): try: - self.capability_worker.update_key(STORAGE_KEY, data) + self.capability_worker.create_key(STORAGE_KEY, data) except Exception: try: - self.capability_worker.create_key(STORAGE_KEY, data) + self.capability_worker.update_key(STORAGE_KEY, data) except Exception as e: - self.worker.editor_logging_handler.error(f"[SpaceWindow] Save error: {e}") + self.worker.editor_logging_handler.error(f"[SpaceWindow] Save error: {e!r}") # ------------------------------------------------------------------ # Time helpers @@ -145,13 +145,24 @@ def _fetch_iss_passes(self, lat: float, lon: float) -> list: def _fetch_kp(self) -> float | None: try: resp = requests.get(NOAA_KP_URL, timeout=10) - if resp.status_code == 200: - data = resp.json() - if len(data) > 1: - return float(data[-1][1]) + if resp.status_code != 200: + return None + rows = resp.json() + if not isinstance(rows, list): + return None + for row in reversed(rows): + if isinstance(row, dict): + for key in ("Kp", "kp", "kp_index", "Planetary_Kp"): + if key in row: + return float(row[key]) + elif isinstance(row, (list, tuple)) and len(row) > 1: + try: + return float(row[1]) + except (ValueError, TypeError): + continue return None except Exception as e: - self.worker.editor_logging_handler.error(f"[SpaceWindow] NOAA error: {e}") + self.worker.editor_logging_handler.error(f"[SpaceWindow] NOAA error: {e!r}") return None def _fetch_launches(self, days: int = 2) -> list: @@ -282,6 +293,8 @@ async def watch_loop(self): self.capability_worker.resume_normal_flow() self.worker.editor_logging_handler.info("[SpaceWindow] daemon started") + started_at = datetime.now(timezone.utc).timestamp() + while True: try: data = self._load_data() @@ -339,7 +352,9 @@ async def watch_loop(self): await self._alert_aurora(kp, name) # Launch alerts (24h and 1h before) - if prefs.get("launch_alerts", True): + # Skip first poll if daemon just started — foreground already announced any imminent launches + daemon_age_secs = datetime.now(timezone.utc).timestamp() - started_at + if prefs.get("launch_alerts", True) and daemon_age_secs > 90: launches = self._fetch_launches(days=2) alerted_launches = data.get("alerted_launches", [])