mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-04 13:48:57 +00:00
@@ -31,6 +31,7 @@ _LANGUAGES = frozenset({"en", "ru", "de", "fr"})
|
||||
_BROWSERS = frozenset({"chromium", "firefox", "webkit", "unknown"})
|
||||
_REGISTRY_ACCESS = frozenset({"full", "partial", "unavailable"})
|
||||
_REGISTRY_AGE = frozenset({"fresh", "stale", "unknown"})
|
||||
_VALUE_BADGE_POSITIONS = frozenset({"right", "bottom", "left", "top"})
|
||||
|
||||
|
||||
class SupportPackageError(ValueError):
|
||||
@@ -232,6 +233,23 @@ def _project_value_source(ids: _Pseudonyms, value: object) -> dict[str, Any] | N
|
||||
return None
|
||||
|
||||
|
||||
def _project_value_badge(ids: _Pseudonyms, value: object) -> dict[str, Any] | None:
|
||||
"""Project the compatibility-tolerant badge config into safe scalar fields."""
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
out: dict[str, Any] = {}
|
||||
enabled = value.get("enabled")
|
||||
if isinstance(enabled, bool):
|
||||
out["enabled"] = enabled
|
||||
position = value.get("position")
|
||||
if isinstance(position, str) and position in _VALUE_BADGE_POSITIONS:
|
||||
out["position"] = position
|
||||
source = _project_value_source(ids, value.get("source"))
|
||||
if source is not None:
|
||||
out["source"] = source
|
||||
return out or None
|
||||
|
||||
|
||||
def _project_marker(ids: _Pseudonyms, marker: dict[str, Any]) -> dict[str, Any]:
|
||||
binding_kind, binding = _binding(ids, marker.get("binding"))
|
||||
out: dict[str, Any] = {
|
||||
@@ -264,12 +282,8 @@ def _project_marker(ids: _Pseudonyms, marker: dict[str, Any]) -> dict[str, Any]:
|
||||
projected = _project_value_source(ids, marker.get(key))
|
||||
if projected:
|
||||
out[key] = projected
|
||||
badge = marker.get("value_badge")
|
||||
if isinstance(badge, dict):
|
||||
safe_badge = _copy_keys(badge, ("enabled", "position"))
|
||||
source = _project_value_source(ids, badge.get("source"))
|
||||
if source:
|
||||
safe_badge["source"] = source
|
||||
safe_badge = _project_value_badge(ids, marker.get("value_badge"))
|
||||
if safe_badge is not None:
|
||||
out["value_badge"] = safe_badge
|
||||
return out
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Support packages now type-check value-badge flags and positions while building
|
||||
their privacy projection, so malformed nested configuration cannot be copied
|
||||
verbatim into a report
|
||||
([#420](https://github.com/Matysh/houseplan-card/issues/420)).
|
||||
- Area relocation provenance now survives a temporarily empty or shortened
|
||||
Home Assistant registry: cleanup uses the full binding roster and requires
|
||||
two non-empty authoritative observations before forgetting a missing device
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- При построении privacy-проекции support-пакет теперь проверяет типы флага и
|
||||
позиции бейджа значения, поэтому некорректный вложенный конфиг не может
|
||||
дословно попасть в отчёт
|
||||
([#420](https://github.com/Matysh/houseplan-card/issues/420)).
|
||||
- Провенанс автоматического переноса между HA-зонами теперь переживает временно
|
||||
пустой или усечённый реестр Home Assistant: уборка сверяется с полным roster
|
||||
привязок и забывает исчезнувшее устройство лишь после двух непустых
|
||||
|
||||
@@ -33,6 +33,7 @@ def _source() -> tuple[dict, dict, list[str]]:
|
||||
"area.private", "device-secret", "sensor.secret_temperature",
|
||||
"https://private.example/plan.png", "C:\\Users\\Private\\floor.png",
|
||||
"person@example.test", "<b>private note</b>", "unknown-private-value",
|
||||
"value-badge-private-sentinel",
|
||||
]
|
||||
config = {
|
||||
"model_version": 9,
|
||||
@@ -80,6 +81,12 @@ def _source() -> tuple[dict, dict, list[str]]:
|
||||
"link": "https://private.example/device", "description": "private note",
|
||||
"pdfs": [{"name": "manual", "url": "C:\\Users\\Private\\floor.png"}],
|
||||
"controls": ["sensor.secret_temperature"],
|
||||
"value_badge": {
|
||||
"enabled": {"private": "value-badge-private-sentinel"},
|
||||
"position": ["value-badge-private-sentinel"],
|
||||
"source": {"kind": "private", "payload": "value-badge-private-sentinel"},
|
||||
"future": {"private": "value-badge-private-sentinel"},
|
||||
},
|
||||
"unknown": "unknown-private-value",
|
||||
}],
|
||||
}
|
||||
@@ -301,7 +308,9 @@ def test_rich_plan_projection_preserves_safe_structure_and_drops_unknown_values(
|
||||
assert virtual["value_source"] == {
|
||||
"entity_id": "entity-rich-1", "kind": "entity_state",
|
||||
}
|
||||
assert virtual["value_badge"]["source"] == {"kind": "derived_lqi"}
|
||||
assert virtual["value_badge"] == {
|
||||
"enabled": True, "position": "bottom", "source": {"kind": "derived_lqi"},
|
||||
}
|
||||
assert entity["value_source"] == {
|
||||
"kind": "derived_marker_state", "ref": "marker:marker-rich-1",
|
||||
}
|
||||
@@ -331,6 +340,44 @@ def test_projection_helpers_fail_closed_on_malformed_shapes():
|
||||
assert support_package._binding_kind(None) == "unknown"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("enabled", "position", "expected"),
|
||||
[
|
||||
({"private": "sentinel"}, "right", {"position": "right"}),
|
||||
(True, {"private": "sentinel"}, {"enabled": True}),
|
||||
([], "sideways", None),
|
||||
("true", ["bottom"], None),
|
||||
],
|
||||
)
|
||||
def test_value_badge_projection_drops_malformed_scalar_fields(
|
||||
enabled, position, expected,
|
||||
):
|
||||
projected = support_package._project_marker(
|
||||
support_package._Pseudonyms("badge"),
|
||||
{
|
||||
"id": "marker",
|
||||
"binding": "virtual",
|
||||
"value_badge": {"enabled": enabled, "position": position},
|
||||
},
|
||||
)
|
||||
assert projected.get("value_badge") == expected
|
||||
assert "sentinel" not in json.dumps(projected)
|
||||
|
||||
|
||||
def test_value_badge_projection_omits_empty_result():
|
||||
projected = support_package._project_marker(
|
||||
support_package._Pseudonyms("badge"),
|
||||
{
|
||||
"id": "marker",
|
||||
"binding": "virtual",
|
||||
"value_badge": {
|
||||
"enabled": [], "position": {}, "source": {"kind": "private"},
|
||||
},
|
||||
},
|
||||
)
|
||||
assert "value_badge" not in projected
|
||||
|
||||
|
||||
def test_package_size_limit_is_enforced_after_projection(monkeypatch):
|
||||
monkeypatch.setattr(support_package, "MAX_SUPPORT_ATTACHMENT_BYTES", 1)
|
||||
with pytest.raises(SupportPackageError, match="support_package_too_large"):
|
||||
|
||||
Reference in New Issue
Block a user