diff --git a/cortex/routers/settings.py b/cortex/routers/settings.py index bb321be..1108c29 100644 --- a/cortex/routers/settings.py +++ b/cortex/routers/settings.py @@ -67,6 +67,9 @@ def _notifications_page(username: str, back_persona: str = "", success: str = "" nc_username = _html.escape(nct.get("nc_username", "") or "") nc_app_password = _html.escape(nct.get("nc_app_password", "") or "") gc_webhook = _html.escape((channels.get("google_chat") or {}).get("outbound_webhook", "") or "") + ha = channels.get("homeassistant") or {} + ha_url = _html.escape(ha.get("url", "") or "") + ha_webhook_id = _html.escape(ha.get("webhook_id", "") or "") html = html.replace("{{ notify_channel }}", notify_ch) html = html.replace("{{ notify_email_override }}", notify_email) @@ -76,6 +79,9 @@ def _notifications_page(username: str, back_persona: str = "", success: str = "" html = html.replace("{{ nc_username }}", nc_username) html = html.replace("{{ nc_app_password }}", nc_app_password) html = html.replace("{{ gc_webhook }}", gc_webhook) + html = html.replace("{{ ha_url }}", ha_url) + html = html.replace("{{ ha_webhook_id }}", ha_webhook_id) + html = html.replace("{{ ha_username }}", username) html = html.replace("{{ back_href }}", f"/{username}/{back_persona}" if back_persona else "/") html = html.replace("{{ help_href }}", f"/help?persona={back_persona}" if back_persona else "/help") if success: @@ -309,6 +315,9 @@ async def save_notifications( nc_username: str = Form(""), nc_app_password: str = Form(""), gc_outbound_webhook: str = Form(""), + ha_url: str = Form(""), + ha_token: str = Form(""), + ha_webhook_id: str = Form(""), ): username = _get_session_user(request) if not username: @@ -356,6 +365,17 @@ async def save_notifications( channels["google_chat"] = {} channels["google_chat"]["outbound_webhook"] = gc_outbound_webhook.strip() + # Home Assistant — nested under "homeassistant" + if "homeassistant" not in channels: + channels["homeassistant"] = {} + ha = channels["homeassistant"] + if ha_url.strip(): + ha["url"] = ha_url.strip().rstrip("/") + if ha_token.strip(): + ha["token"] = ha_token.strip() + if ha_webhook_id.strip(): + ha["webhook_id"] = ha_webhook_id.strip() + channels_path.write_text(json.dumps(channels, indent=2) + "\n") logger.info("notifications updated for %s (channel=%s)", username, notification_channel or "none") return HTMLResponse(_notifications_page(username, back_persona, success="Notification settings saved.")) diff --git a/cortex/static/notifications.html b/cortex/static/notifications.html index 17208d6..07e8a71 100644 --- a/cortex/static/notifications.html +++ b/cortex/static/notifications.html @@ -318,6 +318,58 @@ + +
+

Home Assistant

+

+ Receive events from HA automations and let Inara call the HA REST API + (read states, control devices). Webhook ID is the shared secret used in your + HA rest_command URL. +

+ +
+ Connection +
+

+ HA URL and a Long-Lived Access Token (Profile → scroll to bottom → + Long-Lived Access Tokens → Create Token). +

+
+ + +
+
+ + +
+
+
+ +
+ Inbound webhook (HA → Cortex) +
+

+ The webhook ID is the shared secret in your HA rest_command URL. + Your endpoint: https://cortex.dgrzone.com/webhook/ha/{{ ha_username }}/<webhook_id> +

+
+ + +

Treat this like a password — use a long, random string.

+
+
+
+
+

Google Chat

diff --git a/documentation/TODO__Agents.md b/documentation/TODO__Agents.md index ad3ce0f..0600ae4 100644 --- a/documentation/TODO__Agents.md +++ b/documentation/TODO__Agents.md @@ -116,6 +116,29 @@ Inara reaches out on her own initiative via NC Talk, Google Chat, email, or brow - [x] `POST /api/push/test` + `POST /api/push/reminders/check` — on-demand test endpoints - [x] `push_utils.py` — fixed `pywebpush` 2.x key deserialisation (use `Vapid.from_pem()` instead of passing PEM string) +### [Channel] Home Assistant integration — design & tools +Inara can already receive HA events via `POST /webhook/ha/{username}/{webhook_id}` and +respond via web push. Next steps are deciding what events to send and giving Inara the +ability to act on HA via the REST API. + +- [ ] **Event design** — decide which HA events are worth routing to Inara (security, + climate thresholds, low battery, unexpected device state). Avoid flooding with + high-frequency sensor polling. Per-automation `"tools": true/false` to choose + notify-only vs. agentic response. +- [ ] **Richer payload template** — update `rest_command` in HA to include + `trigger.to_state.attributes`, `area_name`, and `previous_state` so Inara gets + full device context automatically. +- [ ] **HA API tools** — add dedicated orchestrator tools in `cortex/tools/homeassistant.py`: + - `ha_get_state(entity_id)` — current state + attributes of any entity + - `ha_call_service(domain, service, data)` — turn on lights, set HVAC, lock doors, etc. + - `ha_get_states(area=None, domain=None)` — list states with optional filter + - Auth via Long-Lived Access Token stored in `channels.json` under `homeassistant.token` + - HA URL from `channels.json` under `homeassistant.url` +- [ ] **Store HA config in channels.json** — add `url` and `token` fields alongside + `webhook_id` so tools can reach the HA REST API (`https://ha.dgrzone.com`) +- [ ] **`ha_call_service` confirm-required** — destructive actions (locks, alarms) should + go through the confirmation gate + ### [UX] Session delete confirmation The session delete button in the sidebar needs a confirmation step before firing — currently it deletes immediately on click with no undo. A simple `confirm()` dialog or an inline