mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 04:38:55 +00:00
fix: complete radar source inventory (#545)
Issue: #545 User-Visible: yes
This commit is contained in:
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Источники диапазонов и зон радара теперь обновляются в предпросмотре
|
||||
настройки, а проверка прав Home Assistant использует тот же полный список
|
||||
источников, что и расчёт данных радара
|
||||
([#545](https://github.com/Matysh/houseplan-card/issues/545)).
|
||||
|
||||
- План, изменённый в другом браузере, больше не откатывается к старой версии,
|
||||
если подложка старого ответа загрузилась позже новой. Последний принятый план,
|
||||
его ревизия и кэш быстрого запуска теперь остаются согласованными при
|
||||
|
||||
@@ -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 обязан покраснеть.
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user