fix: complete radar source inventory (#545)

Issue: #545
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-12 23:19:16 +03:00
parent 036a15aade
commit 81daebd788
9 changed files with 358 additions and 15 deletions
@@ -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"))
+7
View File
@@ -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
+5
View File
@@ -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
+5
View File
@@ -8,6 +8,11 @@
## Не выпущено
- Источники диапазонов и зон радара теперь обновляются в предпросмотре
настройки, а проверка прав Home Assistant использует тот же полный список
источников, что и расчёт данных радара
([#545](https://github.com/Matysh/houseplan-card/issues/545)).
- План, изменённый в другом браузере, больше не откатывается к старой версии,
если подложка старого ответа загрузилась позже новой. Последний принятый план,
его ревизия и кэш быстрого запуска теперь остаются согласованными при
+12
View File
@@ -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 обязан покраснеть.
+17
View File
@@ -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');
+63 -1
View File
@@ -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,
+163 -7
View File
@@ -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)
+62
View File
@@ -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():