diff --git a/.gitignore b/.gitignore index c18d21d..b3dc60b 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ custom_components/brilliant_mqtt/agent_payload/ custom_components/brilliant_mqtt/voice_payload/ # ─── Tooling caches ────────────────────────────────────────────────────── +.worktrees/ .pytest_cache/ .ruff_cache/ .mypy_cache/ diff --git a/docs/ha-mirror.md b/docs/ha-mirror.md index 62532e4..e5c30e7 100644 --- a/docs/ha-mirror.md +++ b/docs/ha-mirror.md @@ -62,6 +62,7 @@ The manual deploy below is the fallback for panels not managed by the integratio | `MQTT_HOST` / `MQTT_USERNAME` / `MQTT_PASSWORD` | yes | Broker creds (used for the leader election). | | `MQTT_PORT` | no | Default `1883`. | | `MIRROR_LABEL` | no | Entity label to mirror. Default `brilliant`. | +| `ROOM_OVERRIDES` | no | JSON object mapping HA area names to opaque Brilliant room IDs. Overrides automatic name matching. | | `LEADER_PRIORITY` | no | Election rank; **lower number wins**, `0` = never lead. Give each panel a distinct value. | | `LEADER_HEARTBEAT_SECONDS` | no | Election heartbeat. Default `10`. | | `LOG_LEVEL` | no | Default `INFO`. | @@ -93,6 +94,25 @@ The manual deploy below is the fallback for panels not managed by the integratio Repeat per panel with a distinct `LEADER_PRIORITY`. The unit lives under `/var` (survives OTA); after a firmware update, re-install it. +## Room assignment (V2) + +Mirrored entities are placed in native Brilliant rooms automatically. The mirror +uses the entity registry's area, falling back to the entity's device area, and +matches that Home Assistant area name to a Brilliant room name with a +case-insensitive exact comparison. Brilliant room IDs are opaque and are used +verbatim. + +`ROOM_OVERRIDES` takes precedence over automatic matching. Its value is a JSON +object whose keys are HA area names and whose values are Brilliant room IDs; for +example, `{"Back Yard":"opaque-brilliant-room-id"}`. It is optional—automatic +matching works with no new configuration. + +If an area has no matching room and no override, the peripheral remains +unassigned (`room_ids` is empty) and the mirror logs that outcome once rather +than on every reconciliation. On later reconciliations it re-asserts the native +`room_assignment` whenever the entity's HA area or the Brilliant rooms catalog +changes. + ## How it works (for maintainers) - Each mirrored entity is hosted as a peripheral on the leader panel's **own** diff --git a/docs/superpowers/plans/2026-07-12-ha-control-plane-scene-bridge.md b/docs/superpowers/plans/2026-07-12-ha-control-plane-scene-bridge.md new file mode 100644 index 0000000..a51e428 --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-ha-control-plane-scene-bridge.md @@ -0,0 +1,985 @@ +# Home Assistant Control Plane and Scene Bridge Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the unsafe physical-Control HA mirror with one HA-owned, MQTT-only control plane and a safe bidirectional Brilliant scene/mode bridge that creates no hosted peripherals. + +**Architecture:** Home Assistant owns entity selection, registry/area resolution, state publication, command validation, service calls, and scene/mode automation dispatch. Every panel agent keeps its existing single bus peer and MQTT connection, observes its own `execution_peripheral`, performs scoped configuration reads for scene/mode catalogs, and writes only `execution_peripheral.last_executed_scene_id` or `execution_peripheral.manual_mode_id`; the old one-host-per-entity mirror stays stopped and is retired after a hardware validation gate. + +**Tech Stack:** Python 3.10 panel agent, Python 3.14 / Home Assistant Core 2026.6.2 custom integration, asyncio, HA MQTT integration, JSON over MQTT, Brilliant Thrift TBinaryProtocol decoding, pytest, pytest-homeassistant-custom-component, ruff, mypy strict, uv. + +## Global Constraints + +- Never create a `PeripheralHost` with `virtual_device_id=None` for mirrored HA entities. +- Never bid on or overwrite ownership of `brilliant_virtual_device`, `configuration_virtual_device`, or `ble_mesh`. +- Never run one framework host per mirrored entity. +- Never exfiltrate panel private keys, PKCS#12 material, Brilliant passwords, MFA codes, bootstrap tokens, account JWTs, or Home Assistant tokens into the repository, MQTT payloads, diagnostics, or logs. +- Keep the existing forward MQTT bridge independent and available throughout the migration. +- `src/` is the source of truth; the bundled agent payload must be generated from it and byte-for-byte parity-tested for non-vendored files. +- Binary dumps, `/var` collections, credentials, generated Ghidra projects, and pilot logs stay under gitignored `artifacts/` paths. +- Agent code remains compatible with Python 3.10; HA integration code targets the pinned Python 3.14 / HA Core 2026.6.2 environment. +- Commands are non-retained, expire after 15 seconds, and are idempotent by command ID; HA state is authoritative. +- Scene execution is confirmed from a new `execution_state:scene_execution_handler:scene:` record, never from the write response alone. +- Execute in an isolated worktree based on commit `dffb67a`; do not absorb the uncommitted Tier-1 experiments currently present in the primary worktree. + +--- + +## File map + +### Shared protocol and fixtures + +- Create `tests/fixtures/ha_control_v1_vectors.json`: non-secret golden payloads used by both Python projects. +- Create `src/brilliant_mqtt/ha_control_protocol.py`: panel-side topic helpers and strict JSON parsing/encoding. +- Create `custom_components/brilliant_mqtt/ha_control_protocol.py`: HA-side copy of the wire contract; no import from the Python 3.10 package. +- Create `tests/test_ha_control_protocol.py` and `ha/tests/test_ha_control_protocol.py`: enforce identical wire behavior. + +### Home Assistant ownership + +- Create `custom_components/brilliant_mqtt/ha_control_manifest.py`: label selection, registry-area precedence, capability reduction, stable IDs, state payloads. +- Create `custom_components/brilliant_mqtt/ha_control.py`: singleton lifecycle, retained manifest/state publication, command execution, registry/state listeners, debouncing, status, Repairs. +- Create `custom_components/brilliant_mqtt/scene_control.py`: scene/mode catalog and event subscriptions, HA event/action dispatch, `run_scene`/`set_mode` request confirmation, panel availability. +- Modify `custom_components/brilliant_mqtt/__init__.py`, `const.py`, `config_flow.py`, `manager.py`, `components.py`, `panel_ops.py`, `diagnostics.py`, `select.py`, `button.py`, `services.yaml`, `strings.json`, and `translations/en.json`: lifecycle, configuration, scene entities, migration, legacy retirement. + +### Panel transport + +- Modify `src/brilliant_mqtt/model.py`, `bus.py`, and `protocols.py`: preserve variable timestamps and expose one scoped peripheral read. +- Create `src/brilliant_mqtt/thrift_binary.py`: bounded generic TBinaryProtocol decoder extracted from the validated research utility. +- Create `src/brilliant_mqtt/scene_codec.py`: typed scene/mode definitions and execution records. +- Create `src/brilliant_mqtt/scene_bridge.py`: deduplication, catalog publishing, command routing, confirmation, status. +- Modify `src/brilliant_mqtt/config.py` and `src/brilliant_mqtt/__main__.py`: enable the bridge on the existing bus/MQTT session. + +### Retirement, packaging, and documentation + +- Create `src/brilliant_mqtt/cleanup_legacy_mirror.py`: dry-run-first, allowlisted, idempotent legacy peripheral cleanup. +- Create `tests/test_cleanup_legacy_mirror.py` and `tests/test_payload_parity.py`. +- Modify `scripts/build_payload.sh`, `.github/workflows/ci.yml`, `.github/workflows/release.yml`, `.gitignore`, `docs/ha-mirror.md`, and `docs/brilliant-panel/home-assistant-integration.md`. + +--- + +### Task 1: Freeze the versioned MQTT contract + +**Files:** +- Create: `tests/fixtures/ha_control_v1_vectors.json` +- Create: `src/brilliant_mqtt/ha_control_protocol.py` +- Create: `custom_components/brilliant_mqtt/ha_control_protocol.py` +- Create: `tests/test_ha_control_protocol.py` +- Create: `ha/tests/test_ha_control_protocol.py` + +**Interfaces:** +- Produces: `stable_id(entity_id: str) -> str`, all topic helpers, `decode_command(payload: str, *, now_ms: int) -> EntityCommand`, `decode_scene_command(payload: str, *, now_ms: int) -> SceneCommand`, `decode_mode_command(payload: str, *, now_ms: int) -> ModeCommand`, and canonical `encode_json(value: Mapping[str, object]) -> str`. +- Wire constants: `SCHEMA_VERSION = 1`, `MAPPING_VERSION = 1`, namespace UUID `ddd06dfa-168a-5a0b-b8b3-4c5f742b0354`, command TTL `15_000` ms. + +- [ ] **Step 1: Add golden vectors and failing tests in both projects** + +Use this first vector verbatim; add corresponding vectors for entity command, result, scene/mode catalogs, scene/mode events, scene/mode commands, scene/mode results, and transport status with fixed timestamps and sorted JSON keys: + +```json +{ + "stable_ids": { + "light.office_lamp": "d353e38a-793e-5b6f-813b-17a1c38aba96" + }, + "topics": { + "manifest": "brilliant/ha-control/v1/manifest", + "state": "brilliant/ha-control/v1/state/d353e38a-793e-5b6f-813b-17a1c38aba96", + "command": "brilliant/ha-control/v1/command/d353e38a-793e-5b6f-813b-17a1c38aba96", + "result": "brilliant/ha-control/v1/result/11111111-1111-4111-8111-111111111111", + "scene_catalog": "brilliant/ha-control/v1/scene/catalog/office", + "scene_event": "brilliant/ha-control/v1/scene/event/office", + "scene_command": "brilliant/ha-control/v1/scene/command/office", + "scene_result": "brilliant/ha-control/v1/scene/result/11111111-1111-4111-8111-111111111111", + "scene_status": "brilliant/ha-control/v1/status/scene/office", + "mode_catalog": "brilliant/ha-control/v1/mode/catalog/office", + "mode_event": "brilliant/ha-control/v1/mode/event/office", + "mode_command": "brilliant/ha-control/v1/mode/command/office", + "mode_result": "brilliant/ha-control/v1/mode/result/11111111-1111-4111-8111-111111111111" + } +} +``` + +Each test loads the same fixture path and asserts stable IDs, topic strings, rejection of retained/expired/malformed commands, and canonical JSON. The HA path is `Path(__file__).parents[2] / "tests/fixtures/ha_control_v1_vectors.json"`. + +- [ ] **Step 2: Run the two targeted suites and verify they fail** + +Run: `uv run pytest tests/test_ha_control_protocol.py -q` + +Expected: FAIL during import because `brilliant_mqtt.ha_control_protocol` does not exist. + +Run: `uv run --project ha pytest -c ha/pyproject.toml ha/tests/test_ha_control_protocol.py -q` + +Expected: FAIL during import because the HA protocol module does not exist. + +- [ ] **Step 3: Implement the protocol in both runtimes** + +Both files expose the same public surface. Use dataclasses with exact fields and reject unknown schema/mapping versions, missing IDs, mismatched topic stable IDs, timestamps more than 5 seconds in the future, and commands older than 15 seconds. + +```python +SCHEMA_VERSION = 1 +MAPPING_VERSION = 1 +COMMAND_TTL_MS = 15_000 +_STABLE_NAMESPACE = UUID("ddd06dfa-168a-5a0b-b8b3-4c5f742b0354") + +def stable_id(entity_id: str) -> str: + return str(uuid5(_STABLE_NAMESPACE, entity_id)) + +def encode_json(value: Mapping[str, object]) -> str: + return json.dumps(value, separators=(",", ":"), sort_keys=True) + +@dataclass(frozen=True) +class EntityCommand: + command_id: str + stable_id: str + kind: str + value: object + observed_sequence: int + issued_at_ms: int + +@dataclass(frozen=True) +class SceneCommand: + command_id: str + panel: str + scene_id: str + issued_at_ms: int + +@dataclass(frozen=True) +class ModeCommand: + command_id: str + panel: str + mode_id: str + issued_at_ms: int +``` + +Topic helpers must percent-free validate slugs/UUIDs with `re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,62}", panel)` and `UUID(value)` before interpolation. Scene topics are the locked extension to the namespace table in the design spec. + +- [ ] **Step 4: Run both protocol suites** + +Expected: both commands PASS, including exact canonical payload comparisons. + +- [ ] **Step 5: Commit** + +```bash +git add tests/fixtures/ha_control_v1_vectors.json tests/test_ha_control_protocol.py ha/tests/test_ha_control_protocol.py src/brilliant_mqtt/ha_control_protocol.py custom_components/brilliant_mqtt/ha_control_protocol.py +git commit -m "feat: define HA control MQTT v1 contract" +``` + +### Task 2: Build manifests and state exclusively inside Home Assistant + +**Files:** +- Create: `custom_components/brilliant_mqtt/ha_control_manifest.py` +- Create: `ha/tests/test_ha_control_manifest.py` + +**Interfaces:** +- Consumes: `stable_id`, `SCHEMA_VERSION`, and `MAPPING_VERSION` from Task 1. +- Produces: `ControlSettings`, `ManifestEntity`, `ManifestSnapshot`, `build_manifest(hass, settings, revision, generated_at_ms)`, and `build_state_payload(state, entity, sequence, generated_at_ms)`. + +- [ ] **Step 1: Write failing registry/capability tests** + +Cover these exact cases with real HA test registries: + +```python +async def test_entity_area_precedes_device_area(hass: HomeAssistant) -> None: + # label the entity, set entity area to Office and device area to Backyard + snapshot = build_manifest(hass, settings(label_name="brilliant"), 7, 1_700_000_000_000) + assert snapshot.entities[0].ha_area == "Office" + assert snapshot.entities[0].brilliant_room == "Office" + +async def test_unmatched_override_is_case_insensitive(hass: HomeAssistant) -> None: + snapshot = build_manifest( + hass, + settings(label_name="brilliant", room_overrides={"back yard": "Backyard"}), + 1, + 1_700_000_000_000, + ) + assert snapshot.entities[0].brilliant_room == "Backyard" +``` + +Also assert: entity labels select; device labels do not implicitly select; disabled/unavailable/missing entities remain in manifest with availability state; unsupported domains are reported but excluded; max count truncates deterministically by entity ID; light brightness, cover position/tilt, and lock commands are reduced from current state/support flags. + +- [ ] **Step 2: Run and verify failure** + +Run: `uv run --project ha pytest -c ha/pyproject.toml ha/tests/test_ha_control_manifest.py -q` + +Expected: FAIL because the manifest module is absent. + +- [ ] **Step 3: Implement immutable manifest types and registry precedence** + +```python +SUPPORTED_DOMAINS = frozenset({"light", "switch", "lock", "cover"}) + +@dataclass(frozen=True, slots=True) +class ControlSettings: + label_name: str + room_overrides: Mapping[str, str] + enabled_domains: frozenset[str] + maximum_entities: int + +@dataclass(frozen=True, slots=True) +class ManifestEntity: + stable_id: str + entity_id: str + domain: str + device_class: str | None + friendly_name: str + ha_area: str | None + brilliant_room: str | None + commands: tuple[str, ...] + capabilities: Mapping[str, bool] + +def _area_name(entity: er.RegistryEntry, entities: er.EntityRegistry, + devices: dr.DeviceRegistry, areas: ar.AreaRegistry) -> str | None: + area_id = entity.area_id + if area_id is None and entity.device_id is not None: + device = devices.async_get(entity.device_id) + area_id = device.area_id if device is not None else None + area = areas.async_get_area(area_id) if area_id is not None else None + return area.name if area is not None else None +``` + +`build_manifest` resolves `label_registry.async_get_label_by_name`, selects registry entries whose `labels` contains its ID, sorts by `entity_id`, derives commands from domain and supported features, and emits a complete JSON-ready snapshot. Normalize override keys with `casefold().strip()`. + +- [ ] **Step 4: Implement normalized state payloads** + +Keep only `brightness`, `current_position`, `current_tilt_position`, `device_class`, and `supported_features`; do not forward arbitrary attributes. + +```python +def build_state_payload(state: State | None, entity: ManifestEntity, + sequence: int, generated_at_ms: int) -> dict[str, object]: + return { + "schema_version": SCHEMA_VERSION, + "mapping_version": MAPPING_VERSION, + "stable_id": entity.stable_id, + "entity_id": entity.entity_id, + "sequence": sequence, + "generated_at_ms": generated_at_ms, + "available": state is not None and state.state not in {STATE_UNAVAILABLE, STATE_UNKNOWN}, + "state": state.state if state is not None else STATE_UNAVAILABLE, + "attributes": _supported_attributes(state), + } +``` + +- [ ] **Step 5: Run manifest tests and the HA type/lint gate** + +Run: `uv run --project ha pytest -c ha/pyproject.toml ha/tests/test_ha_control_manifest.py -q` + +Expected: PASS. + +Run: `uv run --project ha ruff check --config ha/pyproject.toml custom_components/brilliant_mqtt/ha_control_manifest.py ha/tests/test_ha_control_manifest.py && uv run --project ha mypy --strict --config-file ha/pyproject.toml custom_components/brilliant_mqtt/ha_control_manifest.py ha/tests/test_ha_control_manifest.py` + +Expected: both exit 0. + +- [ ] **Step 6: Commit** + +```bash +git add custom_components/brilliant_mqtt/ha_control_manifest.py ha/tests/test_ha_control_manifest.py +git commit -m "feat: build HA-owned control manifests" +``` + +### Task 3: Publish the singleton HA control plane and execute commands + +**Files:** +- Create: `custom_components/brilliant_mqtt/ha_control.py` +- Create: `ha/tests/test_ha_control.py` +- Modify: `custom_components/brilliant_mqtt/__init__.py` +- Modify: `custom_components/brilliant_mqtt/const.py` +- Modify: `ha/tests/test_init.py` + +**Interfaces:** +- Consumes: Task 1 protocol and Task 2 manifest builder. +- Produces: `HaControlPlane.async_attach(entry)`, `async_detach(entry_id)`, `async_reload_settings()`, `async_start()`, `async_stop()`, and `get_control_plane(hass)`. +- Singleton key: `hass.data[DOMAIN][DATA_CONTROL_PLANE]`; owner is the enabled loaded entry with lexicographically smallest panel slug. + +- [ ] **Step 1: Write failing lifecycle/publication tests** + +Use two config entries and the MQTT mock. Assert exactly one subscription to `brilliant/ha-control/v1/command/+`, one retained manifest publication, and one retained state publication per selected entity. Assert the singleton survives unloading one entry and stops/unsubscribes when the last entry unloads. Fire entity/device/area/label registry update events and verify one debounced manifest rebuild after 500 ms. + +- [ ] **Step 2: Write failing command tests** + +For each vocabulary item, send a valid MQTT command and assert the exact HA service call: + +```python +COMMAND_CASES = ( + ("turn_on", None, "light", "turn_on", {}), + ("set_brightness", 128, "light", "turn_on", {"brightness": 128}), + ("turn_off", None, "switch", "turn_off", {}), + ("lock", None, "lock", "lock", {}), + ("unlock", None, "lock", "unlock", {}), + ("open", None, "cover", "open_cover", {}), + ("close", None, "cover", "close_cover", {}), + ("set_position", 42, "cover", "set_cover_position", {"position": 42}), + ("set_tilt", 25, "cover", "set_cover_tilt_position", {"tilt_position": 25}), +) +``` + +Assert expired commands, stable-ID mismatches, commands absent from the current manifest, duplicate IDs, and invalid ranges never call a service and publish an error result. Duplicate IDs replay the same result without another service call. Result cache holds 1,024 entries for 10 minutes. + +- [ ] **Step 3: Run and verify failure** + +Run: `uv run --project ha pytest -c ha/pyproject.toml ha/tests/test_ha_control.py ha/tests/test_init.py -q` + +Expected: FAIL because the coordinator and constants are absent. + +- [ ] **Step 4: Implement the coordinator lifecycle** + +Register listeners for `EVENT_STATE_CHANGED`, `EVENT_ENTITY_REGISTRY_UPDATED`, `EVENT_DEVICE_REGISTRY_UPDATED`, `EVENT_AREA_REGISTRY_UPDATED`, and `EVENT_LABEL_REGISTRY_UPDATED`. State changes for selected entity IDs publish immediately; registry changes schedule one 500 ms rebuild with `async_call_later`. Increment revision only when the canonical manifest body changes. + +```python +class HaControlPlane: + def __init__(self, hass: HomeAssistant) -> None: + self.hass = hass + self._entries: dict[str, BrilliantMqttConfigEntry] = {} + self._manifest: ManifestSnapshot | None = None + self._state_sequences: defaultdict[str, int] = defaultdict(int) + self._unsubscribers: list[Callable[[], None]] = [] + self._started = False + + async def async_attach(self, entry: BrilliantMqttConfigEntry) -> None: + self._entries[entry.entry_id] = entry + if not self._started: + await self.async_start() + else: + await self.async_reload_settings() +``` + +In `async_setup_entry`, attach after MQTT setup succeeds and before forwarding platforms. In unload, detach after platforms unload but before clearing `runtime_data`. + +- [ ] **Step 5: Implement command validation and HA service routing** + +Use a closed dispatch table, validate integer ranges (`brightness` 0–255; cover values 0–100), call `hass.services.async_call(domain, service, service_data, blocking=True)`, and publish a non-retained result containing `accepted`, `error`, `state_sequence`, and elapsed milliseconds. Never include service exception tracebacks in MQTT; log exception class and a sanitized message. + +- [ ] **Step 6: Run lifecycle, command, and full HA tests** + +Run: `uv run --project ha pytest -c ha/pyproject.toml ha/tests/test_ha_control.py ha/tests/test_init.py -q` + +Expected: PASS. + +Run: `uv run --project ha pytest -c ha/pyproject.toml ha/tests -q` + +Expected: PASS with no new lingering-task warnings. + +- [ ] **Step 7: Commit** + +```bash +git add custom_components/brilliant_mqtt/ha_control.py custom_components/brilliant_mqtt/__init__.py custom_components/brilliant_mqtt/const.py ha/tests/test_ha_control.py ha/tests/test_init.py +git commit -m "feat: publish the HA MQTT control plane" +``` + +### Task 4: Preserve bus timestamps and add one scoped configuration read + +**Files:** +- Modify: `src/brilliant_mqtt/model.py` +- Modify: `src/brilliant_mqtt/bus.py` +- Modify: `src/brilliant_mqtt/protocols.py` +- Modify: `tests/fakes.py` +- Modify: `tests/test_bus_normalize.py` +- Modify: `tests/test_bus_adapter.py` +- Modify: `tests/test_fakes.py` + +**Interfaces:** +- Changes `Variable` to `Variable(name: str, value: str, externally_settable: bool = False, timestamp_ms: int | None = None)`. +- Adds `BusClient.get_peripheral(device_id: str, peripheral_id: str) -> BrilliantDevice | None`. + +- [ ] **Step 1: Write failing normalization and scoped-read tests** + +Assert `normalize_peripheral` converts integer timestamps, tolerates missing/invalid timestamps as `None`, and `RpcBusAdapter.get_peripheral("configuration_virtual_device", "scene_configuration")` calls only `obs.get_peripheral`, not `get_all` or `get_device`. + +- [ ] **Step 2: Run and verify failure** + +Run: `uv run pytest tests/test_bus_normalize.py tests/test_bus_adapter.py tests/test_fakes.py -q` + +Expected: FAIL on missing `timestamp_ms` and `get_peripheral`. + +- [ ] **Step 3: Implement the model and adapter changes** + +```python +raw_timestamp = getattr(raw_var, "timestamp", None) +timestamp_ms = int(raw_timestamp) if isinstance(raw_timestamp, (int, float)) else None +variables[var_name] = Variable( + name=var_name, + value=str(value), + externally_settable=bool(raw_var.externally_settable), + timestamp_ms=timestamp_ms, +) + +async def get_peripheral(self, device_id: str, peripheral_id: str) -> BrilliantDevice | None: + obs, _ = self._require_started() + raw = await obs.get_peripheral(device_id, peripheral_id) + if raw is None: + return None + return normalize_peripheral(device_id, peripheral_id, raw) +``` + +The new read is on-demand only. Do not add `configuration_virtual_device` to `_extra_device_ids`, subscriptions, or the hot `get_all()` loop. + +- [ ] **Step 4: Run the targeted and full agent suites** + +Expected: `uv run pytest tests/test_bus_normalize.py tests/test_bus_adapter.py tests/test_fakes.py -q` PASS, then `uv run pytest -q` PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/brilliant_mqtt/model.py src/brilliant_mqtt/bus.py src/brilliant_mqtt/protocols.py tests/fakes.py tests/test_bus_normalize.py tests/test_bus_adapter.py tests/test_fakes.py +git commit -m "feat: expose scoped scene configuration reads" +``` + +### Task 5: Decode scene catalogs and execution records off-panel + +**Files:** +- Create: `src/brilliant_mqtt/thrift_binary.py` +- Create: `src/brilliant_mqtt/scene_codec.py` +- Create: `tests/test_thrift_binary.py` +- Create: `tests/test_scene_codec.py` +- Create: `tests/fixtures/scene_all_off.json` +- Create: `tests/fixtures/scene_execution_all_off.json` + +**Interfaces:** +- Produces: `decode_struct_base64(value: str, *, max_bytes=262_144, max_depth=16, max_items=10_000) -> dict[int, object]`. +- Produces: `SceneDefinition(scene_id, display_name, icon)`, `SceneExecution(scene_id, executed_at_ms, payload_sha256)`, `ModeDefinition(mode_id, display_name)`, `ModeExecution(mode_id, executed_at_ms)`, `decode_scene_catalog(device)`, `decode_mode_catalog(device)`, and `decode_scene_execution(device)`. + +- [ ] **Step 1: Extract redacted fixtures and write failing tests** + +Copy only the two already committed `all_off` base64 values from `docs/claude/research/2026-07-06-mirror-poc/out/baseline.json`; store the expected decoded values, not any device credentials. Assert field 1/2/3 decode to `all_off`, `All Lights Off`, and its qrc icon. Assert the execution record returns timestamp `1683501714715` and scene ID parsed from the variable name. + +Also test invalid base64, truncated values, negative collection sizes, depth/item/byte limits, non-scene variables, mismatch between variable timestamp and embedded execution timestamp, synthetic `mode:` definitions, and timestamped `manual_mode_id` changes. The embedded field-1 scene timestamp is authoritative; the bus timestamp is diagnostic. An empty `manual_mode_id` is not an execution event. + +- [ ] **Step 2: Run and verify failure** + +Run: `uv run pytest tests/test_thrift_binary.py tests/test_scene_codec.py -q` + +Expected: FAIL because both modules are absent. + +- [ ] **Step 3: Implement a bounded TBinaryProtocol decoder** + +Port the validated primitive readers from `decode_scenes.py`, but use a cursor object that checks every read and decrements item/depth budgets before allocation. Public errors are `ThriftDecodeError`; never log raw blobs. + +```python +class ThriftDecodeError(ValueError): + pass + +def decode_struct_base64(value: str, *, max_bytes: int = 262_144, + max_depth: int = 16, max_items: int = 10_000) -> dict[int, object]: + try: + raw = base64.b64decode(value, validate=True) + except (binascii.Error, ValueError) as exc: + raise ThriftDecodeError("invalid base64 thrift value") from exc + if len(raw) > max_bytes: + raise ThriftDecodeError("thrift value exceeds byte limit") + cursor = _Cursor(raw, max_depth=max_depth, max_items=max_items) + result = cursor.read_struct(depth=0) + if cursor.position != len(raw): + raise ThriftDecodeError("trailing bytes after thrift struct") + return result +``` + +- [ ] **Step 4: Implement typed scene reduction** + +Only expose IDs, display names, icons, execution timestamp, and SHA-256; do not publish the action/device list from execution blobs. Decode optional `mode:` values from `mode_configuration`; when no modes are configured, publish an empty mode catalog rather than inventing defaults. Treat a non-empty `execution_peripheral.manual_mode_id` update as a mode execution keyed by its bus variable timestamp. + +```python +_SCENE_PREFIX = "execution_state:scene_execution_handler:scene:" + +def decode_scene_execution(device: BrilliantDevice) -> tuple[SceneExecution, ...]: + records: list[SceneExecution] = [] + if device.peripheral_id != "execution_peripheral": + return () + for name, variable in device.variables.items(): + if not name.startswith(_SCENE_PREFIX): + continue + decoded = decode_struct_base64(variable.value) + executed_at_ms = decoded.get(1) + if not isinstance(executed_at_ms, int): + raise SceneCodecError("scene execution is missing its timestamp") + records.append(SceneExecution( + scene_id=name.removeprefix(_SCENE_PREFIX), + executed_at_ms=executed_at_ms, + payload_sha256=hashlib.sha256(variable.value.encode()).hexdigest(), + )) + return tuple(sorted(records, key=lambda item: (item.executed_at_ms, item.scene_id))) +``` + +- [ ] **Step 5: Run codec tests and quality checks** + +Run: `uv run pytest tests/test_thrift_binary.py tests/test_scene_codec.py -q` + +Expected: PASS. + +Run: `uv run ruff check src/brilliant_mqtt/thrift_binary.py src/brilliant_mqtt/scene_codec.py tests/test_thrift_binary.py tests/test_scene_codec.py && uv run mypy --strict src/brilliant_mqtt/thrift_binary.py src/brilliant_mqtt/scene_codec.py tests/test_thrift_binary.py tests/test_scene_codec.py` + +Expected: exit 0. + +- [ ] **Step 6: Commit** + +```bash +git add src/brilliant_mqtt/thrift_binary.py src/brilliant_mqtt/scene_codec.py tests/test_thrift_binary.py tests/test_scene_codec.py tests/fixtures/scene_all_off.json tests/fixtures/scene_execution_all_off.json +git commit -m "feat: decode Brilliant scene records safely" +``` + +### Task 6: Implement the panel scene bridge on the existing session + +**Files:** +- Create: `src/brilliant_mqtt/scene_bridge.py` +- Create: `tests/test_scene_bridge.py` +- Modify: `tests/fakes.py` + +**Interfaces:** +- Consumes: `BusClient`, `MqttClient`, Task 1 scene contract, Task 5 codecs. +- Produces: `SceneBridge(bus, mqtt, panel, watermark_path, clock_ms)`, `async_start()`, `async_reconcile()`, `async_shutdown()`. + +- [ ] **Step 1: Write failing event, replay, catalog, and command tests** + +Test all of the following with `FakeBus`/`FakeMqtt`: + +- initial retained records seed the watermark and do not fire as new events; +- a later embedded timestamp publishes one non-retained scene event; +- identical replay after reconnect/process restart is suppressed from the persisted watermark file; +- scene and mode catalogs are read with two scoped calls to `get_peripheral("configuration_virtual_device", "scene_configuration")` and `get_peripheral("configuration_virtual_device", "mode_configuration")` at start and reconnect, then published retained; +- a valid scene command writes only `last_executed_scene_id` on the own device's `execution_peripheral`; a valid mode command writes only `manual_mode_id` there; +- a matching later execution record publishes accepted result; no record within 15 seconds publishes timeout; +- expired/duplicate/unknown-scene commands never write; +- malformed blobs publish degraded status without killing the forward bridge callback fan-out. + +- [ ] **Step 2: Run and verify failure** + +Run: `uv run pytest tests/test_scene_bridge.py -q` + +Expected: FAIL because `SceneBridge` does not exist. + +- [ ] **Step 3: Implement persistent deduplication and catalog publication** + +Persist only the newest timestamp and hash per `(panel, scene_id)` using atomic temp-file replace and mode `0o600`. + +```python +@dataclass(frozen=True, slots=True) +class Watermark: + executed_at_ms: int + payload_sha256: str + +def _is_new(previous: Watermark | None, current: SceneExecution) -> bool: + return previous is None or (current.executed_at_ms, current.payload_sha256) > ( + previous.executed_at_ms, previous.payload_sha256 + ) +``` + +`async_start()` registers the bus callback before I/O, subscribes to its exact panel scene and mode command topics, reconciles the current execution peripheral without emitting history, reads both catalogs once, and publishes retained status. It never subscribes to the configuration device. + +- [ ] **Step 4: Implement command write and confirmation** + +Track scene and mode pending requests separately; after a write, schedule a 15-second timeout task. On a new matching event, publish the event first, then the accepted result, cancel timeout, and cache the result for idempotent replay. + +```python +await self._bus.set_variables( + execution.device_id, + "execution_peripheral", + [VarSet(name="last_executed_scene_id", value=command.scene_id)], +) + +await self._bus.set_variables( + execution.device_id, + "execution_peripheral", + [VarSet(name="manual_mode_id", value=command.mode_id)], +) +``` + +The command write response is not success. If `set_variables` raises, publish an immediate sanitized error result. On shutdown, cancel timeouts, unsubscribe the command topic, and flush the watermark. + +- [ ] **Step 5: Run bridge tests and the full agent suite** + +Run: `uv run pytest tests/test_scene_bridge.py -q` + +Expected: PASS. + +Run: `uv run pytest -q` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/brilliant_mqtt/scene_bridge.py tests/test_scene_bridge.py tests/fakes.py +git commit -m "feat: bridge Brilliant scenes over MQTT" +``` + +### Task 7: Wire the scene bridge without adding a bus peer + +**Files:** +- Modify: `src/brilliant_mqtt/config.py` +- Modify: `src/brilliant_mqtt/__main__.py` +- Modify: `tests/test_config.py` +- Modify: `tests/test_main.py` + +**Interfaces:** +- Adds settings: `scene_bridge_enabled: bool` from `SCENE_BRIDGE_ENABLED` (default false) and `scene_watermark_file: str` from `SCENE_WATERMARK_FILE` (default `/data/brilliant-mqtt/scene-watermarks.json`). + +- [ ] **Step 1: Write failing settings and session-wiring tests** + +Assert false/true parsing, default path, and that enabled sessions construct `SceneBridge` with the exact existing `bus` and `mqtt` objects. Assert call order: callbacks constructed → MQTT connect → bus start → `SceneBridge.async_start`; teardown order: scene bridge shutdown → bus shutdown → MQTT disconnect. Assert disabled mode never subscribes to scene topics. + +- [ ] **Step 2: Run and verify failure** + +Run: `uv run pytest tests/test_config.py tests/test_main.py -q` + +Expected: FAIL on missing settings/wiring. + +- [ ] **Step 3: Implement settings and session composition** + +```python +scene_bridge = ( + SceneBridge( + bus, + mqtt, + settings.panel, + Path(settings.scene_watermark_file), + ) + if settings.scene_bridge_enabled + else None +) +``` + +The bridge is an additional consumer of the existing adapters, just like `Bridge` and `MeshLeader`; do not instantiate `RpcBusAdapter`, `AioMqttAdapter`, `RPCObserver`, or `PeripheralHost` inside it. + +- [ ] **Step 4: Run targeted and complete agent quality gates** + +Run: `uv run pytest tests/test_config.py tests/test_main.py -q` + +Expected: PASS. + +Run: `uv run ruff check && uv run ruff format --check && uv run mypy --strict src tests && uv run pytest` + +Expected: every command exits 0. + +- [ ] **Step 5: Commit** + +```bash +git add src/brilliant_mqtt/config.py src/brilliant_mqtt/__main__.py tests/test_config.py tests/test_main.py +git commit -m "feat: enable scene transport on the shared panel session" +``` + +### Task 8: Add HA scene events, actions, service confirmation, and scene entities + +**Files:** +- Create: `custom_components/brilliant_mqtt/scene_control.py` +- Create: `ha/tests/test_scene_control.py` +- Modify: `custom_components/brilliant_mqtt/ha_control.py` +- Modify: `custom_components/brilliant_mqtt/select.py` +- Modify: `custom_components/brilliant_mqtt/button.py` +- Modify: `custom_components/brilliant_mqtt/services.yaml` +- Modify: `custom_components/brilliant_mqtt/strings.json` +- Modify: `custom_components/brilliant_mqtt/translations/en.json` +- Modify: `ha/tests/test_entities.py` +- Modify: `ha/tests/test_services.py` + +**Interfaces:** +- Produces HA events `brilliant_mqtt_scene` and `brilliant_mqtt_mode` with panel, activation ID, timestamp, and deduplication key. +- Registers services `brilliant_mqtt.run_scene(panel: str | None, scene_id: str)` and `brilliant_mqtt.set_mode(panel: str | None, mode_id: str)`, each waiting up to 16 seconds for result. +- Produces per-panel `SceneSelect` and `RunSelectedSceneButton`; select options are display names and internally map to scene IDs. + +- [ ] **Step 1: Write failing scene runtime tests** + +Assert wildcard subscriptions to scene/mode catalog/event/result/status, retained catalog replacement, stale/out-of-order event suppression, both HA event types, configured action dispatch, selected-panel defaulting, explicit panel override, offline/unknown activation rejection, accepted confirmation, timeout, and unload cleanup. Action mappings use this closed JSON shape: + +```json +{ + "office:all_off": { + "domain": "scene", + "service": "turn_on", + "target": {"entity_id": ["scene.downstairs_off"]}, + "data": {} + } +} +``` + +Reject service names not matching `^[a-z0-9_]+$`, target keys other than `entity_id`, `device_id`, and `area_id`, and mapping keys without exactly one colon. + +- [ ] **Step 2: Write failing entity/service tests** + +After a catalog message with `all_off` and `all_on`, assert the select options are `All Lights Off`, `All Lights On`; selecting an option updates only the local selection, and pressing the scene button calls `brilliant_mqtt.run_scene` with the selected ID. The service schema rejects missing scene ID and unknown panel. + +- [ ] **Step 3: Run and verify failure** + +Run: `uv run --project ha pytest -c ha/pyproject.toml ha/tests/test_scene_control.py ha/tests/test_entities.py ha/tests/test_services.py -q` + +Expected: FAIL because scene control and entities are absent. + +- [ ] **Step 4: Implement scene runtime and service confirmation** + +```python +async def async_run_scene(self, panel: str, scene_id: str) -> None: + catalog = self._catalogs.get(panel) + if catalog is None or scene_id not in catalog.by_id: + raise HomeAssistantError(f"Scene {scene_id} is not available on panel {panel}") + command_id = str(uuid4()) + future = self.hass.loop.create_future() + self._pending[command_id] = future + command = SceneCommand( + command_id=command_id, + panel=panel, + scene_id=scene_id, + issued_at_ms=int(time.time() * 1000), + ) + async_publish( + self.hass, + scene_command_topic(panel), + encode_scene_command(command), + retain=False, + ) + try: + result = await asyncio.wait_for(future, timeout=16) + finally: + self._pending.pop(command_id, None) + if not result.accepted: + raise HomeAssistantError(result.error or "Brilliant scene execution failed") +``` + +Implement `async_set_mode` symmetrically using the mode catalog/topic/result and `manual_mode_id` confirmation. MQTT callbacks must parse defensively, log no raw payload on failure, and keep running. Fire the HA event before the optional action so automation observers always see it. Use `hass.services.async_call` with `blocking=False` for mapped actions to avoid deadlocking the MQTT callback. + +- [ ] **Step 5: Implement scene select/button entities and translations** + +Attach them to the existing MQTT panel device. Set scene entities unavailable unless scene status is online and the catalog is non-empty. Add translation keys `scene`, `run_selected_scene`, and service field descriptions; do not label native tiles as available. + +- [ ] **Step 6: Run scene and full HA gates** + +Run: `uv run --project ha pytest -c ha/pyproject.toml ha/tests/test_scene_control.py ha/tests/test_entities.py ha/tests/test_services.py -q` + +Expected: PASS. + +Run: `uv run --project ha ruff check --config ha/pyproject.toml custom_components/brilliant_mqtt ha/tests && uv run --project ha ruff format --check --config ha/pyproject.toml custom_components/brilliant_mqtt ha/tests && uv run --project ha mypy --strict --config-file ha/pyproject.toml custom_components/brilliant_mqtt ha/tests && uv run --project ha pytest -c ha/pyproject.toml ha/tests` + +Expected: every command exits 0. + +- [ ] **Step 7: Commit** + +```bash +git add custom_components/brilliant_mqtt/scene_control.py custom_components/brilliant_mqtt/ha_control.py custom_components/brilliant_mqtt/select.py custom_components/brilliant_mqtt/button.py custom_components/brilliant_mqtt/services.yaml custom_components/brilliant_mqtt/strings.json custom_components/brilliant_mqtt/translations/en.json ha/tests/test_scene_control.py ha/tests/test_entities.py ha/tests/test_services.py +git commit -m "feat: expose confirmed Brilliant scenes in Home Assistant" +``` + +### Task 9: Add the configuration vertical slice and retire Tier 1 safely + +**Files:** +- Modify: `custom_components/brilliant_mqtt/const.py` +- Modify: `custom_components/brilliant_mqtt/config_flow.py` +- Modify: `custom_components/brilliant_mqtt/components.py` +- Modify: `custom_components/brilliant_mqtt/manager.py` +- Modify: `custom_components/brilliant_mqtt/panel_ops.py` +- Modify: `custom_components/brilliant_mqtt/diagnostics.py` +- Modify: `custom_components/brilliant_mqtt/strings.json` +- Modify: `custom_components/brilliant_mqtt/translations/en.json` +- Modify: `ha/tests/test_config_flow.py` +- Modify: `ha/tests/test_components.py` +- Modify: `ha/tests/test_manager.py` +- Modify: `ha/tests/test_panel_ops.py` +- Modify: `ha/tests/test_diagnostics.py` +- Modify: `ha/tests/test_repairs.py` + +**Interfaces:** +- Adds `CONF_HA_CONTROL_ENABLED`, `CONF_HA_CONTROL_LABEL`, `CONF_ROOM_OVERRIDES`, `CONF_HA_CONTROL_DOMAINS`, `CONF_MAX_MIRRORED_ENTITIES`, `CONF_SCENE_PANEL`, and `CONF_SCENE_ACTIONS`. +- Config-entry migration copies old `CONF_HA_MIRROR_LABEL`, disables `COMPONENT_HA_MIRROR`, removes URL/token/leader fields only after the control plane is enabled, and preserves unrelated component choices. + +- [ ] **Step 1: Write failing config/migration tests** + +Use defaults: control disabled, label `brilliant`, domains `light,switch`, maximum 50, empty overrides/actions, selected panel equal to current panel. Validate maximum 1–200; domains subset of `light/switch/lock/cover`; overrides and actions are JSON objects; panel is one of loaded entries. Reconfigure must propagate global control fields to every Brilliant entry while leaving per-panel SSH/MQTT fields unchanged. + +Test migration from the current entry version with HA mirror enabled: mirror becomes false, label is copied, old token remains only while control plane is disabled; after enabling control plane and successful manager apply, token/URL/leader keys are removed. + +- [ ] **Step 2: Write failing manager/env/repair/diagnostic tests** + +Assert `SCENE_BRIDGE_ENABLED=1` is rendered on every panel when globally enabled; disabled renders `0`. Assert the old mirror unit is stopped/disabled and its env file removed when legacy mirror is selected in old data. Assert a Repair issue `ha_mirror_retired_` explains physical-Control hosting was disabled for responsiveness safety. Assert diagnostics expose label, override count, manifest revision/entity count, scene catalog revision/last event, and blocked native status—but redact mappings' service data and all old tokens. + +- [ ] **Step 3: Run and verify failure** + +Run: `uv run --project ha pytest -c ha/pyproject.toml ha/tests/test_config_flow.py ha/tests/test_components.py ha/tests/test_manager.py ha/tests/test_panel_ops.py ha/tests/test_diagnostics.py ha/tests/test_repairs.py -q` + +Expected: FAIL on missing fields and retirement behavior. + +- [ ] **Step 4: Implement validated fields and all-entry propagation** + +Store overrides/actions as decoded dictionaries in entry data, not raw JSON strings. The form accepts JSON text, validates it, then persists canonical mappings. When a global setting changes, loop over `hass.config_entries.async_entries(DOMAIN)` and `async_update_entry` each with the same seven global keys. + +`panel_ops.render_env` gains only `scene_bridge_enabled`; HA registry/label/room/action data never goes to the panel. This enforces MQTT-only panels. + +- [ ] **Step 5: Hide and disable the legacy component while keeping uninstall support** + +Add `deprecated: bool = False` to `Component`; set true for `COMPONENT_HA_MIRROR`; `optional()` excludes deprecated rows. `selected_ids()` excludes it even when old data says true, so reconciliation invokes `uninstall_ha_mirror`. Keep the registry row until Task 12 so old installs can be removed idempotently. + +Create the Repair before removal and delete it after `inspect_ha_mirror` proves service inactive and env/token files absent. A removal failure keeps the Repair open and never starts the legacy service. + +- [ ] **Step 6: Run targeted and full HA gates** + +Expected: targeted command from Step 3 PASS, then all four HA quality commands from Task 8 PASS. + +- [ ] **Step 7: Commit** + +```bash +git add custom_components/brilliant_mqtt/const.py custom_components/brilliant_mqtt/config_flow.py custom_components/brilliant_mqtt/components.py custom_components/brilliant_mqtt/manager.py custom_components/brilliant_mqtt/panel_ops.py custom_components/brilliant_mqtt/diagnostics.py custom_components/brilliant_mqtt/strings.json custom_components/brilliant_mqtt/translations/en.json ha/tests/test_config_flow.py ha/tests/test_components.py ha/tests/test_manager.py ha/tests/test_panel_ops.py ha/tests/test_diagnostics.py ha/tests/test_repairs.py +git commit -m "feat: migrate HA mirror settings to the safe control plane" +``` + +### Task 10: Add dry-run-first legacy peripheral cleanup + +**Files:** +- Create: `src/brilliant_mqtt/cleanup_legacy_mirror.py` +- Create: `tests/test_cleanup_legacy_mirror.py` + +**Interfaces:** +- CLI: `python -m brilliant_mqtt.cleanup_legacy_mirror [--apply] [--snapshot PATH]`. +- Candidate requires both an allowlisted ID prefix (`ha_`, `ha-pilot-`, `zzz_mirror_`) and an allowlisted display-name prefix (`HA `, `HA_PILOT_`, `ZZZ Mirror `); no match on either side means no deletion. + +- [ ] **Step 1: Write failing candidate, dry-run, apply, and verification tests** + +Include real loads whose name contains “HA” but whose ID is not allowlisted and assert they are never candidates. Dry run prints a canonical JSON report and performs no writes. Apply deletes candidates serially, waits one second between deletes, reads a second scoped own-device snapshot, exits 0 only if every candidate is absent, and produces the same success report on a second run. + +- [ ] **Step 2: Run and verify failure** + +Run: `uv run pytest tests/test_cleanup_legacy_mirror.py -q` + +Expected: FAIL because the module is absent. + +- [ ] **Step 3: Implement the deferred panel-only cleanup client** + +Use deferred Brilliant imports and `MessageBusClient.delete_peripheral(device_id, peripheral_id, deletion_time_ms)`. Require root for `--apply`, reject `--apply` without a writable `--snapshot` report path under `/data/brilliant-mqtt/cleanup/`, and write no variable values or blobs to the report. + +```python +def is_candidate(device: BrilliantDevice) -> bool: + return device.peripheral_id.startswith(ALLOWED_ID_PREFIXES) and device.name.startswith( + ALLOWED_NAME_PREFIXES + ) +``` + +The report contains only timestamp, owning device ID, candidate IDs/names/types, deleted IDs, remaining IDs, and success. + +- [ ] **Step 4: Run cleanup and full agent gates** + +Expected: targeted tests PASS; all four agent quality commands from Task 7 PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/brilliant_mqtt/cleanup_legacy_mirror.py tests/test_cleanup_legacy_mirror.py +git commit -m "feat: add verified legacy mirror cleanup" +``` + +### Task 11: Eliminate source/payload drift in tests and release CI + +**Files:** +- Create: `tests/test_payload_parity.py` +- Modify: `scripts/build_payload.sh` +- Modify: `.github/workflows/ci.yml` +- Modify: `.github/workflows/release.yml` +- Modify: `.gitignore` + +**Interfaces:** +- Parity compares every file under `src/brilliant_mqtt` to `custom_components/brilliant_mqtt/agent_payload/app/brilliant_mqtt`, excluding only `__pycache__` and `*.pyc`. + +- [ ] **Step 1: Write the failing parity test** + +Build maps of relative path → SHA-256 for source and payload, then assert the maps are equal. The failure message prints missing, extra, and changed relative paths. Do not auto-build inside pytest; drift must fail visibly. + +- [ ] **Step 2: Run and verify failure against the currently stale payload** + +Run: `uv run pytest tests/test_payload_parity.py -q` + +Expected: FAIL and list the new control/scene modules as missing from the payload. + +- [ ] **Step 3: Update build and CI behavior** + +Keep the existing deterministic copy for the agent. While legacy uninstall support remains, continue packaging the mirror service, but add a comment that Task 12 removes it after live validation. In release CI, run: + +```yaml +- name: Build panel payload + run: scripts/build_payload.sh +- name: Verify generated payload is committed + run: git diff --exit-code -- custom_components/brilliant_mqtt/agent_payload +``` + +Add/retain ignore rules for `artifacts/`, `*.gpr`, `*.rep`, `*.p12`, `*.pfx`, `*.pem`, `*.key`, `*.token`, `pilot-logs/`, and `var-collections/` without unignoring existing tracked sanitized analysis. + +- [ ] **Step 4: Build payload and run parity/full gates** + +Run: `scripts/build_payload.sh` + +Expected: `payload built: custom_components/brilliant_mqtt/agent_payload` (the script prints the absolute repository path) and no errors. + +Run: `uv run pytest tests/test_payload_parity.py -q && uv run ruff check && uv run ruff format --check && uv run mypy --strict src tests && uv run pytest` + +Expected: all exit 0. + +Run the four HA quality commands from Task 8; expected all exit 0. + +- [ ] **Step 5: Commit generated payload and CI changes** + +```bash +git add tests/test_payload_parity.py scripts/build_payload.sh .github/workflows/ci.yml .github/workflows/release.yml .gitignore custom_components/brilliant_mqtt/agent_payload +git commit -m "build: enforce panel payload source parity" +``` + +### Task 12: Document, validate on Office, then remove legacy runtime code + +**Files:** +- Modify: `docs/ha-mirror.md` +- Create: `docs/brilliant-panel/home-assistant-integration.md` +- Create: `docs/brilliant-panel/runbooks/scene-bridge-pilot.md` +- Modify after hardware pass: `src/brilliant_ha_mirror/**`, `tests/test_ha_mirror_*.py`, `deploy/brilliant-ha-mirror.service`, `scripts/build_payload.sh`, `custom_components/brilliant_mqtt/components.py`, `custom_components/brilliant_mqtt/panel_ops.py`, and related HA tests. + +**Interfaces:** +- Hardware acceptance: Office scene event → configured HA action; HA `run_scene` → Brilliant execution confirmation; no extra bus peer/host; physical controls remain responsive. + +- [ ] **Step 1: Write the documentation before deployment** + +Document the ownership model, exact topics and payload fields, label/area precedence, room overrides, command vocabulary, scene catalog semantics, replay deduplication, service confirmation, diagnostics, safety invariants, cleanup dry-run/apply sequence, rollback, and the fact that this does not create native HA tiles. Link the approved design and reverse-engineering findings. + +- [ ] **Step 2: Commit documentation and deploy only the safe feature** + +```bash +git add docs/ha-mirror.md docs/brilliant-panel/home-assistant-integration.md docs/brilliant-panel/runbooks/scene-bridge-pilot.md +git commit -m "docs: add HA control and scene bridge runbook" +``` + +Build the committed payload, install it through the integration, enable the safe scene bridge, and leave native tiles disabled/blocked. + +- [ ] **Step 3: Run the Office hardware gate** + +Record a redacted baseline and post-test report under ignored `artifacts/brilliant-panel/pilots/scene-bridge-/`. Verify: + +1. `systemctl is-active brilliant-ha-mirror` is inactive and `brilliant-mqtt` is active. +2. Bus peer count does not increase relative to the forward-bridge baseline. +3. Tapping a known scene produces exactly one MQTT event and one HA event/action; if the home has a configured mode, changing it produces one mode event. +4. Reconnect/restart does not replay old scene or mode events. +5. `brilliant_mqtt.run_scene` returns only after a matching execution record; `brilliant_mqtt.set_mode` is hardware-tested when a real mode exists and otherwise remains covered by off-panel tests with an explicit “no configured modes” diagnostic. +6. HA, MQTT, agent, and panel restarts recover. +7. Ten consecutive physical light interactions remain subjectively immediate; no peer-add timeout, cloud-peer drop, or reconnect storm appears. +8. Disable/rollback removes scene subscriptions without deleting any Brilliant device/peripheral. + +Any failure leaves the old mirror stopped and blocks the removal step; it does not restart Tier 1. + +- [ ] **Step 4: After the hardware gate passes, delete legacy runtime packaging** + +Remove the `brilliant_ha_mirror` source/tests/service and mirror payload subtree. Keep only the deprecated config migration and cleanup command for one release. Replace the deprecated registry row with migration-time direct `panel_ops.uninstall_ha_mirror`; never expose an install path. + +- [ ] **Step 5: Rebuild and run every gate after removal** + +Run: `scripts/build_payload.sh` + +Run: `uv run ruff check && uv run ruff format --check && uv run mypy --strict src tests && uv run pytest` + +Run: `uv run --project ha ruff check --config ha/pyproject.toml custom_components/brilliant_mqtt ha/tests && uv run --project ha ruff format --check --config ha/pyproject.toml custom_components/brilliant_mqtt ha/tests && uv run --project ha mypy --strict --config-file ha/pyproject.toml custom_components/brilliant_mqtt ha/tests && uv run --project ha pytest -c ha/pyproject.toml ha/tests` + +Expected: all commands exit 0; `rg -n "enable_ha_mirror|brilliant-ha-mirror.service|src/brilliant_ha_mirror" src custom_components deploy scripts tests ha/tests` finds no install/start path. + +- [ ] **Step 6: Commit the validated retirement** + +```bash +git add -A src/brilliant_ha_mirror tests deploy/brilliant-ha-mirror.service custom_components/brilliant_mqtt scripts/build_payload.sh ha/tests docs/brilliant-panel +git commit -m "refactor: retire unsafe physical-control HA hosting" +``` + +--- + +## Completion evidence + +Before opening a PR, attach or summarize: + +- complete agent and HA quality-gate output; +- payload parity and clean post-build diff; +- protocol golden-vector parity across both runtimes; +- redacted Office scene event and confirmed HA→panel execution timing; +- before/after bus peer count and absence of reconnect/cloud-peer regressions; +- proof the panel has no HA token and the legacy service is inactive/removed; +- cleanup dry-run output, and apply/second-snapshot proof only if legacy candidates existed. diff --git a/docs/superpowers/plans/2026-07-12-virtual-control-feasibility-gates.md b/docs/superpowers/plans/2026-07-12-virtual-control-feasibility-gates.md new file mode 100644 index 0000000..8a96b9a --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-virtual-control-feasibility-gates.md @@ -0,0 +1,600 @@ +# Brilliant Virtual Control Feasibility Gates Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Determine, with one disposable officially provisioned Virtual Control, whether Brilliant native HA tiles are removable, isolated, operationally safe, and materially useful—including their real WAN dependency—before any production native transport is designed. + +**Architecture:** A repository-safe probe package creates redacted, hash-verifiable gate records while all tokens, certificates, bootstrap blobs, `/var` captures, packet captures, and live logs remain root-only on the selected panel or under gitignored artifacts. Gates VC0–VC5 execute strictly in order; provisioning is impossible without a fresh approval file, runtime tests use one supervised Virtual Control identity, and any failed/blocked gate stops expansion while leaving the safe scene bridge as the supported path. + +**Tech Stack:** Python 3.10 on-panel Brilliant libraries, stdlib-only off-panel gate tooling, Brilliant official app workflow, root-only filesystem storage, SSH, MQTT, Home Assistant, router/firewall WAN isolation, `/proc` resource sampling, pytest, ruff, mypy strict, uv. + +## Global Constraints + +- Never provision an account-visible device without a fresh operator approval immediately before the write. +- Use only an official Brilliant app/device-add workflow or a directly observed supported request made by that workflow. +- Do not blind-guess GraphQL mutation names or production API fields. +- Never print, log, commit, upload, or publish panel private keys, PKCS#12 material, Brilliant passwords, MFA codes, bootstrap tokens, account JWTs, refresh tokens, or certificate contents. +- Keep returned Virtual Control identity material under `/data/brilliant-vc/identity/` with directory mode `0700` and file mode `0600`; never copy it into the repository. +- Never bid on or overwrite ownership of `brilliant_virtual_device`, `configuration_virtual_device`, `ble_mesh`, or a physical Control. +- Never run the Virtual Control identity on more than one host and never implement automatic failover in this feasibility track. +- Abort on sustained agent CPU above 15%, RSS above 100 MiB, new peer-add timeouts, Brilliant cloud-peer disconnects, operator-observed physical-control lag, or inability to prove cleanup. +- Every live process has a hard runtime limit, SIGTERM/SIGINT cleanup, and an idempotent second-snapshot verification path. +- Binary dumps, `/var` collections, packet captures, generated Ghidra projects, credentials, and pilot logs stay under gitignored `artifacts/brilliant-panel/pilots/virtual-control/` or root-only panel paths. +- A failed or blocked Virtual Control gate does not block, disable, or roll back the HA/MQTT control plane or scene bridge. +- This plan ends with a feasibility decision. Production native-host implementation receives a new plan only after VC5 passes; no multi-entity, lock, shade, or garage hosting belongs here. + +--- + +## File map + +- Create `tools/brilliant_vc/__init__.py`: probe package marker. +- Create `tools/brilliant_vc/gates.py`: ordered gate model, redaction-safe ledger, progression validation. +- Create `tools/brilliant_vc/audit.py`: VC0 local/panel prior-state audit that never reads secret contents. +- Create `tools/brilliant_vc/token_check.py`: offline JWT shape/claim validation and fingerprinting without token output. +- Create `tools/brilliant_vc/provision_panel.py`: on-panel, approval-gated call through Brilliant's shipped provisioning client. +- Create `tools/brilliant_vc/monitor.py`: bounded process, bus-health, resource, cloud-peer, and latency sampler. +- Create `tools/brilliant_vc/single_light_pilot.py`: VC5 one-light framework pilot with a typed room assignment and one shared host. +- Create `tests/test_vc_gates.py`, `tests/test_vc_audit.py`, `tests/test_vc_token_check.py`, `tests/test_vc_provision.py`, `tests/test_vc_monitor.py`, and `tests/test_vc_single_light.py`. +- Create `docs/brilliant-panel/runbooks/virtual-control-gates.md`: operator workflow and stop conditions. +- Create `docs/brilliant-panel/virtual-control-gate-schema.json`: committed JSON Schema for redacted evidence. +- Modify `.gitignore`: keep every sensitive/generated live artifact out of Git. + +The tool package is not copied by `scripts/build_payload.sh`, not installed by the HA integration, and not started by systemd. The operator copies only the required script into an ignored pilot directory for a bounded gate. + +--- + +### Task 1: Create the ordered, secret-free gate ledger + +**Files:** +- Create: `tools/brilliant_vc/__init__.py` +- Create: `tools/brilliant_vc/gates.py` +- Create: `docs/brilliant-panel/virtual-control-gate-schema.json` +- Create: `tests/test_vc_gates.py` + +**Interfaces:** +- Produces: `GateName`, `GateStatus`, `GateRecord`, `GateLedger.load(path: Path) -> GateLedger`, `GateLedger.record(gate: GateName, status: GateStatus, summary: str, evidence: Sequence[Evidence]) -> None`, and `GateLedger.save(path: Path) -> None`. +- Ledger path during live work: ignored `artifacts/brilliant-panel/pilots/virtual-control//gate-ledger.json`. + +- [ ] **Step 1: Write failing progression and redaction tests** + +```python +def test_cannot_pass_vc2_before_vc1() -> None: + ledger = GateLedger.new(run_id="20260712-office") + with pytest.raises(GateProgressionError, match="VC1 must pass before VC2"): + ledger.record(GateName.VC2, GateStatus.PASS, summary="provisioned", evidence=[]) + +def test_secret_shaped_evidence_is_rejected() -> None: + with pytest.raises(UnsafeEvidenceError): + Evidence(kind="note", value="eyJhbGciOiJIUzI1NiJ9.abcdefgh.signature") +``` + +Test statuses `not_run`, `pass`, `fail`, and `blocked`; immutable earlier passes; a failed/blocked gate prevents later gates; evidence accepts relative artifact paths, SHA-256 digests, counts, durations, booleans, firmware versions, HTTP status, and redacted identifiers only. Reject PEM markers, JWT shapes, base64 values over 256 characters, fields containing token/password/secret/certificate/private-key names, and absolute sensitive paths. + +- [ ] **Step 2: Run and verify failure** + +Run: `uv run pytest tests/test_vc_gates.py -q` + +Expected: FAIL because `tools.brilliant_vc.gates` does not exist. + +- [ ] **Step 3: Implement exact ordered gate types** + +```python +class GateName(str, Enum): + VC0 = "VC0" + VC1 = "VC1" + VC2 = "VC2" + VC3 = "VC3" + VC4 = "VC4" + VC5 = "VC5" + +class GateStatus(str, Enum): + NOT_RUN = "not_run" + PASS = "pass" + FAIL = "fail" + BLOCKED = "blocked" + +GATE_ORDER = tuple(GateName) + +@dataclass(frozen=True, slots=True) +class Evidence: + kind: str + value: str | int | float | bool + sha256: str | None = None + +@dataclass(frozen=True, slots=True) +class GateRecord: + gate: GateName + status: GateStatus + recorded_at: str + summary: str + evidence: tuple[Evidence, ...] +``` + +Write ledger JSON atomically with `sort_keys=True`, indent 2, UTC timestamps, and no environment dump. Validate against the committed schema before replace. + +- [ ] **Step 4: Run tests and quality checks** + +Run: `uv run pytest tests/test_vc_gates.py -q && uv run ruff check tools/brilliant_vc tests/test_vc_gates.py && uv run mypy --strict tools/brilliant_vc/gates.py tests/test_vc_gates.py` + +Expected: all exit 0. + +- [ ] **Step 5: Commit** + +```bash +git add tools/brilliant_vc/__init__.py tools/brilliant_vc/gates.py docs/brilliant-panel/virtual-control-gate-schema.json tests/test_vc_gates.py +git commit -m "test: add ordered Virtual Control gate ledger" +``` + +### Task 2: Implement VC0 prior-state and security audit + +**Files:** +- Create: `tools/brilliant_vc/audit.py` +- Create: `tests/test_vc_audit.py` +- Modify: `.gitignore` + +**Interfaces:** +- CLI: `python -m tools.brilliant_vc.audit --panel office --snapshot-json PATH --output PATH`. +- The tool consumes an already-redacted device snapshot and a stat-only JSON inventory from the panel; it never opens credential files. + +- [ ] **Step 1: Write failing audit tests** + +Assert the audit: + +- reports firmware version, bus home ID hash, physical Control count, and DeviceType 6 count; +- marks VC0 failed when any type-6 device cannot be explained as pre-existing; +- inventories `/tmp/mirror_poc/.access` and similar paths by existence, owner UID, mode, size, and mtime only; +- marks world/group-readable credential-shaped files failed; +- rejects input containing file content, JWT shape, PEM marker, or PKCS#12 value; +- records whether the July 9 attempt created no VC without asserting that from absence alone—both app inventory and bus/home-graph evidence are required. + +- [ ] **Step 2: Run and verify failure** + +Run: `uv run pytest tests/test_vc_audit.py -q` + +Expected: FAIL because the audit module is absent. + +- [ ] **Step 3: Implement safe stat-only audit input** + +The panel inventory command must be generated by the tool and limited to these fields: + +```python +SAFE_STAT_FIELDS = ("path", "exists", "uid", "gid", "mode", "size", "mtime_ns") +SENSITIVE_PATHS = ( + "/tmp/mirror_poc/.access", + "/tmp/mirror_poc/.vc_record.json", + "/data/brilliant-vc/identity", +) +``` + +Do not hash file contents: even a digest would create an unnecessary stable identifier for a credential. The operator chooses one of two explicit VC0 actions: delete expired prior tokens on-panel, or retain them root-only with a recorded reason and expiry. Never copy them. + +- [ ] **Step 4: Add ignore coverage** + +Add: + +```gitignore +artifacts/brilliant-panel/pilots/virtual-control/ +**/virtual-control-identity/ +**/vc-captures/ +*.pcap +*.pcapng +*.p12 +*.pfx +*.pem +*.key +*.token +``` + +Do not remove already tracked sanitized reverse-engineering outputs. + +- [ ] **Step 5: Run tests, then execute VC0 read-only** + +Run: `uv run pytest tests/test_vc_audit.py -q` + +Expected: PASS. + +On Office, collect stat-only inventory and a redacted bus snapshot. In the official Brilliant app, record device count/type/name without screenshots containing personal data. Confirm no unexplained DeviceType 6 exists and no July 9 `.vc_record.json` exists. Save only sanitized outputs under the ignored run directory, then record VC0 PASS. If an unexplained VC exists, record BLOCKED and stop. + +- [ ] **Step 6: Commit code only** + +```bash +git add tools/brilliant_vc/audit.py tests/test_vc_audit.py .gitignore +git commit -m "test: add Virtual Control prior-state audit" +``` + +### Task 3: Implement VC1 offline bootstrap-token verification + +**Files:** +- Create: `tools/brilliant_vc/token_check.py` +- Create: `tests/test_vc_token_check.py` +- Create: `docs/brilliant-panel/runbooks/virtual-control-gates.md` + +**Interfaces:** +- CLI on the panel: `python -m tools.brilliant_vc.token_check --token-file PATH --report PATH`. +- Output contains issuer/audience hashes, issued/expiry times, allowed-path booleans, token SHA-256 prefix (8 hex characters), and no token bytes. + +- [ ] **Step 1: Write failing token checks** + +Use synthetic JWTs only. Assert account tokens that allow GraphQL but not `/provisioning/virtual-control-self-bootstrap` fail VC1; expired/future tokens fail; an official-workflow token whose claims allow the exact endpoint passes. The checker must not verify cryptographic authenticity—it labels its result “claims-only”; the shipped Brilliant client/server performs actual verification at VC2. + +- [ ] **Step 2: Run and verify failure** + +Run: `uv run pytest tests/test_vc_token_check.py -q` + +Expected: FAIL because the module is absent. + +- [ ] **Step 3: Implement claims-only parsing with no token output** + +```python +@dataclass(frozen=True, slots=True) +class TokenReport: + jwt_shape: bool + expires_at: int | None + issued_at: int | None + allows_self_bootstrap: bool + fingerprint8: str + +def inspect_token(raw: bytes, now_s: int) -> TokenReport: + text = raw.decode("ascii") + parts = text.split(".") + if len(parts) != 3: + raise TokenCheckError("bootstrap token is not JWT-shaped") + claims = json.loads(_decode_segment(parts[1])) + allowed = claims.get("allowed_paths", ()) + return TokenReport( + jwt_shape=True, + expires_at=_optional_int(claims.get("exp")), + issued_at=_optional_int(claims.get("iat")), + allows_self_bootstrap="/provisioning/virtual-control-self-bootstrap" in allowed, + fingerprint8=hashlib.sha256(raw).hexdigest()[:8], + ) +``` + +Open the token file with `O_NOFOLLOW`, require UID 0 and mode with no group/other bits, cap at 64 KiB, and overwrite the in-memory bytearray after parsing where practical. + +- [ ] **Step 4: Document the only allowed VC1 acquisition workflow** + +The runbook requires the official Brilliant app on a test handset/account session. Navigate its supported “add device/control” path and observe the request made by that workflow using normal OS/app diagnostic facilities or an operator-controlled network capture. Do not enumerate mutation names, fuzz fields, or replay unrelated production calls. Outcomes are exact: + +- the app has no Virtual Control/device-add path or produces no provisioning-scoped token: VC1 BLOCKED, stop; +- TLS pinning prevents normal observation: VC1 BLOCKED unless the operator separately authorizes app instrumentation; +- the official flow yields a root-only token and `token_check` confirms the exact self-bootstrap path: record capture timestamp, app version, endpoint/path, token fingerprint8, and VC1 PASS. + +The capture itself remains ignored; the committed/run ledger stores only the sanitized facts. + +- [ ] **Step 5: Run tests and commit** + +Run: `uv run pytest tests/test_vc_token_check.py -q && uv run ruff check tools/brilliant_vc/token_check.py tests/test_vc_token_check.py && uv run mypy --strict tools/brilliant_vc/token_check.py tests/test_vc_token_check.py` + +Expected: all exit 0. + +```bash +git add tools/brilliant_vc/token_check.py tests/test_vc_token_check.py docs/brilliant-panel/runbooks/virtual-control-gates.md +git commit -m "test: verify official Virtual Control bootstrap tokens" +``` + +### Task 4: Build a one-shot, approval-gated VC2 provisioning client + +**Files:** +- Create: `tools/brilliant_vc/provision_panel.py` +- Create: `tests/test_vc_provision.py` + +**Interfaces:** +- Panel CLI dry run: `python provision_panel.py --token-file PATH --property-id ID --expected-home-id ID --identity-dir /data/brilliant-vc/identity`. +- Live CLI adds: `--apply --approval-file /run/brilliant-vc-approval.json`. +- Uses shipped `WebAPIProvisioningClient.get_virtual_control_self_bootstrap(home_property_id, token)` against `https://web-api.brilliant.tech` through the panel's device-cert session. + +- [ ] **Step 1: Write failing guard/storage tests with fakes** + +Assert no network call unless all conditions hold: + +1. `--apply` is present; +2. VC0 and VC1 are PASS in the referenced ledger; +3. approval file is root-owned, mode `0600`, less than 10 minutes old, names this run ID/property ID/pilot panel, and contains `approved: true`; +4. token check passes; +5. identity directory does not exist or is empty; +6. no prior VC record exists; +7. expected home ID/property ID are 32 lowercase hex characters. + +Test response status other than 200 writes no identity. Status 200 requires `device_id`, `pkcs12_certificate`, and `bootstrap`; decode `BootstrapParameters.target_home_id` and reject/move to quarantine if it differs. Never include response bodies in exceptions or reports. + +- [ ] **Step 2: Run and verify failure** + +Run: `uv run pytest tests/test_vc_provision.py -q` + +Expected: FAIL because the provisioning module is absent. + +- [ ] **Step 3: Implement fail-closed provisioning and storage** + +```python +def validate_approval(path: Path, *, run_id: str, property_id: str, + panel: str, now_s: int) -> None: + file_stat = path.lstat() + if not stat.S_ISREG(file_stat.st_mode) or file_stat.st_uid != 0 or file_stat.st_mode & 0o077: + raise ProvisioningGuardError("approval file must be root-owned mode 0600") + data = json.loads(path.read_text()) + if now_s - int(data["approved_at_s"]) > 600: + raise ProvisioningGuardError("approval is older than 10 minutes") + expected = {"approved": True, "run_id": run_id, "property_id": property_id, "panel": panel} + if any(data.get(key) != value for key, value in expected.items()): + raise ProvisioningGuardError("approval scope does not match this request") +``` + +Create identity directory with `mkdir(mode=0o700)`; write `device_id`, certificate, bootstrap, and decoded non-secret metadata to separate temp files opened with `O_CREAT|O_EXCL|O_NOFOLLOW`, mode `0600`, `fsync`, then atomic rename. Log only HTTP status, device ID redacted to first/last four characters, presence booleans, target-home match, and durations. + +- [ ] **Step 4: Run tests and quality checks** + +Run: `uv run pytest tests/test_vc_provision.py -q && uv run ruff check tools/brilliant_vc/provision_panel.py tests/test_vc_provision.py && uv run mypy --strict tools/brilliant_vc/provision_panel.py tests/test_vc_provision.py` + +Expected: all exit 0. + +- [ ] **Step 5: Commit** + +```bash +git add tools/brilliant_vc/provision_panel.py tests/test_vc_provision.py +git commit -m "test: add guarded Virtual Control provisioner" +``` + +### Task 5: Execute VC2 and prove official rollback before hosting + +**Files:** +- Update only ignored live ledger/artifacts; no repository source change. + +**Interfaces:** +- Produces one account-visible disposable VC, root-only identity, app/home-graph visibility evidence, and a proven official removal path. + +- [ ] **Step 1: Reconfirm preconditions without writing** + +Run the provisioner without `--apply`; expected output is a redacted request summary and `DRY RUN — no provisioning request sent`. Confirm the official app is logged in, the account device count is known, the exact removal UI is documented through its final confirmation screen, and Office physical controls/forward bridge/cloud peer are healthy. + +- [ ] **Step 2: Obtain a fresh operator approval immediately before the write** + +Pause execution and request explicit approval naming: one disposable Virtual Control, the target home/property, Office as identity host, cloud account change, root-only identity storage, and the official removal requirement. After approval, create `/run/brilliant-vc-approval.json` root-owned mode `0600` with current epoch and the exact scoped fields. Do not treat the earlier general approval as this fresh write approval. + +- [ ] **Step 3: Provision exactly once** + +Run the approved `--apply` command once. Expected success: HTTP 200; target home matches; identity files are mode `0600`; the app device count increases by exactly one; bus/home graph shows exactly one new DeviceType 6 identity. Any retry after ambiguous failure is blocked until the app/home graph proves no device was created. + +- [ ] **Step 4: Prove the supported rollback path before hosting any peripheral** + +In the official app, navigate the disposable VC's removal flow through the final confirmation screen and directly observe the supported removal request shape without submitting it. Confirm the target identity/account/home match, record the app version and removal endpoint/action name, then cancel at the final confirmation. Do not guess or call a private removal mutation. The actual removal and second-snapshot proof occurs in Task 9 after VC5 (or immediately after any failed gate). If no official removal action is offered or its target cannot be verified, record VC2 FAIL and stop before hosting. + +- [ ] **Step 5: Record VC2** + +Record only redacted device ID, before/after counts, HTTP status, target-home match, file modes, official removal-action availability, and pilot creation duration. VC2 PASS requires one currently visible pilot VC and a verified official removal flow ready for immediate use; final removal success remains a completion condition for the entire track. + +### Task 6: Implement and execute VC3 runtime-topology measurements + +**Files:** +- Create: `tools/brilliant_vc/monitor.py` +- Create: `tests/test_vc_monitor.py` +- Modify: `docs/brilliant-panel/runbooks/virtual-control-gates.md` + +**Interfaces:** +- CLI: `python -m tools.brilliant_vc.monitor --pid PID --duration-s SECONDS --interval-s 5 --output-jsonl PATH`. +- Samples process CPU/RSS, load average, bus socket peer count, reconnect/peer-timeout/cloud-peer log counters, MQTT round-trip marker latency, and physical-control observation markers. + +- [ ] **Step 1: Write failing `/proc` and log-counter tests** + +Use fixture proc trees. CPU is delta process ticks / delta total ticks; RSS uses resident pages × page size. Assert secrets in journal lines are redacted/dropped; only allowlisted counters are stored. A threshold violation writes one `abort_reason` and invokes the supplied terminator once. + +- [ ] **Step 2: Run and verify failure** + +Run: `uv run pytest tests/test_vc_monitor.py -q` + +Expected: FAIL because monitor does not exist. + +- [ ] **Step 3: Implement bounded monitoring and hard aborts** + +```python +THRESHOLDS = Thresholds( + cpu_percent=15.0, + rss_bytes=100 * 1024 * 1024, + peer_add_timeouts=1, + cloud_disconnects=1, + reconnect_storms=1, +) +``` + +Require `--duration-s` between 60 and 90,000. On violation send SIGTERM, wait up to 10 seconds, then SIGKILL only to the exact monitored PID. Never kill `message_bus`, `switch-ui`, or the forward `brilliant-mqtt` process by name. + +- [ ] **Step 4: Determine the exact shipped VC launcher read-only** + +On Office, run the existing read-only introspection of `bus.message_bus.start_as_virtual_control`, `run_as_main`, `BootstrapParameters`, and accepted flagfile/constructor parameters. Record module, callable signature, firmware build, and SHA-256 of the defining `.so`; record no bootstrap/certificate values. If no supported launcher consumes the official returned identity without replacing the physical panel's message bus, mark VC3 BLOCKED and stop. + +- [ ] **Step 5: Start only the VC identity under the bounded supervisor** + +Use a dedicated process, device ID, data directory, and bus/client name. It must not claim the physical Control device ID or modify the panel's main message-bus configuration. Start with no hosted peripherals, a 10-minute hard limit, and the monitor attached. Confirm the home graph sees the VC and the physical bus peer/loads remain healthy. + +- [ ] **Step 6: Measure WAN-up and WAN-off topology** + +With WAN up, measure process start/join, cross-panel visibility, and no-op heartbeat propagation. Then isolate only the pilot VC/panel from public internet at the router while allowing RFC1918 LAN, MQTT, HA, DNS as locally provided, SSH, and panel-to-panel traffic. Prove isolation by failed connection to `web-api.brilliant.tech:443` and successful MQTT/HA/LAN probes. Repeat visibility, restart, and propagation measurements. Restore WAN and verify recovery. + +Classify the result exactly: + +- all runtime paths work with WAN denied: `local`; +- existing state remains but commands/restart/propagation fail: `cloud-dependent`; +- no reliable join/visibility even with WAN up: `not viable`. + +Cloud-dependent is not mislabeled local; the operator may still accept it only if measured latency/reliability improves on SmartThings. + +- [ ] **Step 7: Run tests, commit code, and record VC3** + +Run: `uv run pytest tests/test_vc_monitor.py -q && uv run ruff check tools/brilliant_vc/monitor.py tests/test_vc_monitor.py && uv run mypy --strict tools/brilliant_vc/monitor.py tests/test_vc_monitor.py` + +Expected: all exit 0. + +```bash +git add tools/brilliant_vc/monitor.py tests/test_vc_monitor.py docs/brilliant-panel/runbooks/virtual-control-gates.md +git commit -m "test: measure Virtual Control runtime topology" +``` + +Record VC3 PASS only when topology is conclusively classified and restart/visibility are repeatable. `cloud-dependent` may pass feasibility only with an explicit operator acceptance recorded in the ledger; it can never satisfy a claim of local control. + +### Task 7: Execute VC4 isolation and 24-hour resource soak + +**Files:** +- Update ignored ledger/artifacts and the runbook only if a discovered operational command needs documentation. + +**Interfaces:** +- Produces 24 hours of 5-second samples, 1-minute aggregates, abort state, and physical-control observations for the no-peripheral VC runtime. + +- [ ] **Step 1: Establish the comparable baseline** + +Collect 30 minutes on Office with the VC stopped: process list, load, forward-agent CPU/RSS, bus peers, cloud-peer state, reconnects, peer-add timeouts, and 20 timestamped physical-light interactions. Do not scrape `/var` broadly; collect only allowlisted counters and retain raw logs in the ignored run directory. + +- [ ] **Step 2: Start the same single VC runtime for 24 hours** + +Use a systemd transient unit or supervisor with `RuntimeMaxSec=90000`, `Restart=no`, `KillMode=control-group`, and no identity copy. The identity path remains root-only and mounted/readable only by the probe process. Attach the monitor from Task 6. + +- [ ] **Step 3: Exercise controls throughout the soak** + +At hours 0, 1, 6, 12, 18, and 24, perform ten physical light interactions, one HA/MQTT scene round trip, and one UI navigation check. Record operator-observed lag as a boolean and optional non-sensitive note. Any lag, cloud disconnect, peer timeout, threshold violation, or forward-bridge regression stops the runtime immediately. + +- [ ] **Step 4: Analyze and record VC4** + +VC4 PASS requires: no abort, no new peer-add timeout/cloud disconnect/reconnect storm, peak RSS ≤100 MiB, no sustained (five consecutive samples) CPU >15%, no physical lag, and forward bridge availability throughout. Preserve raw samples ignored; ledger stores min/median/p95/max and SHA-256 of the sample file. + +### Task 8: Build and execute the VC5 single-native-light pilot + +**Files:** +- Create: `tools/brilliant_vc/single_light_pilot.py` +- Create: `tests/test_vc_single_light.py` +- Modify: `docs/brilliant-panel/runbooks/virtual-control-gates.md` + +**Interfaces:** +- CLI on Office: `python single_light_pilot.py --vc-identity-dir /data/brilliant-vc/identity --stable-id --display-name "HA VC Pilot Light" --room-id --runtime-s 1800`. +- Exactly one `PeripheralHost`, one LIGHT peripheral, one registration at a time, and one MQTT entity command/state route. + +- [ ] **Step 1: Write failing schema/guard/lifecycle tests with firmware fakes** + +Assert the hosted light contains exact variables/types: + +```python +{ + "on": (int, True, 0), + "intensity": (int, True, 500), + "dimmable": (int, False, 1), + "max_intensity_value": (int, False, 1000), + "minimum_dim_level": (int, True, 100), + "maximum_dim_level": (int, True, 1000), + "display_name": (str, True, "HA VC Pilot Light"), + "room_assignment": (RoomAssignment, True, RoomAssignment(room_ids=[room_id])), + "mode_transition_settings": (str, True, "{}"), + "configuration_peripheral_id": (str, False, vc_configuration_id), +} +``` + +Assert HA brightness 0–255 scales round-half-up to Brilliant 0–1000 and back; stable ID determines peripheral name; display rename does not change it; only the VC device ID is passed as `virtual_device_id`; missing room/config linkage fails before registration; SIGTERM deletes the pilot and verifies absence; second cleanup is successful/no-op. + +- [ ] **Step 2: Run and verify failure** + +Run: `uv run pytest tests/test_vc_single_light.py -q` + +Expected: FAIL because the pilot module is absent. + +- [ ] **Step 3: Implement one typed framework peripheral and shared host** + +Defer every Brilliant import until `main()`. Decode room catalog with a scoped `configuration_virtual_device/home_configuration` read and require the supplied room ID to exist. Discover the provisioned VC's own configuration peripheral from its home-graph record; never borrow `brilliant_virtual_device_configuration` or a physical Control configuration. + +Use `PeripheralHost`/`HostedStartableSpec` with `virtual_device_id=`. Reject `None`, `brilliant_virtual_device`, `configuration_virtual_device`, `ble_mesh`, or the Office physical ID. The push callbacks publish v1 entity commands to HA; retained v1 state updates drive pull/state variables. No direct HA WebSocket/token is accepted. + +- [ ] **Step 4: Run off-panel tests and a preflight dry run** + +Run: `uv run pytest tests/test_vc_single_light.py -q` + +Expected: PASS. + +Run the pilot with `--dry-run`; expected output lists only redacted VC ID, stable peripheral ID, display name, validated room/config IDs, runtime, topics, and `DRY RUN — no host started`. + +- [ ] **Step 5: Run the bounded live light test** + +Start monitor first, then the pilot for at most 30 minutes. Validate on Office and a second panel: + +1. tile renders in the intended room with correct display name; +2. panel on/off and slider commands reach MQTT/HA exactly once; +3. HA on/off/brightness updates render on both panels; +4. restart HA, MQTT, pilot process, Office panel, and the second panel one at a time; +5. temporarily remove network, restore it, and verify reconciliation; +6. repeat WAN-denied behavior from VC3 and record latency; +7. physical Office loads remain responsive and monitor thresholds remain clear. + +- [ ] **Step 6: Delete and prove no phantom** + +Terminate normally, issue timestamped deletion if the framework did not remove it, and take two scoped VC snapshots at least 30 seconds apart. Confirm the peripheral is absent on both panels and the app/home graph while the VC identity itself remains. Run cleanup a second time and require success/no-op. + +- [ ] **Step 7: Run quality checks, commit code, and record VC5** + +Run: `uv run pytest tests/test_vc_single_light.py -q && uv run ruff check tools/brilliant_vc/single_light_pilot.py tests/test_vc_single_light.py && uv run mypy --strict tools/brilliant_vc/single_light_pilot.py tests/test_vc_single_light.py` + +Expected: all exit 0. + +```bash +git add tools/brilliant_vc/single_light_pilot.py tests/test_vc_single_light.py docs/brilliant-panel/runbooks/virtual-control-gates.md +git commit -m "test: validate one Virtual Control native light" +``` + +VC5 PASS requires every rendering/control/restart/network/cleanup check and no safety abort. Any persistent phantom is FAIL. + +### Task 9: Close the feasibility track and remove the disposable identity + +**Files:** +- Create after the run: `docs/brilliant-panel/virtual-control-feasibility.md` +- Modify: `docs/brilliant-panel/home-assistant-integration.md` + +**Interfaces:** +- Produces one of three decisions: `blocked`, `rejected`, or `eligible_for_native_transport_plan`. + +- [ ] **Step 1: Generate a sanitized evidence summary** + +Summarize every gate status, firmware/app versions, topology classification, latency distributions, resource aggregates, two-panel rendering, restart matrix, and cleanup proof. Link artifact SHA-256 values without committing raw artifacts or absolute panel paths. State cloud dependency plainly. + +- [ ] **Step 2: Apply the decision rule** + +- Any BLOCKED gate → decision `blocked`; scene bridge remains supported. +- Any FAIL gate or unacceptable cloud dependency/resource result → `rejected`; remove the pilot VC. +- VC0–VC5 all PASS → `eligible_for_native_transport_plan`; this authorizes planning, not production deployment. + +- [ ] **Step 3: Remove the disposable VC through the official app** + +Stop the runtime, confirm no hosted peripherals, remove the VC through the already-proven official path, verify account/home graph absence from a second snapshot, then securely delete `/data/brilliant-vc/identity/` and `/run/brilliant-vc-approval.json` on Office. Record counts, modes, and absence only. A failed removal changes the decision to `rejected`. + +- [ ] **Step 4: Run repository secret and artifact scans** + +Run: + +```bash +git status --short +git check-ignore -v artifacts/brilliant-panel/pilots/virtual-control/example/identity.p12 +rg -n "BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY|pkcs12_certificate|server_authentication_token|refresh_token|eyJ[A-Za-z0-9_-]+\.eyJ" --glob '!artifacts/**' --glob '!docs/claude/research/**' . +``` + +Expected: live artifacts are ignored; the secret scan finds no newly introduced value. Field-name mentions in probe code are reviewed and contain no values. + +- [ ] **Step 5: Run full repository gates and commit the conclusion** + +Run: `uv run ruff check && uv run ruff format --check && uv run mypy --strict src tests tools && uv run pytest` + +Run the four HA quality commands from the baseline implementation plan. + +Expected: all exit 0. + +```bash +git add docs/brilliant-panel/virtual-control-feasibility.md docs/brilliant-panel/home-assistant-integration.md +git commit -m "docs: record Virtual Control feasibility decision" +``` + +If and only if the decision is `eligible_for_native_transport_plan`, create a new design/implementation plan from the observed VC identity launcher, its own configuration peripheral, verified room linkage, real WAN topology, restart semantics, resource envelope, and VC5 light schema. That later plan may cover the shared multi-entity host and additional domains; this feasibility branch must not grow them opportunistically. + +--- + +## Completion evidence + +- VC0–VC5 ordered ledger with sanitized evidence and artifact digests. +- Fresh provisioning approval timestamp less than ten minutes before the single write. +- Official token provenance and exact endpoint allow-path confirmation without token bytes. +- Official removal rehearsal before hosting and final removal after testing. +- WAN-up/WAN-denied topology classification with successful isolation proof. +- 24-hour resource/physical-control soak and all abort counters. +- Two-panel single-light rendering, bidirectional command/state timing, restart/network matrix, and double-snapshot cleanup. +- Secret scan, ignored artifact verification, and root-only identity deletion. diff --git a/docs/superpowers/research/2026-07-11-ha-mirror-room-assignment.md b/docs/superpowers/research/2026-07-11-ha-mirror-room-assignment.md new file mode 100644 index 0000000..013c38e --- /dev/null +++ b/docs/superpowers/research/2026-07-11-ha-mirror-room-assignment.md @@ -0,0 +1,69 @@ +# HA Mirror V2 — Room Assignment: firmware facts (live-verified) + +- **Date:** 2026-07-11 (pilot panel, read-only introspection) +- **Resolves:** the two open sub-questions from + `2026-07-10-ha-mirror-v1-visibility.md` (room enumeration; struct variables). + +## 1. Enumerating Brilliant rooms (id + name) + +The room catalog lives on the **virtual home device** as the +`home_configuration` peripheral's `rooms` variable. The value is a +**base64-encoded thrift-binary `Rooms` struct** +(`thrift_types.configuration.ttypes.Rooms` = `{rooms: map}`, +`Room = {id: string, name: string}`), decodable with the firmware's own helper: + +```python +from lib.serialization import deserialize, serialize # firmware, on-panel only +from thrift_types.configuration.ttypes import Rooms, RoomAssignment +rooms = deserialize(Rooms, rooms_variable_value) # id -> Room{id,name} +``` + +Live catalog (2026-07-11) includes `Backyard`, `Balcony`, `Office`, etc. Room +ids are opaque strings — three formats coexist in this home (`"1"`, `"2"`, +`"<32-hex>:"`, `"<20-hex>:"`). **Treat the id as fully opaque.** + +## 2. `room_assignment` — encoding CORRECTION + +`room_assignment` exists on **every** peripheral (the base `Peripheral` class +provides it — our mirrored lamps already expose it, empty: +`'DwABCwAAAAAA'` = `RoomAssignment(room_ids=[])`). + +The value is a base64 thrift-binary `RoomAssignment{room_ids: list}`, +and — **correction to the 2026-07-10 note** — each `room_ids` entry is the +catalog `Room.id` **verbatim**. The `:timestamp` suffix seen in decoded values +is *part of the room id itself*, not an assignment timestamp to append. +Verified: office panel's gangbox `room_assignment.room_ids == +["b6f97347b34010df5d52:1683406303305"]` which is exactly the catalog id of the +"Office" room; a Kitchen-assigned load carries `["2"]` — the literal Kitchen id. + +Round-trip verified: `serialize(deserialize(RoomAssignment, v)) == v` +(byte-identical) using `lib.serialization`. + +## 3. Setting it on a mirrored peripheral (CONFIRMED on pilot, 2026-07-12) + +The working mechanism is the existing +`Peripheral.__dict__["_set_value_internal"](notify=True)` reflection path, +passing the **`RoomAssignment` struct OBJECT** (`RoomAssignment(room_ids=[...])`). +Passing the base64-serialized string raises +`TypeError: Expected type RoomAssignment but got str` — the framework validates +the in-process value against the variable's declared thrift STRUCT type. The +base64 string form is only the wire/snapshot representation seen via observer +`get_all()` (so READS still `deserialize(...)` from the string). + +Also confirmed the hard way: the firmware `PeripheralHost` (hosting side) has +**no** registry-read API — reading `home_configuration.rooms` requires a +dedicated read-only `RPCObserver` connection (the `brilliant_mqtt/bus.py` +recipe). Both facts were live-verified end-to-end: all five mirrored lamps +assigned to their Backyard/Balcony rooms on the pilot. + +## 4. Where to read the catalog from + +`get_all()` (which the mirror's host process can already reach via its bus +connection) returns the virtual home device with `home_configuration` and its +`rooms` variable — no new API needed. Note `get_all()` device containers vary +(`.items()` map vs immutable list) — iterate defensively. + +## Safety + +All introspection was read-only (`get_all` + local thrift decode); no sets, no +writes, no reboots. diff --git a/docs/superpowers/specs/2026-07-12-ha-control-plane-and-virtual-control-design.md b/docs/superpowers/specs/2026-07-12-ha-control-plane-and-virtual-control-design.md new file mode 100644 index 0000000..959fb41 --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-ha-control-plane-and-virtual-control-design.md @@ -0,0 +1,471 @@ +# Home Assistant Control Plane and Brilliant Virtual Control — Design + +- **Date:** 2026-07-12 +- **Status:** Approved direction; pending specification review +- **Scope:** Replace the unsafe Tier-1 reverse mirror with a safe local control + plane, ship a scene/mode bridge, and retry native tiles behind a gated + Virtual Control feasibility track. +- **Supersedes:** `2026-07-10-ha-mirror-tier1-design.md` +- **Related evidence:** + - `docs/claude/research/2026-07-06-mirror-poc/FINDINGS.md` + - `docs/claude/research/2026-07-06-mirror-poc/REPORT.md` + +## Executive decision + +The current HA mirror must not host peripherals on a physical Brilliant +Control. That creates a second manager for the Control, adds one message-bus +peer per entity, can starve real loads, and has not produced reliable native UI +tiles. Room assignment and additional metadata cannot repair the ownership and +routing model. + +The replacement has two tracks sharing one HA/MQTT control plane: + +1. **Safe baseline:** a local Brilliant scene/mode ↔ Home Assistant bridge. It + provides dependable panel-to-HA and HA-to-panel actions without hosting new + peripherals or co-managing physical devices. +2. **Native-tile research track:** retry Brilliant's distinct + `DeviceType.VIRTUAL_CONTROL` mechanism. Treat it as blocked until an official + app-generated bootstrap token is available and live testing proves that + runtime control remains local when WAN access is removed. Only after every + gate passes may Virtual Control become the native-peripheral transport. + +This design does **not** treat Virtual Control as already feasible. A July 9 +live attempt authenticated the account and completed MFA, but +`/provisioning/virtual-control-self-bootstrap` rejected the account JWT. The +endpoint needs a provisioning-scoped token normally minted by the Brilliant +app. No Virtual Control was created. Static and live evidence also classify it +as a cloud-relayed device, so its latency and offline behavior remain suspect. + +## Terminology and prior failures + +These mechanisms are different and must not be conflated: + +| Mechanism | What it is | Proven result | Decision | +|---|---|---|---| +| Physical Control hosting | Additional `PeripheralHost` managing a real panel device | Commands can reach `push_func`, but real lights became unresponsive and assigned test lights did not reliably render | Rejected | +| Invented third-party virtual device | A new name such as `homeassistant` or `shelly` | No cloud-seeded config/owner record, so registration and lease ownership fail | Rejected | +| `brilliant_virtual_device` | Existing shared Brilliant software device | Raw records can be registered by its current owner, but command ownership remains with its manager; taking its lease risks breaking built-in solar/weather/group services | Diagnostic only; never take its lease | +| Raw injected peripheral | A persisted bus record with no owned host | Can render with the right home graph and metadata, but UI commands route to the device manager and revert | Diagnostic only | +| Virtual Control | Brilliant device type 6, a non-physical Control with its own identity | Correct ownership paradigm, but app-mediated provisioning and runtime locality are unproven | Gated research track | + +## Evidence incorporated from the 2026-07-12 validation + +The Office pilot established several independent defects in Tier 1: + +- The panel ran byte-for-byte copies of the stale bundled payload rather than + the room-aware `src/` files. +- All five selected HA lights inherited `Backyard` or `Balcony` from their HA + devices. The deployed client read only entity-level areas, so every mirror + appeared unassigned. +- The UI binary excludes empty room assignments from normal room models, but a + correctly assigned own-Control pilot still did not render. Room assignment is + necessary for placement, not sufficient for admission. +- Inside the firmware framework, `room_assignment` is typed as + `RoomAssignment`. Passing a serialized string fails with + `TypeError: Expected type RoomAssignment but got str`; the framework performs + serialization itself. +- `PeripheralHost.get_all()` for a room lookup consumed its CPU allowance and + did not complete promptly. A scoped read of + `configuration_virtual_device/home_configuration` completed and returned the + exact room catalog. +- Fetching HA's device registry on-panel stalled long enough to hit resource and + responsiveness concerns. HA already owns those registries and should resolve + areas itself. +- The five-entity service opened five framework hosts and contributed to local + bus peer timeouts. A single shared host is required for any future native + transport. +- The configured BVD comparison was present on its owning panel but absent from + Office's bus snapshot. Office's home-graph/cloud peer was disconnected, so + the missing UI tile was a propagation failure and not valid evidence about + metadata admission. + +All transient BVD pilots were deleted. The unsafe `brilliant-ha-mirror` service +was left stopped on Office after validation. + +## Goals + +1. Make Home Assistant the authoritative entity, area, state, and command hub. +2. Provide useful local panel control even if native HA device tiles remain + blocked. +3. Preserve physical Brilliant load responsiveness and the existing forward + MQTT bridge. +4. Keep normal state and command traffic on LAN/MQTT whenever the selected + transport supports it. +5. Prove rather than assume Virtual Control provisioning, rendering, routing, + latency, and offline behavior. +6. Make every hardware experiment bounded, observable, and cleanly reversible. + +## Non-goals + +- Taking over `brilliant_virtual_device`, `configuration_virtual_device`, + `ble_mesh`, or a physical Control lease. +- Blindly guessing private production GraphQL mutations to mint a provisioning + token. +- Claiming cloud independence for a Virtual Control before a WAN-disconnect + test proves it. +- Cameras, doorbells, WebRTC, and media in the initial native-peripheral tier. +- Automatic failover of a Virtual Control identity between panels in the first + release. + +## Safety invariants + +The implementation and runbooks must enforce these rules: + +- Never create a `PeripheralHost` with `virtual_device_id=None` for mirrored HA + entities. +- Never bid on or overwrite ownership of `brilliant_virtual_device`, + `configuration_virtual_device`, or `ble_mesh`. +- Never run one framework host per mirrored entity. +- Never provision an account-visible device without a fresh operator approval + immediately before the write. +- Never exfiltrate panel private keys, PKCS#12 material, Brilliant passwords, + MFA codes, bootstrap tokens, or account JWTs into the repository or logs. +- Abort a pilot if physical controls become sluggish, the cloud peer drops, the + local bus begins rejecting peers, CPU/RSS exceed the gate, or cleanup cannot + be proven. +- Every pilot has a hard runtime limit and an idempotent cleanup path verified + from a second bus snapshot. + +## Shared Home Assistant/MQTT control plane + +The custom integration, not a wall panel, resolves HA registries and invokes HA +services. The panel-side process becomes a constrained transport adapter. + +```text +HA entity/device/area registries + │ + ▼ +custom_components/brilliant_mqtt + manifest + state publisher + command executor + │ MQTT + ▼ +panel transport adapter + scene bridge (baseline) + Virtual Control host (only after gates pass) + │ + ▼ +Brilliant message bus / UI +``` + +### Selection and registry ownership + +- Continue using an HA label as the entity selection mechanism. +- Resolve entity registry area first and device registry area second inside HA. +- Resolve friendly name, device class, supported features, and the minimal + attribute subset required by mappings inside HA. +- Subscribe to HA registry and state changes; do not poll full registries from + the panel. +- Keep explicit case-insensitive HA-area → Brilliant-room overrides for names + that cannot match automatically. + +### MQTT namespace + +Use a versioned namespace separate from the existing forward bridge: + +| Topic | Retained | Payload purpose | +|---|---:|---| +| `brilliant/ha-control/v1/manifest` | Yes | Complete selected-entity catalog and monotonically increasing revision | +| `brilliant/ha-control/v1/state/` | Yes | Authoritative entity state and supported attribute subset | +| `brilliant/ha-control/v1/command/` | No | Panel-originated command with command ID and requested value | +| `brilliant/ha-control/v1/result/` | No | HA service-call acceptance/error and resulting state sequence | +| `brilliant/ha-control/v1/status/` | Yes | Availability, manifest revision, resource use, and circuit-breaker reason | + +`stable_id` is a deterministic UUIDv5 of the HA entity ID. Entity IDs and +friendly names remain payload fields rather than topic path components. + +### Manifest contract + +The retained JSON manifest contains: + +- `schema_version`, `revision`, and generation timestamp; +- entity ID and stable ID; +- domain, device class, friendly name, and HA area name; +- supported command vocabulary; +- normalized capabilities such as dimming, position, tilt, and lock support; +- optional explicit Brilliant room override; +- a mapping-version field so incompatible agents fail closed. + +Manifest changes are debounced and published atomically. State changes use +per-entity topics so normal updates do not republish the whole manifest. + +### Commands and confirmation + +- Panel commands contain a unique command ID, stable entity ID, command kind, + value, and last observed state sequence. +- The HA integration validates the command against the current manifest before + calling a service. +- HA state remains authoritative. A panel may show a short optimistic change, + but it must reconcile to the subsequent HA state event or display failure. +- Duplicate command IDs are idempotently ignored. +- Commands expire quickly and are never retained. + +## Track A: local scene and mode bridge + +This is the shippable baseline because it requires no new peripheral owner. + +### Panel → HA + +- Observe each panel's `execution_peripheral`. +- Detect timestamped dynamic variables named + `execution_state:scene_execution_handler:scene:`. +- Decode the execution payload and publish a non-retained scene event with + panel ID, scene ID, execution timestamp, and deduplication key. +- The HA integration exposes the event to automations and optional configured + HA actions. +- Deduplicate retained/replayed execution variables across reconnects. + +### HA → panel + +- Expose an HA service such as `brilliant_mqtt.run_scene`. +- Route the request through the existing agent connection to a selected online + panel's `execution_peripheral.last_executed_scene_id` handler. +- Confirm execution from the resulting dynamic execution-state variable rather + than assuming the set request succeeded. + +### Scene catalog + +- Read scene and mode definitions through scoped configuration-device reads. +- Publish IDs and display names for HA selectors and diagnostics. +- Treat the catalog as cached home configuration; executing a known cached + scene remains local, while creating/editing Brilliant scenes is outside scope. + +## Track B: Virtual Control feasibility gates + +Virtual Control is promoted to a production transport only if every gate passes. + +### VC0 — security and prior-state audit + +- Inventory and remove or deliberately retain any prior root-only account token + under `/tmp/mirror_poc/`; never copy it into the repo. +- Confirm no Virtual Control was created by the July 9 attempt. +- Record the exact firmware release and API behavior before retrying. + +### VC1 — obtain an official bootstrap token + +- Use only an official Brilliant app/device-add workflow or a directly observed + supported request made by that workflow. +- Do not blind-guess GraphQL mutation names against production. +- If the official workflow cannot create or authorize a Virtual Control, mark + the track blocked and stop. The scene bridge remains the product path. + +### VC2 — provision one disposable Virtual Control + +Requires a new operator approval immediately before execution. + +- Use the provisioning-scoped token with + `/provisioning/virtual-control-self-bootstrap`. +- Keep the returned device identity and PKCS#12 material on the designated + panel with root-only permissions. +- Confirm the new device is visible in the Brilliant account and home graph. +- Prove the official removal/rollback path before hosting HA entities. + +### VC3 — determine runtime topology + +- Establish a baseline for command latency and state propagation with WAN up. +- Remove WAN access while retaining LAN, MQTT, HA, and panel-to-panel access. +- Test tile visibility, panel → HA commands, HA → panel state, process restart, + and cross-panel propagation. +- If control or propagation needs the Brilliant cloud relay, report it plainly + and do not market the transport as local. The operator decides whether the + result still improves on SmartThings. + +### VC4 — resource and isolation gate + +- Run the Virtual Control identity on one explicitly selected pilot panel. +- Verify that it does not co-manage the panel's physical device ID. +- Measure message-bus peer count, CPU, RSS, load average, cloud-peer stability, + UI frame responsiveness, and physical light latency for at least 24 hours. +- Abort on sustained agent CPU above 15%, RSS above 100 MiB, new peer-add + timeouts, cloud disconnects, or operator-observed physical-control lag. + +### VC5 — single native light + +- Host one complete native light on the Virtual Control. +- Verify room placement and UI rendering on two panels. +- Verify panel → MQTT → HA command routing and HA → MQTT → tile state. +- Verify behavior across agent restart, HA restart, MQTT restart, panel reboot, + and temporary network loss. +- Remove the light and prove no persistent phantom remains. + +Only after VC5 passes may implementation expand to multiple entities. + +## Native transport design after VC gates pass + +### One shared host + +- Create one `PeripheralHost` containing every mirror + `HostedStartableSpec` rather than one host per entity. +- Batch manifest changes and apply them through one host reload/reconcile path. +- Limit registration concurrency to one and rate-limit churn. +- Use a stable internal peripheral name derived from `stable_id`; expose the HA + friendly name through `display_name` so HA renames do not delete/recreate the + bus identity. + +### Room catalog + +- Subscribe only to + `configuration_virtual_device/home_configuration.rooms`. +- Decode and cache the room ID/name map once, then update it from scoped + notifications. +- Use a typed `RoomAssignment(room_ids=[...])` as the framework value. +- Populate room assignment before initial registration, preserving the + framework's timestamp-zero user-configuration expectations. +- Leave unmatched areas unhosted by default and surface a Repair/diagnostic; + an explicit option may allow an `Unassigned` fallback. + +### Complete type schemas + +Do not use the Tier-1 minimal variable dictionaries. Build each peripheral from +the firmware thrift interface plus live exemplars and configuration linkage for +the provisioned Virtual Control. + +Initial order: + +1. LIGHT (27): `on`, `intensity`, `dimmable`, `max_intensity_value`, dim bounds, + display name, room assignment, mode transitions, and required configuration + linkage. Scale HA 0–255 to Brilliant 0–1000. +2. GENERIC_ON_OFF (45): on/off switch after a native exemplar comparison. +3. LOCK (1): lock state and lock/unlock commands with explicit security opt-in. +4. SHADE (53): position first; tilt only when both sides support it. +5. GARAGE_DOOR (74): open/close with confirmation and safety warning. + +Every type receives its own live admission, rendering, command, and state test. + +### Lifecycle + +- The Virtual Control identity is tied to one designated host in the first + release; systemd restarts it on that host. +- No automatic cross-panel identity failover until certificate/identity locking + and split-brain behavior are understood. +- On normal agent restart, retain stable peripherals if the framework supports + clean reattachment; on removal/unlabel, perform explicit timestamped delete. +- Handle SIGTERM and SIGINT so a bounded pilot can reconcile or delete before + exit. +- A circuit breaker stops native hosting while leaving the scene bridge and + forward MQTT bridge intact. + +## Integration configuration and UX + +The HA integration exposes: + +- enable/disable for the safe scene bridge; +- selected scene-execution panel and scene/action mappings; +- HA mirror label; +- area/room override editor; +- native-tile experimental status and gate results; +- designated Virtual Control host only after provisioning succeeds; +- maximum mirrored entity count and per-domain opt-ins; +- Repairs for unmatched rooms, unsupported entities, stale transport, + incompatible schema, and circuit-breaker activation. + +The UI must never present native tiles as available merely because the feature +code is installed. It becomes selectable only after the Virtual Control gates +are recorded as passed. + +The direct panel HA WebSocket URL/token fields are deprecated after migration; +panels consume MQTT only. + +## Packaging and deployment + +- `src/` remains the source of truth. +- Add a deterministic test that compares the packaged + `custom_components/brilliant_mqtt/agent_payload` Python tree with `src/` for + every non-vendored file. +- Release CI must run `scripts/build_payload.sh` and fail on a dirty diff. +- Add `ROOM_OVERRIDES` and any remaining runtime settings to config-entry, + manager, env rendering, reconfigure, diagnostics, translations, and tests as + one vertical slice; do not support settings only in the standalone unit. +- Keep binary dumps, `/var` collections, credentials, generated Ghidra projects, + and pilot logs under gitignored artifacts. + +## Migration and retirement of Tier 1 + +1. Keep `brilliant-ha-mirror.service` stopped by default. +2. Add an integration Repair explaining that physical-Control hosting was + disabled for safety. +3. Provide an idempotent cleanup command for persistent `HA ` / pilot + peripherals, verified from a second snapshot. +4. Migrate label and room-override settings to the HA-side manifest publisher. +5. Remove panel HA tokens after the MQTT control plane is active. +6. Remove the old leader election and one-host-per-entity implementation only + after the scene bridge migration is validated. + +## Observability and circuit breakers + +Publish diagnostics without secrets: + +- manifest revision and entity count; +- scene catalog revision and last execution event; +- MQTT connected state and command/result latency; +- bus connected state, peer count, registration latency, and reconnect count; +- process CPU/RSS and panel load average; +- Brilliant cloud-peer state as an observation, not a claimed dependency; +- hosted peripheral count, unmatched rooms, and last cleanup result; +- Virtual Control gate status and explicit blocked reason. + +The native transport circuit breaker opens on resource threshold violation, +peer-add timeout, reconnect storm, repeated registration failure, or physical +control health alarm. It stops only the experimental transport and requires an +operator reset after the underlying condition clears. + +## Testing strategy + +Implementation follows test-driven development. + +### Off-panel tests + +- Manifest schema, stable IDs, registry-area precedence, capability reduction, + and debouncing. +- MQTT state, command, result, expiry, idempotency, and reconnect behavior. +- Scene execution decoding and replay deduplication. +- Scene service routing and confirmation. +- Room matching, typed assignments, and scoped catalog updates. +- Shared-host reconciliation, stable rename behavior, deletion, and circuit + breakers using firmware fakes. +- Per-domain state/command mapping and intensity conversion. +- Config-flow, reconfigure, diagnostics redaction, translations, migration, and + payload-parity tests. + +### On-panel gates + +- Scene bridge on Office with no extra framework host. +- Virtual Control VC0–VC5 only in order and only after explicit approvals. +- Two-panel UI validation for every native type. +- WAN-disconnect test separated from ordinary Wi-Fi loss. +- Twenty-four-hour load and physical-control soak before entity count grows. +- OTA/reinstall and cleanup verification. + +## Rollback + +- Scene bridge rollback disables its MQTT subscriptions and removes no native + devices. +- Native transport rollback stops the Virtual Control host, deletes test + peripherals, verifies the home graph, and uses the official app removal path + for the disposable Virtual Control. +- If the app cannot remove the Virtual Control cleanly, provisioning fails its + precondition and must not proceed. +- The forward `brilliant-mqtt` bridge remains independent throughout. + +## Success criteria + +The baseline release succeeds when: + +- Brilliant scene executions reliably trigger configured HA actions locally; +- HA can run Brilliant scenes and observe confirmation; +- physical controls and the forward bridge show no regression; +- panels hold no HA API token; +- packaged payload and source cannot drift. + +The native-tile track succeeds only when: + +- an officially provisioned Virtual Control is removable and isolated; +- a complete light renders on at least two panels; +- bidirectional control survives component restarts; +- WAN-disconnect measurements establish the real dependency and acceptable + latency; +- a 24-hour soak produces no physical lag, peer failures, or cloud disconnects; +- cleanup leaves no phantom peripherals. + +Failure of the Virtual Control track does not block or roll back the scene +bridge and HA/MQTT control plane. diff --git a/src/brilliant_ha_mirror/ha_client.py b/src/brilliant_ha_mirror/ha_client.py index 9a223d3..bc92684 100644 --- a/src/brilliant_ha_mirror/ha_client.py +++ b/src/brilliant_ha_mirror/ha_client.py @@ -140,21 +140,31 @@ def labeled_entity_ids( def area_by_entity( entity_registry: list[dict[str, object]], + device_registry: list[dict[str, object]], area_registry: list[dict[str, object]], ) -> dict[str, str | None]: - """Map each entity registry id to its assigned Home Assistant area name.""" + """Map entities to area names, preferring entity over device assignment.""" names_by_id = { area_id: name for area in area_registry for area_id, name in [(area.get("area_id"), area.get("name"))] if isinstance(area_id, str) and isinstance(name, str) } + area_id_by_device = { + device_id: area_id + for device in device_registry + for device_id, area_id in [(device.get("id"), device.get("area_id"))] + if isinstance(device_id, str) and isinstance(area_id, str) + } result: dict[str, str | None] = {} for entity in entity_registry: entity_id = entity.get("entity_id") if not isinstance(entity_id, str): continue area_id = entity.get("area_id") + if not isinstance(area_id, str): + device_id = entity.get("device_id") + area_id = area_id_by_device.get(device_id) if isinstance(device_id, str) else None result[entity_id] = names_by_id.get(area_id) if isinstance(area_id, str) else None return result @@ -278,11 +288,12 @@ async def get_entities(self, label: str) -> list[HaEntity]: entities = await self._registry_list( "config/entity_registry/list", "entity registry result" ) + devices = await self._registry_list("config/device_registry/list", "device registry result") areas = await self._registry_list("config/area_registry/list", "area registry result") labels = await self._registry_list("config/label_registry/list", "label registry result") labeled_ids = labeled_entity_ids(entities, labels, label) - self._areas_by_entity = area_by_entity(entities, areas) + self._areas_by_entity = area_by_entity(entities, devices, areas) result: list[HaEntity] = [] for state in states: # Skip (and log) a single malformed state rather than aborting the diff --git a/src/brilliant_ha_mirror/hosting.py b/src/brilliant_ha_mirror/hosting.py index bdb06ce..1e034dd 100644 --- a/src/brilliant_ha_mirror/hosting.py +++ b/src/brilliant_ha_mirror/hosting.py @@ -1,8 +1,8 @@ """Real Brilliant peripheral-host adapter (``PeripheralHostClient``). This is the ONLY module in :mod:`brilliant_ha_mirror` that touches the on-panel -firmware framework (``lib.*`` / ``peripherals.*``). Those imports are DEFERRED — -performed inside methods, never at module level — so ``import +firmware framework (``lib.*`` / ``peripherals.*`` / ``thrift_types.*``). Those +imports are DEFERRED — performed inside methods, never at module level — so ``import brilliant_ha_mirror.hosting`` succeeds on any machine without the panel libs (matching :mod:`brilliant_mqtt.bus`). Everything else runs off panel behind the :class:`~brilliant_ha_mirror.protocols.PeripheralHostClient` Protocol with fakes. @@ -28,13 +28,23 @@ from __future__ import annotations +import asyncio +import logging import re +import secrets import time from collections.abc import Awaitable, Callable, Mapping from typing import Any from brilliant_ha_mirror.mapping import INT_VARIABLES, PeripheralSpec +logger = logging.getLogger(__name__) + +_ROOM_OBSERVER_CONNECT_TIMEOUT_SECONDS = 10.0 +_ROOM_OBSERVER_CONNECT_POLL_SECONDS = 0.25 + +RoomObserverFactory = Callable[[], Awaitable[tuple[Any, Any]]] + # Live peripheral instances by name, so the adapter can push variable updates via # each instance's set_value(). The host instantiates the peripheral class, so the # instance registers itself here in __init__. This is a MODULE global keyed by @@ -72,6 +82,68 @@ def _typed_value(var: str, raw: str) -> Any: return int(raw) if var in INT_VARIABLES else raw +def _container_entries(container: Any) -> list[tuple[str | None, Any]]: + """Normalize thrift maps and immutable thrift lists into keyed entries.""" + if container is None: + return [] + items = getattr(container, "items", None) + if callable(items): + return [(key if isinstance(key, str) else None, value) for key, value in items()] + try: + return [(None, value) for value in container] + except TypeError: + return [] + + +def _entry_name(key: str | None, value: Any) -> str | None: + if key is not None: + return key + for field in ("name", "id"): + candidate = getattr(value, field, None) + if isinstance(candidate, str): + return candidate + return None + + +def _find_rooms_value(snapshot: Any) -> str | None: + """Find home_configuration.rooms across firmware container variants.""" + devices = getattr(snapshot, "devices", snapshot) + for _, device in _container_entries(devices): + peripherals = getattr(device, "peripherals", None) + for peripheral_key, peripheral in _container_entries(peripherals): + if _entry_name(peripheral_key, peripheral) != "home_configuration": + continue + variables = getattr(peripheral, "variables", None) + for variable_key, variable in _container_entries(variables): + if _entry_name(variable_key, variable) != "rooms": + continue + value = getattr(variable, "value", None) + return value if isinstance(value, str) else None + return None + + +def _decode_rooms(value: str) -> dict[str, str]: + """Deserialize a firmware Rooms value into an opaque id/name catalog.""" + from lib.serialization import deserialize + from thrift_types.configuration.ttypes import Rooms + + decoded = deserialize(Rooms, value) + catalog: dict[str, str] = {} + for key, room in _container_entries(getattr(decoded, "rooms", None)): + room_id = key if key is not None else getattr(room, "id", None) + room_name = getattr(room, "name", None) + if isinstance(room_id, str) and isinstance(room_name, str): + catalog[room_id] = room_name + return catalog + + +def _room_assignment_value(room_ids: list[str]) -> Any: + """Build the firmware struct expected by the in-process value setter.""" + from thrift_types.configuration.ttypes import RoomAssignment + + return RoomAssignment(room_ids=list(room_ids)) + + def _make_peripheral_class( display_name: str, spec: PeripheralSpec, @@ -123,10 +195,20 @@ class RpcPeripheralHost: Satisfies :class:`~brilliant_ha_mirror.protocols.PeripheralHostClient`. """ - def __init__(self, loop: Any, socket_path: str = "/var/run/brilliant/server_socket") -> None: + def __init__( + self, + loop: Any, + socket_path: str = "/var/run/brilliant/server_socket", + *, + room_observer_factory: RoomObserverFactory | None = None, + ) -> None: self._loop = loop self._socket_path = socket_path self._hosts: dict[str, Any] = {} + self._room_observer_factory = room_observer_factory + self._room_observer: Any = None + self._room_processor: Any = None + self._room_catalog: dict[str, str] = {} async def start(self) -> None: """No global connection; each peripheral host connects on register.""" @@ -205,6 +287,102 @@ async def update_variables(self, name: str, values: Mapping[str, str]) -> None: for var, raw in values.items(): await _await_if_coroutine(update(instance, var, _typed_value(var, raw), notify=True)) + async def _open_room_observer(self) -> tuple[Any, Any]: + """Connect a dedicated read-only observer using the proven bus recipe.""" + import lib.protocol.message_bus_peer_service as mbps + from lib.message_bus_api.observer_interface import RPCObserver + from lib.protocol.processor import SinglePeerProcessor + + observer = RPCObserver(self._loop) + processor = SinglePeerProcessor( + socket_path=self._socket_path, + my_name=f"brilliant_ha_mirror_rooms-{secrets.token_hex(4)}", + handler=mbps.PeripheralServer(observer), + client_class=mbps.MessageBusClient, + loop=self._loop, + ) + try: + await processor.start() + waited = 0.0 + while not processor.is_connected(): + if waited >= _ROOM_OBSERVER_CONNECT_TIMEOUT_SECONDS: + raise TimeoutError( + "room observer did not connect within " + f"{_ROOM_OBSERVER_CONNECT_TIMEOUT_SECONDS:.0f}s" + ) + await asyncio.sleep(_ROOM_OBSERVER_CONNECT_POLL_SECONDS) + waited += _ROOM_OBSERVER_CONNECT_POLL_SECONDS + await observer.start(processor, None) + except BaseException: + for component, label in ( + (observer, "room observer"), + (processor, "room observer processor"), + ): + try: + await component.shutdown() + except Exception: + logger.exception("%s shutdown after startup failure failed", label) + raise + return observer, processor + + async def _get_room_observer(self) -> Any: + if self._room_observer is None: + factory = self._room_observer_factory or self._open_room_observer + observer, processor = await factory() + self._room_observer = observer + self._room_processor = processor + return self._room_observer + + async def _close_room_observer(self) -> None: + observer = self._room_observer + processor = self._room_processor + self._room_observer = None + self._room_processor = None + for component, label in ( + (observer, "room observer"), + (processor, "room observer processor"), + ): + if component is None: + continue + try: + await component.shutdown() + except Exception: + logger.exception("%s shutdown failed", label) + + async def get_rooms(self) -> Mapping[str, str]: + """Read and decode the virtual home's Brilliant room catalog.""" + try: + observer = await self._get_room_observer() + snapshot = await observer.get_all() + value = _find_rooms_value(snapshot) + if value is None: + logger.warning( + "home_configuration.rooms was not found on the message bus; " + "keeping the last room catalog" + ) + return dict(self._room_catalog) + self._room_catalog = _decode_rooms(value) + return dict(self._room_catalog) + except Exception as exc: + logger.warning( + "failed to read Brilliant room catalog; keeping the last catalog: %s", + exc, + exc_info=True, + ) + await self._close_room_observer() + return dict(self._room_catalog) + + async def set_room_assignment(self, name: str, room_ids: list[str]) -> None: + """Reflect a RoomAssignment struct into a hosted peripheral.""" + instance = _INSTANCES.get(name) + if instance is None: + return + from peripherals.lib.peripheral_service.peripheral import Peripheral + + update = Peripheral.__dict__["_set_value_internal"] + value = _room_assignment_value(room_ids) + await _await_if_coroutine(update(instance, "room_assignment", value, notify=True)) + async def delete(self, name: str) -> None: host = self._hosts.pop(name, None) if host is None: @@ -224,5 +402,6 @@ async def delete(self, name: str) -> None: await host.shutdown() async def shutdown(self) -> None: + await self._close_room_observer() for name in list(self._hosts): await self.delete(name) diff --git a/src/brilliant_ha_mirror/mapping.py b/src/brilliant_ha_mirror/mapping.py index 7d3c4e7..b9f5620 100644 --- a/src/brilliant_ha_mirror/mapping.py +++ b/src/brilliant_ha_mirror/mapping.py @@ -53,6 +53,24 @@ def _domain(entity_id: str) -> str: return entity_id.partition(".")[0] +def resolve_room_id( + area_name: str | None, + rooms: Mapping[str, str], + overrides: Mapping[str, str], +) -> str | None: + """Resolve an HA area name to an opaque Brilliant room id.""" + if area_name is None: + return None + normalized_area = area_name.casefold() + for override_area, room_id in overrides.items(): + if override_area.casefold() == normalized_area: + return room_id + for room_id, room_name in rooms.items(): + if room_name.casefold() == normalized_area: + return room_id + return None + + def _int_attribute(entity: HaEntity, name: str) -> int: value = entity.attributes.get(name, 0) return value if isinstance(value, int) else 0 diff --git a/src/brilliant_ha_mirror/mirror.py b/src/brilliant_ha_mirror/mirror.py index 9e40d19..55f17c7 100644 --- a/src/brilliant_ha_mirror/mirror.py +++ b/src/brilliant_ha_mirror/mirror.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from collections import Counter from collections.abc import Awaitable, Callable @@ -10,11 +11,14 @@ HaEntity, PeripheralSpec, command_to_service, + resolve_room_id, spec_for, state_to_variables, ) from brilliant_ha_mirror.protocols import HaClient, PeripheralHostClient +logger = logging.getLogger(__name__) + class Mirror: """Keep hosted peripherals aligned with labeled Home Assistant entities.""" @@ -30,6 +34,10 @@ def __init__( self._settings = settings self._name_by_entity: dict[str, str] = {} self._entity_by_name: dict[str, str] = {} + self._rooms: dict[str, str] = {} + self._area_by_entity: dict[str, str | None] = {} + self._room_id_by_entity: dict[str, str | None] = {} + self._logged_unmatched: set[tuple[str, str | None]] = set() def _base_name(self, entity: HaEntity) -> str: friendly = entity.attributes.get("friendly_name") @@ -81,6 +89,7 @@ async def reconcile(self) -> None: if spec is not None: supported[entity.entity_id] = (entity, spec) names = self._assign_names([entity for entity, _ in supported.values()]) + registered_or_renamed: set[str] = set() for entity_id, (entity, spec) in supported.items(): name = names[entity_id] @@ -93,18 +102,67 @@ async def reconcile(self) -> None: # one and only mutate the maps once registration succeeds, so a # failed register cannot leave the entity deleted-but-still-tracked. await self._host.register(name, spec, self._command_handler(entity_id)) + registered_or_renamed.add(entity_id) if current is not None: await self._host.delete(current) del self._entity_by_name[current] self._name_by_entity[entity_id] = name self._entity_by_name[name] = entity_id + rooms: dict[str, str] | None = None + if supported: + try: + rooms = dict(await self._host.get_rooms()) + except Exception: + logger.warning( + "failed to read Brilliant room catalog; skipping room assignment this cycle", + exc_info=True, + ) + + if rooms is not None: + catalog_changed = rooms != self._rooms + for entity_id, (entity, _) in supported.items(): + room_id = resolve_room_id(entity.area, rooms, self._settings.room_overrides) + assignment_changed = ( + entity_id not in self._room_id_by_entity + or self._room_id_by_entity[entity_id] != room_id + ) + area_changed = ( + entity_id not in self._area_by_entity + or self._area_by_entity[entity_id] != entity.area + ) + if ( + entity_id in registered_or_renamed + or catalog_changed + or assignment_changed + or area_changed + ): + name = self._name_by_entity[entity_id] + await self._host.set_room_assignment( + name, + [room_id] if room_id is not None else [], + ) + if room_id is None: + unmatched = (entity_id, entity.area) + if unmatched not in self._logged_unmatched: + logger.debug( + "no Brilliant room matched HA area %r for %s; leaving unassigned", + entity.area, + entity_id, + ) + self._logged_unmatched.add(unmatched) + self._area_by_entity[entity_id] = entity.area + self._room_id_by_entity[entity_id] = room_id + self._rooms = rooms + stale_entity_ids = self._name_by_entity.keys() - supported.keys() for entity_id in list(stale_entity_ids): name = self._name_by_entity[entity_id] await self._host.delete(name) del self._name_by_entity[entity_id] del self._entity_by_name[name] + self._area_by_entity.pop(entity_id, None) + self._room_id_by_entity.pop(entity_id, None) async def _handle_state_change(self, entity: HaEntity) -> None: name = self._name_by_entity.get(entity.entity_id) @@ -117,3 +175,7 @@ async def stop(self) -> None: await self._host.delete(name) self._name_by_entity.clear() self._entity_by_name.clear() + self._rooms.clear() + self._area_by_entity.clear() + self._room_id_by_entity.clear() + self._logged_unmatched.clear() diff --git a/src/brilliant_ha_mirror/protocols.py b/src/brilliant_ha_mirror/protocols.py index 381e681..b474504 100644 --- a/src/brilliant_ha_mirror/protocols.py +++ b/src/brilliant_ha_mirror/protocols.py @@ -61,6 +61,14 @@ async def update_variables(self, name: str, values: Mapping[str, str]) -> None: """Update the hosted peripheral *name* with the supplied variable values.""" ... + async def get_rooms(self) -> Mapping[str, str]: + """Return the Brilliant room catalog as opaque id to display name.""" + ... + + async def set_room_assignment(self, name: str, room_ids: list[str]) -> None: + """Replace the hosted peripheral's Brilliant room assignment.""" + ... + async def delete(self, name: str) -> None: """Delete the hosted peripheral identified by *name*.""" ... diff --git a/tests/fakes.py b/tests/fakes.py index 4af34ac..277a10c 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -175,13 +175,17 @@ async def emit_state(self, entity: HaEntity) -> None: class FakePeripheralHost: """Fake peripheral host that records registrations, updates, and deletes.""" - def __init__(self) -> None: + def __init__(self, rooms: Mapping[str, str] | None = None) -> None: self.registered: list[str] = [] self.registered_types: list[int] = [] self.specs: dict[str, PeripheralSpec] = {} self.variables: dict[str, dict[str, str]] = {} self.commands: dict[str, Callable[[str, str], Awaitable[None]]] = {} self.deleted: list[str] = [] + self.rooms = dict(rooms or {}) + self.get_rooms_error: Exception | None = None + self.room_assignments: dict[str, list[str]] = {} + self.room_assignment_calls: list[tuple[str, list[str]]] = [] async def start(self) -> None: pass @@ -201,6 +205,16 @@ async def register( async def update_variables(self, name: str, values: Mapping[str, str]) -> None: self.variables[name].update(values) + async def get_rooms(self) -> Mapping[str, str]: + if self.get_rooms_error is not None: + raise self.get_rooms_error + return dict(self.rooms) + + async def set_room_assignment(self, name: str, room_ids: list[str]) -> None: + assignment = list(room_ids) + self.room_assignments[name] = assignment + self.room_assignment_calls.append((name, assignment)) + async def delete(self, name: str) -> None: self.deleted.append(name) diff --git a/tests/test_ha_mirror_fakes.py b/tests/test_ha_mirror_fakes.py index a3b6a2c..c30e7ae 100644 --- a/tests/test_ha_mirror_fakes.py +++ b/tests/test_ha_mirror_fakes.py @@ -5,7 +5,7 @@ async def test_fake_host_register_update_command_delete() -> None: - host = FakePeripheralHost() + host = FakePeripheralHost(rooms={"room-kitchen": "Kitchen"}) seen: list[tuple[str, str]] = [] async def on_cmd(var: str, value: str) -> None: @@ -22,6 +22,10 @@ async def on_cmd(var: str, value: str) -> None: await host.update_variables("HA L", {"on": "1"}) assert host.variables["HA L"]["on"] == "1" + assert await host.get_rooms() == {"room-kitchen": "Kitchen"} + await host.set_room_assignment("HA L", ["room-kitchen"]) + assert host.room_assignments["HA L"] == ["room-kitchen"] + await host.fire_command("HA L", "on", "0") assert seen == [("on", "0")] diff --git a/tests/test_ha_mirror_ha_client.py b/tests/test_ha_mirror_ha_client.py index 36fb875..c5dd128 100644 --- a/tests/test_ha_mirror_ha_client.py +++ b/tests/test_ha_mirror_ha_client.py @@ -85,7 +85,8 @@ async def send(self, message: dict[str, object]) -> None: result = [ { "entity_id": "light.kitchen", - "area_id": "area-kitchen", + "area_id": None, + "device_id": "device-kitchen", "labels": ["label-brilliant"], "disabled_by": None, "hidden_by": None, @@ -109,6 +110,13 @@ async def send(self, message: dict[str, object]) -> None: "hidden_by": None, }, ] + elif message_type == "config/device_registry/list": + result = [ + { + "id": "device-kitchen", + "area_id": "area-kitchen", + } + ] elif message_type == "config/area_registry/list": result = [ { @@ -185,22 +193,41 @@ def test_labeled_entity_ids_resolves_name_and_excludes_unlabeled() -> None: assert labeled_entity_ids(entity_registry, label_registry, "brilliant") == {"light.kitchen"} -def test_area_by_entity_maps_names_and_preserves_unassigned() -> None: +def test_area_by_entity_prefers_entity_area_then_falls_back_to_device_area() -> None: entity_registry: list[dict[str, object]] = [ { "entity_id": "light.kitchen", "area_id": "area-kitchen", + "device_id": "device-downstairs", "labels": ["label-brilliant"], }, - {"entity_id": "lock.front_door", "area_id": None, "labels": []}, + { + "entity_id": "lock.front_door", + "area_id": None, + "device_id": "device-entry", + "labels": [], + }, + { + "entity_id": "switch.unassigned", + "area_id": None, + "device_id": None, + "labels": [], + }, + ] + device_registry: list[dict[str, object]] = [ + {"id": "device-downstairs", "area_id": "area-downstairs"}, + {"id": "device-entry", "area_id": "area-entry"}, ] area_registry: list[dict[str, object]] = [ {"area_id": "area-kitchen", "name": "Kitchen"}, + {"area_id": "area-downstairs", "name": "Downstairs"}, + {"area_id": "area-entry", "name": "Entry"}, ] - assert area_by_entity(entity_registry, area_registry) == { + assert area_by_entity(entity_registry, device_registry, area_registry) == { "light.kitchen": "Kitchen", - "lock.front_door": None, + "lock.front_door": "Entry", + "switch.unassigned": None, } @@ -369,7 +396,7 @@ async def on_state_change(entity: HaEntity) -> None: ) ) assert transport.sent[-1] == { - "id": 6, + "id": 7, "type": "call_service", "domain": "light", "service": "turn_on", diff --git a/tests/test_ha_mirror_hosting_smoke.py b/tests/test_ha_mirror_hosting_smoke.py index 5398e36..b84fea0 100644 --- a/tests/test_ha_mirror_hosting_smoke.py +++ b/tests/test_ha_mirror_hosting_smoke.py @@ -8,13 +8,45 @@ from __future__ import annotations +import asyncio import importlib.util import pathlib import re +import sys +from types import ModuleType +from typing import Any import pytest +class _Struct: + def __init__(self, **fields: object) -> None: + for name, value in fields.items(): + setattr(self, name, value) + + +class _FakeRoomObserver: + def __init__(self, snapshot: object) -> None: + self.snapshot = snapshot + self.get_all_calls = 0 + self.shutdown_calls = 0 + + async def get_all(self) -> object: + self.get_all_calls += 1 + return self.snapshot + + async def shutdown(self) -> None: + self.shutdown_calls += 1 + + +class _FakeRoomProcessor: + def __init__(self) -> None: + self.shutdown_calls = 0 + + async def shutdown(self) -> None: + self.shutdown_calls += 1 + + def _firmware_available() -> bool: # find_spec on a submodule imports the parent package, which raises # ModuleNotFoundError off panel where `lib` does not exist at all. @@ -35,6 +67,217 @@ def test_hosting_module_imports_off_panel() -> None: assert hosting._slug("HA Kitchen Light") == "ha_mirror_ha_kitchen_light" +def test_find_rooms_value_handles_dict_like_firmware_containers() -> None: + from brilliant_ha_mirror.hosting import _find_rooms_value + + snapshot = _Struct( + devices={ + "virtual-home": _Struct( + peripherals={ + "home_configuration": _Struct( + variables={"rooms": _Struct(value="encoded-rooms")} + ) + } + ) + } + ) + + assert _find_rooms_value(snapshot) == "encoded-rooms" + + +def test_find_rooms_value_handles_immutable_list_firmware_containers() -> None: + from brilliant_ha_mirror.hosting import _find_rooms_value + + snapshot = _Struct( + devices=( + _Struct( + peripherals=( + _Struct( + name="home_configuration", + variables=(_Struct(name="rooms", value="encoded-list-rooms"),), + ), + ) + ), + ) + ) + + assert _find_rooms_value(snapshot) == "encoded-list-rooms" + + +@pytest.mark.parametrize( + ("snapshot", "encoded"), + [ + ( + _Struct( + devices={ + "virtual-home": _Struct( + peripherals={ + "home_configuration": _Struct( + variables={"rooms": _Struct(value="encoded-dict-rooms")} + ) + } + ) + } + ), + "encoded-dict-rooms", + ), + ( + ( + _Struct( + peripherals=( + _Struct( + name="home_configuration", + variables=(_Struct(name="rooms", value="encoded-list-rooms"),), + ), + ) + ), + ), + "encoded-list-rooms", + ), + ], +) +async def test_get_rooms_reads_persistent_observer_for_firmware_container_variants( + monkeypatch: pytest.MonkeyPatch, + snapshot: object, + encoded: str, +) -> None: + import brilliant_ha_mirror.hosting as hosting + + observer = _FakeRoomObserver(snapshot) + processor = _FakeRoomProcessor() + factory_calls = 0 + + async def open_observer() -> tuple[_FakeRoomObserver, _FakeRoomProcessor]: + nonlocal factory_calls + factory_calls += 1 + return observer, processor + + monkeypatch.setattr(hosting, "_decode_rooms", lambda value: {"room-id": value}) + host = hosting.RpcPeripheralHost( + asyncio.get_running_loop(), + room_observer_factory=open_observer, + ) + + assert await host.get_rooms() == {"room-id": encoded} + assert await host.get_rooms() == {"room-id": encoded} + assert factory_calls == 1 + assert observer.get_all_calls == 2 + + await host.shutdown() + assert observer.shutdown_calls == 1 + assert processor.shutdown_calls == 1 + + +async def test_get_rooms_tolerates_observer_failure( + caplog: pytest.LogCaptureFixture, +) -> None: + import brilliant_ha_mirror.hosting as hosting + + class FailingObserver(_FakeRoomObserver): + async def get_all(self) -> object: + raise RuntimeError("catalog unavailable") + + observer = FailingObserver(object()) + processor = _FakeRoomProcessor() + + async def open_observer() -> tuple[_FakeRoomObserver, _FakeRoomProcessor]: + return observer, processor + + host = hosting.RpcPeripheralHost( + asyncio.get_running_loop(), + room_observer_factory=open_observer, + ) + + with caplog.at_level("WARNING"): + assert await host.get_rooms() == {} + + assert "catalog unavailable" in caplog.text + + +async def test_get_rooms_tolerates_absent_catalog( + caplog: pytest.LogCaptureFixture, +) -> None: + import brilliant_ha_mirror.hosting as hosting + + observer = _FakeRoomObserver(_Struct(devices={})) + processor = _FakeRoomProcessor() + + async def open_observer() -> tuple[_FakeRoomObserver, _FakeRoomProcessor]: + return observer, processor + + host = hosting.RpcPeripheralHost( + asyncio.get_running_loop(), + room_observer_factory=open_observer, + ) + + with caplog.at_level("WARNING"): + assert await host.get_rooms() == {} + + assert "home_configuration.rooms" in caplog.text + + +async def test_set_room_assignment_passes_struct_to_firmware( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import brilliant_ha_mirror.hosting as hosting + + class FakeRoomAssignment: + def __init__(self, room_ids: list[str]) -> None: + self.room_ids = room_ids + + writes: list[tuple[object, str, Any, bool]] = [] + + class FakePeripheral: + def _set_value_internal( + self, + variable_name: str, + value: Any, + *, + notify: bool, + ) -> None: + writes.append((self, variable_name, value, notify)) + + serialize_calls: list[Any] = [] + + def fake_serialize(value: Any) -> str: + serialize_calls.append(value) + return "serialized-room-assignment" + + modules = { + "lib": ModuleType("lib"), + "lib.serialization": ModuleType("lib.serialization"), + "peripherals": ModuleType("peripherals"), + "peripherals.lib": ModuleType("peripherals.lib"), + "peripherals.lib.peripheral_service": ModuleType("peripherals.lib.peripheral_service"), + "peripherals.lib.peripheral_service.peripheral": ModuleType( + "peripherals.lib.peripheral_service.peripheral" + ), + "thrift_types": ModuleType("thrift_types"), + "thrift_types.configuration": ModuleType("thrift_types.configuration"), + "thrift_types.configuration.ttypes": ModuleType("thrift_types.configuration.ttypes"), + } + modules["lib.serialization"].__dict__["serialize"] = fake_serialize + modules["peripherals.lib.peripheral_service.peripheral"].__dict__["Peripheral"] = FakePeripheral + modules["thrift_types.configuration.ttypes"].__dict__["RoomAssignment"] = FakeRoomAssignment + for name, module in modules.items(): + monkeypatch.setitem(sys.modules, name, module) + + instance = object() + monkeypatch.setitem(hosting._INSTANCES, "HA Kitchen", instance) + host = hosting.RpcPeripheralHost(asyncio.get_running_loop()) + + await host.set_room_assignment("HA Kitchen", ["room-kitchen"]) + + assert len(writes) == 1 + written_instance, variable_name, value, notify = writes[0] + assert written_instance is instance + assert variable_name == "room_assignment" + assert isinstance(value, FakeRoomAssignment) + assert value.room_ids == ["room-kitchen"] + assert notify is True + assert serialize_calls == [] + + @pytest.mark.skipif(not _HAVE_FW, reason="firmware framework only exists on the panel") def test_rpc_host_constructs_on_panel() -> None: from brilliant_ha_mirror.hosting import RpcPeripheralHost diff --git a/tests/test_ha_mirror_mapping.py b/tests/test_ha_mirror_mapping.py index 49a8d65..ffb77d1 100644 --- a/tests/test_ha_mirror_mapping.py +++ b/tests/test_ha_mirror_mapping.py @@ -6,6 +6,7 @@ HaEntity, ServiceCall, command_to_service, + resolve_room_id, spec_for, state_to_variables, ) @@ -97,3 +98,22 @@ def test_int_variables_is_the_shared_type_source() -> None: # int-typed here; the text-only display/event vars are not. assert "display_name" not in INT_VARIABLES assert "event" not in INT_VARIABLES + + +def test_resolve_room_id_matches_area_name_case_insensitively() -> None: + rooms = {"opaque-office-id": "Office", "2": "Kitchen"} + + assert resolve_room_id("office", rooms, {}) == "opaque-office-id" + + +def test_resolve_room_id_prefers_override_over_name_match() -> None: + rooms = {"automatic-id": "Back Yard", "override-id": "Patio"} + + assert resolve_room_id("Back Yard", rooms, {"back yard": "override-id"}) == "override-id" + + +def test_resolve_room_id_leaves_missing_or_unassigned_area_empty() -> None: + rooms = {"2": "Kitchen"} + + assert resolve_room_id("Garage", rooms, {}) is None + assert resolve_room_id(None, rooms, {}) is None diff --git a/tests/test_ha_mirror_orchestrator.py b/tests/test_ha_mirror_orchestrator.py index c28a2bb..3b36f25 100644 --- a/tests/test_ha_mirror_orchestrator.py +++ b/tests/test_ha_mirror_orchestrator.py @@ -1,13 +1,22 @@ """Tests for the reconciling Home Assistant mirror orchestrator.""" +import logging + +import pytest + from brilliant_ha_mirror.config import Settings from brilliant_ha_mirror.mapping import HaEntity from brilliant_ha_mirror.mirror import Mirror from tests.fakes import FakeHaClient, FakePeripheralHost -def _settings() -> Settings: - return Settings(panel="p", ha_ws_url="ws://x", ha_token="t") +def _settings(room_overrides: dict[str, str] | None = None) -> Settings: + return Settings( + panel="p", + ha_ws_url="ws://x", + ha_token="t", + room_overrides=room_overrides or {}, + ) async def test_start_registers_only_supported_entities() -> None: @@ -134,3 +143,101 @@ async def test_unique_friendly_name_stays_clean() -> None: host = FakePeripheralHost() await Mirror(ha, host, _settings()).start() assert host.registered == ["HA Kitchen"] # no disambiguation when unique + + +async def test_initial_hosting_assigns_matching_brilliant_room() -> None: + ha = FakeHaClient(entities=[HaEntity("switch.s", "off", {}, "kitchen")]) + host = FakePeripheralHost(rooms={"opaque-kitchen-id": "Kitchen"}) + + await Mirror(ha, host, _settings()).start() + + assert host.room_assignments[host.registered[0]] == ["opaque-kitchen-id"] + + +async def test_room_override_precedes_automatic_name_match() -> None: + ha = FakeHaClient(entities=[HaEntity("switch.s", "off", {}, "Back Yard")]) + host = FakePeripheralHost(rooms={"automatic-id": "Back Yard", "override-id": "Patio"}) + + await Mirror( + ha, + host, + _settings({"Back Yard": "override-id"}), + ).start() + + assert host.room_assignments[host.registered[0]] == ["override-id"] + + +async def test_unmatched_area_is_unassigned_and_logged_only_once( + caplog: pytest.LogCaptureFixture, +) -> None: + ha = FakeHaClient(entities=[HaEntity("switch.s", "off", {}, "Garage")]) + host = FakePeripheralHost(rooms={"kitchen-id": "Kitchen"}) + mirror = Mirror(ha, host, _settings()) + + with caplog.at_level(logging.DEBUG): + await mirror.start() + await mirror.reconcile() + + assert host.room_assignments[host.registered[0]] == [] + messages = [ + record.message for record in caplog.records if "no Brilliant room" in record.message + ] + assert len(messages) == 1 + + +async def test_reconcile_reassigns_when_ha_area_changes() -> None: + ha = FakeHaClient(entities=[HaEntity("switch.s", "off", {}, "Kitchen")]) + host = FakePeripheralHost(rooms={"kitchen-id": "Kitchen", "office-id": "Office"}) + mirror = Mirror(ha, host, _settings()) + await mirror.start() + host.room_assignment_calls.clear() + + ha.entities = [HaEntity("switch.s", "off", {}, "Office")] + await mirror.reconcile() + + assert host.room_assignment_calls == [(host.registered[0], ["office-id"])] + + +async def test_reconcile_reasserts_when_brilliant_catalog_changes() -> None: + ha = FakeHaClient(entities=[HaEntity("switch.s", "off", {}, "Kitchen")]) + host = FakePeripheralHost(rooms={"old-id": "Kitchen"}) + mirror = Mirror(ha, host, _settings()) + await mirror.start() + host.room_assignment_calls.clear() + + host.rooms = {"new-id": "Kitchen"} + await mirror.reconcile() + + assert host.room_assignment_calls == [(host.registered[0], ["new-id"])] + + +async def test_reconcile_does_not_rewrite_unchanged_room_assignment() -> None: + ha = FakeHaClient(entities=[HaEntity("switch.s", "off", {}, "Kitchen")]) + host = FakePeripheralHost(rooms={"kitchen-id": "Kitchen"}) + mirror = Mirror(ha, host, _settings()) + await mirror.start() + host.room_assignment_calls.clear() + + await mirror.reconcile() + + assert host.room_assignment_calls == [] + + +async def test_reconcile_survives_room_catalog_failure_and_recovers( + caplog: pytest.LogCaptureFixture, +) -> None: + ha = FakeHaClient(entities=[HaEntity("switch.s", "off", {}, "Kitchen")]) + host = FakePeripheralHost(rooms={"kitchen-id": "Kitchen"}) + host.get_rooms_error = RuntimeError("room observer unavailable") + mirror = Mirror(ha, host, _settings()) + + with caplog.at_level(logging.WARNING): + await mirror.start() + + assert host.registered == ["HA switch.s"] + assert host.room_assignment_calls == [] + assert "room observer unavailable" in caplog.text + + host.get_rooms_error = None + await mirror.reconcile() + assert host.room_assignment_calls == [("HA switch.s", ["kitchen-id"])]