feat: Home Assistant API tools (ha_get_state, ha_get_states, ha_call_service)

Register three HA orchestrator tools so Inara can read device states and
control devices via the HA REST API. ha_call_service requires admin role
and user confirmation. Also includes accumulated UI fixes (setProcessing
helper, wasNewSession flag cleanup).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-05-11 21:39:35 -04:00
parent ba91de37c5
commit fc6600c33e
5 changed files with 334 additions and 15 deletions

View File

@@ -384,6 +384,16 @@
updateSendBtnTitle();
}
function setProcessing(state) {
if (state) {
headerEmoji.classList.add('processing');
document.body.classList.add('processing');
} else {
headerEmoji.classList.remove('processing');
document.body.classList.remove('processing');
}
}
// ── Settings dropdown ─────────────────────────────────────────
settings_btn_el.addEventListener('click', (e) => {
e.stopPropagation();
@@ -1148,7 +1158,7 @@
// ── Chat fetch + SSE handler ─────────────────────────────────
// Extracted so the retry button can call it without re-adding the
// user message to the DOM or currentHistory.
async function _doSend(payload, thinkingDiv) {
async function _doSend(payload, thinkingDiv, wasNewSession = false) {
try {
const res = await fetch('/chat', {
method: 'POST',
@@ -1238,13 +1248,13 @@
activeController = new AbortController();
sendBtn.style.display = 'none';
stopBtn.style.display = 'flex';
headerEmoji.classList.add('processing');
setProcessing(true);
startRunTimer();
await _doSend(payload, thinkingDiv);
await _doSend(payload, thinkingDiv, false);
activeController = null;
headerEmoji.classList.remove('processing');
setProcessing(false);
sendBtn.style.display = 'block';
stopBtn.style.display = 'none';
stopRunTimer();
@@ -1265,7 +1275,7 @@
syncHeight();
sendBtn.style.display = 'none';
stopBtn.style.display = 'flex';
headerEmoji.classList.add('processing');
setProcessing(true);
startRunTimer();
activeController = new AbortController();
@@ -1294,10 +1304,10 @@
persona: CORTEX_PERSONA,
};
await _doSend(payload, thinkingDiv);
await _doSend(payload, thinkingDiv, wasNewSession);
activeController = null;
headerEmoji.classList.remove('processing');
setProcessing(false);
sendBtn.style.display = 'block';
stopBtn.style.display = 'none';
stopRunTimer();
@@ -1437,13 +1447,13 @@
activeController = new AbortController();
sendBtn.style.display = 'none';
stopBtn.style.display = 'flex';
headerEmoji.classList.add('processing');
setProcessing(true);
startRunTimer();
await _doOrchestrate(text, thinkingDiv, userMsgDiv);
activeController = null;
headerEmoji.classList.remove('processing');
setProcessing(false);
sendBtn.style.display = 'block';
stopBtn.style.display = 'none';
stopRunTimer();
@@ -1462,7 +1472,7 @@
syncHeight();
sendBtn.style.display = 'none';
stopBtn.style.display = 'flex';
headerEmoji.classList.add('processing');
setProcessing(true);
startRunTimer();
activeController = new AbortController();
@@ -1476,7 +1486,7 @@
await _doOrchestrate(text, thinkingDiv, userMsgDiv);
activeController = null;
headerEmoji.classList.remove('processing');
setProcessing(false);
sendBtn.style.display = 'block';
stopBtn.style.display = 'none';
stopRunTimer();

View File

@@ -142,6 +142,15 @@
.header-emoji.processing { animation: shimmer 0.75s ease-in-out infinite; }
@keyframes border-pulse {
0%, 100% { box-shadow: inset 0 0 15px var(--amber-glow); }
50% { box-shadow: inset 0 0 30px var(--amber-glow); }
}
body.processing {
animation: border-pulse 1.5s ease-in-out infinite;
}
header .name { font-size: 1.1rem; font-weight: 600; color: var(--accent); }
header .subtitle { font-size: 0.78rem; color: var(--muted); }

View File

@@ -71,6 +71,11 @@ from tools.agent_notes import (
agent_notes_clear as _agent_notes_clear,
)
from tools.agents import spawn_agent as _spawn_agent
from tools.homeassistant import (
ha_get_state as _ha_get_state,
ha_get_states as _ha_get_states,
ha_call_service as _ha_call_service,
)
# ── Declaration imports ───────────────────────────────────────────────────────
@@ -85,7 +90,8 @@ import tools.reminders as _mod_reminders
import tools.scratch as _mod_scratch
import tools.notify as _mod_notify
import tools.agent_notes as _mod_agent_notes
import tools.agents as _mod_agents
import tools.agents as _mod_agents
import tools.homeassistant as _mod_homeassistant
# ── Tool categories — used by the Model Registry UI for grouped checkboxes ───
@@ -109,6 +115,7 @@ TOOL_CATEGORIES: dict[str, list[str]] = {
"Aether Tasks": ["ae_task_list"],
"Agent Notes": ["agent_notes_read", "agent_notes_write", "agent_notes_append", "agent_notes_clear"],
"Agents": ["spawn_agent"],
"Home Assistant": ["ha_get_state", "ha_get_states", "ha_call_service"],
}
# ── Callable registry ─────────────────────────────────────────────────────────
@@ -164,6 +171,9 @@ _CALLABLES: dict[str, callable] = {
"agent_notes_append": _agent_notes_append,
"agent_notes_clear": _agent_notes_clear,
"spawn_agent": _spawn_agent,
"ha_get_state": _ha_get_state,
"ha_get_states": _ha_get_states,
"ha_call_service": _ha_call_service,
}
# ── Role-based access control ─────────────────────────────────────────────────
@@ -185,6 +195,7 @@ TOOL_ROLES: dict[str, str] = {
"nc_talk_send": "admin",
"http_post": "admin",
"nc_talk_history": "admin",
"ha_call_service": "admin",
}
# Tools that require explicit user confirmation before executing.
@@ -196,6 +207,7 @@ CONFIRM_REQUIRED: set[str] = {
"cron_remove",
"reminders_clear",
"http_post",
"ha_call_service",
}
_ROLE_RANK: dict[str, int] = {"user": 0, "admin": 1}
@@ -221,6 +233,7 @@ _ALL_DECLARATIONS: list[types.FunctionDeclaration] = (
+ _mod_ae_tasks.DECLARATIONS
+ _mod_agent_notes.DECLARATIONS
+ _mod_agents.DECLARATIONS
+ _mod_homeassistant.DECLARATIONS
)
# Full Gemini Tool object (all tools — use get_tools_for_role() in production)

View File

@@ -0,0 +1,277 @@
"""
Home Assistant tools — read device states and call services.
Credentials are read automatically from the current user's channels.json:
"homeassistant": {"url": "https://ha.example.com", "token": "<long-lived-token>"}
Configure in Settings → Notifications → Home Assistant.
"""
import json
import logging
import httpx
from google.genai import types
from auth_utils import get_user_channels
from persona import get_user
logger = logging.getLogger(__name__)
_TIMEOUT = 10
# Attributes that are internal/noisy and not useful to show
_SKIP_ATTRS = {
"friendly_name", "icon", "entity_picture", "supported_features",
"supported_color_modes", "color_mode", "min_color_temp_kelvin",
"max_color_temp_kelvin", "min_mireds", "max_mireds",
"assumed_state", "attribution",
}
def _get_ha_cfg() -> tuple[str, str]:
"""Return (base_url, token) from the current user's channels.json."""
channels = get_user_channels(get_user())
ha = channels.get("homeassistant") or {}
url = (ha.get("url") or "").rstrip("/")
token = ha.get("token") or ""
if not url or not token:
raise ValueError(
"Home Assistant not configured — add URL and token in Settings → Notifications."
)
return url, token
def _auth(token: str) -> dict:
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
def _fmt_state(s: dict) -> str:
"""Format a single HA state dict as a compact readable line."""
entity_id = s.get("entity_id", "")
state = s.get("state", "unknown")
attrs = s.get("attributes", {})
name = attrs.get("friendly_name", entity_id)
label = f"{name} ({entity_id})" if name != entity_id else entity_id
useful = {k: v for k, v in attrs.items() if k not in _SKIP_ATTRS}
extra = ""
if useful:
parts = []
for k, v in list(useful.items())[:6]: # cap at 6 attrs per entity
parts.append(f"{k}: {v}")
extra = " [" + ", ".join(parts) + "]"
return f"{label}: {state}{extra}"
async def ha_get_state(entity_id: str) -> str:
"""Return the current state and attributes of a single Home Assistant entity."""
try:
url, token = _get_ha_cfg()
except ValueError as e:
return str(e)
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
resp = await client.get(f"{url}/api/states/{entity_id}", headers=_auth(token))
if resp.status_code == 404:
return f"Entity not found: {entity_id}"
if resp.status_code != 200:
return f"HA API error {resp.status_code}: {resp.text[:400]}"
s = resp.json()
attrs = s.get("attributes", {})
lines = [
f"**{attrs.get('friendly_name', entity_id)}** (`{entity_id}`)",
f"State: **{s.get('state', 'unknown')}**",
]
changed = (s.get("last_changed") or "")[:19].replace("T", " ")
if changed:
lines.append(f"Last changed: {changed} UTC")
useful = {k: v for k, v in attrs.items() if k not in _SKIP_ATTRS}
if useful:
lines.append("Attributes:")
for k, v in useful.items():
lines.append(f" {k}: {v}")
return "\n".join(lines)
except httpx.HTTPError as e:
return f"Connection error: {e}"
except Exception as e:
logger.warning("ha_get_state error: %s", e)
return f"Error: {e}"
async def ha_get_states(domain: str = "", area: str = "") -> str:
"""List HA entity states, optionally filtered by domain (e.g. 'light') or area name."""
try:
url, token = _get_ha_cfg()
except ValueError as e:
return str(e)
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
resp = await client.get(f"{url}/api/states", headers=_auth(token))
if resp.status_code != 200:
return f"HA API error {resp.status_code}: {resp.text[:400]}"
states = resp.json()
if domain:
states = [s for s in states if s.get("entity_id", "").startswith(f"{domain}.")]
if area:
al = area.lower()
states = [s for s in states
if al in (s.get("attributes", {}).get("friendly_name") or "").lower()]
if not states:
filters = [f"domain={domain}"] * bool(domain) + [f"area={area}"] * bool(area)
return "No entities found" + (f" ({', '.join(filters)})" if filters else "")
lines = [f"{len(states)} entit{'y' if len(states) == 1 else 'ies'}:"]
for s in sorted(states, key=lambda x: x.get("entity_id", "")):
lines.append(_fmt_state(s))
return "\n".join(lines)
except httpx.HTTPError as e:
return f"Connection error: {e}"
except Exception as e:
logger.warning("ha_get_states error: %s", e)
return f"Error: {e}"
async def ha_call_service(
domain: str,
service: str,
entity_id: str = "",
data: str = "",
) -> str:
"""Call a Home Assistant service (turn on/off lights, set thermostat, lock doors, etc.)."""
try:
url, token = _get_ha_cfg()
except ValueError as e:
return str(e)
payload: dict = {}
if entity_id:
payload["entity_id"] = entity_id
if data:
try:
extra = json.loads(data)
if isinstance(extra, dict):
payload.update(extra)
except json.JSONDecodeError:
return f"Invalid JSON in data: {data}"
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
resp = await client.post(
f"{url}/api/services/{domain}/{service}",
headers=_auth(token),
json=payload,
)
if resp.status_code not in (200, 201):
return f"HA API error {resp.status_code}: {resp.text[:400]}"
changed = resp.json()
if not changed:
return f"{domain}.{service} called (no state changes reported)."
lines = [f"{domain}.{service}{len(changed)} entity state(s) updated:"]
for s in changed:
lines.append(f" {s.get('entity_id', '')}: {s.get('state', '')}")
return "\n".join(lines)
except httpx.HTTPError as e:
return f"Connection error: {e}"
except Exception as e:
logger.warning("ha_call_service error: %s", e)
return f"Error: {e}"
DECLARATIONS = [
types.FunctionDeclaration(
name="ha_get_state",
description=(
"Get the current state and attributes of a single Home Assistant entity. "
"Use to check if a light is on, read a thermostat temperature, check a "
"door/window sensor, battery level, HVAC mode, etc. "
"entity_id format: domain.name — e.g. light.living_room, switch.garage, "
"climate.ecobee, binary_sensor.front_door, sensor.outdoor_temp."
),
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"entity_id": types.Schema(
type=types.Type.STRING,
description="Full entity ID, e.g. light.living_room or climate.ecobee_main",
),
},
required=["entity_id"],
),
),
types.FunctionDeclaration(
name="ha_get_states",
description=(
"List Home Assistant entity states, optionally filtered by domain or area. "
"Use to survey what devices exist or check multiple entities at once. "
"Domain examples: light, switch, sensor, climate, binary_sensor, lock, cover, "
"media_player, input_boolean. Leave both blank to list everything (can be large)."
),
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"domain": types.Schema(
type=types.Type.STRING,
description="Filter to this domain, e.g. 'light' or 'switch' (optional)",
),
"area": types.Schema(
type=types.Type.STRING,
description="Filter by area name substring match on friendly name (optional)",
),
},
),
),
types.FunctionDeclaration(
name="ha_call_service",
description=(
"Call a Home Assistant service to control a device or trigger an automation. "
"Requires user confirmation before executing. Common examples: "
"domain=light service=turn_on entity_id=light.living_room; "
"domain=light service=turn_off entity_id=light.all; "
"domain=switch service=toggle entity_id=switch.garage; "
"domain=climate service=set_temperature data={\"temperature\":72}; "
"domain=lock service=lock entity_id=lock.front_door; "
"domain=script service=turn_on entity_id=script.goodnight."
),
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"domain": types.Schema(
type=types.Type.STRING,
description="Service domain: light, switch, climate, lock, cover, script, automation, etc.",
),
"service": types.Schema(
type=types.Type.STRING,
description="Service name: turn_on, turn_off, toggle, set_temperature, lock, unlock, open, close, etc.",
),
"entity_id": types.Schema(
type=types.Type.STRING,
description="Target entity ID — omit for services that don't target a specific entity",
),
"data": types.Schema(
type=types.Type.STRING,
description='Extra service data as JSON string, e.g. {"temperature": 72, "hvac_mode": "heat"}',
),
},
required=["domain", "service"],
),
),
]

