diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..bf60edb --- /dev/null +++ b/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "ha": { + "command": "./services/ha-mcp/run.sh", + "args": [], + "env": {} + } + } +} diff --git a/services/ha-mcp/README.md b/services/ha-mcp/README.md new file mode 100644 index 0000000..4ee8282 --- /dev/null +++ b/services/ha-mcp/README.md @@ -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//` in the repo, then +`scripts/ha/deploy.sh ` (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__`. +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//`, 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/.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. diff --git a/services/ha-mcp/requirements.txt b/services/ha-mcp/requirements.txt new file mode 100644 index 0000000..43eaff3 --- /dev/null +++ b/services/ha-mcp/requirements.txt @@ -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 diff --git a/services/ha-mcp/run.sh b/services/ha-mcp/run.sh new file mode 100755 index 0000000..b38e265 --- /dev/null +++ b/services/ha-mcp/run.sh @@ -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 "$@" diff --git a/services/ha-mcp/src/ha_mcp/__init__.py b/services/ha-mcp/src/ha_mcp/__init__.py new file mode 100644 index 0000000..9ebc93d --- /dev/null +++ b/services/ha-mcp/src/ha_mcp/__init__.py @@ -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" diff --git a/services/ha-mcp/src/ha_mcp/backend.py b/services/ha-mcp/src/ha_mcp/backend.py new file mode 100644 index 0000000..ba53bf4 --- /dev/null +++ b/services/ha-mcp/src/ha_mcp/backend.py @@ -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//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//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")) + ] diff --git a/services/ha-mcp/src/ha_mcp/config.py b/services/ha-mcp/src/ha_mcp/config.py new file mode 100644 index 0000000..75811ab --- /dev/null +++ b/services/ha-mcp/src/ha_mcp/config.py @@ -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//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 {}) diff --git a/services/ha-mcp/src/ha_mcp/match.py b/services/ha-mcp/src/ha_mcp/match.py new file mode 100644 index 0000000..9160435 --- /dev/null +++ b/services/ha-mcp/src/ha_mcp/match.py @@ -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) diff --git a/services/ha-mcp/src/ha_mcp/server.py b/services/ha-mcp/src/ha_mcp/server.py new file mode 100644 index 0000000..b15f787 --- /dev/null +++ b/services/ha-mcp/src/ha_mcp/server.py @@ -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// 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//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() diff --git a/services/ha-mcp/src/ha_mcp/tools.py b/services/ha-mcp/src/ha_mcp/tools.py new file mode 100644 index 0000000..d32d0b3 --- /dev/null +++ b/services/ha-mcp/src/ha_mcp/tools.py @@ -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/`, 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 diff --git a/services/ha-mcp/tests/conftest.py b/services/ha-mcp/tests/conftest.py new file mode 100644 index 0000000..f4a8289 --- /dev/null +++ b/services/ha-mcp/tests/conftest.py @@ -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 diff --git a/services/ha-mcp/tests/fixtures/registries.json b/services/ha-mcp/tests/fixtures/registries.json new file mode 100644 index 0000000..fa49862 --- /dev/null +++ b/services/ha-mcp/tests/fixtures/registries.json @@ -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} + ] +} diff --git a/services/ha-mcp/tests/fixtures/states.json b/services/ha-mcp/tests/fixtures/states.json new file mode 100644 index 0000000..d2966d6 --- /dev/null +++ b/services/ha-mcp/tests/fixtures/states.json @@ -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" + } +] diff --git a/services/ha-mcp/tests/run.sh b/services/ha-mcp/tests/run.sh new file mode 100755 index 0000000..34f4e8c --- /dev/null +++ b/services/ha-mcp/tests/run.sh @@ -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" "$@" diff --git a/services/ha-mcp/tests/smoke_live.py b/services/ha-mcp/tests/smoke_live.py new file mode 100644 index 0000000..7f7f93b --- /dev/null +++ b/services/ha-mcp/tests/smoke_live.py @@ -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)) diff --git a/services/ha-mcp/tests/test_backend_offline.py b/services/ha-mcp/tests/test_backend_offline.py new file mode 100644 index 0000000..60823c2 --- /dev/null +++ b/services/ha-mcp/tests/test_backend_offline.py @@ -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() diff --git a/services/ha-mcp/tests/test_tools_offline.py b/services/ha-mcp/tests/test_tools_offline.py new file mode 100644 index 0000000..edafc8a --- /dev/null +++ b/services/ha-mcp/tests/test_tools_offline.py @@ -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 diff --git a/services/home-assistant/DESIGN.md b/services/home-assistant/DESIGN.md index 907505f..5f06463 100644 --- a/services/home-assistant/DESIGN.md +++ b/services/home-assistant/DESIGN.md @@ -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