From ecbf946b0f7ab3d8ac934fd32437c185a9f3e788 Mon Sep 17 00:00:00 2001 From: Franci Penov Date: Wed, 18 Mar 2026 19:47:39 -0700 Subject: [PATCH 1/3] Porch testing ability --- community/porch/README.md | 39 +++++++++++++++++++++++ community/porch/__init__.py | 0 community/porch/main.py | 63 +++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 community/porch/README.md create mode 100644 community/porch/__init__.py create mode 100644 community/porch/main.py diff --git a/community/porch/README.md b/community/porch/README.md new file mode 100644 index 00000000..50d3b788 --- /dev/null +++ b/community/porch/README.md @@ -0,0 +1,39 @@ +# Porch + +![Community](https://img.shields.io/badge/OpenHome-Community-orange?style=flat-square) +![Author](https://img.shields.io/badge/Author-@kortexa--ai-lightgrey?style=flat-square) + +## What It Does +A minimal test ability for the [Porch](https://github.com/kortexa-ai/openhome-porch) macOS client. Sends commands to your Mac via `exec_local_command()` to verify the end-to-end connection is working. + +## Suggested Trigger Words +- "porch" +- "open porch" + +## Setup +1. Install and run [Porch](https://github.com/kortexa-ai/openhome-porch) on your Mac +2. Upload this ability to OpenHome and set trigger words in the dashboard + +## How It Works +1. Say the trigger word +2. Porch asks what to do +3. Say "open dashboard" → opens app.openhome.com in your Mac's browser +4. Say "stop" or "cancel" to exit + +## Supported Commands + +| Command | What it does | +|---------|-------------| +| "open dashboard" | Opens app.openhome.com in your default browser | + +More commands coming as Porch develops. + +## Example Conversation +> **User:** "porch" +> **AI:** "Porch here. What should I do?" +> **User:** "open dashboard" +> **AI:** "Opening the OpenHome dashboard." +> *(Browser opens app.openhome.com on your Mac)* + +## Logs +Look for `[Porch]` entries in OpenHome Live Editor logs. diff --git a/community/porch/__init__.py b/community/porch/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/community/porch/main.py b/community/porch/main.py new file mode 100644 index 00000000..025ad5da --- /dev/null +++ b/community/porch/main.py @@ -0,0 +1,63 @@ +"""OpenHome ability — Porch test ability. + +A minimal ability to test the Porch macOS client connection. +Trigger word: "porch" + +Supported commands: + - "open dashboard" → opens app.openhome.com in the default browser +""" + +from src.agent.capability import MatchingCapability +from src.main import AgentWorker +from src.agent.capability_worker import CapabilityWorker + +EXIT_WORDS = {"stop", "cancel", "exit", "quit", "never mind"} +TAG = "[Porch]" + + +class PorchCapability(MatchingCapability): + worker: AgentWorker = None + capability_worker: CapabilityWorker = None + + # Do not change following tag of register capability + #{{register capability}} + + def call(self, worker: AgentWorker): + self.worker = worker + self.capability_worker = CapabilityWorker(self) + self.worker.session_tasks.create(self.run()) + + async def run(self): + try: + await self.capability_worker.speak("Porch here. What should I do?") + user_input = await self.capability_worker.user_response() + + if not user_input: + await self.capability_worker.speak("I didn't catch that.") + return + + lowered = user_input.lower().strip() + + if any(lowered == w or lowered.startswith(f"{w} ") for w in EXIT_WORDS): + await self.capability_worker.speak("Okay, nevermind.") + return + + if "dashboard" in lowered or "open" in lowered: + await self.capability_worker.speak("Opening the OpenHome dashboard.") + response = await self.capability_worker.exec_local_command( + "open https://app.openhome.com", + timeout=10.0, + ) + self.worker.editor_logging_handler.info(f"{TAG} exec_local_command response: {response}") + return + + # Unknown command — echo it back + await self.capability_worker.speak( + f"I don't know how to do that yet. You said: {user_input}" + ) + + except Exception as err: + self.worker.editor_logging_handler.error(f"{TAG} error: {err}") + await self.capability_worker.speak("Something went wrong.") + finally: + self.capability_worker.resume_normal_flow() From a237954a267d6bbdf2ca2288f9ba4f0308511d9a Mon Sep 17 00:00:00 2001 From: Franci Penov Date: Sat, 28 Mar 2026 02:01:26 -0700 Subject: [PATCH 2/3] Weather as json render example for porch --- community/kortexa-weather/README.md | 38 ++++ community/kortexa-weather/__init__.py | 1 + community/kortexa-weather/config.json | 4 + community/kortexa-weather/main.py | 266 ++++++++++++++++++++++++++ community/porch/README.md | 39 ---- community/porch/__init__.py | 0 community/porch/main.py | 63 ------ 7 files changed, 309 insertions(+), 102 deletions(-) create mode 100644 community/kortexa-weather/README.md create mode 100644 community/kortexa-weather/__init__.py create mode 100644 community/kortexa-weather/config.json create mode 100644 community/kortexa-weather/main.py delete mode 100644 community/porch/README.md delete mode 100644 community/porch/__init__.py delete mode 100644 community/porch/main.py diff --git a/community/kortexa-weather/README.md b/community/kortexa-weather/README.md new file mode 100644 index 00000000..b334736a --- /dev/null +++ b/community/kortexa-weather/README.md @@ -0,0 +1,38 @@ +# Weather Display + +![Community](https://img.shields.io/badge/OpenHome-Community-orange?style=flat-square) +![Author](https://img.shields.io/badge/Author-@kortexa--ai-lightgrey?style=flat-square) + +## What It Does +Shows current weather on [Window](https://github.com/kortexa-ai/openhome-porch) as a rich card with temperature, conditions, humidity, wind, and UV index. Automatically detects your location via IP geolocation — no setup needed. + +Uses [Open-Meteo](https://open-meteo.com/) (free, no API key) for weather data and [json-render](https://github.com/vercel-labs/json-render) for the visual display. + +## Suggested Trigger Words +- "weather" +- "what's the weather" + +## Requirements +- [Porch](https://github.com/kortexa-ai/openhome-porch) running on your Mac (for geolocation + Window) +- [Window](https://github.com/kortexa-ai/openhome-porch) companion app + +## How It Works +1. Say the trigger word +2. Porch runs `curl ipinfo.io/json` on your Mac to get your location +3. Fetches weather from Open-Meteo using your lat/lon +4. Opens Window and renders a weather card via json-render +5. Speaks a brief summary + +Automatically uses Fahrenheit for US locations, Celsius everywhere else. + +## Example Conversation +> **User:** "weather" +> **AI:** "Checking the weather." +> *(Window opens with a weather card showing 72°F, Partly cloudy, humidity, wind, UV)* +> **AI:** "It's 72 degrees and partly cloudy in Portland. Feels like 70. Humidity 55 percent." + +## Graceful Degradation +If Porch or Window aren't running, the ability still speaks the weather (or reports that it couldn't connect). No crashes. + +## Logs +Look for `[Weather]` entries in OpenHome Live Editor logs. diff --git a/community/kortexa-weather/__init__.py b/community/kortexa-weather/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/community/kortexa-weather/__init__.py @@ -0,0 +1 @@ + diff --git a/community/kortexa-weather/config.json b/community/kortexa-weather/config.json new file mode 100644 index 00000000..2c0bf327 --- /dev/null +++ b/community/kortexa-weather/config.json @@ -0,0 +1,4 @@ +{ + "unique_name": "kortexa_weather", + "matching_hotwords": ["weather", "what's the weather"] +} diff --git a/community/kortexa-weather/main.py b/community/kortexa-weather/main.py new file mode 100644 index 00000000..edaf7674 --- /dev/null +++ b/community/kortexa-weather/main.py @@ -0,0 +1,266 @@ +"""OpenHome ability — Weather Display via Porch + Window. + +Shows rich weather information on Window using json-render, with +automatic IP-based geolocation via Porch. + +Trigger words: "weather", "what's the weather" +Requires: Porch + Window running on the user's Mac +""" + +import json + +import httpx + +from src.agent.capability import MatchingCapability +from src.main import AgentWorker +from src.agent.capability_worker import CapabilityWorker + +TAG = "[Weather]" +OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast" + +WEATHER_CODES = { + 0: "Clear sky", + 1: "Mainly clear", + 2: "Partly cloudy", + 3: "Overcast", + 45: "Foggy", + 48: "Rime fog", + 51: "Light drizzle", + 53: "Moderate drizzle", + 55: "Dense drizzle", + 61: "Slight rain", + 63: "Moderate rain", + 65: "Heavy rain", + 71: "Slight snow", + 73: "Moderate snow", + 75: "Heavy snow", + 80: "Light rain showers", + 81: "Moderate rain showers", + 82: "Heavy rain showers", + 95: "Thunderstorm", + 96: "Thunderstorm with hail", + 99: "Thunderstorm with heavy hail", +} + + +class WeatherDisplayCapability(MatchingCapability): + worker: AgentWorker = None + capability_worker: CapabilityWorker = None + + # Do not change following tag of register capability + #{{register_capability}} + + def call(self, worker: AgentWorker): + self.worker = worker + self.capability_worker = CapabilityWorker(self) + self.worker.session_tasks.create(self.run()) + + async def run(self): + try: + await self.capability_worker.speak("Checking the weather.") + + # Geolocate via the user's Mac (through Porch) + location = await self._get_location() + if not location: + await self.capability_worker.speak( + "Couldn't determine your location. Is Porch running?" + ) + return + + city = location.get("city", "Unknown") + region = location.get("region", "") + lat, lon = self._parse_loc(location.get("loc", "0,0")) + country = location.get("country", "") + + self.worker.editor_logging_handler.info( + f"{TAG} Location: {city}, {region} ({lat}, {lon})" + ) + + # Use Fahrenheit for US, Celsius everywhere else + use_fahrenheit = country == "US" + + # Fetch weather from Open-Meteo (no API key needed) + weather = await self._fetch_weather(lat, lon, use_fahrenheit) + if not weather: + await self.capability_worker.speak(f"Couldn't get weather data for {city}.") + return + + current = weather["current"] + temp = round(current["temperature_2m"]) + feels_like = round(current["apparent_temperature"]) + humidity = round(current["relative_humidity_2m"]) + wind = round(current["wind_speed_10m"]) + uv = current.get("uv_index", 0) + code = current.get("weather_code", 0) + condition = WEATHER_CODES.get(code, "Unknown") + unit = "F" if use_fahrenheit else "C" + + # Open Window and show weather card + await self._window_cmd("window:open") + await self.worker.session_tasks.sleep(1) + + spec = self._build_spec( + city, region, temp, feels_like, humidity, wind, uv, condition, unit + ) + await self._window_msg({"type": "render", "data": spec}) + + # Speak a short summary + await self.capability_worker.speak( + f"It's {temp} degrees and {condition.lower()} in {city}. " + f"Feels like {feels_like}. Humidity {humidity} percent." + ) + + except Exception as err: + self.worker.editor_logging_handler.error(f"{TAG} error: {err}") + await self.capability_worker.speak("Something went wrong getting the weather.") + finally: + self.capability_worker.resume_normal_flow() + + # -- Geolocation via Porch -- + + async def _get_location(self): + """Get user's location via IP geolocation on their Mac.""" + try: + response = await self.capability_worker.exec_local_command( + "curl -s ipinfo.io/json", timeout=10.0 + ) + data = response.get("data", {}) if isinstance(response, dict) else {} + stdout = data.get("stdout", "") if isinstance(data, dict) else str(data) + if stdout: + return json.loads(stdout) + except Exception as err: + self.worker.editor_logging_handler.error(f"{TAG} geoloc error: {err}") + return None + + def _parse_loc(self, loc_str): + """Parse 'lat,lon' string from ipinfo.""" + try: + parts = loc_str.split(",") + return float(parts[0]), float(parts[1]) + except (ValueError, IndexError): + return 0.0, 0.0 + + # -- Weather API -- + + async def _fetch_weather(self, lat, lon, use_fahrenheit): + """Fetch current weather from Open-Meteo (free, no API key).""" + try: + params = { + "latitude": lat, + "longitude": lon, + "current": ",".join([ + "temperature_2m", + "relative_humidity_2m", + "apparent_temperature", + "weather_code", + "wind_speed_10m", + "uv_index", + ]), + "temperature_unit": "fahrenheit" if use_fahrenheit else "celsius", + "wind_speed_unit": "mph" if use_fahrenheit else "kmh", + } + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.get(OPEN_METEO_URL, params=params) + if resp.status_code == 200: + return resp.json() + except Exception as err: + self.worker.editor_logging_handler.error(f"{TAG} weather API error: {err}") + return None + + # -- Window display -- + + def _build_spec(self, city, region, temp, feels_like, humidity, wind, uv, condition, unit): + """Build a json-render spec for the weather card.""" + location_label = f"{city}, {region}" if region else city + wind_unit = "mph" if unit == "F" else "km/h" + + if uv <= 2: + uv_label = f"UV Index: {uv} (Low)" + elif uv <= 5: + uv_label = f"UV Index: {uv} (Moderate)" + else: + uv_label = f"UV Index: {uv} (High)" + + return { + "elements": [ + { + "component": "Stack", + "props": {"direction": "vertical", "gap": "lg", "align": "center"}, + "children": [ + # Location + condition + { + "component": "Stack", + "props": {"direction": "vertical", "gap": "sm", "align": "center"}, + "children": [ + { + "component": "Text", + "props": {"text": condition, "variant": "caption"}, + }, + { + "component": "Heading", + "props": {"text": f"{temp}\u00b0{unit}", "level": "h1"}, + }, + { + "component": "Text", + "props": {"text": location_label}, + }, + { + "component": "Text", + "props": {"text": f"Feels like {feels_like}\u00b0{unit}", "variant": "muted"}, + }, + ], + }, + # Separator + {"component": "Separator"}, + # Details grid + { + "component": "Grid", + "props": {"columns": 2, "gap": "md"}, + "children": [ + { + "component": "Stack", + "props": {"direction": "vertical", "gap": "sm", "align": "center"}, + "children": [ + {"component": "Text", "props": {"text": "Wind", "variant": "caption"}}, + {"component": "Heading", "props": {"text": f"{wind} {wind_unit}", "level": "h3"}}, + ], + }, + { + "component": "Stack", + "props": {"direction": "vertical", "gap": "sm", "align": "center"}, + "children": [ + {"component": "Text", "props": {"text": "Humidity", "variant": "caption"}}, + {"component": "Heading", "props": {"text": f"{humidity}%", "level": "h3"}}, + ], + }, + ], + }, + # UV bar + { + "component": "Stack", + "props": {"direction": "vertical", "gap": "sm"}, + "children": [ + {"component": "Text", "props": {"text": uv_label, "variant": "muted"}}, + {"component": "Progress", "props": {"value": min(int(uv * 10), 100)}}, + ], + }, + ], + } + ] + } + + async def _window_cmd(self, cmd): + """Send a window management command via Porch.""" + try: + await self.capability_worker.exec_local_command(cmd, timeout=5.0) + except Exception: + pass # Porch/Window not running, that's fine + + async def _window_msg(self, msg): + """Send a display message to Window via Porch.""" + try: + await self.capability_worker.exec_local_command( + "window:" + json.dumps(msg), timeout=5.0 + ) + except Exception: + pass diff --git a/community/porch/README.md b/community/porch/README.md deleted file mode 100644 index 50d3b788..00000000 --- a/community/porch/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# Porch - -![Community](https://img.shields.io/badge/OpenHome-Community-orange?style=flat-square) -![Author](https://img.shields.io/badge/Author-@kortexa--ai-lightgrey?style=flat-square) - -## What It Does -A minimal test ability for the [Porch](https://github.com/kortexa-ai/openhome-porch) macOS client. Sends commands to your Mac via `exec_local_command()` to verify the end-to-end connection is working. - -## Suggested Trigger Words -- "porch" -- "open porch" - -## Setup -1. Install and run [Porch](https://github.com/kortexa-ai/openhome-porch) on your Mac -2. Upload this ability to OpenHome and set trigger words in the dashboard - -## How It Works -1. Say the trigger word -2. Porch asks what to do -3. Say "open dashboard" → opens app.openhome.com in your Mac's browser -4. Say "stop" or "cancel" to exit - -## Supported Commands - -| Command | What it does | -|---------|-------------| -| "open dashboard" | Opens app.openhome.com in your default browser | - -More commands coming as Porch develops. - -## Example Conversation -> **User:** "porch" -> **AI:** "Porch here. What should I do?" -> **User:** "open dashboard" -> **AI:** "Opening the OpenHome dashboard." -> *(Browser opens app.openhome.com on your Mac)* - -## Logs -Look for `[Porch]` entries in OpenHome Live Editor logs. diff --git a/community/porch/__init__.py b/community/porch/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/community/porch/main.py b/community/porch/main.py deleted file mode 100644 index 025ad5da..00000000 --- a/community/porch/main.py +++ /dev/null @@ -1,63 +0,0 @@ -"""OpenHome ability — Porch test ability. - -A minimal ability to test the Porch macOS client connection. -Trigger word: "porch" - -Supported commands: - - "open dashboard" → opens app.openhome.com in the default browser -""" - -from src.agent.capability import MatchingCapability -from src.main import AgentWorker -from src.agent.capability_worker import CapabilityWorker - -EXIT_WORDS = {"stop", "cancel", "exit", "quit", "never mind"} -TAG = "[Porch]" - - -class PorchCapability(MatchingCapability): - worker: AgentWorker = None - capability_worker: CapabilityWorker = None - - # Do not change following tag of register capability - #{{register capability}} - - def call(self, worker: AgentWorker): - self.worker = worker - self.capability_worker = CapabilityWorker(self) - self.worker.session_tasks.create(self.run()) - - async def run(self): - try: - await self.capability_worker.speak("Porch here. What should I do?") - user_input = await self.capability_worker.user_response() - - if not user_input: - await self.capability_worker.speak("I didn't catch that.") - return - - lowered = user_input.lower().strip() - - if any(lowered == w or lowered.startswith(f"{w} ") for w in EXIT_WORDS): - await self.capability_worker.speak("Okay, nevermind.") - return - - if "dashboard" in lowered or "open" in lowered: - await self.capability_worker.speak("Opening the OpenHome dashboard.") - response = await self.capability_worker.exec_local_command( - "open https://app.openhome.com", - timeout=10.0, - ) - self.worker.editor_logging_handler.info(f"{TAG} exec_local_command response: {response}") - return - - # Unknown command — echo it back - await self.capability_worker.speak( - f"I don't know how to do that yet. You said: {user_input}" - ) - - except Exception as err: - self.worker.editor_logging_handler.error(f"{TAG} error: {err}") - await self.capability_worker.speak("Something went wrong.") - finally: - self.capability_worker.resume_normal_flow() From bbdc81c7b258e718472475952da239a9d737201b Mon Sep 17 00:00:00 2001 From: Franci Penov Date: Mon, 30 Mar 2026 09:06:12 -0700 Subject: [PATCH 3/3] Remove config.json and improve voice UX per PR review - Delete config.json (handled by platform at runtime) - Voice audit: soften error messages, improve spoken output naturalness Co-Authored-By: Claude Opus 4.6 (1M context) --- community/kortexa-weather/config.json | 4 ---- community/kortexa-weather/main.py | 8 ++++---- 2 files changed, 4 insertions(+), 8 deletions(-) delete mode 100644 community/kortexa-weather/config.json diff --git a/community/kortexa-weather/config.json b/community/kortexa-weather/config.json deleted file mode 100644 index 2c0bf327..00000000 --- a/community/kortexa-weather/config.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "unique_name": "kortexa_weather", - "matching_hotwords": ["weather", "what's the weather"] -} diff --git a/community/kortexa-weather/main.py b/community/kortexa-weather/main.py index edaf7674..73ad782e 100644 --- a/community/kortexa-weather/main.py +++ b/community/kortexa-weather/main.py @@ -63,7 +63,7 @@ async def run(self): location = await self._get_location() if not location: await self.capability_worker.speak( - "Couldn't determine your location. Is Porch running?" + "I can't find your location. Is Porch running?" ) return @@ -82,7 +82,7 @@ async def run(self): # Fetch weather from Open-Meteo (no API key needed) weather = await self._fetch_weather(lat, lon, use_fahrenheit) if not weather: - await self.capability_worker.speak(f"Couldn't get weather data for {city}.") + await self.capability_worker.speak(f"Sorry, couldn't get the weather for {city}.") return current = weather["current"] @@ -107,12 +107,12 @@ async def run(self): # Speak a short summary await self.capability_worker.speak( f"It's {temp} degrees and {condition.lower()} in {city}. " - f"Feels like {feels_like}. Humidity {humidity} percent." + f"Feels like {feels_like}, with {humidity} percent humidity." ) except Exception as err: self.worker.editor_logging_handler.error(f"{TAG} error: {err}") - await self.capability_worker.speak("Something went wrong getting the weather.") + await self.capability_worker.speak("Sorry, something went wrong with the weather.") finally: self.capability_worker.resume_normal_flow()