View File

@@ -90,7 +90,8 @@ Stored in `home/{user}/model_registry.json`.
"models": [
{"id": "m1", "type": "claude_cli", "label": "Sonnet 4.6 (CLI)", "model_name": "claude-sonnet-4-6", "provider": "anthropic", "credential_id": "cli", "context_k": 1000, "tags": []},
{"id": "m2", "type": "gemini_api", "label": "Gemini 2.5 Flash", "model_name": "gemini-2.5-flash", "provider": "google", "account_id": "a1b2", "context_k": 1000, "tags": []},
{"id": "m3", "type": "local_openai", "label": "Gemma 4 E4B", "model_name": "gemma4:e4b", "provider": "local", "host_id": "h1", "context_k": 72, "tags": []}
{"id": "m3", "type": "local_openai", "label": "Gemma 4 E4B", "model_name": "gemma4:e4b", "provider": "local", "host_id": "h1", "context_k": 72, "tags": []},
{"id": "m4", "type": "local_openai", "label": "DeepSeek: V4 Flash", "model_name": "deepseek/deepseek-v4-flash", "provider": "local", "host_id": "h1", "context_k": 750, "reasoning_budget_tokens": 4096, "tags": ["frontier"]}
],
"roles": {
"chat": {"primary": "m1", "backup_1": "m2", "backup_2": "m3"},
@@ -109,6 +110,15 @@ Stored in `home/{user}/model_registry.json`.
| `gemini_api` | Currently: Gemini CLI (gap — see Phase 4) | Should use google-genai SDK |
| `local_openai` | HTTP to OpenAI-compatible endpoint | host_type controls path |
### Optional model fields
| Field | Type | Default | Meaning |
|---|---|---|---|
| `context_k` | int | 32 | Context window in thousands of tokens. Used for compaction budget (75% of window). |
| `max_rounds` | int \| null | null | Per-model tool loop cap. `null` = use global `orchestrator_max_rounds`. Effective limit = `min(per_model, global)`. |
| `tools` | bool | true | Whether this model supports tool calling. `false` = skip tool loop entirely; model gets a plain chat request. |
| `reasoning_budget_tokens` | int \| null | null | Per-model reasoning/thinking budget for models that support it (e.g., DeepSeek V4 via OpenRouter). `null` = no reasoning override. When set, injected as `{"reasoning": {"budget_tokens": <value>}}` in the API call to OpenRouter-compatible endpoints. |
### Built-in model IDs
Always resolvable without a registry entry (used as `.env` role defaults):