feat(ha-mcp): read-only MCP server (faza 2a)
Own minimal MCP server exposing the live state of the HA instances in
services/home-assistant/instances.yaml to Claude Code over stdio — the
phase-2 "MCP read-only" gate in services/home-assistant/DESIGN.md.
Operator decision 2026-07-30: build our own rather than adopt hass-mcp,
so the tools reuse scripts/ha/lib/{ha_api,ha_ws}.py (one token-handling
story for the whole HA toolchain) and can answer from the repo and from
instances.yaml, which a generic server cannot.
Seven tools, all read-only, default instance `ken`: list_entities,
get_state, get_areas, find_entities_by_description, read_automation,
list_automations, instance_status.
Read-only by construction, not by policy: REST goes through ha_api.Client
(get/get_raw_text only — no POST method exists), WebSocket commands are
checked against a three-entry *_list allowlist before being sent, and
read_automation reads services/home-assistant/config/<instance>/ rather
than /api/config. Tests assert all three, including a grep guard that
fails if requests.post/call_service ever appears in the package. The
write path stays repo + scripts/ha/deploy.sh.
Details that follow from how this instance actually behaves:
- unavailable is never silent — every entity view carries unavailable +
unavailable_since, every list a count. The 2026-07-23 audit traced ~15
silently dead automations to conditions sitting on dead sensors.
- chelsty-ha (status: offline in instances.yaml) is answered from the
file, never dialed — no 5s timeout for a known-offline LTE site.
- areas come from the WS registries (entity area_id > device area_id) with
a storage-export fallback; area_source/area_note say which was used and
what the offline export cannot resolve.
- PL->EN fuzzy matching, since the house is Polish and the entity_ids are
transliterated English: "czujnik temperatury salon" ->
sensor.thsalon_temperature, each hit explaining why it matched.
- 5s timeouts and errors returned as {"error": ...} inside a normal tool
result — a missing token or an unreachable instance never crashes the
server or hangs the agent.
Registered for Claude Code in the repo-root .mcp.json (new file) as `ha`,
via services/ha-mcp/run.sh (prefers the venv, falls back to system
python3). The mcp SDK lives in services/ha-mcp/.venv — rationale for venv
over --break-system-packages is in the README.
Tests: 42 offline (no network, no HA, no token) + a live read-only smoke
against ken — HA 2026.7.2, 1647 entities, 377 unavailable, 115
automations, 13 areas.
This commit is contained in:
parent
cb8a19de83
commit
0650eb857a
9
.mcp.json
Normal file
9
.mcp.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ha": {
|
||||||
|
"command": "./services/ha-mcp/run.sh",
|
||||||
|
"args": [],
|
||||||
|
"env": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
259
services/ha-mcp/README.md
Normal file
259
services/ha-mcp/README.md
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
# ha-mcp — read-only MCP server for Home Assistant
|
||||||
|
|
||||||
|
**Status: phase 2a** of `services/home-assistant/DESIGN.md` — own minimal MCP
|
||||||
|
server (the alternative, adopting `hass-mcp`, was the other option in that
|
||||||
|
document's Open questions; operator decision 2026-07-30: build our own).
|
||||||
|
|
||||||
|
Exposes the live state of the HA instances defined in
|
||||||
|
`services/home-assistant/instances.yaml` to Claude Code over stdio, so an
|
||||||
|
agent can reason about the real house — which entities exist, which are dead,
|
||||||
|
what an automation actually contains — without going near the import/deploy
|
||||||
|
paths.
|
||||||
|
|
||||||
|
## Read-only by construction
|
||||||
|
|
||||||
|
This is not a policy, a flag, or a prompt instruction — there is no code path
|
||||||
|
in this server that changes anything in Home Assistant:
|
||||||
|
|
||||||
|
- REST goes through `scripts/ha/lib/ha_api.py`, whose `Client` exposes exactly
|
||||||
|
`get` and `get_raw_text`. There is no `post`/`put`/`delete` method to call.
|
||||||
|
- WebSocket goes through `scripts/ha/lib/ha_ws.py` and every command is checked
|
||||||
|
against `READ_ONLY_WS_COMMANDS` (three `*_list` registry reads) before it is
|
||||||
|
sent. HA's mutating registry commands are not in the allowlist.
|
||||||
|
- `read_automation` reads the repo, not the API.
|
||||||
|
- Both facts are asserted by the test suite
|
||||||
|
(`tests/test_backend_offline.py`), including a grep-level guard that fails
|
||||||
|
if `requests.post` / `call_service` ever appears in this package.
|
||||||
|
|
||||||
|
**The write path back into Home Assistant is unchanged and lives elsewhere:**
|
||||||
|
edit `services/home-assistant/config/<instance>/` in the repo, then
|
||||||
|
`scripts/ha/deploy.sh <instance>` (drift-abort → `check_config` → write →
|
||||||
|
verify). See `services/home-assistant/DESIGN.md`, "Sync model" and
|
||||||
|
"Validation gate". Nothing in this server bypasses that, and nothing in this
|
||||||
|
server should ever learn to.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
The `mcp` SDK (and its pydantic/anyio/httpx dependency tree) is not packaged
|
||||||
|
for Debian and is not needed by anything else in this repo, so it goes into a
|
||||||
|
venv rather than into the system Python:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv --system-site-packages services/ha-mcp/.venv
|
||||||
|
services/ha-mcp/.venv/bin/pip install mcp pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
`--system-site-packages` is deliberate: `requests`, `PyYAML` and
|
||||||
|
`websocket-client` are already installed system-wide and used by
|
||||||
|
`scripts/ha/lib/*`; the venv reuses those exact versions instead of shadowing
|
||||||
|
them with a second copy. `.venv/` is already covered by the repo `.gitignore`.
|
||||||
|
|
||||||
|
`pip install --break-system-packages mcp` also works and is one line shorter,
|
||||||
|
but it writes into the system interpreter that runs every deploy script on
|
||||||
|
this workstation — a venv keeps a 40-package dependency tree out of that blast
|
||||||
|
radius for a tool only Claude Code uses. Use the venv.
|
||||||
|
|
||||||
|
`run.sh` prefers `services/ha-mcp/.venv/bin/python` and falls back to the
|
||||||
|
system `python3`; if neither can import `mcp` it exits with one actionable
|
||||||
|
line on stderr rather than a traceback.
|
||||||
|
|
||||||
|
## Registration in Claude Code
|
||||||
|
|
||||||
|
`.mcp.json` in the repo root (project scope — shared with anyone who checks
|
||||||
|
out this repo):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ha": {
|
||||||
|
"command": "./services/ha-mcp/run.sh",
|
||||||
|
"args": [],
|
||||||
|
"env": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The command path is relative, so it resolves in any checkout (main or task
|
||||||
|
worktree) as long as Claude Code is started from the repo root. Start CC
|
||||||
|
there; on first run it asks whether to trust the project's MCP servers. Check
|
||||||
|
with `/mcp` — the server appears as `ha`, its tools as `mcp__ha__<tool>`.
|
||||||
|
Which repo the server reads is derived from its own location; override with
|
||||||
|
the `HA_MCP_REPO` environment variable if you ever need to point one checkout
|
||||||
|
at another's config.
|
||||||
|
|
||||||
|
## Standalone
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./services/ha-mcp/run.sh # stdio server — speaks JSON-RPC on stdout
|
||||||
|
services/ha-mcp/tests/run.sh # offline test suite (no HA, no token)
|
||||||
|
services/ha-mcp/.venv/bin/python services/ha-mcp/tests/smoke_live.py [instance] [entity_id]
|
||||||
|
```
|
||||||
|
|
||||||
|
`smoke_live.py` is the only thing here that touches the network (GET only). A
|
||||||
|
one-off tool call without a client is easiest through the package:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=services/ha-mcp/src services/ha-mcp/.venv/bin/python -c "
|
||||||
|
from ha_mcp import tools; from ha_mcp.backend import LiveBackend
|
||||||
|
from ha_mcp.config import get_instance, repo_root
|
||||||
|
n, c = get_instance('ken', repo_root()); print(tools.instance_status(LiveBackend(n, c, repo_root())))"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
All seven are read-only, take an optional `instance` (default `ken` — the
|
||||||
|
canonical home instance), and return a JSON object. Failures come back as
|
||||||
|
`{"error": "..."}` inside a normal result: a missing token, an unreachable
|
||||||
|
instance or a typo'd entity_id never crashes the server or leaves a tool call
|
||||||
|
hanging (timeouts are 5 s).
|
||||||
|
|
||||||
|
| Tool | Arguments | Returns |
|
||||||
|
|---|---|---|
|
||||||
|
| `list_entities` | `domain?`, `area?`, `limit?` (200) | entity_id, friendly_name, state, area; `unavailable` flag |
|
||||||
|
| `get_state` | `entity_id` | full state incl. every attribute and area |
|
||||||
|
| `get_areas` | — | areas with entity counts and dead-entity counts |
|
||||||
|
| `find_entities_by_description` | `text` | ≤20 fuzzy hits, each with the reason it matched |
|
||||||
|
| `read_automation` | `id_or_alias` | automation YAML **from the repo** + live `last_triggered` |
|
||||||
|
| `list_automations` | `filter?` | id, alias, state, last_triggered, repo path |
|
||||||
|
| `instance_status` | — | reachability, HA version, entity/automation counts, dead count |
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
`instance_status()` — on the live `ken`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"instance": "ken", "status": "active", "reachable": true,
|
||||||
|
"version": "2026.7.2", "location_name": "KEN",
|
||||||
|
"entity_count": 1647, "unavailable_count": 377,
|
||||||
|
"automation_count": 115, "repo_automations": 115, "area_count": 13,
|
||||||
|
"area_source": "websocket",
|
||||||
|
"unavailable_note": "377 entities are unavailable/unknown — the 2026-07-23 audit traced ~15 silently dead automations to exactly this (…)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`instance_status(instance="chelsty-ha")` — no socket is opened for an instance
|
||||||
|
`instances.yaml` marks offline; you get the recorded status instead of a
|
||||||
|
timeout:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"instance": "chelsty-ha", "status": "offline", "reachable": false,
|
||||||
|
"reason": "status: offline in instances.yaml — not queried. This instance sits behind an intermittent LTE uplink (site chelsty) …"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`find_entities_by_description(text="czujnik temperatury salon")` — Polish
|
||||||
|
description against transliterated English entity_ids:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"entity_id": "sensor.thsalon_temperature",
|
||||||
|
"friendly_name": "thSalon Temperature", "state": "24.5", "area": null,
|
||||||
|
"score": 15, "matched_tokens": "3/3",
|
||||||
|
"why": "'czujnik'->'sensor' in domain=sensor+entity_id; 'temperatury'->'temperature' in name+entity_id; 'salon' in name+entity_id"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Matching is substring + prefix over a diacritic-normalized haystack
|
||||||
|
(entity_id, friendly_name, area) plus a small PL→EN synonym table in
|
||||||
|
`src/ha_mcp/match.py` (`czujnik`→`sensor`, `swiatlo`→`light`, `ruch`→
|
||||||
|
`motion`/`occupancy`, …). Every hit carries `why`, so a wrong hit is
|
||||||
|
diagnosable instead of mysterious.
|
||||||
|
|
||||||
|
`list_entities(area="Salon", domain="light")` — area accepts a name, an
|
||||||
|
`area_id` or a registry alias ("Wejście" → Hall), with or without diacritics:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"count": 1, "unavailable_count": 1, "area_source": "websocket",
|
||||||
|
"entities": [{
|
||||||
|
"entity_id": "light.ledtv", "friendly_name": "LED za TV",
|
||||||
|
"state": "unavailable", "area": "Salon",
|
||||||
|
"unavailable": true, "unavailable_since": "2026-07-29T18:38:39.830594+00:00"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`read_automation(id_or_alias="Klima salon: wyłącz")` — content from the repo,
|
||||||
|
state from the instance:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "1784804668795",
|
||||||
|
"alias": "Klima salon: wyłącz chłodzenie i osusz parownik",
|
||||||
|
"path": "services/home-assistant/config/ken/automations/1784804668795.yaml",
|
||||||
|
"source": "repo (services/home-assistant/config/) — not /api/config",
|
||||||
|
"automation": {"…": "parsed YAML"},
|
||||||
|
"yaml": "actions:\n- choose:\n…",
|
||||||
|
"live": {"entity_id": "automation.klima_salon_wylacz_chlodzenie_i_osusz_parownik",
|
||||||
|
"state": "on", "last_triggered": "2026-07-29T19:41:21.554395+00:00"}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
An id or a unique alias substring both work; an ambiguous substring returns
|
||||||
|
the candidate list rather than guessing. The repo read works with the instance
|
||||||
|
down — you then get `live_error` instead of `live`.
|
||||||
|
|
||||||
|
## Why `unavailable` is in every result
|
||||||
|
|
||||||
|
The 2026-07-23 audit (`services/home-assistant/docs/audyt-automatyzacji-2026-07-23.md`,
|
||||||
|
1.2–1.4) traced ~15 silently broken automations to dead sensors: a condition
|
||||||
|
on an `unavailable` entity is simply never true, and HA reports no error. So
|
||||||
|
every entity view here carries an explicit `unavailable: true` plus
|
||||||
|
`unavailable_since`, every list reports `unavailable_count`, and `get_areas`
|
||||||
|
counts dead entities per room. An agent reading this instance through these
|
||||||
|
tools cannot conclude "the automation looks fine" without seeing that its
|
||||||
|
trigger is dead.
|
||||||
|
|
||||||
|
## Areas
|
||||||
|
|
||||||
|
Area assignment is not in `/api/states`; it comes from the registries over
|
||||||
|
WebSocket (`area_registry` + `entity_registry` + `device_registry`, an
|
||||||
|
entity's own `area_id` winning over its device's — the same precedence HA
|
||||||
|
uses). Every result says where its areas came from in `area_source`:
|
||||||
|
|
||||||
|
- `websocket` — live registries (~1470 of ~1650 entities resolve; the rest
|
||||||
|
genuinely have no area assigned in HA, e.g. the thSalon device).
|
||||||
|
- `storage-export` — offline fallback from
|
||||||
|
`services/home-assistant/storage-export/<instance>/`, used when the
|
||||||
|
WebSocket is unreachable or `websocket-client` is missing. Incomplete by
|
||||||
|
nature: that curated export has no device registry, so only entities with
|
||||||
|
an explicit `area_id` resolve. `area_note` says so in the result.
|
||||||
|
|
||||||
|
## Tokens
|
||||||
|
|
||||||
|
Same rule as the rest of `scripts/ha/`: the token is read from the
|
||||||
|
`token_path` in `instances.yaml` (`~/.config/ha-deploy/<instance>.token`,
|
||||||
|
`chmod 600`) by `ha_api.read_token`, in-process. It never appears in argv, in
|
||||||
|
a log line, in a tool result, or in this repo. A missing token is a reported
|
||||||
|
tool error naming the path it looked at — not a crash, and not a silent empty
|
||||||
|
result.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
services/ha-mcp/tests/run.sh # 42 tests, offline
|
||||||
|
```
|
||||||
|
|
||||||
|
Offline in the same sense as `scripts/ha/tests/*`: no network, no HA
|
||||||
|
instance, no token. Small hand-made fixtures under `tests/fixtures/` (shaped
|
||||||
|
like real `/api/states` and WebSocket registry payloads) cover the tool logic;
|
||||||
|
the repo's own `config/ken/automations/` and newest `fixtures/ken-states-*.yaml`
|
||||||
|
cover the repo-backed and at-scale paths (ranking over ~1650 real entities
|
||||||
|
behaves differently from ranking over ten).
|
||||||
|
|
||||||
|
Live smoke (read-only, run against `ken` on 2026-07-30):
|
||||||
|
`instance_status` → HA 2026.7.2, 1647 entities, 377 unavailable, 115
|
||||||
|
automations, 13 areas; `get_state("sensor.thsalon_temperature")` → `24.5 °C`.
|
||||||
|
|
||||||
|
## Not a deployed service
|
||||||
|
|
||||||
|
No `docker-compose.yml`, no `service.yaml`, no `healthcheck.sh`: this is
|
||||||
|
dev-station tooling that Claude Code spawns over stdio for the duration of a
|
||||||
|
session, not a container that runs on a node. It has no `owner_node`, no
|
||||||
|
exposure, and nothing in `hosts/*/services.yaml` refers to it — the same way
|
||||||
|
`scripts/ha/` is repo tooling rather than a service. Phase 3 of DESIGN.md
|
||||||
|
(agents proposing changes) is where a long-running process may appear; it is
|
||||||
|
not this.
|
||||||
8
services/ha-mcp/requirements.txt
Normal file
8
services/ha-mcp/requirements.txt
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# ha-mcp — dev-station tool, not a deployed service (no image, no compose).
|
||||||
|
# Install into services/ha-mcp/.venv; see README.md, "Install".
|
||||||
|
mcp>=2.0
|
||||||
|
# Also required, and already present system-wide on the dev station /
|
||||||
|
# control nodes (used by scripts/ha/lib/*): requests, PyYAML,
|
||||||
|
# websocket-client. The venv is created with --system-site-packages so it
|
||||||
|
# reuses them instead of shadowing the versions the rest of scripts/ha uses.
|
||||||
|
pytest # tests only
|
||||||
25
services/ha-mcp/run.sh
Executable file
25
services/ha-mcp/run.sh
Executable file
|
|
@ -0,0 +1,25 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Launch the ha-mcp stdio server. Referenced by the repo-root .mcp.json.
|
||||||
|
#
|
||||||
|
# Picks the venv at services/ha-mcp/.venv when it exists (see README.md,
|
||||||
|
# "Install"), otherwise falls back to the system python3 — which works only
|
||||||
|
# if the `mcp` SDK is installed there. Never prints to stdout: stdout is the
|
||||||
|
# JSON-RPC channel and any stray byte breaks the protocol.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SERVICE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
VENV_PY="$SERVICE_DIR/.venv/bin/python"
|
||||||
|
|
||||||
|
if [[ -x "$VENV_PY" ]]; then
|
||||||
|
PY="$VENV_PY"
|
||||||
|
else
|
||||||
|
PY="$(command -v python3)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! "$PY" -c "import mcp" 2>/dev/null; then
|
||||||
|
echo "ha-mcp: the 'mcp' SDK is not importable by $PY — see services/ha-mcp/README.md, 'Install'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export PYTHONPATH="$SERVICE_DIR/src${PYTHONPATH:+:$PYTHONPATH}"
|
||||||
|
exec "$PY" -m ha_mcp.server "$@"
|
||||||
9
services/ha-mcp/src/ha_mcp/__init__.py
Normal file
9
services/ha-mcp/src/ha_mcp/__init__.py
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
"""Read-only MCP server for Home Assistant instances (DESIGN.md, phase 2a).
|
||||||
|
|
||||||
|
Read-only by construction: the backend only ever issues HTTP GET (via
|
||||||
|
scripts/ha/lib/ha_api.py, which has no POST method) and WebSocket commands
|
||||||
|
from an explicit allowlist of `*_list`/`*/get` reads. The write path back
|
||||||
|
into HA stays where DESIGN.md put it: repo + scripts/ha/deploy.sh.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
280
services/ha-mcp/src/ha_mcp/backend.py
Normal file
280
services/ha-mcp/src/ha_mcp/backend.py
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
"""Data sources behind the tools: live HA instance + repo files.
|
||||||
|
|
||||||
|
Everything the tools need is behind one small interface so the offline test
|
||||||
|
suite can substitute a fake (see tests/test_tools_offline.py) without any
|
||||||
|
network or HA instance:
|
||||||
|
|
||||||
|
states() -> list of /api/states dicts
|
||||||
|
ha_config() -> /api/config dict
|
||||||
|
area_index() -> AreaIndex (entity_id -> area name, area -> entity count)
|
||||||
|
automation_files()-> list of (repo-relative path, absolute path)
|
||||||
|
|
||||||
|
Read-only by construction:
|
||||||
|
|
||||||
|
* REST goes through scripts/ha/lib/ha_api.py, whose Client exposes `get`
|
||||||
|
and `get_raw_text` and nothing else — there is no POST/PUT/DELETE method
|
||||||
|
to call by accident.
|
||||||
|
* WebSocket goes through scripts/ha/lib/ha_ws.py, and every command this
|
||||||
|
module sends must be in READ_ONLY_WS_COMMANDS — an allowlist, checked at
|
||||||
|
call time, so a future edit cannot quietly introduce a mutating command.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from . import config as cfg
|
||||||
|
|
||||||
|
#: Every WebSocket command this server is allowed to send. HA's mutating
|
||||||
|
#: registry commands (`config/*_registry/create|update|delete`) are absent
|
||||||
|
#: deliberately and a non-listed command raises before it hits the socket.
|
||||||
|
READ_ONLY_WS_COMMANDS = frozenset(
|
||||||
|
{
|
||||||
|
"config/area_registry/list",
|
||||||
|
"config/entity_registry/list",
|
||||||
|
"config/device_registry/list",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Short by policy: the MCP server is interactive tooling — a hung tool call
|
||||||
|
#: blocks the agent, so a fast, clear failure beats a slow success.
|
||||||
|
DEFAULT_TIMEOUT = 5
|
||||||
|
|
||||||
|
|
||||||
|
class BackendError(RuntimeError):
|
||||||
|
"""Instance unreachable / token missing / instance not live — reportable."""
|
||||||
|
|
||||||
|
|
||||||
|
def _import_ha_lib(root=None):
|
||||||
|
"""Import scripts/ha/lib/{ha_api,ha_ws} — reuse, don't reimplement."""
|
||||||
|
lib_dir = str(cfg.ha_lib_dir(root))
|
||||||
|
if lib_dir not in sys.path:
|
||||||
|
sys.path.insert(0, lib_dir)
|
||||||
|
import ha_api # noqa: E402 (path set up above)
|
||||||
|
|
||||||
|
return ha_api
|
||||||
|
|
||||||
|
|
||||||
|
class AreaIndex:
|
||||||
|
"""entity_id -> area name, plus the area list, plus where it came from."""
|
||||||
|
|
||||||
|
def __init__(self, areas, entity_area, source, note=None):
|
||||||
|
#: [{"area_id":..., "name":..., "aliases": [...]}]
|
||||||
|
self.areas = areas
|
||||||
|
#: {entity_id: area name}
|
||||||
|
self.entity_area = entity_area
|
||||||
|
#: "websocket" | "storage-export" | "none"
|
||||||
|
self.source = source
|
||||||
|
self.note = note
|
||||||
|
|
||||||
|
def area_of(self, entity_id):
|
||||||
|
return self.entity_area.get(entity_id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def empty(cls, note):
|
||||||
|
return cls([], {}, "none", note)
|
||||||
|
|
||||||
|
|
||||||
|
def _index_from_registries(area_reg, entity_reg, device_reg, source, note=None):
|
||||||
|
"""Build an AreaIndex from raw registry lists (WS or storage-export shape).
|
||||||
|
|
||||||
|
An entity's area is its own `area_id` when set, else its device's — the
|
||||||
|
same precedence HA itself uses. `device_reg` may be None (the offline
|
||||||
|
storage-export has no device registry), in which case device-inherited
|
||||||
|
areas are simply unresolved rather than wrong.
|
||||||
|
"""
|
||||||
|
areas = [
|
||||||
|
{
|
||||||
|
"area_id": a.get("area_id") or a.get("id"),
|
||||||
|
"name": a.get("name"),
|
||||||
|
"aliases": list(a.get("aliases") or []),
|
||||||
|
}
|
||||||
|
for a in (area_reg or [])
|
||||||
|
]
|
||||||
|
names = {a["area_id"]: a["name"] for a in areas if a["area_id"]}
|
||||||
|
device_area = {
|
||||||
|
d.get("id"): d.get("area_id") for d in (device_reg or []) if d.get("id")
|
||||||
|
}
|
||||||
|
entity_area = {}
|
||||||
|
for ent in entity_reg or []:
|
||||||
|
entity_id = ent.get("entity_id")
|
||||||
|
if not entity_id:
|
||||||
|
continue
|
||||||
|
area_id = ent.get("area_id") or device_area.get(ent.get("device_id"))
|
||||||
|
name = names.get(area_id)
|
||||||
|
if name:
|
||||||
|
entity_area[entity_id] = name
|
||||||
|
return AreaIndex(areas, entity_area, source, note)
|
||||||
|
|
||||||
|
|
||||||
|
def load_offline_area_index(instance, root=None):
|
||||||
|
"""Fallback area map from storage-export/<instance>/core.*_registry.yaml.
|
||||||
|
|
||||||
|
Used when the WebSocket path is unavailable (dependency missing,
|
||||||
|
instance unreachable). Incomplete by nature: the curated export has no
|
||||||
|
`core.device_registry`, so only entities carrying an explicit `area_id`
|
||||||
|
resolve — the note says so rather than pretending the rest are area-less.
|
||||||
|
"""
|
||||||
|
export_dir = cfg.ha_service_dir(root) / "storage-export" / instance
|
||||||
|
|
||||||
|
def _load(name, key):
|
||||||
|
path = export_dir / name
|
||||||
|
if not path.is_file():
|
||||||
|
return None
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
data = yaml.safe_load(f) or {}
|
||||||
|
return ((data.get("data") or {}).get(key)) or []
|
||||||
|
|
||||||
|
try:
|
||||||
|
area_reg = _load("core.area_registry.yaml", "areas")
|
||||||
|
entity_reg = _load("core.entity_registry.yaml", "entities")
|
||||||
|
except (OSError, yaml.YAMLError) as exc:
|
||||||
|
return AreaIndex.empty(f"storage-export unreadable: {exc}")
|
||||||
|
if area_reg is None and entity_reg is None:
|
||||||
|
return AreaIndex.empty(
|
||||||
|
f"no storage-export for instance '{instance}' and no live websocket"
|
||||||
|
)
|
||||||
|
return _index_from_registries(
|
||||||
|
area_reg,
|
||||||
|
entity_reg,
|
||||||
|
None,
|
||||||
|
"storage-export",
|
||||||
|
"offline fallback: storage-export has no device registry, so entities "
|
||||||
|
"that inherit their area from a device are reported without one",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LiveBackend:
|
||||||
|
"""One HA instance, read through the REST + WebSocket clients in scripts/ha/lib.
|
||||||
|
|
||||||
|
Everything is fetched lazily and cached for the lifetime of the object
|
||||||
|
(one MCP tool call), so a tool that needs states and areas pays for one
|
||||||
|
REST GET and one WS session, not several.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, instance, instance_cfg, root=None, timeout=DEFAULT_TIMEOUT):
|
||||||
|
self.instance = instance
|
||||||
|
self.cfg = instance_cfg
|
||||||
|
self.root = root or cfg.repo_root()
|
||||||
|
self.timeout = timeout
|
||||||
|
self._states = None
|
||||||
|
self._config = None
|
||||||
|
self._area_index = None
|
||||||
|
self._token = None
|
||||||
|
|
||||||
|
# --- plumbing -----------------------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self):
|
||||||
|
return self.cfg.get("status") or "unknown"
|
||||||
|
|
||||||
|
def _require_live(self):
|
||||||
|
"""Refuse network I/O for instances instances.yaml calls offline.
|
||||||
|
|
||||||
|
`chelsty-ha` sits behind an intermittent LTE uplink and is marked
|
||||||
|
`status: offline`; hitting it would burn the timeout on every call
|
||||||
|
and tell the caller nothing instances.yaml did not already say.
|
||||||
|
"""
|
||||||
|
if self.status == "offline":
|
||||||
|
raise BackendError(
|
||||||
|
f"instance '{self.instance}' is marked status: offline in "
|
||||||
|
"instances.yaml — no live query attempted (intermittent LTE "
|
||||||
|
"uplink; see DESIGN.md). Repo-backed tools (read_automation) "
|
||||||
|
"still work."
|
||||||
|
)
|
||||||
|
if not self.cfg.get("base_url"):
|
||||||
|
raise BackendError(f"instance '{self.instance}' has no base_url in instances.yaml")
|
||||||
|
|
||||||
|
def _client(self):
|
||||||
|
self._require_live()
|
||||||
|
ha_api = _import_ha_lib(self.root)
|
||||||
|
if self._token is None:
|
||||||
|
try:
|
||||||
|
self._token = ha_api.read_token(self.cfg.get("token_path"))
|
||||||
|
except ha_api.HaApiError as exc:
|
||||||
|
raise BackendError(str(exc)) from exc
|
||||||
|
return ha_api.Client(self.cfg["base_url"], self._token, timeout=self.timeout)
|
||||||
|
|
||||||
|
def _get(self, path):
|
||||||
|
import requests
|
||||||
|
|
||||||
|
client = self._client()
|
||||||
|
try:
|
||||||
|
return client.get(path)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise BackendError(
|
||||||
|
f"instance '{self.instance}' unreachable at "
|
||||||
|
f"{self.cfg.get('base_url')} ({type(exc).__name__}: {exc})"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
# --- data ---------------------------------------------------------
|
||||||
|
|
||||||
|
def states(self):
|
||||||
|
if self._states is None:
|
||||||
|
data = self._get("/api/states")
|
||||||
|
if not isinstance(data, list):
|
||||||
|
raise BackendError(f"/api/states returned {type(data).__name__}, expected a list")
|
||||||
|
self._states = data
|
||||||
|
return self._states
|
||||||
|
|
||||||
|
def ha_config(self):
|
||||||
|
if self._config is None:
|
||||||
|
data = self._get("/api/config")
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise BackendError(f"/api/config returned {type(data).__name__}, expected an object")
|
||||||
|
self._config = data
|
||||||
|
return self._config
|
||||||
|
|
||||||
|
def _ws_registries(self):
|
||||||
|
"""One WS session, three allowlisted `*_list` reads."""
|
||||||
|
self._require_live()
|
||||||
|
ha_api = _import_ha_lib(self.root)
|
||||||
|
token = self._token or ha_api.read_token(self.cfg.get("token_path"))
|
||||||
|
self._token = token
|
||||||
|
import ha_ws # noqa: E402 (sys.path set up by _import_ha_lib)
|
||||||
|
|
||||||
|
out = {}
|
||||||
|
with ha_ws.HaWsClient(self.cfg["base_url"], token, timeout=self.timeout) as conn:
|
||||||
|
for command in (
|
||||||
|
"config/area_registry/list",
|
||||||
|
"config/entity_registry/list",
|
||||||
|
"config/device_registry/list",
|
||||||
|
):
|
||||||
|
if command not in READ_ONLY_WS_COMMANDS: # pragma: no cover - guard
|
||||||
|
raise BackendError(f"refusing non-allowlisted WS command '{command}'")
|
||||||
|
out[command] = conn.command(command)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def area_index(self):
|
||||||
|
"""Live area map via WebSocket, falling back to storage-export."""
|
||||||
|
if self._area_index is not None:
|
||||||
|
return self._area_index
|
||||||
|
try:
|
||||||
|
reg = self._ws_registries()
|
||||||
|
self._area_index = _index_from_registries(
|
||||||
|
reg["config/area_registry/list"],
|
||||||
|
reg["config/entity_registry/list"],
|
||||||
|
reg["config/device_registry/list"],
|
||||||
|
"websocket",
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 — any WS failure degrades, never crashes
|
||||||
|
index = load_offline_area_index(self.instance, self.root)
|
||||||
|
reason = f"{type(exc).__name__}: {exc}"
|
||||||
|
index.note = f"live websocket unavailable ({reason}); {index.note or ''}".strip("; ")
|
||||||
|
self._area_index = index
|
||||||
|
return self._area_index
|
||||||
|
|
||||||
|
def automation_files(self):
|
||||||
|
"""[(repo-relative path, absolute path)] for config/<instance>/automations/."""
|
||||||
|
return automation_files(self.instance, self.root)
|
||||||
|
|
||||||
|
|
||||||
|
def automation_files(instance, root=None):
|
||||||
|
root = root or cfg.repo_root()
|
||||||
|
directory = cfg.ha_service_dir(root) / "config" / instance / "automations"
|
||||||
|
if not directory.is_dir():
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
(str(Path(p).relative_to(root)), p)
|
||||||
|
for p in sorted(directory.glob("*.yaml"))
|
||||||
|
]
|
||||||
68
services/ha-mcp/src/ha_mcp/config.py
Normal file
68
services/ha-mcp/src/ha_mcp/config.py
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
"""Repo-root discovery and `instances.yaml` access.
|
||||||
|
|
||||||
|
The MCP server is a dev-station tool that reads the repo it lives in: HA
|
||||||
|
instance definitions come from services/home-assistant/instances.yaml (same
|
||||||
|
file scripts/ha/import.sh and deploy.sh read), automations come from
|
||||||
|
services/home-assistant/config/<instance>/automations/.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
DEFAULT_INSTANCE = "ken"
|
||||||
|
|
||||||
|
#: Where scripts/ha/lib lives, so ha_api.py / ha_ws.py can be imported
|
||||||
|
#: instead of reimplemented (DESIGN.md, "Deploy path: adapter per instance").
|
||||||
|
_HA_LIB_RELPATH = "scripts/ha/lib"
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigError(RuntimeError):
|
||||||
|
"""Raised for a missing/broken instances.yaml or an unknown instance."""
|
||||||
|
|
||||||
|
|
||||||
|
def repo_root():
|
||||||
|
"""Absolute path to the repo checkout this server was started from.
|
||||||
|
|
||||||
|
`HA_MCP_REPO` overrides it (useful when the server is launched from a
|
||||||
|
different worktree than the one it should read); otherwise it is derived
|
||||||
|
from this file's location: src/ha_mcp/config.py -> services/ha-mcp -> repo.
|
||||||
|
"""
|
||||||
|
override = os.environ.get("HA_MCP_REPO")
|
||||||
|
if override:
|
||||||
|
return Path(override).expanduser().resolve()
|
||||||
|
return Path(__file__).resolve().parents[4]
|
||||||
|
|
||||||
|
|
||||||
|
def ha_service_dir(root=None):
|
||||||
|
return (root or repo_root()) / "services" / "home-assistant"
|
||||||
|
|
||||||
|
|
||||||
|
def ha_lib_dir(root=None):
|
||||||
|
return (root or repo_root()) / _HA_LIB_RELPATH
|
||||||
|
|
||||||
|
|
||||||
|
def load_instances(root=None):
|
||||||
|
"""Parse instances.yaml -> {name: dict}. Raises ConfigError if unusable."""
|
||||||
|
path = ha_service_dir(root) / "instances.yaml"
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
except OSError as exc:
|
||||||
|
raise ConfigError(f"cannot read {path}: {exc}") from exc
|
||||||
|
except yaml.YAMLError as exc:
|
||||||
|
raise ConfigError(f"{path} is not valid YAML: {exc}") from exc
|
||||||
|
instances = (data or {}).get("instances") or {}
|
||||||
|
if not instances:
|
||||||
|
raise ConfigError(f"{path} defines no instances")
|
||||||
|
return instances
|
||||||
|
|
||||||
|
|
||||||
|
def get_instance(name=None, root=None):
|
||||||
|
"""Return (name, config-dict) for `name` (default: `ken`)."""
|
||||||
|
instances = load_instances(root)
|
||||||
|
name = name or DEFAULT_INSTANCE
|
||||||
|
if name not in instances:
|
||||||
|
known = ", ".join(sorted(instances))
|
||||||
|
raise ConfigError(f"unknown instance '{name}' (known: {known})")
|
||||||
|
return name, dict(instances[name] or {})
|
||||||
144
services/ha-mcp/src/ha_mcp/match.py
Normal file
144
services/ha-mcp/src/ha_mcp/match.py
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
"""Deliberately simple fuzzy matching for find_entities_by_description.
|
||||||
|
|
||||||
|
No embeddings, no Levenshtein: substring + prefix matching over a normalized
|
||||||
|
haystack (entity_id, friendly_name, area), plus a small Polish->English
|
||||||
|
synonym table, because that is what the actual failure mode needs. The
|
||||||
|
instance is a Polish household whose entity_ids are English-ish and
|
||||||
|
transliterated (`sensor.thsalon_temperature`, `binary_sensor.mdwejscie_*`),
|
||||||
|
so an agent asking for "czujnik temperatury salon" has to cross both the
|
||||||
|
language boundary and the missing-diacritics boundary. Everything here is
|
||||||
|
explainable, which is why each hit carries the reason it matched.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
_PL = str.maketrans(
|
||||||
|
{
|
||||||
|
"ą": "a",
|
||||||
|
"ć": "c",
|
||||||
|
"ę": "e",
|
||||||
|
"ł": "l",
|
||||||
|
"ń": "n",
|
||||||
|
"ó": "o",
|
||||||
|
"ś": "s",
|
||||||
|
"ż": "z",
|
||||||
|
"ź": "z",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Polish query word (normalized) -> extra terms to try against the haystack.
|
||||||
|
#: Values are matched exactly the same way the original token is.
|
||||||
|
SYNONYMS = {
|
||||||
|
"czujnik": ["sensor"],
|
||||||
|
"czujniki": ["sensor"],
|
||||||
|
"czujnika": ["sensor"],
|
||||||
|
"temperatura": ["temperature", "temp"],
|
||||||
|
"temperatury": ["temperature", "temp"],
|
||||||
|
"temperature": ["temperature"],
|
||||||
|
"wilgotnosc": ["humidity"],
|
||||||
|
"wilgotnosci": ["humidity"],
|
||||||
|
"swiatlo": ["light", "swiatlo"],
|
||||||
|
"swiatla": ["light", "swiatlo"],
|
||||||
|
"lampa": ["light", "lamp"],
|
||||||
|
"lampka": ["light", "lampka"],
|
||||||
|
"przelacznik": ["switch"],
|
||||||
|
"gniazdko": ["switch", "gniazdk", "socket", "plug"],
|
||||||
|
"gniazdka": ["switch", "gniazdk", "socket", "plug"],
|
||||||
|
"ruch": ["motion", "occupancy", "md"],
|
||||||
|
"ruchu": ["motion", "occupancy", "md"],
|
||||||
|
"obecnosc": ["occupancy", "occu", "presence"],
|
||||||
|
"obecnosci": ["occupancy", "occu", "presence"],
|
||||||
|
"bateria": ["battery"],
|
||||||
|
"baterii": ["battery"],
|
||||||
|
"drzwi": ["door", "contact"],
|
||||||
|
"okno": ["window"],
|
||||||
|
"okna": ["window"],
|
||||||
|
"klima": ["climate", "klima"],
|
||||||
|
"klimatyzacja": ["climate", "klima"],
|
||||||
|
"termostat": ["climate", "thermostat", "trv"],
|
||||||
|
"grzejnik": ["climate", "trv"],
|
||||||
|
"roleta": ["cover"],
|
||||||
|
"zaluzje": ["cover"],
|
||||||
|
"zamek": ["lock"],
|
||||||
|
"kamera": ["camera"],
|
||||||
|
"odkurzacz": ["vacuum"],
|
||||||
|
"glosnik": ["media_player"],
|
||||||
|
"zalanie": ["leak", "water"],
|
||||||
|
"wyciek": ["leak", "water"],
|
||||||
|
"dym": ["smoke"],
|
||||||
|
"automatyzacja": ["automation"],
|
||||||
|
"skrypt": ["script"],
|
||||||
|
"scena": ["scene"],
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Tokens too generic to carry signal — dropped before scoring.
|
||||||
|
STOPWORDS = frozenset({"w", "we", "na", "do", "z", "ze", "i", "the", "a", "of", "in"})
|
||||||
|
|
||||||
|
_PREFIX_LEN = 5
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(text):
|
||||||
|
"""Lowercase, de-diacritic, collapse punctuation to single spaces."""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
lowered = str(text).lower().translate(_PL)
|
||||||
|
return re.sub(r"[^a-z0-9]+", " ", lowered).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def tokenize(text):
|
||||||
|
return [t for t in normalize(text).split() if t and t not in STOPWORDS]
|
||||||
|
|
||||||
|
|
||||||
|
def _term_score(term, entity_id_n, name_n, area_n, domain):
|
||||||
|
"""Score one term against one entity; returns (score, reason-or-None)."""
|
||||||
|
score = 0
|
||||||
|
where = []
|
||||||
|
if term == domain:
|
||||||
|
score += 3
|
||||||
|
where.append(f"domain={domain}")
|
||||||
|
if term in name_n:
|
||||||
|
score += 3
|
||||||
|
where.append("name")
|
||||||
|
elif len(term) >= _PREFIX_LEN and term[:_PREFIX_LEN] in name_n:
|
||||||
|
score += 2
|
||||||
|
where.append("name~")
|
||||||
|
if term in entity_id_n:
|
||||||
|
score += 2
|
||||||
|
where.append("entity_id")
|
||||||
|
elif len(term) >= _PREFIX_LEN and term[:_PREFIX_LEN] in entity_id_n:
|
||||||
|
score += 1
|
||||||
|
where.append("entity_id~")
|
||||||
|
if area_n and term in area_n:
|
||||||
|
score += 3
|
||||||
|
where.append("area")
|
||||||
|
if not score:
|
||||||
|
return 0, None
|
||||||
|
return score, "+".join(where)
|
||||||
|
|
||||||
|
|
||||||
|
def score_entity(tokens, entity_id, friendly_name, area):
|
||||||
|
"""(total score, matched-token count, reason string) for one entity.
|
||||||
|
|
||||||
|
Each query token scores at most once — its own form and its synonyms
|
||||||
|
compete, best one wins — so a token with three synonyms cannot outweigh
|
||||||
|
a token with none.
|
||||||
|
"""
|
||||||
|
entity_id_n = normalize(entity_id)
|
||||||
|
name_n = normalize(friendly_name)
|
||||||
|
area_n = normalize(area)
|
||||||
|
domain = entity_id.split(".", 1)[0] if "." in entity_id else ""
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
matched = 0
|
||||||
|
reasons = []
|
||||||
|
for token in tokens:
|
||||||
|
best = (0, None, None)
|
||||||
|
for term in [token] + SYNONYMS.get(token, []):
|
||||||
|
score, where = _term_score(term, entity_id_n, name_n, area_n, domain)
|
||||||
|
if score > best[0]:
|
||||||
|
best = (score, where, term)
|
||||||
|
if best[0]:
|
||||||
|
total += best[0]
|
||||||
|
matched += 1
|
||||||
|
term_note = f"'{token}'" if best[2] == token else f"'{token}'->'{best[2]}'"
|
||||||
|
reasons.append(f"{term_note} in {best[1]}")
|
||||||
|
return total, matched, "; ".join(reasons)
|
||||||
135
services/ha-mcp/src/ha_mcp/server.py
Normal file
135
services/ha-mcp/src/ha_mcp/server.py
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
"""MCP server (stdio) exposing the read-only HA tools in tools.py.
|
||||||
|
|
||||||
|
Every tool is declared with `read_only_hint=True` / `destructive_hint=False`
|
||||||
|
— but the guarantee is structural, not advisory: this process has no code
|
||||||
|
path that writes to HA (see backend.py).
|
||||||
|
|
||||||
|
Errors never propagate as exceptions: a missing token, an unreachable
|
||||||
|
instance or an unknown entity comes back as `{"error": "..."}` inside a
|
||||||
|
normal tool result, so one bad call cannot take the server down mid-session.
|
||||||
|
|
||||||
|
Logging goes to stderr only — stdout is the JSON-RPC channel.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from mcp.server.mcpserver import MCPServer
|
||||||
|
from mcp.types import ToolAnnotations
|
||||||
|
|
||||||
|
from . import tools
|
||||||
|
from .backend import DEFAULT_TIMEOUT, BackendError, LiveBackend
|
||||||
|
from .config import DEFAULT_INSTANCE, ConfigError, get_instance, repo_root
|
||||||
|
|
||||||
|
logging.basicConfig(stream=sys.stderr, level=logging.INFO, format="ha-mcp: %(message)s")
|
||||||
|
log = logging.getLogger("ha-mcp")
|
||||||
|
|
||||||
|
READ_ONLY = ToolAnnotations(read_only_hint=True, destructive_hint=False, open_world_hint=True)
|
||||||
|
|
||||||
|
mcp = MCPServer(
|
||||||
|
name="ha-mcp",
|
||||||
|
version="0.1.0",
|
||||||
|
instructions=(
|
||||||
|
"Read-only access to the Home Assistant instances defined in "
|
||||||
|
"services/home-assistant/instances.yaml. Default instance: 'ken' (the "
|
||||||
|
"canonical home instance); pass `instance` to target another one. "
|
||||||
|
"These tools cannot change anything in Home Assistant — to change an "
|
||||||
|
"automation, edit services/home-assistant/config/<instance>/ in the repo "
|
||||||
|
"and deploy with scripts/ha/deploy.sh (see services/home-assistant/DESIGN.md). "
|
||||||
|
"Entities reported with unavailable=true are dead: their automations "
|
||||||
|
"silently do not fire."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _backend(instance):
|
||||||
|
name, cfg = get_instance(instance, repo_root())
|
||||||
|
return LiveBackend(name, cfg, repo_root(), timeout=DEFAULT_TIMEOUT)
|
||||||
|
|
||||||
|
|
||||||
|
def _call(fn, instance, **kwargs):
|
||||||
|
"""Run one tool, turning any failure into a readable result payload."""
|
||||||
|
try:
|
||||||
|
backend = _backend(instance)
|
||||||
|
return fn(backend, **kwargs)
|
||||||
|
except (BackendError, ConfigError) as exc:
|
||||||
|
return {"error": str(exc), "instance": instance or DEFAULT_INSTANCE}
|
||||||
|
except Exception as exc: # noqa: BLE001 — the server must survive any tool
|
||||||
|
log.exception("unhandled error in %s", getattr(fn, "__name__", fn))
|
||||||
|
return {
|
||||||
|
"error": f"{type(exc).__name__}: {exc}",
|
||||||
|
"instance": instance or DEFAULT_INSTANCE,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(annotations=READ_ONLY)
|
||||||
|
def list_entities(domain: str | None = None, area: str | None = None,
|
||||||
|
limit: int = tools.DEFAULT_ENTITY_LIMIT,
|
||||||
|
instance: str | None = None) -> dict:
|
||||||
|
"""List Home Assistant entities with state and area.
|
||||||
|
|
||||||
|
Filter by `domain` (e.g. "sensor", "light", "binary_sensor") and/or `area`
|
||||||
|
(area name or area_id, e.g. "Salon"). Entities that are dead are flagged
|
||||||
|
with unavailable=true and unavailable_since.
|
||||||
|
"""
|
||||||
|
return _call(tools.list_entities, instance, domain=domain, area=area, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(annotations=READ_ONLY)
|
||||||
|
def get_state(entity_id: str, instance: str | None = None) -> dict:
|
||||||
|
"""Full state of one entity, including every attribute and its area."""
|
||||||
|
return _call(tools.get_state, instance, entity_id=entity_id)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(annotations=READ_ONLY)
|
||||||
|
def get_areas(instance: str | None = None) -> dict:
|
||||||
|
"""List areas (rooms) with the number of entities in each, dead ones counted separately."""
|
||||||
|
return _call(tools.get_areas, instance)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(annotations=READ_ONLY)
|
||||||
|
def find_entities_by_description(text: str, instance: str | None = None) -> dict:
|
||||||
|
"""Find entities from a loose description, in Polish or English.
|
||||||
|
|
||||||
|
Matches entity_id, friendly_name and area with substring/prefix matching
|
||||||
|
plus a PL->EN synonym table (e.g. "czujnik temperatury salon" ->
|
||||||
|
sensor.thsalon_temperature). Returns up to 20 hits, each with the reason
|
||||||
|
it matched.
|
||||||
|
"""
|
||||||
|
return _call(tools.find_entities_by_description, instance, text=text)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(annotations=READ_ONLY)
|
||||||
|
def read_automation(id_or_alias: str, instance: str | None = None) -> dict:
|
||||||
|
"""Read one automation's YAML from the repo, annotated with its live state.
|
||||||
|
|
||||||
|
Accepts a numeric automation id or (part of) its alias. The content comes
|
||||||
|
from services/home-assistant/config/<instance>/automations/ — the
|
||||||
|
reviewable source of truth — not from the HA API; last_triggered and the
|
||||||
|
on/off state are read from the live instance when it is reachable.
|
||||||
|
"""
|
||||||
|
return _call(tools.read_automation, instance, id_or_alias=id_or_alias)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(annotations=READ_ONLY)
|
||||||
|
def list_automations(filter: str | None = None, instance: str | None = None) -> dict:
|
||||||
|
"""List automations (id, alias, state, last_triggered), optional substring filter."""
|
||||||
|
return _call(tools.list_automations, instance, filter=filter)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(annotations=READ_ONLY)
|
||||||
|
def instance_status(instance: str | None = None) -> dict:
|
||||||
|
"""Reachability, HA version, entity/automation counts and how many entities are dead.
|
||||||
|
|
||||||
|
Instances marked `status: offline` in instances.yaml (chelsty-ha, LTE site)
|
||||||
|
report that status directly instead of burning a network timeout.
|
||||||
|
"""
|
||||||
|
return _call(tools.instance_status, instance)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
log.info("starting stdio server (read-only), repo=%s", repo_root())
|
||||||
|
mcp.run("stdio")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
429
services/ha-mcp/src/ha_mcp/tools.py
Normal file
429
services/ha-mcp/src/ha_mcp/tools.py
Normal file
|
|
@ -0,0 +1,429 @@
|
||||||
|
"""Tool implementations. Pure Python, no MCP imports — see server.py for wiring.
|
||||||
|
|
||||||
|
Two conventions hold across every function here:
|
||||||
|
|
||||||
|
1. **Read-only.** Nothing in this module writes anything, anywhere: not to
|
||||||
|
HA (the backend has no mutating call), not to the repo. The write path
|
||||||
|
is repo + scripts/ha/deploy.sh (DESIGN.md, "Sync model").
|
||||||
|
2. **`unavailable` is never silent.** The 2026-07-23 audit found ~15
|
||||||
|
automations dead because conditions sat on `unavailable` sensors that
|
||||||
|
nobody noticed (docs/audyt-automatyzacji-2026-07-23.md, 1.2). Every
|
||||||
|
entity view carries an explicit `unavailable` flag plus `last_changed`,
|
||||||
|
and every list reports how many of its results are dead, so an agent
|
||||||
|
reasoning about this instance cannot miss it.
|
||||||
|
"""
|
||||||
|
import difflib
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from . import match
|
||||||
|
from .backend import BackendError
|
||||||
|
|
||||||
|
#: Cap on list_entities results — the live instance has ~1650 entities and
|
||||||
|
#: dumping all of them into an agent's context is worse than useless.
|
||||||
|
DEFAULT_ENTITY_LIMIT = 200
|
||||||
|
FIND_LIMIT = 20
|
||||||
|
|
||||||
|
DEAD_STATES = ("unavailable", "unknown")
|
||||||
|
|
||||||
|
|
||||||
|
def _entity_view(state, area=None):
|
||||||
|
attrs = state.get("attributes") or {}
|
||||||
|
value = state.get("state")
|
||||||
|
view = {
|
||||||
|
"entity_id": state.get("entity_id"),
|
||||||
|
"friendly_name": attrs.get("friendly_name"),
|
||||||
|
"state": value,
|
||||||
|
"area": area,
|
||||||
|
}
|
||||||
|
if value in DEAD_STATES:
|
||||||
|
view["unavailable"] = True
|
||||||
|
# How long it has been dead is the actionable part (audit 1.2).
|
||||||
|
view["unavailable_since"] = state.get("last_changed")
|
||||||
|
return view
|
||||||
|
|
||||||
|
|
||||||
|
def _domain(entity_id):
|
||||||
|
return entity_id.split(".", 1)[0] if "." in entity_id else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_area(index, wanted):
|
||||||
|
"""Map user input (name, area_id or registry alias) to the canonical area name.
|
||||||
|
|
||||||
|
Aliases count: the `hall` area is called "Wejście" by everyone in the
|
||||||
|
house, and that alias is in the registry — refusing it would be pedantry.
|
||||||
|
Returns None when nothing matches.
|
||||||
|
"""
|
||||||
|
needle = match.normalize(wanted)
|
||||||
|
for area in index.areas:
|
||||||
|
candidates = [area.get("name"), area.get("area_id")] + list(area.get("aliases") or [])
|
||||||
|
if any(needle == match.normalize(c) for c in candidates if c):
|
||||||
|
return area.get("name") or area.get("area_id")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _count_dead(views):
|
||||||
|
return sum(1 for v in views if v.get("unavailable"))
|
||||||
|
|
||||||
|
|
||||||
|
def _area_meta(index):
|
||||||
|
meta = {"area_source": index.source}
|
||||||
|
if index.note:
|
||||||
|
meta["area_note"] = index.note
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
def list_entities(backend, domain=None, area=None, limit=DEFAULT_ENTITY_LIMIT):
|
||||||
|
"""Entities with state and area, optionally filtered by domain and/or area."""
|
||||||
|
states = backend.states()
|
||||||
|
index = backend.area_index()
|
||||||
|
|
||||||
|
wanted_area = None
|
||||||
|
if area:
|
||||||
|
wanted_area = _resolve_area(index, area)
|
||||||
|
if not wanted_area:
|
||||||
|
known = sorted(a.get("name") or a.get("area_id") for a in index.areas)
|
||||||
|
raise BackendError(
|
||||||
|
f"unknown area '{area}' (known: {', '.join(k for k in known if k) or 'none'})"
|
||||||
|
)
|
||||||
|
|
||||||
|
views = []
|
||||||
|
for state in states:
|
||||||
|
entity_id = state.get("entity_id") or ""
|
||||||
|
if domain and _domain(entity_id) != domain.strip().lower():
|
||||||
|
continue
|
||||||
|
entity_area = index.area_of(entity_id)
|
||||||
|
if wanted_area and entity_area != wanted_area:
|
||||||
|
continue
|
||||||
|
views.append(_entity_view(state, entity_area))
|
||||||
|
|
||||||
|
views.sort(key=lambda v: v["entity_id"])
|
||||||
|
result = {
|
||||||
|
"instance": backend.instance,
|
||||||
|
"filters": {"domain": domain, "area": wanted_area or area},
|
||||||
|
"count": len(views),
|
||||||
|
"unavailable_count": _count_dead(views),
|
||||||
|
"entities": views[:limit],
|
||||||
|
}
|
||||||
|
if len(views) > limit:
|
||||||
|
result["truncated"] = True
|
||||||
|
result["truncated_note"] = (
|
||||||
|
f"showing {limit} of {len(views)} — narrow with domain/area or raise limit"
|
||||||
|
)
|
||||||
|
result.update(_area_meta(index))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_state(backend, entity_id):
|
||||||
|
"""Full state object (all attributes) for one entity."""
|
||||||
|
entity_id = (entity_id or "").strip()
|
||||||
|
if not entity_id:
|
||||||
|
raise BackendError("entity_id is required")
|
||||||
|
states = backend.states()
|
||||||
|
for state in states:
|
||||||
|
if state.get("entity_id") == entity_id:
|
||||||
|
index = backend.area_index()
|
||||||
|
view = _entity_view(state, index.area_of(entity_id))
|
||||||
|
view["attributes"] = state.get("attributes") or {}
|
||||||
|
view["last_changed"] = state.get("last_changed")
|
||||||
|
view["last_updated"] = state.get("last_updated")
|
||||||
|
view["instance"] = backend.instance
|
||||||
|
view.update(_area_meta(index))
|
||||||
|
return view
|
||||||
|
|
||||||
|
# Not found: a typo'd entity_id is the common case, so answer with the
|
||||||
|
# nearest ids by edit distance (difflib, not the fuzzy matcher — this is
|
||||||
|
# an id comparison, not a description).
|
||||||
|
close = difflib.get_close_matches(
|
||||||
|
entity_id, [s.get("entity_id") or "" for s in states], n=5, cutoff=0.7
|
||||||
|
)
|
||||||
|
raise BackendError(
|
||||||
|
f"no entity '{entity_id}' on instance '{backend.instance}'"
|
||||||
|
+ (
|
||||||
|
" — closest: " + ", ".join(close)
|
||||||
|
if close
|
||||||
|
else " (try find_entities_by_description for a description-based search)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_areas(backend):
|
||||||
|
"""Areas from the registry, each with a live entity count."""
|
||||||
|
index = backend.area_index()
|
||||||
|
states = backend.states()
|
||||||
|
|
||||||
|
counts = {}
|
||||||
|
dead = {}
|
||||||
|
unassigned = 0
|
||||||
|
for state in states:
|
||||||
|
entity_id = state.get("entity_id") or ""
|
||||||
|
area = index.area_of(entity_id)
|
||||||
|
if not area:
|
||||||
|
unassigned += 1
|
||||||
|
continue
|
||||||
|
counts[area] = counts.get(area, 0) + 1
|
||||||
|
if state.get("state") in DEAD_STATES:
|
||||||
|
dead[area] = dead.get(area, 0) + 1
|
||||||
|
|
||||||
|
areas = [
|
||||||
|
{
|
||||||
|
"area_id": a.get("area_id"),
|
||||||
|
"name": a.get("name"),
|
||||||
|
"aliases": a.get("aliases") or [],
|
||||||
|
"entity_count": counts.get(a.get("name"), 0),
|
||||||
|
"unavailable_count": dead.get(a.get("name"), 0),
|
||||||
|
}
|
||||||
|
for a in index.areas
|
||||||
|
]
|
||||||
|
areas.sort(key=lambda a: (a["name"] or a["area_id"] or "").lower())
|
||||||
|
result = {
|
||||||
|
"instance": backend.instance,
|
||||||
|
"count": len(areas),
|
||||||
|
"areas": areas,
|
||||||
|
"entities_without_area": unassigned,
|
||||||
|
}
|
||||||
|
result.update(_area_meta(index))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def find_entities_by_description(backend, text, limit=FIND_LIMIT):
|
||||||
|
"""Fuzzy lookup over entity_id / friendly_name / area (see match.py)."""
|
||||||
|
tokens = match.tokenize(text)
|
||||||
|
if not tokens:
|
||||||
|
raise BackendError("give me something to search for (text was empty after normalization)")
|
||||||
|
|
||||||
|
index = backend.area_index()
|
||||||
|
scored = []
|
||||||
|
for state in backend.states():
|
||||||
|
entity_id = state.get("entity_id") or ""
|
||||||
|
attrs = state.get("attributes") or {}
|
||||||
|
area = index.area_of(entity_id)
|
||||||
|
score, matched, reason = match.score_entity(
|
||||||
|
tokens, entity_id, attrs.get("friendly_name"), area
|
||||||
|
)
|
||||||
|
if not score:
|
||||||
|
continue
|
||||||
|
view = _entity_view(state, area)
|
||||||
|
view["score"] = score
|
||||||
|
view["matched_tokens"] = f"{matched}/{len(tokens)}"
|
||||||
|
view["why"] = reason
|
||||||
|
scored.append((matched, score, entity_id, view))
|
||||||
|
|
||||||
|
scored.sort(key=lambda row: (-row[0], -row[1], row[2]))
|
||||||
|
matches = [row[3] for row in scored[:limit]]
|
||||||
|
result = {
|
||||||
|
"instance": backend.instance,
|
||||||
|
"query": text,
|
||||||
|
"tokens": tokens,
|
||||||
|
"total_matches": len(scored),
|
||||||
|
"returned": len(matches),
|
||||||
|
"unavailable_count": _count_dead(matches),
|
||||||
|
"matches": matches,
|
||||||
|
}
|
||||||
|
result.update(_area_meta(index))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# --- automations: repo is the source of truth, live state is annotation ---
|
||||||
|
|
||||||
|
|
||||||
|
def _automation_entries(backend):
|
||||||
|
"""[(id, alias, repo-relative path, absolute path, parsed dict)] from the repo."""
|
||||||
|
entries = []
|
||||||
|
for rel_path, abs_path in backend.automation_files():
|
||||||
|
try:
|
||||||
|
with open(abs_path, "r", encoding="utf-8") as f:
|
||||||
|
parsed = yaml.safe_load(f)
|
||||||
|
except (OSError, yaml.YAMLError):
|
||||||
|
parsed = None
|
||||||
|
parsed = parsed if isinstance(parsed, dict) else {}
|
||||||
|
auto_id = str(parsed.get("id") or abs_path.stem)
|
||||||
|
entries.append((auto_id, parsed.get("alias"), rel_path, abs_path, parsed))
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def _live_automations(backend):
|
||||||
|
"""{automation id: live state dict}, or ({}, error message) if unreachable."""
|
||||||
|
try:
|
||||||
|
states = backend.states()
|
||||||
|
except BackendError as exc:
|
||||||
|
return {}, str(exc)
|
||||||
|
live = {}
|
||||||
|
for state in states:
|
||||||
|
entity_id = state.get("entity_id") or ""
|
||||||
|
if not entity_id.startswith("automation."):
|
||||||
|
continue
|
||||||
|
attrs = state.get("attributes") or {}
|
||||||
|
auto_id = attrs.get("id")
|
||||||
|
record = {
|
||||||
|
"entity_id": entity_id,
|
||||||
|
"state": state.get("state"),
|
||||||
|
"last_triggered": attrs.get("last_triggered"),
|
||||||
|
"friendly_name": attrs.get("friendly_name"),
|
||||||
|
}
|
||||||
|
if auto_id:
|
||||||
|
live[str(auto_id)] = record
|
||||||
|
return live, None
|
||||||
|
|
||||||
|
|
||||||
|
def read_automation(backend, id_or_alias):
|
||||||
|
"""Automation YAML straight from the repo, annotated with live state.
|
||||||
|
|
||||||
|
Repo, not `/api/config/automation/config/<id>`, on purpose: the repo is
|
||||||
|
the reviewable source of truth (DESIGN.md, phase 1), and reading it works
|
||||||
|
even when the instance is unreachable.
|
||||||
|
"""
|
||||||
|
needle = (id_or_alias or "").strip()
|
||||||
|
if not needle:
|
||||||
|
raise BackendError("id_or_alias is required")
|
||||||
|
entries = _automation_entries(backend)
|
||||||
|
if not entries:
|
||||||
|
raise BackendError(
|
||||||
|
f"no automations in the repo for instance '{backend.instance}' "
|
||||||
|
f"(expected services/home-assistant/config/{backend.instance}/automations/)"
|
||||||
|
)
|
||||||
|
|
||||||
|
needle_n = match.normalize(needle)
|
||||||
|
exact = [e for e in entries if e[0] == needle or match.normalize(e[1]) == needle_n]
|
||||||
|
if not exact:
|
||||||
|
exact = [e for e in entries if needle_n and needle_n in match.normalize(e[1])]
|
||||||
|
if not exact:
|
||||||
|
exact = [e for e in entries if needle_n and needle_n in match.normalize(e[0])]
|
||||||
|
|
||||||
|
if not exact:
|
||||||
|
raise BackendError(
|
||||||
|
f"no automation matching '{id_or_alias}' in the repo "
|
||||||
|
f"(searched id and alias of {len(entries)} files)"
|
||||||
|
)
|
||||||
|
if len(exact) > 1:
|
||||||
|
raise BackendError(
|
||||||
|
f"'{id_or_alias}' matches {len(exact)} automations — be more specific: "
|
||||||
|
+ "; ".join(f"{e[0]} ({e[1]})" for e in exact[:10])
|
||||||
|
)
|
||||||
|
|
||||||
|
auto_id, alias, rel_path, abs_path, parsed = exact[0]
|
||||||
|
with open(abs_path, "r", encoding="utf-8") as f:
|
||||||
|
raw = f.read()
|
||||||
|
|
||||||
|
live, live_error = _live_automations(backend)
|
||||||
|
result = {
|
||||||
|
"instance": backend.instance,
|
||||||
|
"id": auto_id,
|
||||||
|
"alias": alias,
|
||||||
|
"path": rel_path,
|
||||||
|
"automation": parsed,
|
||||||
|
"yaml": raw,
|
||||||
|
"source": "repo (services/home-assistant/config/) — not /api/config",
|
||||||
|
}
|
||||||
|
record = live.get(auto_id)
|
||||||
|
if record:
|
||||||
|
result["live"] = record
|
||||||
|
elif live_error:
|
||||||
|
result["live_error"] = live_error
|
||||||
|
else:
|
||||||
|
result["live"] = None
|
||||||
|
result["live_note"] = (
|
||||||
|
"no automation entity with this id on the live instance — "
|
||||||
|
"present in the repo but not loaded (deploy pending, or disabled/removed in HA)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def list_automations(backend, filter=None):
|
||||||
|
"""id / alias / state / last_triggered for every automation, repo-joined."""
|
||||||
|
entries = _automation_entries(backend)
|
||||||
|
by_id = {e[0]: e for e in entries}
|
||||||
|
live, live_error = _live_automations(backend)
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for auto_id in sorted(set(by_id) | set(live)):
|
||||||
|
entry = by_id.get(auto_id)
|
||||||
|
record = live.get(auto_id) or {}
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"id": auto_id,
|
||||||
|
"alias": (entry[1] if entry else None) or record.get("friendly_name"),
|
||||||
|
"state": record.get("state"),
|
||||||
|
"last_triggered": record.get("last_triggered"),
|
||||||
|
"entity_id": record.get("entity_id"),
|
||||||
|
"path": entry[2] if entry else None,
|
||||||
|
"in_repo": entry is not None,
|
||||||
|
"on_instance": auto_id in live,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if filter:
|
||||||
|
needle = match.normalize(filter)
|
||||||
|
rows = [
|
||||||
|
r
|
||||||
|
for r in rows
|
||||||
|
if needle
|
||||||
|
in match.normalize(" ".join(str(v) for v in (r["alias"], r["id"], r["entity_id"]) if v))
|
||||||
|
]
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"instance": backend.instance,
|
||||||
|
"filter": filter,
|
||||||
|
"count": len(rows),
|
||||||
|
"automations": rows,
|
||||||
|
}
|
||||||
|
if live_error:
|
||||||
|
result["live_error"] = live_error
|
||||||
|
result["live_note"] = "state/last_triggered unavailable — listing repo files only"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def instance_status(backend):
|
||||||
|
"""Reachability, HA version, entity counts — or the offline record from instances.yaml."""
|
||||||
|
cfg = backend.cfg
|
||||||
|
result = {
|
||||||
|
"instance": backend.instance,
|
||||||
|
"status": backend.status,
|
||||||
|
"adapter": cfg.get("adapter"),
|
||||||
|
"base_url": cfg.get("base_url"),
|
||||||
|
"site": cfg.get("site"),
|
||||||
|
"repo_automations": len(backend.automation_files()),
|
||||||
|
}
|
||||||
|
|
||||||
|
if backend.status == "offline":
|
||||||
|
# instances.yaml already answers this; a 5s timeout would not add anything.
|
||||||
|
result["reachable"] = False
|
||||||
|
result["reason"] = (
|
||||||
|
"status: offline in instances.yaml — not queried. This instance sits "
|
||||||
|
"behind an intermittent LTE uplink (site chelsty) and is offline-first "
|
||||||
|
"by design; see DESIGN.md and CLAUDE.md, 'CHELSTY-Specific Rules'."
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = backend.ha_config()
|
||||||
|
states = backend.states()
|
||||||
|
except BackendError as exc:
|
||||||
|
result["reachable"] = False
|
||||||
|
result["error"] = str(exc)
|
||||||
|
return result
|
||||||
|
|
||||||
|
dead = [s for s in states if s.get("state") in DEAD_STATES]
|
||||||
|
result.update(
|
||||||
|
{
|
||||||
|
"reachable": True,
|
||||||
|
"version": config.get("version"),
|
||||||
|
"location_name": config.get("location_name"),
|
||||||
|
"time_zone": config.get("time_zone"),
|
||||||
|
"entity_count": len(states),
|
||||||
|
"unavailable_count": len(dead),
|
||||||
|
"automation_count": sum(
|
||||||
|
1 for s in states if (s.get("entity_id") or "").startswith("automation.")
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
index = backend.area_index()
|
||||||
|
result["area_count"] = len(index.areas)
|
||||||
|
result.update(_area_meta(index))
|
||||||
|
if dead:
|
||||||
|
result["unavailable_note"] = (
|
||||||
|
f"{len(dead)} entities are unavailable/unknown — the 2026-07-23 audit "
|
||||||
|
"traced ~15 silently dead automations to exactly this "
|
||||||
|
"(docs/audyt-automatyzacji-2026-07-23.md, 1.2)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
116
services/ha-mcp/tests/conftest.py
Normal file
116
services/ha-mcp/tests/conftest.py
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
"""Offline test rig: a fake backend fed from JSON fixtures.
|
||||||
|
|
||||||
|
Same idea as scripts/ha/tests/test_import_api_offline.sh — no network, no HA
|
||||||
|
instance, no token. The fixtures under tests/fixtures/ are shaped exactly
|
||||||
|
like real `/api/states` and WebSocket registry payloads (trimmed down);
|
||||||
|
read_automation tests read the real repo automations, which is offline too.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
SERVICE_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
REPO_ROOT = SERVICE_DIR.parents[1]
|
||||||
|
FIXTURES = Path(__file__).resolve().parent / "fixtures"
|
||||||
|
|
||||||
|
sys.path.insert(0, str(SERVICE_DIR / "src"))
|
||||||
|
|
||||||
|
from ha_mcp import backend as backend_mod # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def load_fixture(name):
|
||||||
|
with open(FIXTURES / name, encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeBackend:
|
||||||
|
"""Stands in for LiveBackend: canned states/config/registries, real repo files."""
|
||||||
|
|
||||||
|
def __init__(self, instance="ken", states=None, registries=None, config=None,
|
||||||
|
area_source="websocket", automations_instance=None):
|
||||||
|
self.instance = instance
|
||||||
|
self.cfg = {
|
||||||
|
"adapter": "api",
|
||||||
|
"base_url": "http://ha.example:8123",
|
||||||
|
"status": "active",
|
||||||
|
"site": "ken",
|
||||||
|
"token_path": "/nonexistent",
|
||||||
|
}
|
||||||
|
self._states = load_fixture("states.json") if states is None else states
|
||||||
|
reg = load_fixture("registries.json") if registries is None else registries
|
||||||
|
self._config = config or {
|
||||||
|
"version": "2026.7.2",
|
||||||
|
"location_name": "KEN",
|
||||||
|
"time_zone": "Europe/Warsaw",
|
||||||
|
}
|
||||||
|
self._index = backend_mod._index_from_registries(
|
||||||
|
reg.get("areas"), reg.get("entities"), reg.get("devices"), area_source
|
||||||
|
)
|
||||||
|
self._automations_instance = automations_instance or instance
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self):
|
||||||
|
return self.cfg.get("status") or "unknown"
|
||||||
|
|
||||||
|
def states(self):
|
||||||
|
return self._states
|
||||||
|
|
||||||
|
def ha_config(self):
|
||||||
|
return self._config
|
||||||
|
|
||||||
|
def area_index(self):
|
||||||
|
return self._index
|
||||||
|
|
||||||
|
def automation_files(self):
|
||||||
|
return backend_mod.automation_files(self._automations_instance, REPO_ROOT)
|
||||||
|
|
||||||
|
|
||||||
|
class UnreachableBackend(FakeBackend):
|
||||||
|
"""Live calls fail; repo-backed calls still work (read_automation offline path)."""
|
||||||
|
|
||||||
|
def states(self):
|
||||||
|
raise backend_mod.BackendError("instance 'ken' unreachable at http://ha.example:8123")
|
||||||
|
|
||||||
|
def ha_config(self):
|
||||||
|
raise backend_mod.BackendError("instance 'ken' unreachable at http://ha.example:8123")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_backend():
|
||||||
|
return FakeBackend()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def unreachable_backend():
|
||||||
|
return UnreachableBackend()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def repo_root():
|
||||||
|
return REPO_ROOT
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def real_ken_backend():
|
||||||
|
"""FakeBackend over the newest committed /api/states snapshot of `ken`.
|
||||||
|
|
||||||
|
Still offline (the fixture is a file in the repo), but ~1650 real
|
||||||
|
entities instead of ten — ranking behaves differently at that scale.
|
||||||
|
Areas come from the storage-export fallback, same as a live run with the
|
||||||
|
WebSocket unavailable.
|
||||||
|
"""
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from ha_mcp import backend as bm
|
||||||
|
|
||||||
|
snapshots = sorted((REPO_ROOT / "services" / "home-assistant" / "fixtures").glob("ken-states-*.yaml"))
|
||||||
|
if not snapshots:
|
||||||
|
pytest.skip("no ken-states-*.yaml fixture in the repo")
|
||||||
|
with open(snapshots[-1], encoding="utf-8") as f:
|
||||||
|
states = yaml.safe_load(f)
|
||||||
|
|
||||||
|
backend = FakeBackend(states=states)
|
||||||
|
backend._index = bm.load_offline_area_index("ken", REPO_ROOT)
|
||||||
|
return backend
|
||||||
21
services/ha-mcp/tests/fixtures/registries.json
vendored
Normal file
21
services/ha-mcp/tests/fixtures/registries.json
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"areas": [
|
||||||
|
{"area_id": "salon", "name": "Salon", "aliases": []},
|
||||||
|
{"area_id": "hall", "name": "Hall", "aliases": ["Wejście"]},
|
||||||
|
{"area_id": "sypialnia", "name": "Sypialnia", "aliases": []}
|
||||||
|
],
|
||||||
|
"devices": [
|
||||||
|
{"id": "dev_thsalon", "area_id": "salon"},
|
||||||
|
{"id": "dev_mdwejscie", "area_id": "hall"},
|
||||||
|
{"id": "dev_thsypialnia", "area_id": "sypialnia"}
|
||||||
|
],
|
||||||
|
"entities": [
|
||||||
|
{"entity_id": "sensor.thsalon_temperature", "device_id": "dev_thsalon", "area_id": null},
|
||||||
|
{"entity_id": "sensor.thsalon_humidity", "device_id": "dev_thsalon", "area_id": null},
|
||||||
|
{"entity_id": "select.thsalon_temperature_unit", "device_id": "dev_thsalon", "area_id": null},
|
||||||
|
{"entity_id": "sensor.thsypialnia_temperature", "device_id": "dev_thsypialnia", "area_id": null},
|
||||||
|
{"entity_id": "binary_sensor.0x00124b00251472ef_occupancy", "device_id": "dev_mdwejscie", "area_id": null},
|
||||||
|
{"entity_id": "light.salon_lampa", "device_id": null, "area_id": "salon"},
|
||||||
|
{"entity_id": "switch.tasmota_14", "device_id": null, "area_id": null}
|
||||||
|
]
|
||||||
|
}
|
||||||
111
services/ha-mcp/tests/fixtures/states.json
vendored
Normal file
111
services/ha-mcp/tests/fixtures/states.json
vendored
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"entity_id": "sensor.thsalon_temperature",
|
||||||
|
"state": "24.3",
|
||||||
|
"attributes": {
|
||||||
|
"friendly_name": "thSalon Temperature",
|
||||||
|
"device_class": "temperature",
|
||||||
|
"unit_of_measurement": "°C"
|
||||||
|
},
|
||||||
|
"last_changed": "2026-07-27T09:12:03.101000+00:00",
|
||||||
|
"last_updated": "2026-07-27T09:12:03.101000+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entity_id": "sensor.thsalon_humidity",
|
||||||
|
"state": "49.5",
|
||||||
|
"attributes": {
|
||||||
|
"friendly_name": "thSalon Humidity",
|
||||||
|
"device_class": "humidity",
|
||||||
|
"unit_of_measurement": "%"
|
||||||
|
},
|
||||||
|
"last_changed": "2026-07-27T09:12:03.102000+00:00",
|
||||||
|
"last_updated": "2026-07-27T09:12:03.102000+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entity_id": "select.thsalon_temperature_unit",
|
||||||
|
"state": "celsius",
|
||||||
|
"attributes": {
|
||||||
|
"friendly_name": "thSalon Temperature unit",
|
||||||
|
"options": ["celsius", "fahrenheit"]
|
||||||
|
},
|
||||||
|
"last_changed": "2026-07-26T21:14:41.396310+00:00",
|
||||||
|
"last_updated": "2026-07-26T21:14:41.396310+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entity_id": "sensor.thsypialnia_temperature",
|
||||||
|
"state": "unavailable",
|
||||||
|
"attributes": {
|
||||||
|
"friendly_name": "thSypialnia Temperature",
|
||||||
|
"device_class": "temperature"
|
||||||
|
},
|
||||||
|
"last_changed": "2026-07-17T15:07:11.000000+00:00",
|
||||||
|
"last_updated": "2026-07-17T15:07:11.000000+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entity_id": "binary_sensor.0x00124b00251472ef_occupancy",
|
||||||
|
"state": "unavailable",
|
||||||
|
"attributes": {
|
||||||
|
"friendly_name": "mdWejscie Occupancy",
|
||||||
|
"device_class": "occupancy"
|
||||||
|
},
|
||||||
|
"last_changed": "2026-07-17T15:07:12.000000+00:00",
|
||||||
|
"last_updated": "2026-07-17T15:07:12.000000+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entity_id": "light.salon_lampa",
|
||||||
|
"state": "on",
|
||||||
|
"attributes": {
|
||||||
|
"friendly_name": "Lampa salon",
|
||||||
|
"brightness": 180
|
||||||
|
},
|
||||||
|
"last_changed": "2026-07-27T18:40:00.000000+00:00",
|
||||||
|
"last_updated": "2026-07-27T18:40:00.000000+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entity_id": "switch.tasmota_14",
|
||||||
|
"state": "on",
|
||||||
|
"attributes": {
|
||||||
|
"friendly_name": "Tasmota 14"
|
||||||
|
},
|
||||||
|
"last_changed": "2026-03-26T04:00:00.000000+00:00",
|
||||||
|
"last_updated": "2026-03-26T04:00:00.000000+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entity_id": "automation.klima_salon_off",
|
||||||
|
"state": "on",
|
||||||
|
"attributes": {
|
||||||
|
"id": "1784804668795",
|
||||||
|
"friendly_name": "Klima salon: wyłącz chłodzenie i osusz parownik",
|
||||||
|
"last_triggered": "2026-07-27T19:31:00.000000+00:00",
|
||||||
|
"mode": "single",
|
||||||
|
"current": 0
|
||||||
|
},
|
||||||
|
"last_changed": "2026-07-26T21:15:19.374224+00:00",
|
||||||
|
"last_updated": "2026-07-27T19:31:00.000000+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entity_id": "automation.mirror_on",
|
||||||
|
"state": "on",
|
||||||
|
"attributes": {
|
||||||
|
"id": "1636927813132",
|
||||||
|
"friendly_name": "Mirror ON",
|
||||||
|
"last_triggered": "2026-07-24T04:30:00.102503+00:00",
|
||||||
|
"mode": "single",
|
||||||
|
"current": 0
|
||||||
|
},
|
||||||
|
"last_changed": "2026-07-26T21:15:19.375401+00:00",
|
||||||
|
"last_updated": "2026-07-26T21:15:19.375401+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entity_id": "automation.only_on_instance",
|
||||||
|
"state": "off",
|
||||||
|
"attributes": {
|
||||||
|
"id": "9999999999999",
|
||||||
|
"friendly_name": "Created in the UI, never imported",
|
||||||
|
"mode": "single",
|
||||||
|
"current": 0
|
||||||
|
},
|
||||||
|
"last_changed": "2026-07-26T21:15:19.376000+00:00",
|
||||||
|
"last_updated": "2026-07-26T21:15:19.376000+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
13
services/ha-mcp/tests/run.sh
Executable file
13
services/ha-mcp/tests/run.sh
Executable file
|
|
@ -0,0 +1,13 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Run the ha-mcp test suite (offline: no network, no HA instance, no token).
|
||||||
|
# Uses services/ha-mcp/.venv when present, else whatever python3 has pytest.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TESTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
SERVICE_DIR="$(dirname "$TESTS_DIR")"
|
||||||
|
VENV_PY="$SERVICE_DIR/.venv/bin/python"
|
||||||
|
|
||||||
|
PY="${VENV_PY}"
|
||||||
|
[[ -x "$PY" ]] || PY="$(command -v python3)"
|
||||||
|
|
||||||
|
exec "$PY" -m pytest "$TESTS_DIR" "$@"
|
||||||
41
services/ha-mcp/tests/smoke_live.py
Normal file
41
services/ha-mcp/tests/smoke_live.py
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Live read-only smoke test against a real instance (default: `ken`).
|
||||||
|
|
||||||
|
NOT part of the pytest suite: it needs a reachable instance and a token at
|
||||||
|
`token_path`, so it is a separate opt-in script. Read-only, like everything
|
||||||
|
else here — it calls GET /api/config, GET /api/states and the WebSocket
|
||||||
|
`*_list` reads, nothing that writes.
|
||||||
|
|
||||||
|
services/ha-mcp/.venv/bin/python services/ha-mcp/tests/smoke_live.py [instance]
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SERVICE_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(SERVICE_DIR / "src"))
|
||||||
|
|
||||||
|
from ha_mcp import tools # noqa: E402
|
||||||
|
from ha_mcp.backend import LiveBackend # noqa: E402
|
||||||
|
from ha_mcp.config import get_instance, repo_root # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
instance = argv[1] if len(argv) > 1 else None
|
||||||
|
name, cfg = get_instance(instance, repo_root())
|
||||||
|
|
||||||
|
status = tools.instance_status(LiveBackend(name, cfg, repo_root()))
|
||||||
|
print("== instance_status ==")
|
||||||
|
print(json.dumps(status, indent=2, ensure_ascii=False))
|
||||||
|
if not status.get("reachable"):
|
||||||
|
return 0 if status.get("status") == "offline" else 1
|
||||||
|
|
||||||
|
entity_id = argv[2] if len(argv) > 2 else "sensor.thsalon_temperature"
|
||||||
|
print(f"\n== get_state({entity_id}) ==")
|
||||||
|
state = tools.get_state(LiveBackend(name, cfg, repo_root()), entity_id)
|
||||||
|
print(json.dumps(state, indent=2, ensure_ascii=False))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main(sys.argv))
|
||||||
135
services/ha-mcp/tests/test_backend_offline.py
Normal file
135
services/ha-mcp/tests/test_backend_offline.py
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
"""Backend/config tests, plus the structural read-only guarantees.
|
||||||
|
|
||||||
|
No network here either: the token/unreachable paths are exercised with a
|
||||||
|
bogus token_path and a base_url nothing listens on, and the WebSocket
|
||||||
|
allowlist is asserted on the constant, not by dialing an instance.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ha_mcp import backend as backend_mod
|
||||||
|
from ha_mcp import config as cfg
|
||||||
|
from ha_mcp.backend import BackendError, LiveBackend
|
||||||
|
from ha_mcp.config import ConfigError, get_instance
|
||||||
|
|
||||||
|
SERVICE_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
# --- read-only by construction ------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_rest_client_has_no_mutating_method(repo_root):
|
||||||
|
"""scripts/ha/lib/ha_api.Client is GET-only — nothing to call by accident."""
|
||||||
|
sys.path.insert(0, str(cfg.ha_lib_dir(repo_root)))
|
||||||
|
import ha_api
|
||||||
|
|
||||||
|
public = {n for n in dir(ha_api.Client) if not n.startswith("_")}
|
||||||
|
assert public == {"get", "get_raw_text"}
|
||||||
|
for forbidden in ("post", "put", "patch", "delete", "post_config", "call_service"):
|
||||||
|
assert not hasattr(ha_api.Client, forbidden)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ws_allowlist_contains_only_list_reads():
|
||||||
|
for command in backend_mod.READ_ONLY_WS_COMMANDS:
|
||||||
|
assert command.endswith("/list"), command
|
||||||
|
for mutating in (
|
||||||
|
"config/area_registry/create",
|
||||||
|
"config/entity_registry/update",
|
||||||
|
"config/device_registry/update",
|
||||||
|
"call_service",
|
||||||
|
):
|
||||||
|
assert mutating not in backend_mod.READ_ONLY_WS_COMMANDS
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_write_verbs_anywhere_in_the_package():
|
||||||
|
"""A grep-level guard: this package must never learn to POST."""
|
||||||
|
forbidden = ("requests.post", "requests.put", "requests.delete", "call_service", "post_config")
|
||||||
|
for path in (SERVICE_DIR / "src" / "ha_mcp").glob("*.py"):
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
for token in forbidden:
|
||||||
|
# backend.py names the mutating commands only inside comments
|
||||||
|
code = "\n".join(
|
||||||
|
line for line in text.splitlines() if not line.strip().startswith("#")
|
||||||
|
)
|
||||||
|
assert token not in code, f"{path.name} references {token}"
|
||||||
|
|
||||||
|
|
||||||
|
# --- instances.yaml ------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_instance_is_ken(repo_root):
|
||||||
|
name, instance = get_instance(None, repo_root)
|
||||||
|
assert name == "ken"
|
||||||
|
assert instance["adapter"] == "api"
|
||||||
|
assert instance["status"] == "active"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_instance_lists_the_known_ones(repo_root):
|
||||||
|
with pytest.raises(ConfigError) as exc:
|
||||||
|
get_instance("nope", repo_root)
|
||||||
|
assert "ken" in str(exc.value) and "chelsty-ha" in str(exc.value)
|
||||||
|
|
||||||
|
|
||||||
|
# --- failure paths: reportable, never a crash ---------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_token_is_a_readable_error(repo_root, tmp_path):
|
||||||
|
backend = LiveBackend(
|
||||||
|
"ken",
|
||||||
|
{"base_url": "http://127.0.0.1:1", "status": "active", "token_path": str(tmp_path / "absent")},
|
||||||
|
repo_root,
|
||||||
|
)
|
||||||
|
with pytest.raises(BackendError) as exc:
|
||||||
|
backend.states()
|
||||||
|
message = str(exc.value)
|
||||||
|
assert "no HA token" in message
|
||||||
|
assert "absent" in message # says which path it looked at
|
||||||
|
|
||||||
|
|
||||||
|
def test_offline_instance_never_opens_a_socket(repo_root):
|
||||||
|
name, instance = get_instance("chelsty-ha", repo_root)
|
||||||
|
backend = LiveBackend(name, instance, repo_root)
|
||||||
|
with pytest.raises(BackendError) as exc:
|
||||||
|
backend.states()
|
||||||
|
assert "status: offline" in str(exc.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_timeout_default_is_short():
|
||||||
|
assert backend_mod.DEFAULT_TIMEOUT == 5
|
||||||
|
|
||||||
|
|
||||||
|
# --- area index ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_area_index_prefers_entity_area_over_device_area():
|
||||||
|
index = backend_mod._index_from_registries(
|
||||||
|
[{"area_id": "salon", "name": "Salon"}, {"area_id": "hall", "name": "Hall"}],
|
||||||
|
[{"entity_id": "light.x", "area_id": "hall", "device_id": "d1"}],
|
||||||
|
[{"id": "d1", "area_id": "salon"}],
|
||||||
|
"websocket",
|
||||||
|
)
|
||||||
|
assert index.area_of("light.x") == "Hall"
|
||||||
|
|
||||||
|
|
||||||
|
def test_offline_area_index_falls_back_to_storage_export(repo_root):
|
||||||
|
index = backend_mod.load_offline_area_index("ken", repo_root)
|
||||||
|
assert index.source == "storage-export"
|
||||||
|
assert {a["name"] for a in index.areas} >= {"Salon", "Kuchnia", "Sypialnia"}
|
||||||
|
# and it says out loud what the offline export cannot resolve
|
||||||
|
assert "device" in index.note
|
||||||
|
|
||||||
|
|
||||||
|
def test_offline_area_index_for_an_instance_without_an_export(repo_root):
|
||||||
|
index = backend_mod.load_offline_area_index("chelsty-ha", repo_root)
|
||||||
|
assert index.source == "none"
|
||||||
|
assert index.entity_area == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_automation_files_are_repo_relative(repo_root):
|
||||||
|
files = backend_mod.automation_files("ken", repo_root)
|
||||||
|
assert len(files) > 100
|
||||||
|
rel, absolute = files[0]
|
||||||
|
assert rel.startswith("services/home-assistant/config/ken/automations/")
|
||||||
|
assert absolute.is_file()
|
||||||
304
services/ha-mcp/tests/test_tools_offline.py
Normal file
304
services/ha-mcp/tests/test_tools_offline.py
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
"""Offline tests for the seven read-only tools."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ha_mcp import backend as backend_mod
|
||||||
|
from ha_mcp import tools
|
||||||
|
from ha_mcp.backend import BackendError
|
||||||
|
|
||||||
|
SALON_TEMP = "sensor.thsalon_temperature"
|
||||||
|
MDWEJSCIE = "binary_sensor.0x00124b00251472ef_occupancy"
|
||||||
|
KLIMA_OFF_ID = "1784804668795"
|
||||||
|
|
||||||
|
|
||||||
|
# --- list_entities -------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_entities_filters_by_domain(fake_backend):
|
||||||
|
result = tools.list_entities(fake_backend, domain="sensor")
|
||||||
|
ids = [e["entity_id"] for e in result["entities"]]
|
||||||
|
assert ids == [
|
||||||
|
"sensor.thsalon_humidity",
|
||||||
|
SALON_TEMP,
|
||||||
|
"sensor.thsypialnia_temperature",
|
||||||
|
]
|
||||||
|
assert result["count"] == 3
|
||||||
|
# select.thsalon_temperature_unit shares the prefix but not the domain
|
||||||
|
assert "select.thsalon_temperature_unit" not in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_entities_filters_by_area(fake_backend):
|
||||||
|
result = tools.list_entities(fake_backend, area="Salon")
|
||||||
|
ids = {e["entity_id"] for e in result["entities"]}
|
||||||
|
# three via the device registry, one via its own area_id
|
||||||
|
assert ids == {
|
||||||
|
SALON_TEMP,
|
||||||
|
"sensor.thsalon_humidity",
|
||||||
|
"select.thsalon_temperature_unit",
|
||||||
|
"light.salon_lampa",
|
||||||
|
}
|
||||||
|
assert all(e["area"] == "Salon" for e in result["entities"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_entities_area_accepts_id_alias_and_any_casing(fake_backend):
|
||||||
|
assert tools.list_entities(fake_backend, area="salon")["count"] == 4 # area_id / lowercase
|
||||||
|
by_alias = tools.list_entities(fake_backend, area="wejście") # registry alias for Hall
|
||||||
|
assert by_alias["count"] == 1
|
||||||
|
assert by_alias["filters"]["area"] == "Hall" # reported under its canonical name
|
||||||
|
assert tools.list_entities(fake_backend, area="wejscie")["count"] == 1 # no diacritics
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_entities_domain_and_area_combined(fake_backend):
|
||||||
|
result = tools.list_entities(fake_backend, domain="sensor", area="Salon")
|
||||||
|
assert [e["entity_id"] for e in result["entities"]] == [
|
||||||
|
"sensor.thsalon_humidity",
|
||||||
|
SALON_TEMP,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_entities_unknown_area_is_an_error_not_an_empty_list(fake_backend):
|
||||||
|
with pytest.raises(BackendError) as exc:
|
||||||
|
tools.list_entities(fake_backend, area="Piwnica")
|
||||||
|
assert "unknown area" in str(exc.value)
|
||||||
|
assert "Salon" in str(exc.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_entities_marks_unavailable(fake_backend):
|
||||||
|
result = tools.list_entities(fake_backend)
|
||||||
|
by_id = {e["entity_id"]: e for e in result["entities"]}
|
||||||
|
|
||||||
|
dead = by_id[MDWEJSCIE]
|
||||||
|
assert dead["unavailable"] is True
|
||||||
|
# how long it has been dead is the actionable part (audit 1.2)
|
||||||
|
assert dead["unavailable_since"] == "2026-07-17T15:07:12.000000+00:00"
|
||||||
|
|
||||||
|
alive = by_id[SALON_TEMP]
|
||||||
|
assert "unavailable" not in alive
|
||||||
|
|
||||||
|
assert result["unavailable_count"] == 2 # mdwejscie + thsypialnia
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_entities_truncates_loudly(fake_backend):
|
||||||
|
result = tools.list_entities(fake_backend, limit=2)
|
||||||
|
assert len(result["entities"]) == 2
|
||||||
|
assert result["truncated"] is True
|
||||||
|
assert result["count"] == 10 # the full match count is still reported
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_entities_reports_where_areas_came_from(fake_backend):
|
||||||
|
assert tools.list_entities(fake_backend)["area_source"] == "websocket"
|
||||||
|
|
||||||
|
|
||||||
|
# --- get_state -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_state_returns_full_attributes_and_area(fake_backend):
|
||||||
|
result = tools.get_state(fake_backend, SALON_TEMP)
|
||||||
|
assert result["state"] == "24.3"
|
||||||
|
assert result["area"] == "Salon"
|
||||||
|
assert result["attributes"]["unit_of_measurement"] == "°C"
|
||||||
|
assert result["attributes"]["device_class"] == "temperature"
|
||||||
|
assert result["last_changed"] == "2026-07-27T09:12:03.101000+00:00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_state_flags_unavailable(fake_backend):
|
||||||
|
result = tools.get_state(fake_backend, MDWEJSCIE)
|
||||||
|
assert result["unavailable"] is True
|
||||||
|
assert result["state"] == "unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_state_unknown_entity_suggests_candidates(fake_backend):
|
||||||
|
with pytest.raises(BackendError) as exc:
|
||||||
|
tools.get_state(fake_backend, "sensor.thsalon_temperatur")
|
||||||
|
message = str(exc.value)
|
||||||
|
assert "no entity" in message
|
||||||
|
assert SALON_TEMP in message # closest match offered, not just a flat error
|
||||||
|
|
||||||
|
|
||||||
|
# --- get_areas -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_areas_counts_entities_and_dead_ones(fake_backend):
|
||||||
|
result = tools.get_areas(fake_backend)
|
||||||
|
by_name = {a["name"]: a for a in result["areas"]}
|
||||||
|
assert by_name["Salon"]["entity_count"] == 4
|
||||||
|
assert by_name["Salon"]["unavailable_count"] == 0
|
||||||
|
assert by_name["Hall"]["entity_count"] == 1
|
||||||
|
assert by_name["Hall"]["unavailable_count"] == 1
|
||||||
|
assert by_name["Sypialnia"]["unavailable_count"] == 1
|
||||||
|
assert by_name["Hall"]["aliases"] == ["Wejście"]
|
||||||
|
# switch.tasmota_14 + the three automations have no area
|
||||||
|
assert result["entities_without_area"] == 4
|
||||||
|
|
||||||
|
|
||||||
|
# --- find_entities_by_description ---------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_entities_by_description_finds_the_salon_thermometer(fake_backend):
|
||||||
|
result = tools.find_entities_by_description(fake_backend, "czujnik temperatury salon")
|
||||||
|
top = result["matches"][0]
|
||||||
|
assert top["entity_id"] == SALON_TEMP
|
||||||
|
assert top["matched_tokens"] == "3/3"
|
||||||
|
# the reason is human-readable and names the PL->EN bridge it crossed
|
||||||
|
assert "'czujnik'->'sensor'" in top["why"]
|
||||||
|
assert "'temperatury'->'temperature'" in top["why"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_entities_ranks_full_token_matches_first(fake_backend):
|
||||||
|
result = tools.find_entities_by_description(fake_backend, "czujnik temperatury salon")
|
||||||
|
ids = [m["entity_id"] for m in result["matches"]]
|
||||||
|
assert ids.index(SALON_TEMP) < ids.index("sensor.thsalon_humidity")
|
||||||
|
assert ids.index(SALON_TEMP) < ids.index("light.salon_lampa")
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_entities_matches_by_area(fake_backend):
|
||||||
|
result = tools.find_entities_by_description(fake_backend, "sypialnia")
|
||||||
|
assert result["matches"][0]["entity_id"] == "sensor.thsypialnia_temperature"
|
||||||
|
assert result["matches"][0]["unavailable"] is True
|
||||||
|
assert result["unavailable_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_entities_caps_at_20(fake_backend):
|
||||||
|
states = [
|
||||||
|
{"entity_id": f"sensor.salon_{i}", "state": "1", "attributes": {"friendly_name": f"Salon {i}"}}
|
||||||
|
for i in range(40)
|
||||||
|
]
|
||||||
|
backend = type(fake_backend)(states=states)
|
||||||
|
result = tools.find_entities_by_description(backend, "salon")
|
||||||
|
assert result["total_matches"] == 40
|
||||||
|
assert result["returned"] == 20
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_entities_empty_query_errors(fake_backend):
|
||||||
|
with pytest.raises(BackendError):
|
||||||
|
tools.find_entities_by_description(fake_backend, " ")
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_entities_ranks_first_against_the_real_1600_entity_snapshot(real_ken_backend):
|
||||||
|
"""Ten hits differently at scale: 672 of ~1650 entities match at least one token."""
|
||||||
|
result = tools.find_entities_by_description(real_ken_backend, "czujnik temperatury salon")
|
||||||
|
assert result["total_matches"] > 100
|
||||||
|
assert result["matches"][0]["entity_id"] == SALON_TEMP
|
||||||
|
assert result["matches"][0]["state"] # a real reading, not a placeholder
|
||||||
|
|
||||||
|
|
||||||
|
# --- read_automation (repo-backed) --------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_automation_by_alias_returns_the_klima_file(fake_backend):
|
||||||
|
result = tools.read_automation(fake_backend, "Klima salon: wyłącz")
|
||||||
|
assert result["id"] == KLIMA_OFF_ID
|
||||||
|
assert result["path"] == (
|
||||||
|
f"services/home-assistant/config/ken/automations/{KLIMA_OFF_ID}.yaml"
|
||||||
|
)
|
||||||
|
assert result["alias"] == "Klima salon: wyłącz chłodzenie i osusz parownik"
|
||||||
|
assert "script.klima_salon_dry_off" in result["yaml"]
|
||||||
|
assert result["automation"]["mode"] == "single"
|
||||||
|
assert result["source"].startswith("repo")
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_automation_annotates_with_live_state(fake_backend):
|
||||||
|
result = tools.read_automation(fake_backend, KLIMA_OFF_ID)
|
||||||
|
assert result["live"]["state"] == "on"
|
||||||
|
assert result["live"]["last_triggered"] == "2026-07-27T19:31:00.000000+00:00"
|
||||||
|
assert result["live"]["entity_id"] == "automation.klima_salon_off"
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_automation_works_with_the_instance_down(unreachable_backend):
|
||||||
|
result = tools.read_automation(unreachable_backend, KLIMA_OFF_ID)
|
||||||
|
assert "script.klima_salon_dry_off" in result["yaml"] # repo read still succeeds
|
||||||
|
assert "unreachable" in result["live_error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_automation_ambiguous_alias_lists_candidates(fake_backend):
|
||||||
|
with pytest.raises(BackendError) as exc:
|
||||||
|
tools.read_automation(fake_backend, "Klima salon")
|
||||||
|
message = str(exc.value)
|
||||||
|
assert "matches 2 automations" in message
|
||||||
|
assert KLIMA_OFF_ID in message
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_automation_unknown_errors_cleanly(fake_backend):
|
||||||
|
with pytest.raises(BackendError) as exc:
|
||||||
|
tools.read_automation(fake_backend, "nie ma takiej automatyzacji")
|
||||||
|
assert "no automation matching" in str(exc.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_automation_does_not_escape_the_automations_dir(fake_backend):
|
||||||
|
with pytest.raises(BackendError):
|
||||||
|
tools.read_automation(fake_backend, "../../../etc/passwd")
|
||||||
|
|
||||||
|
|
||||||
|
# --- list_automations ----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_automations_joins_repo_and_live_state(fake_backend):
|
||||||
|
rows = {r["id"]: r for r in tools.list_automations(fake_backend)["automations"]}
|
||||||
|
|
||||||
|
klima = rows[KLIMA_OFF_ID]
|
||||||
|
assert klima["alias"] == "Klima salon: wyłącz chłodzenie i osusz parownik"
|
||||||
|
assert klima["state"] == "on"
|
||||||
|
assert klima["last_triggered"] == "2026-07-27T19:31:00.000000+00:00"
|
||||||
|
assert klima["in_repo"] and klima["on_instance"]
|
||||||
|
|
||||||
|
# in the repo but not loaded on the instance
|
||||||
|
repo_only = [r for r in rows.values() if not r["on_instance"]]
|
||||||
|
assert repo_only and all(r["state"] is None for r in repo_only)
|
||||||
|
|
||||||
|
# on the instance but never imported into the repo
|
||||||
|
ui_only = rows["9999999999999"]
|
||||||
|
assert ui_only["in_repo"] is False
|
||||||
|
assert ui_only["alias"] == "Created in the UI, never imported"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_automations_filter_is_substring_and_diacritic_insensitive(fake_backend):
|
||||||
|
result = tools.list_automations(fake_backend, filter="klima salon")
|
||||||
|
assert result["count"] == 2
|
||||||
|
assert all("Klima salon" in r["alias"] for r in result["automations"])
|
||||||
|
|
||||||
|
assert tools.list_automations(fake_backend, filter="wylacz chlodzenie")["count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_automations_degrades_when_instance_is_down(unreachable_backend):
|
||||||
|
result = tools.list_automations(unreachable_backend)
|
||||||
|
assert result["count"] > 100 # repo listing still works
|
||||||
|
assert "unreachable" in result["live_error"]
|
||||||
|
assert all(r["state"] is None for r in result["automations"])
|
||||||
|
|
||||||
|
|
||||||
|
# --- instance_status -----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_instance_status_reports_version_and_dead_entities(fake_backend):
|
||||||
|
result = tools.instance_status(fake_backend)
|
||||||
|
assert result["reachable"] is True
|
||||||
|
assert result["version"] == "2026.7.2"
|
||||||
|
assert result["entity_count"] == 10
|
||||||
|
assert result["unavailable_count"] == 2
|
||||||
|
assert result["automation_count"] == 3
|
||||||
|
assert result["area_count"] == 3
|
||||||
|
assert "audyt" in result["unavailable_note"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_instance_status_unreachable_is_reported_not_raised(unreachable_backend):
|
||||||
|
result = tools.instance_status(unreachable_backend)
|
||||||
|
assert result["reachable"] is False
|
||||||
|
assert "unreachable" in result["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_instance_status_offline_instance_short_circuits(repo_root):
|
||||||
|
"""chelsty-ha: answer from instances.yaml, never open a socket."""
|
||||||
|
from ha_mcp.config import get_instance
|
||||||
|
|
||||||
|
name, cfg = get_instance("chelsty-ha", repo_root)
|
||||||
|
backend = backend_mod.LiveBackend(name, cfg, repo_root)
|
||||||
|
|
||||||
|
def explode(*_args, **_kwargs): # pragma: no cover - must never run
|
||||||
|
raise AssertionError("network call attempted against an offline instance")
|
||||||
|
|
||||||
|
backend._get = explode
|
||||||
|
|
||||||
|
result = tools.instance_status(backend)
|
||||||
|
assert result["reachable"] is False
|
||||||
|
assert result["status"] == "offline"
|
||||||
|
assert "instances.yaml" in result["reason"]
|
||||||
|
assert "error" not in result
|
||||||
|
|
@ -12,7 +12,7 @@ entry.
|
||||||
|---|---|
|
|---|---|
|
||||||
| **0 — Snapshot** | Import-only tooling (this skeleton). Pull `/config` + `.storage` from each instance into the repo, read-only. No deploy, no write path back to HA. |
|
| **0 — Snapshot** | Import-only tooling (this skeleton). Pull `/config` + `.storage` from each instance into the repo, read-only. No deploy, no write path back to HA. |
|
||||||
| **1 — Repo + CC** | Repo is the reviewable source of truth. Changes are authored in the repo (by a human or Claude Code) and pushed manually via the docker-exec / api adapters described below. Deploy has a hard drift-abort (see Sync model). **Partially built**: `scripts/ha/deploy.sh` covers the `api` adapter's automations/scripts/scenes scope; `docker-exec` deploy and dashboards/helpers writes are still open. |
|
| **1 — Repo + CC** | Repo is the reviewable source of truth. Changes are authored in the repo (by a human or Claude Code) and pushed manually via the docker-exec / api adapters described below. Deploy has a hard drift-abort (see Sync model). **Partially built**: `scripts/ha/deploy.sh` covers the `api` adapter's automations/scripts/scenes scope; `docker-exec` deploy and dashboards/helpers writes are still open. |
|
||||||
| **2 — MCP read-only** | Expose HA state (entities, areas, config) to agents via an MCP server in read-only mode — either a self-hosted MCP or `hass-mcp` — to let agents reason about the live instance without touching import/deploy paths. Undecided which (see Open questions). |
|
| **2 — MCP read-only** | Expose HA state (entities, areas, config) to agents via an MCP server in read-only mode, to let agents reason about the live instance without touching import/deploy paths. **2a done (operator decision 2026-07-30: own minimal MCP, not `hass-mcp`)** — `services/ha-mcp/`, seven read-only tools over stdio, registered for Claude Code in the repo-root `.mcp.json`. Read-only by construction: GET + allowlisted WS `*_list` reads only; automations are read from the repo, not the API. Open: exposing it to anything other than CC on the dev station (2b). |
|
||||||
| **3 — Agents** | Agents propose changes (automations, scripts, scenes) through the same reviewable repo path used by humans; the human-in-the-loop approval flow from `services/control-plane/` (pending → approved → executed) governs anything destructive. Telegram becomes a first-class interface alongside CC. |
|
| **3 — Agents** | Agents propose changes (automations, scripts, scenes) through the same reviewable repo path used by humans; the human-in-the-loop approval flow from `services/control-plane/` (pending → approved → executed) governs anything destructive. Telegram becomes a first-class interface alongside CC. |
|
||||||
|
|
||||||
Each phase is a hard gate: no phase-N tooling depends on phase-(N+1) existing.
|
Each phase is a hard gate: no phase-N tooling depends on phase-(N+1) existing.
|
||||||
|
|
@ -235,8 +235,13 @@ krzyżowych, to osobny task, nie efekt uboczny porządków).
|
||||||
|
|
||||||
- What actually drives the phase-3 operational agent (a new agent process
|
- What actually drives the phase-3 operational agent (a new agent process
|
||||||
vs. extending an existing one in `services/`)?
|
vs. extending an existing one in `services/`)?
|
||||||
- Own minimal MCP server vs. adopting `hass-mcp` for phase 2 read-only
|
- ~~Own minimal MCP server vs. adopting `hass-mcp` for phase 2 read-only
|
||||||
access — tradeoffs not yet evaluated.
|
access~~ — decided 2026-07-30: own minimal server (`services/ha-mcp/`).
|
||||||
|
Reusing `scripts/ha/lib/{ha_api,ha_ws}.py` keeps one token-handling and
|
||||||
|
one read-only-by-construction story for the whole HA toolchain, and lets
|
||||||
|
the tools answer from the repo (`read_automation`) and from
|
||||||
|
`instances.yaml` (offline `chelsty-ha`) — neither of which a generic
|
||||||
|
server knows about.
|
||||||
- Whether SSH access to `chelsty-ha` itself (not just its HA API) is
|
- Whether SSH access to `chelsty-ha` itself (not just its HA API) is
|
||||||
available/reliable enough to justify a `file` adapter there, which would
|
available/reliable enough to justify a `file` adapter there, which would
|
||||||
let phase-1 tooling treat `chelsty-ha` more like `ken` for drift-checking
|
let phase-1 tooling treat `chelsty-ha` more like `ken` for drift-checking
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue