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:
Scott Idem
2026-05-11 21:18:45 -04:00
parent 1d361fe809
commit ba91de37c5
3 changed files with 95 additions and 0 deletions

View File

@@ -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."))

View File

@@ -318,6 +318,58 @@
</details>
</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 }}/&lt;webhook_id&gt;</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 -->
<div class="section">
<h2>Google Chat</h2>