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. |
|
||||
| **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. |
|
||||
|
||||
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
|
||||
vs. extending an existing one in `services/`)?
|
||||
- Own minimal MCP server vs. adopting `hass-mcp` for phase 2 read-only
|
||||
access — tradeoffs not yet evaluated.
|
||||
- ~~Own minimal MCP server vs. adopting `hass-mcp` for phase 2 read-only
|
||||
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
|
||||
available/reliable enough to justify a `file` adapter there, which would
|
||||
let phase-1 tooling treat `chelsty-ha` more like `ken` for drift-checking
|
||||
|
|
|
|||
Loading…
Reference in a new issue