From 81daebd788c532f9d0e321010ba471282ae076d1 Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Sat, 12 Sep 2026 23:19:16 +0300 Subject: [PATCH] fix: complete radar source inventory (#545) Issue: #545 User-Visible: yes --- .../houseplan/radar_validation.py | 31 +++- docs/ARCHITECTURE.md | 7 + docs/CHANGELOG.md | 5 + docs/CHANGELOG.ru.md | 5 + docs/TESTING.md | 12 ++ scripts/mutation-gate.mjs | 17 ++ tests_backend/test_ha_radar.py | 64 ++++++- tests_backend/test_ha_radar_websocket.py | 170 +++++++++++++++++- tests_backend/test_radar_validation.py | 62 +++++++ 9 files changed, 358 insertions(+), 15 deletions(-) diff --git a/custom_components/houseplan/radar_validation.py b/custom_components/houseplan/radar_validation.py index 19c24322..12fa906b 100644 --- a/custom_components/houseplan/radar_validation.py +++ b/custom_components/houseplan/radar_validation.py @@ -20,6 +20,17 @@ RADAR_PROFILES = frozenset({ "esphome_ld2450_v1", "cartesian_v1", "polar_v1", "range_v1", "zones_v1", "presence_v1", }) +_COMMON_SOURCE_ROLES = ( + "occupancy_entity", "count_entity", "availability_entity", +) +_PROFILE_SOURCE_ROLES: dict[str, tuple[str, tuple[str, ...]]] = { + "esphome_ld2450_v1": ("slots", ("x_entity", "y_entity", "presence_entity")), + "cartesian_v1": ("slots", ("x_entity", "y_entity", "presence_entity")), + "polar_v1": ("slots", ("distance_entity", "angle_entity", "presence_entity")), + "range_v1": ("ranges", ("entity_id", "presence_entity")), + "zones_v1": ("zones", ("entity_id",)), + "presence_v1": ("", ()), +} LENGTH_UNITS = frozenset({"mm", "cm", "m", "in", "ft"}) ANGLE_UNITS = frozenset({"degrees", "radians"}) ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$") @@ -90,18 +101,23 @@ def _point(value: Any, name: str) -> tuple[float, float]: ) -def _source_ids(sources: dict[str, Any]) -> set[str]: +def _source_ids(profile: Any, sources: dict[str, Any]) -> set[str]: + profile_roles = _PROFILE_SOURCE_ROLES.get(profile) + if profile_roles is None: + return set() out: set[str] = set() - for key in ("occupancy_entity", "count_entity", "availability_entity"): + for key in _COMMON_SOURCE_ROLES: value = sources.get(key) if isinstance(value, str): out.add(value) - for group in ("slots", "ranges", "zones"): + group, roles = profile_roles + if group: for item in sources.get(group) or []: if not isinstance(item, dict): continue - for key, value in item.items(): - if key.endswith("_entity") and isinstance(value, str): + for key in roles: + value = item.get(key) + if isinstance(value, str): out.add(value) return out @@ -111,7 +127,8 @@ def radar_source_entity_ids(radar: Any) -> set[str]: if not isinstance(radar, dict): return set() sources = radar.get("sources") - out = _source_ids(sources) if isinstance(sources, dict) else set() + out = _source_ids(radar.get("profile"), sources) \ + if isinstance(sources, dict) else set() return out @@ -154,7 +171,7 @@ def _validate_verified_adapter( if profile != "esphome_ld2450_v1" or registry is None: return entities = registry.get("entities") or {} - source_ids = _source_ids(sources) + source_ids = _source_ids(profile, sources) rows = [entities.get(entity_id) for entity_id in source_ids] device_ids = { str(row.get("device_id")) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index baba070d..79bbfe3a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -354,6 +354,13 @@ authorizes source discovery, recording or hardware writes. independent-pair skew, source/calibration epochs, projection, real-room clipping and bounded public frames. It reconciles on config revision, has one runtime instance per integration entry and closes all listeners/timers on unload. +The explicit profile/source-role inventory is the shared authority for those +listeners, setup listeners and per-entity read ACLs. Common roles are +`occupancy_entity`, `count_entity` and `availability_entity`; Cartesian slots +add `x_entity`/`y_entity`, polar slots add +`distance_entity`/`angle_entity`, range rows add `entity_id`, zone rows add +`entity_id`, and slot/range presence gates add `presence_entity`. Unknown +future fields remain inert instead of becoming sources by naming convention. Only the backend interprets raw HA states; the eager View graph receives normalized `targets/ranges/zones/health` snapshots from `houseplan/radar/subscribe`. Per-user entity-read ACLs are checked for initial diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 559bd60e..0fe945d4 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- Range and zone radar sources now stay live in the setup preview and use the + same complete source list for Home Assistant read-permission checks as for + frame calculation + ([#545](https://github.com/Matysh/houseplan-card/issues/545)). + - A plan updated from another browser no longer jumps back to an older version when that older response's background image finishes loading late. The newest accepted plan, its revision and the instant-start cache now remain together diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index f5348b3a..dc085e19 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -8,6 +8,11 @@ ## Не выпущено +- Источники диапазонов и зон радара теперь обновляются в предпросмотре + настройки, а проверка прав Home Assistant использует тот же полный список + источников, что и расчёт данных радара + ([#545](https://github.com/Matysh/houseplan-card/issues/545)). + - План, изменённый в другом браузере, больше не откатывается к старой версии, если подложка старого ответа загрузилась позже новой. Последний принятый план, его ревизия и кэш быстрого запуска теперь остаются согласованными при diff --git a/docs/TESTING.md b/docs/TESTING.md index f8f09924..15ba8312 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -4040,3 +4040,15 @@ require hands on real hardware — they remain for the human pass. снятие DOM bound, state-value rebuild, local-scale authority, lifecycle generation и Optimize writer guard. Полные golden/smoke/performance и Linux CI HA harness остаются обязательным гейтом точного SHA беты. + +## Полнота источников радара (#545) + +- [ ] `tests_backend/test_radar_validation.py` проверяет точный inventory всех + Stage 1 profiles: известные общие и профильные роли включены, future-поля + остаются inert. +- [ ] `tests_backend/test_ha_radar.py` и + `tests_backend/test_ha_radar_websocket.py` доказывают подписку, rebind, + cleanup и fail-closed read ACL именно для основных `entity_id` профилей + `range_v1`/`zones_v1`. +- [ ] Мутант `radar-profile-source-entity-id-omitted` удаляет обе роли + `entity_id`; точный backend guard обязан покраснеть. diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 5ba33881..8ff9dadc 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -9030,6 +9030,23 @@ const MUTANT_DEFINITIONS = [ + " afterAdopt: () => { host._regSignature = ''; host._maybeRebuildDevices(); },", }], }, + { + id: 'radar-profile-source-entity-id-omitted', + guard: 'python3 -m pytest tests_backend/test_radar_validation.py -q -p no:cacheprovider ' + + '-k "stage1_source_inventory_is_exact_for_each_profile"', + because: 'range_v1 and zones_v1 read their primary value from entity_id; omitting that ' + + 'profile role removes both the HA subscription and the per-source read-permission check ' + + '(#545 AC1, AC4)', + patches: [{ + file: 'custom_components/houseplan/radar_validation.py', + find: ' "range_v1": ("ranges", ("entity_id", "presence_entity")),', + replace: ' "range_v1": ("ranges", ("presence_entity",)),', + }, { + file: 'custom_components/houseplan/radar_validation.py', + find: ' "zones_v1": ("zones", ("entity_id",)),', + replace: ' "zones_v1": ("zones", ()),', + }], + }, ]; const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8'); diff --git a/tests_backend/test_ha_radar.py b/tests_backend/test_ha_radar.py index e9a32022..e7a43626 100644 --- a/tests_backend/test_ha_radar.py +++ b/tests_backend/test_ha_radar.py @@ -1,8 +1,8 @@ """HA-state witnesses for the Stage-1 radar coordinator (#485).""" from __future__ import annotations -import copy import asyncio +import copy from types import SimpleNamespace from unittest.mock import AsyncMock @@ -12,6 +12,7 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers import entity_registry as er from pytest_homeassistant_custom_component.common import MockConfigEntry +from custom_components.houseplan import radar as radar_module from custom_components.houseplan.radar import RadarCoordinator from custom_components.houseplan.radar_validation import radar_source_entity_ids @@ -393,6 +394,67 @@ async def test_range_profile_clips_arc_to_owner_room( coordinator.teardown() +@pytest.mark.asyncio +async def test_range_and_zone_primary_source_subscriptions_rebind_and_teardown( + monkeypatch, hass: HomeAssistant, +) -> None: + tracked = [] + cleaned = [] + + def track(kind): + def register(_hass, entity_ids, _callback): + ids = tuple(entity_ids) + tracked.append((kind, ids)) + + def cleanup() -> None: + cleaned.append((kind, ids)) + + return cleanup + + return register + + monkeypatch.setattr(radar_module, "async_track_state_report_event", track("reported")) + monkeypatch.setattr(radar_module, "async_track_state_change_event", track("changed")) + + range_document = _stored("range_v1") + range_radar = range_document["config"]["markers"][0]["radar"] + range_radar["sources"] = {"ranges": [{ + "id": "distance", "entity_id": "sensor.radar_distance", "unit": "m", + }]} + hass.states.async_set("sensor.radar_distance", "2") + coordinator = await _coordinator(hass, range_document) + assert coordinator.source_ids("radar") == {"sensor.radar_distance"} + assert tracked == [ + ("reported", ("sensor.radar_distance",)), + ("changed", ("sensor.radar_distance",)), + ] + + zone_document = _stored("zones_v1") + zone_radar = zone_document["config"]["markers"][0]["radar"] + zone_radar["sources"] = {"zones": [{ + "id": "desk", "entity_id": "binary_sensor.desk", "kind": "occupancy", + }]} + hass.states.async_set("binary_sensor.desk", "on") + coordinator.runtime.config_store.async_load.return_value = zone_document + await coordinator.async_refresh() + + assert coordinator.source_ids("radar") == {"binary_sensor.desk"} + assert cleaned == [ + ("reported", ("sensor.radar_distance",)), + ("changed", ("sensor.radar_distance",)), + ] + assert tracked[-2:] == [ + ("reported", ("binary_sensor.desk",)), + ("changed", ("binary_sensor.desk",)), + ] + + coordinator.teardown() + assert cleaned[-2:] == [ + ("reported", ("binary_sensor.desk",)), + ("changed", ("binary_sensor.desk",)), + ] + + @pytest.mark.asyncio async def test_polar_profile_projects_explicit_bearing( hass: HomeAssistant, diff --git a/tests_backend/test_ha_radar_websocket.py b/tests_backend/test_ha_radar_websocket.py index 709a87ec..0c2b07ec 100644 --- a/tests_backend/test_ha_radar_websocket.py +++ b/tests_backend/test_ha_radar_websocket.py @@ -8,19 +8,27 @@ import pytest from custom_components.houseplan import radar_websocket as radar_ws from custom_components.houseplan.radar import RadarCoordinator +from custom_components.houseplan.radar_validation import radar_source_entity_ids class _Permissions: - def __init__(self, allowed: bool = True) -> None: + def __init__(self, allowed: bool = True, denied: set[str] | None = None) -> None: self.allowed = allowed + self.denied = denied or set() + self.checked: list[tuple[str, str]] = [] - def check_entity(self, _entity_id: str, _policy: str) -> bool: - return self.allowed + def check_entity(self, entity_id: str, policy: str) -> bool: + self.checked.append((entity_id, policy)) + return self.allowed and entity_id not in self.denied class _Connection: - def __init__(self, *, allowed: bool = True) -> None: - self.user = SimpleNamespace(id="user-1", permissions=_Permissions(allowed)) + def __init__( + self, *, allowed: bool = True, denied: set[str] | None = None, + ) -> None: + self.user = SimpleNamespace( + id="user-1", permissions=_Permissions(allowed, denied), + ) self.subscriptions: dict[int, object] = {} self.results: list[tuple[int, object]] = [] self.errors: list[tuple[int, str, str]] = [] @@ -37,10 +45,11 @@ class _Connection: class _Coordinator: - def __init__(self) -> None: + def __init__(self, source_ids: set[str] | None = None) -> None: self.hass = SimpleNamespace(states={ "sensor.x": SimpleNamespace(state="1"), }) + self._source_ids = source_ids or {"sensor.x"} self.closed = False self.server_session_id = "session-1" self.config_rev = 7 @@ -54,7 +63,7 @@ class _Coordinator: return space_id == "floor" def source_ids(self, marker_id: str) -> set[str]: - return {"sensor.x"} if marker_id == "radar" else set() + return self._source_ids if marker_id == "radar" else set() def space_for_marker(self, marker_id: str) -> str | None: return "floor" if marker_id == "radar" else "other" @@ -93,6 +102,34 @@ class _Coordinator: return unregister +def _radar_config(profile: str, sources: dict) -> tuple[dict, dict, dict]: + radar = { + "version": 1, "enabled": True, "profile": profile, + "sources": sources, + "mount": { + "installation_id": "installation-1", "x": .5, "y": .5, + "heading_deg": 0, "range_cm": 600, "fov_deg": 120, + }, + "room_id": "living", + "calibration": {"method": "manual", "mirror": False, "cell_cm": 5}, + } + marker = { + "id": "radar", "binding": "device:radar", "space": "floor", + "radar": radar, + } + config = { + "spaces": [{ + "id": "floor", "cell_cm": 5, + "rooms": [{ + "id": "living", + "poly": [[.1, .1], [.9, .1], [.9, .9], [.1, .9]], + }], + }], + "markers": [marker], "settings": {}, + } + return config, marker, radar + + @pytest.fixture(autouse=True) def _clear_radar_ws_state(monkeypatch): monkeypatch.setattr(radar_ws, "radar_registry_evidence", lambda _hass: {}) @@ -327,6 +364,125 @@ async def test_setup_subscription_coalesces_restricts_and_removes(monkeypatch, h assert coordinator.external_cleanup is None +@pytest.mark.asyncio +@pytest.mark.parametrize(("profile", "sources", "source_id"), [ + ( + "range_v1", + {"ranges": [{"id": "range", "entity_id": "sensor.distance", "unit": "m"}]}, + "sensor.distance", + ), + ( + "zones_v1", + {"zones": [{ + "id": "zone", "entity_id": "binary_sensor.zone", "kind": "occupancy", + }]}, + "binary_sensor.zone", + ), +]) +async def test_setup_subscribes_to_range_and_zone_primary_sources( + monkeypatch, hass, profile, sources, source_id, +) -> None: + config, marker, radar = _radar_config(profile, sources) + coordinator = _Coordinator(radar_source_entity_ids(radar)) + coordinator.config = config + coordinator.radars = {"radar": marker} + coordinator.hass.states = {source_id: SimpleNamespace(state="1")} + tracks = [] + cleanups = [] + monkeypatch.setattr(radar_ws, "_coordinator", lambda *_args: coordinator) + monkeypatch.setattr(radar_ws, "may_write", lambda *_args: True) + + def track(_hass, entity_ids, callback): + tracks.append((tuple(entity_ids), callback)) + + def cleanup() -> None: + cleanups.append(tuple(entity_ids)) + + return cleanup + + monkeypatch.setattr(radar_ws, "async_track_state_report_event", track) + monkeypatch.setattr(radar_ws, "async_track_state_change_event", track) + + saved = _Connection() + message = {"id": 10, "marker_id": "radar", "expected_config_rev": 7} + radar_ws.ws_radar_setup_subscribe(hass, saved, message) + assert [ids for ids, _callback in tracks] == [(source_id,), (source_id,)] + initial_events = len(saved.events) + tracks[0][1](None) + await asyncio.sleep(1 / radar_ws.MAX_FRAME_HZ + .05) + assert len(saved.events) == initial_events + 1 + saved.subscriptions[10]() + + draft = _Connection() + radar_ws.ws_radar_setup_subscribe( + hass, draft, {**message, "id": 11, "draft_sources": {"radar": radar}}, + ) + assert [ids for ids, _callback in tracks[2:]] == [(source_id,), (source_id,)] + draft.subscriptions[11]() + assert cleanups == [(source_id,)] * 4 + + +@pytest.mark.parametrize(("profile", "sources", "source_id"), [ + ( + "range_v1", + {"ranges": [{"id": "range", "entity_id": "sensor.distance", "unit": "m"}]}, + "sensor.distance", + ), + ( + "zones_v1", + {"zones": [{ + "id": "zone", "entity_id": "binary_sensor.zone", "kind": "occupancy", + }]}, + "binary_sensor.zone", + ), +]) +def test_range_and_zone_primary_sources_are_permission_checked_fail_closed( + monkeypatch, hass, profile, sources, source_id, +) -> None: + config, marker, radar = _radar_config(profile, sources) + coordinator = _Coordinator(radar_source_entity_ids(radar)) + coordinator.config = config + coordinator.radars = {"radar": marker} + coordinator.hass.states = {source_id: SimpleNamespace(state="1")} + coordinator.frames_for_space = lambda _space_id: [{ + "marker_id": "radar", "seq": 8, + "targets": [{"x": .5, "y": .5}], + "ranges": [{"id": "secret-range", "radius": .5}], + "zones": [{"id": "secret-zone", "state": True}], + }] + monkeypatch.setattr(radar_ws, "_coordinator", lambda *_args: coordinator) + monkeypatch.setattr(radar_ws, "may_write", lambda *_args: True) + + saved = _Connection(denied={source_id}) + radar_ws.ws_radar_setup_inspect( + hass, saved, {"id": 20, "marker_id": "radar"}, + ) + assert saved.errors[-1][1] == "source_restricted" + assert saved.results == [] + + draft = _Connection(denied={source_id}) + radar_ws.ws_radar_setup_inspect( + hass, draft, { + "id": 21, "marker_id": "radar", "draft_sources": {"radar": radar}, + }, + ) + assert draft.errors[-1][1] == "source_restricted" + assert draft.results == [] + + live = _Connection(denied={source_id}) + radar_ws.ws_radar_subscribe(hass, live, {"id": 22, "space_id": "floor"}) + restricted = live.events[-1][1] + assert restricted["health"] == "restricted" + assert restricted["targets"] == [] + assert restricted["ranges"] == [] + assert restricted["zones"] == [] + assert { + entity_id + for connection in (saved, draft, live) + for entity_id, _policy in connection.user.permissions.checked + } == {source_id} + + def test_setup_subscribe_draft_failure_and_source_restriction(monkeypatch, hass) -> None: coordinator = _Coordinator() connection = _Connection(allowed=False) diff --git a/tests_backend/test_radar_validation.py b/tests_backend/test_radar_validation.py index 7d2eca23..e8784503 100644 --- a/tests_backend/test_radar_validation.py +++ b/tests_backend/test_radar_validation.py @@ -180,6 +180,64 @@ def test_all_stage1_profiles_have_a_valid_explicit_source_contract(profile): validate_marker_radars(config, validate_all=True) +@pytest.mark.parametrize(("profile", "group", "item", "expected"), [ + ( + "esphome_ld2450_v1", "slots", + { + "x_entity": "sensor.x", "y_entity": "sensor.y", + "presence_entity": "binary_sensor.slot", + }, + {"sensor.x", "sensor.y", "binary_sensor.slot"}, + ), + ( + "cartesian_v1", "slots", + { + "x_entity": "sensor.x", "y_entity": "sensor.y", + "presence_entity": "binary_sensor.slot", + }, + {"sensor.x", "sensor.y", "binary_sensor.slot"}, + ), + ( + "polar_v1", "slots", + { + "distance_entity": "sensor.distance", "angle_entity": "sensor.angle", + "presence_entity": "binary_sensor.slot", + }, + {"sensor.distance", "sensor.angle", "binary_sensor.slot"}, + ), + ( + "range_v1", "ranges", + { + "entity_id": "sensor.distance", + "presence_entity": "binary_sensor.range", + }, + {"sensor.distance", "binary_sensor.range"}, + ), + ( + "zones_v1", "zones", + {"entity_id": "binary_sensor.zone"}, + {"binary_sensor.zone"}, + ), + ("presence_v1", None, {}, set()), +]) +def test_stage1_source_inventory_is_exact_for_each_profile( + profile, group, item, expected, +): + common = { + "occupancy_entity": "binary_sensor.presence", + "count_entity": "sensor.count", + "availability_entity": "binary_sensor.available", + "future_entity": "sensor.future_top", + } + if group is not None: + common[group] = [{**item, "future_entity": "sensor.future_nested"}] + radar = {"profile": profile, "sources": common} + + assert radar_source_entity_ids(radar) == expected | { + "binary_sensor.presence", "sensor.count", "binary_sensor.available", + } + + def test_stage1_source_inventory_ignores_future_stage_extensions(): config = _config() radar = config["markers"][0]["radar"] @@ -222,6 +280,10 @@ def test_stage1_source_inventory_ignores_future_stage_extensions(): } assert radar_source_entity_ids(None) == set() assert radar_source_entity_ids({"sources": []}) == set() + assert radar_source_entity_ids({ + "profile": "future_v2", + "sources": {"occupancy_entity": "binary_sensor.future"}, + }) == set() def test_future_fusion_and_output_settings_round_trip_without_stage1_validation():