feat: Home Assistant settings UI + fix channels.json
notifications.html: add Home Assistant section with two collapsible blocks — Connection (HA URL + Long-Lived Access Token) and Inbound webhook (webhook ID with endpoint URL hint showing the username). Token field uses keep-existing pattern (blank = no change). settings.py: wire ha_url, ha_token, ha_webhook_id through _notifications_page() template substitution and save_notifications() POST handler. Preserves existing HA config fields (persona, tier, role, tools) on save. TODO__Agents.md: add Home Assistant integration planning section (event design, richer payload template, HA API tools). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -67,6 +67,9 @@ def _notifications_page(username: str, back_persona: str = "", success: str = ""
|
|||||||
nc_username = _html.escape(nct.get("nc_username", "") or "")
|
nc_username = _html.escape(nct.get("nc_username", "") or "")
|
||||||
nc_app_password = _html.escape(nct.get("nc_app_password", "") 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 "")
|
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_channel }}", notify_ch)
|
||||||
html = html.replace("{{ notify_email_override }}", notify_email)
|
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_username }}", nc_username)
|
||||||
html = html.replace("{{ nc_app_password }}", nc_app_password)
|
html = html.replace("{{ nc_app_password }}", nc_app_password)
|
||||||
html = html.replace("{{ gc_webhook }}", gc_webhook)
|
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("{{ 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")
|
html = html.replace("{{ help_href }}", f"/help?persona={back_persona}" if back_persona else "/help")
|
||||||
if success:
|
if success:
|
||||||
@@ -309,6 +315,9 @@ async def save_notifications(
|
|||||||
nc_username: str = Form(""),
|
nc_username: str = Form(""),
|
||||||
nc_app_password: str = Form(""),
|
nc_app_password: str = Form(""),
|
||||||
gc_outbound_webhook: 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)
|
username = _get_session_user(request)
|
||||||
if not username:
|
if not username:
|
||||||
@@ -356,6 +365,17 @@ async def save_notifications(
|
|||||||
channels["google_chat"] = {}
|
channels["google_chat"] = {}
|
||||||
channels["google_chat"]["outbound_webhook"] = gc_outbound_webhook.strip()
|
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")
|
channels_path.write_text(json.dumps(channels, indent=2) + "\n")
|
||||||
logger.info("notifications updated for %s (channel=%s)", username, notification_channel or "none")
|
logger.info("notifications updated for %s (channel=%s)", username, notification_channel or "none")
|
||||||
return HTMLResponse(_notifications_page(username, back_persona, success="Notification settings saved."))
|
return HTMLResponse(_notifications_page(username, back_persona, success="Notification settings saved."))
|
||||||
|
|||||||
@@ -318,6 +318,58 @@
|
|||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Home Assistant -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Home Assistant</h2>
|
||||||
|
<p class="hint" style="margin-bottom:1rem;">
|
||||||
|
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 <code>rest_command</code> URL.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<details class="channel-block" {{ ha_url and 'open' or '' }}>
|
||||||
|
<summary>Connection</summary>
|
||||||
|
<div class="channel-block-body">
|
||||||
|
<p class="channel-hint">
|
||||||
|
HA URL and a Long-Lived Access Token (Profile → scroll to bottom →
|
||||||
|
Long-Lived Access Tokens → Create Token).
|
||||||
|
</p>
|
||||||
|
<div class="field">
|
||||||
|
<label for="ha_url">Home Assistant URL</label>
|
||||||
|
<input type="url" id="ha_url" name="ha_url"
|
||||||
|
value="{{ ha_url }}"
|
||||||
|
placeholder="https://ha.yourdomain.com"
|
||||||
|
autocomplete="off" spellcheck="false">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="ha_token">Long-Lived Access Token</label>
|
||||||
|
<input type="password" id="ha_token" name="ha_token"
|
||||||
|
value=""
|
||||||
|
placeholder="Leave blank to keep existing token"
|
||||||
|
autocomplete="new-password" spellcheck="false">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="channel-block" {{ ha_webhook_id and 'open' or '' }}>
|
||||||
|
<summary>Inbound webhook (HA → Cortex)</summary>
|
||||||
|
<div class="channel-block-body">
|
||||||
|
<p class="channel-hint">
|
||||||
|
The webhook ID is the shared secret in your HA <code>rest_command</code> URL.
|
||||||
|
Your endpoint: <code>https://cortex.dgrzone.com/webhook/ha/{{ ha_username }}/<webhook_id></code>
|
||||||
|
</p>
|
||||||
|
<div class="field">
|
||||||
|
<label for="ha_webhook_id">Webhook ID</label>
|
||||||
|
<input type="text" id="ha_webhook_id" name="ha_webhook_id"
|
||||||
|
value="{{ ha_webhook_id }}"
|
||||||
|
placeholder="Paste or generate a random secret"
|
||||||
|
autocomplete="off" spellcheck="false">
|
||||||
|
<p class="hint">Treat this like a password — use a long, random string.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Google Chat -->
|
<!-- Google Chat -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>Google Chat</h2>
|
<h2>Google Chat</h2>
|
||||||
|
|||||||
@@ -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] `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)
|
- [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
|
### [UX] Session delete confirmation
|
||||||
The session delete button in the sidebar needs a confirmation step before firing — currently
|
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
|
it deletes immediately on click with no undo. A simple `confirm()` dialog or an inline
|
||||||
|
|||||||
Reference in New Issue
Block a user