M8-R15: fix HA discovery identities and thermal totals
This commit is contained in:
@@ -118,6 +118,35 @@ def test_app_start_seeds_missing_config_from_env_without_overwriting_existing_va
|
||||
reset_db_caches()
|
||||
|
||||
|
||||
def test_startup_initializes_fresh_legacy_discovery_cleanup_ledger(
|
||||
tmp_path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The cleanup ledger exists before the expose UI can write its first toggle."""
|
||||
import app.main as main
|
||||
|
||||
app_database_url = _prepare_app_db(tmp_path)
|
||||
monkeypatch.setenv("APP_DATABASE_URL", app_database_url)
|
||||
monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin")
|
||||
monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "test-password")
|
||||
get_settings.cache_clear()
|
||||
reset_db_caches()
|
||||
|
||||
main.ensure_auth_db_ready()
|
||||
|
||||
conn = sqlite3.connect(tmp_path / "app_ready.db")
|
||||
try:
|
||||
value = conn.execute(
|
||||
"SELECT value FROM app_config WHERE key = ?",
|
||||
("HA_DISCOVERY_LEGACY_THERMAL_CLEANUP_V1",),
|
||||
).fetchone()[0]
|
||||
finally:
|
||||
conn.close()
|
||||
assert value == '{"complete": true, "inventory": [], "topics": []}'
|
||||
|
||||
get_settings.cache_clear()
|
||||
reset_db_caches()
|
||||
|
||||
|
||||
def test_app_start_syncs_app_hostname_from_env_even_when_db_has_old_value(
|
||||
tmp_path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
|
||||
+48
-47
@@ -13,7 +13,7 @@ Coverage:
|
||||
7. value_getter returns None when no non-degraded period exists.
|
||||
8. MQTT not enabled → publish_states is a no-op (no raises, no publish calls).
|
||||
9. Integration: build_discovery_payload on an energy_cost entity does NOT raise
|
||||
IndexError (validates 2-element identifiers).
|
||||
IndexError (validates one-item HA identifiers and independent internal identities).
|
||||
10. Keys are stable fixed strings (not derived from mutable data or DB ids).
|
||||
11. Provider registered: energy_cost entities appear alongside modbus entities
|
||||
in the full catalog.
|
||||
@@ -767,9 +767,8 @@ def test_publish_states_noop_when_not_connected() -> None:
|
||||
def test_build_discovery_payload_no_index_error_for_energy_entities(energy_db) -> None:
|
||||
"""build_discovery_payload must NOT raise IndexError for energy_cost entities.
|
||||
|
||||
FUE-T05: identifiers is now ('energy-cost', meter.uuid).
|
||||
Validates that the 2-element identifiers tuple satisfies ha_discovery.py's
|
||||
requirement to access identifiers[1] as node_id.
|
||||
The HA grouping identifier and internal MQTT identity are deliberately
|
||||
separate; a one-item HA identifier must therefore remain sufficient.
|
||||
"""
|
||||
from app.integrations.expose import build_catalog
|
||||
from app.services.ha_discovery import build_discovery_payload
|
||||
@@ -800,15 +799,11 @@ def test_build_discovery_payload_no_index_error_for_energy_entities(energy_db) -
|
||||
assert len(energy_entries) == 6, "Expected 6 energy_cost entities in catalog"
|
||||
|
||||
for entry in energy_entries:
|
||||
# identifiers[1] must be the meter uuid (not "energy-cost").
|
||||
assert entry.entity.device.identifiers[1] == meter_uuid, (
|
||||
f"identifiers[1] must be meter uuid {meter_uuid!r}, "
|
||||
f"got {entry.entity.device.identifiers[1]!r}"
|
||||
)
|
||||
# Must not raise — specifically no IndexError from identifiers[1]
|
||||
assert entry.entity.device.internal_identity == meter_uuid
|
||||
assert entry.entity.device.identifiers == (f"home-automation:energy-cost:{meter_uuid}",)
|
||||
topic, config = build_discovery_payload(entry.entity, "homeassistant")
|
||||
|
||||
# node_id = identifiers[1] with hyphens → underscores
|
||||
# node_id = internal meter identity with hyphens → underscores
|
||||
node_id = meter_uuid.replace("-", "_")
|
||||
assert node_id in topic, (
|
||||
f"Expected meter uuid node_id {node_id!r} in discovery topic, got {topic!r}"
|
||||
@@ -817,13 +812,12 @@ def test_build_discovery_payload_no_index_error_for_energy_entities(energy_db) -
|
||||
f"Discovery topic must end with /config, got {topic!r}"
|
||||
)
|
||||
assert "unique_id" in config
|
||||
# unique_id seed is identifiers[1] (meter uuid) + entity key
|
||||
# unique_id seed is the internal meter identity + entity key
|
||||
assert meter_uuid in config["unique_id"], (
|
||||
f"unique_id must contain meter uuid, got {config['unique_id']!r}"
|
||||
)
|
||||
assert "device" in config
|
||||
assert "energy-cost" in config["device"]["identifiers"]
|
||||
assert meter_uuid in config["device"]["identifiers"]
|
||||
assert config["device"]["identifiers"] == [f"home-automation:energy-cost:{meter_uuid}"]
|
||||
|
||||
|
||||
def test_energy_cost_entities_omit_availability_so_ha_shows_them(energy_db) -> None:
|
||||
@@ -859,18 +853,18 @@ def test_energy_cost_entities_omit_availability_so_ha_shows_them(energy_db) -> N
|
||||
def test_energy_entity_discovery_topics_contain_correct_node_id() -> None:
|
||||
"""Discovery topic node_id for energy entities must be derived from meter uuid.
|
||||
|
||||
FUE-T05: identifiers[1] is now the active meter's uuid.
|
||||
ha_discovery._node_id() replaces hyphens with underscores in identifiers[1]
|
||||
to build the MQTT node_id. This test verifies that the topic reflects the
|
||||
The internal identity is the active meter's uuid, independent from the
|
||||
singleton HA identifier. This test verifies that the topic reflects the
|
||||
meter uuid (not the old fixed 'energy-cost' string).
|
||||
"""
|
||||
from app.integrations.expose import DeviceInfo, ExposableEntity
|
||||
from app.services.ha_discovery import build_discovery_payload
|
||||
|
||||
meter_uuid = "12345678-abcd-ef00-1234-567890abcdef"
|
||||
# identifiers[1] = meter uuid — this is what FUE-T05 sets.
|
||||
# ``identity`` is the meter UUID; HA grouping is a separate one-item tuple.
|
||||
device = DeviceInfo(
|
||||
identifiers=("energy-cost", meter_uuid),
|
||||
identifiers=(f"home-automation:energy-cost:{meter_uuid}",),
|
||||
identity=meter_uuid,
|
||||
name="Test Meter",
|
||||
provides_availability=False,
|
||||
)
|
||||
@@ -886,7 +880,7 @@ def test_energy_entity_discovery_topics_contain_correct_node_id() -> None:
|
||||
|
||||
topic, config = build_discovery_payload(entity, discovery_prefix="homeassistant")
|
||||
|
||||
# node_id: identifiers[1] = meter_uuid, hyphens → underscores
|
||||
# node_id: internal identity = meter_uuid, hyphens → underscores
|
||||
expected_node = meter_uuid.replace("-", "_")
|
||||
assert f"/{expected_node}/" in topic, (
|
||||
f"Expected meter uuid node_id {expected_node!r} in topic {topic!r}"
|
||||
@@ -2345,12 +2339,11 @@ def test_energy_cost_provider_returns_empty_when_no_active_meter(energy_db) -> N
|
||||
)
|
||||
|
||||
|
||||
def test_energy_cost_provider_identifiers_match_meter_uuid(energy_db) -> None:
|
||||
"""FUE-T05 ②: with an active meter, identifiers[1] == meter.uuid.
|
||||
def test_energy_cost_provider_identity_matches_meter_uuid(energy_db) -> None:
|
||||
"""The active meter anchors the internal identity and namespaced HA identifier.
|
||||
|
||||
The HA device identity is anchored to the active meter's uuid. ha_discovery.py
|
||||
uses identifiers[1] as the MQTT node_id and unique_id seed; changing the active
|
||||
meter (meter swap) produces a new uuid → new node_id → new HA sensor.
|
||||
The HA device identity and MQTT internal identity are both anchored to the
|
||||
active meter's uuid. Changing the meter produces a new node_id and HA sensor.
|
||||
"""
|
||||
from app.integrations.expose import _energy_cost_provider
|
||||
|
||||
@@ -2367,11 +2360,8 @@ def test_energy_cost_provider_identifiers_match_meter_uuid(energy_db) -> None:
|
||||
|
||||
assert len(entities) == 6, f"Expected 6 entities, got {len(entities)}"
|
||||
for entity in entities:
|
||||
assert entity.device.identifiers == ("energy-cost", meter_uuid), (
|
||||
f"identifiers must be ('energy-cost', meter.uuid); "
|
||||
f"expected ('energy-cost', {meter_uuid!r}), "
|
||||
f"got {entity.device.identifiers!r}"
|
||||
)
|
||||
assert entity.device.internal_identity == meter_uuid
|
||||
assert entity.device.identifiers == (f"home-automation:energy-cost:{meter_uuid}",)
|
||||
assert entity.device.name == meter_label, (
|
||||
f"device.name must be exactly the meter label {meter_label!r}, "
|
||||
f"got {entity.device.name!r}"
|
||||
@@ -2382,7 +2372,7 @@ def test_energy_cost_entity_keys_do_not_contain_meter_uuid(energy_db) -> None:
|
||||
"""FUE-T05 ③: entity keys remain stable 'energy.*' strings (no uuid injected).
|
||||
|
||||
Keys are the anchor for the toggle table; they must NOT change when the meter
|
||||
changes. Only identifiers[1] (node_id / unique_id) changes on a meter swap.
|
||||
changes. Only the internal identity (node_id / unique_id) changes on a meter swap.
|
||||
"""
|
||||
from app.integrations.expose import _energy_cost_provider
|
||||
|
||||
@@ -2418,7 +2408,7 @@ def test_energy_cost_entity_keys_do_not_contain_meter_uuid(energy_db) -> None:
|
||||
def test_energy_cost_toggle_survives_meter_swap(energy_db) -> None:
|
||||
"""FUE-T05 ③ (toggle stability): enabled toggle on 'energy.buy_price_now' survives meter swap.
|
||||
|
||||
After a meter swap the provider queries a new active meter → new identifiers[1] /
|
||||
After a meter swap the provider queries a new active meter → new internal identity /
|
||||
unique_id / topic in HA. But the entity key stays 'energy.buy_price_now', so the
|
||||
existing toggle row (keyed by 'energy.buy_price_now') is still found → enabled=True.
|
||||
|
||||
@@ -2475,11 +2465,7 @@ def test_energy_cost_toggle_survives_meter_swap(energy_db) -> None:
|
||||
(e for e in catalog if e.entity.key == "energy.buy_price_now"), None
|
||||
)
|
||||
assert buy_entry is not None, "energy.buy_price_now must be present in catalog"
|
||||
# identifiers[1] must now be the NEW meter's uuid
|
||||
assert buy_entry.entity.device.identifiers[1] == new_meter_uuid, (
|
||||
f"After swap, identifiers[1] must be new meter uuid {new_meter_uuid!r}, "
|
||||
f"got {buy_entry.entity.device.identifiers[1]!r}"
|
||||
)
|
||||
assert buy_entry.entity.device.internal_identity == new_meter_uuid
|
||||
# Toggle state must still be enabled (key unchanged → same toggle row found)
|
||||
assert buy_entry.enabled is True, (
|
||||
"energy.buy_price_now toggle must remain enabled after meter swap "
|
||||
@@ -2488,7 +2474,7 @@ def test_energy_cost_toggle_survives_meter_swap(energy_db) -> None:
|
||||
|
||||
|
||||
def test_energy_cost_identifiers_change_after_meter_swap(energy_db) -> None:
|
||||
"""FUE-T05 ④: after meter swap, provider produces new identifiers[1] (new meter uuid).
|
||||
"""After a meter swap, provider produces a new internal meter identity.
|
||||
|
||||
Old uuid's entities are no longer produced → HA sensor for old uuid is frozen.
|
||||
New uuid's entities appear → HA creates fresh sensors for the new meter.
|
||||
@@ -2544,12 +2530,9 @@ def test_energy_cost_identifiers_change_after_meter_swap(energy_db) -> None:
|
||||
f"Expected 6 entities after swap, got {len(entities_after_swap)}"
|
||||
)
|
||||
for entity in entities_after_swap:
|
||||
assert entity.device.identifiers[1] == new_uuid, (
|
||||
f"After swap, identifiers[1] must be new uuid {new_uuid!r}, "
|
||||
f"got {entity.device.identifiers[1]!r}"
|
||||
)
|
||||
assert entity.device.internal_identity == new_uuid
|
||||
# Old uuid must not appear in identifiers
|
||||
assert entity.device.identifiers[1] != old_uuid, (
|
||||
assert entity.device.internal_identity != old_uuid, (
|
||||
f"After swap, old uuid {old_uuid!r} must not appear in identifiers"
|
||||
)
|
||||
|
||||
@@ -2809,8 +2792,22 @@ def test_m8_catalog_has_source_meter_and_thermal_entities_disabled(energy_db) ->
|
||||
assert entries[f"meter.{electricity.uuid}.total"].entity.unit == "kWh"
|
||||
assert entries[f"meter.{electricity.uuid}.total"].entity.device_class == "energy"
|
||||
assert entries[f"meter.{electricity.uuid}.today"].entity.state_class == "total_increasing"
|
||||
device_ids = {
|
||||
entries[f"source.{heating_source.uuid}.online"].entity.device.identifiers[0],
|
||||
entries[f"meter.{heating.uuid}.total"].entity.device.identifiers[0],
|
||||
entries[f"meter.{water.uuid}.total"].entity.device.identifiers[0],
|
||||
entries[f"meter.{electricity.uuid}.total"].entity.device.identifiers[0],
|
||||
}
|
||||
assert len(device_ids) == 4
|
||||
assert all(len(entry.entity.device.identifiers) == 1 for entry in entries.values())
|
||||
thermal = [entry for key, entry in entries.items() if key.startswith("thermal_cost.")]
|
||||
assert len(thermal) == 12
|
||||
assert len(thermal) == 14
|
||||
assert {entry.entity.key.rsplit(".", 1)[-1] for entry in thermal} >= {
|
||||
"hot_water_total_total", "hot_water_total_today"
|
||||
}
|
||||
assert {entry.entity.name for entry in thermal if ".hot_water_total_" in entry.entity.key} == {
|
||||
"Thermal Hot Water Total", "Thermal Hot Water Today"
|
||||
}
|
||||
assert all(entry.enabled is False for entry in thermal)
|
||||
assert all(entry.entity.unit == "EUR" and entry.entity.device_class == "monetary" for entry in thermal)
|
||||
|
||||
@@ -2972,9 +2969,10 @@ def test_m8_thermal_today_summary_ends_at_frozen_now(energy_db) -> None:
|
||||
captured: dict[str, datetime] = {}
|
||||
result = {
|
||||
"period_count": 1, "fixed_cost": Decimal("0"), "total_cost": Decimal("0"),
|
||||
"breakdown": {key: Decimal("0") for key in (
|
||||
"heating", "hot_water_heating", "hot_water", "hot_water_tax",
|
||||
)},
|
||||
"breakdown": {
|
||||
"heating": Decimal("0"), "hot_water_heating": Decimal("1.25"),
|
||||
"hot_water": Decimal("2.75"), "hot_water_tax": Decimal("9.99"),
|
||||
},
|
||||
}
|
||||
|
||||
def summarize_spy(_session: Session, start: datetime, end: datetime, *, now: datetime) -> dict:
|
||||
@@ -2994,4 +2992,7 @@ def test_m8_thermal_today_summary_ends_at_frozen_now(energy_db) -> None:
|
||||
entity = next(item.entity for item in build_catalog(session)
|
||||
if item.entity.key.endswith(".heating_today"))
|
||||
assert entity.value_getter(session) == Decimal("0")
|
||||
hot_water_total = next(item.entity for item in build_catalog(session)
|
||||
if item.entity.key.endswith(".hot_water_total_today"))
|
||||
assert hot_water_total.value_getter(session) == Decimal("4.00")
|
||||
assert captured["end"] == now
|
||||
|
||||
@@ -362,7 +362,21 @@ def test_build_catalog_device_grouping(expose_db):
|
||||
assert len(identifiers_set) == 1, (
|
||||
"All entities for one device must share the same DeviceInfo identifiers"
|
||||
)
|
||||
assert identifiers_set.pop() == ("modbus", test_uuid)
|
||||
own_identifiers = identifiers_set.pop()
|
||||
assert own_identifiers == (f"home-automation:modbus:{test_uuid}",)
|
||||
|
||||
# A second Modbus device must never overlap its singleton HA identifier.
|
||||
other_uuid = "aaaaaaaa-0000-0000-0000-000000000099"
|
||||
with Session(expose_db) as session:
|
||||
_make_modbus_device(session, friendly_name="SDM120 E", uuid=other_uuid)
|
||||
session.commit()
|
||||
with Session(expose_db) as session:
|
||||
second_catalog = build_catalog(session)
|
||||
other_identifiers = {
|
||||
entry.entity.device.identifiers for entry in second_catalog if other_uuid in entry.entity.key
|
||||
}
|
||||
assert other_identifiers == {(f"home-automation:modbus:{other_uuid}",)}
|
||||
assert {own_identifiers} != other_identifiers
|
||||
|
||||
|
||||
def test_build_catalog_metric_metadata_from_profile(expose_db):
|
||||
|
||||
+500
-5
@@ -15,6 +15,7 @@ Coverage:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -443,14 +444,14 @@ def test_publish_discovery_enabled_vs_disabled_payload(disco_db) -> None:
|
||||
voltage_config_topic = f"homeassistant/sensor/{node_id}/{voltage_obj_id}/config"
|
||||
|
||||
# The voltage entity's config topic should have non-empty JSON payload
|
||||
voltage_call = next(
|
||||
((t, p, r) for t, p, r in config_calls if t == voltage_config_topic), None
|
||||
)
|
||||
assert voltage_call is not None, (
|
||||
voltage_calls = [(t, p, r) for t, p, r in config_calls if t == voltage_config_topic]
|
||||
assert voltage_calls, (
|
||||
f"Expected config publish for voltage topic {voltage_config_topic!r}. "
|
||||
f"Got topics: {[t for t, _, _ in config_calls]}"
|
||||
)
|
||||
_t, payload, retain = voltage_call
|
||||
# First v1.6.1 repair run deliberately unloads the old config before it
|
||||
# re-adds the same unique_id under the corrected HA device identifier.
|
||||
_t, payload, retain = voltage_calls[-1]
|
||||
assert payload not in (b"", "", None), "Enabled entity should get non-empty config payload"
|
||||
assert retain is True, "Discovery config must be retained"
|
||||
|
||||
@@ -1378,3 +1379,497 @@ def test_stale_m8_entities_includes_ended_electricity_meter(disco_db) -> None:
|
||||
session.commit()
|
||||
keys = {entity.key for entity in _stale_m8_entities(session)}
|
||||
assert keys == {f"meter.{old.uuid}.total", f"meter.{old.uuid}.today"}
|
||||
|
||||
|
||||
def test_discovery_uses_single_namespaced_identifier_and_safe_thermal_topic() -> None:
|
||||
"""HA device grouping is independent from a dot-containing internal seed."""
|
||||
from app.integrations.expose import DeviceInfo, ExposableEntity
|
||||
from app.services.ha_discovery import build_discovery_payload
|
||||
|
||||
identity = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa.bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
||||
device = DeviceInfo(
|
||||
identifiers=(f"home-automation:thermal-cost:{identity}",),
|
||||
identity=identity,
|
||||
name="Thermal Energy Cost",
|
||||
provides_availability=False,
|
||||
)
|
||||
entity = ExposableEntity(
|
||||
key=f"thermal_cost.{identity}.heating_total", component="sensor", device=device,
|
||||
device_class="monetary", unit="EUR", name="Thermal Heating Total", state_class="total",
|
||||
)
|
||||
topic, config = build_discovery_payload(entity, "homeassistant")
|
||||
assert config["device"]["identifiers"] == [f"home-automation:thermal-cost:{identity}"]
|
||||
assert all(part.replace("-", "").replace("_", "").isalnum()
|
||||
for part in topic.split("/")[2:4])
|
||||
assert "." not in topic
|
||||
|
||||
|
||||
def test_legacy_thermal_cleanup_persists_each_success_and_retries_only_failure(disco_db) -> None:
|
||||
"""Illegal v1.6.1 cleanup never re-sends a durably accepted topic."""
|
||||
from app.integrations.expose import DeviceInfo, ExposableEntity
|
||||
from app.services import ha_discovery
|
||||
from app.models.config import AppConfigEntry
|
||||
|
||||
identity = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa.bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
||||
legacy = ExposableEntity(
|
||||
key=f"thermal_cost.{identity}.heating_total", component="sensor",
|
||||
device=DeviceInfo(identifiers=("thermal-cost", identity), identity=identity, name="obsolete"),
|
||||
device_class=None, unit="", name="obsolete",
|
||||
)
|
||||
second_legacy = ExposableEntity(
|
||||
key=f"thermal_cost.{identity}.heating_today", component="sensor",
|
||||
device=legacy.device, device_class=None, unit="", name="obsolete",
|
||||
)
|
||||
settings = _make_settings()
|
||||
manager = _make_mock_manager()
|
||||
with (
|
||||
patch("app.services.ha_discovery.build_runtime_settings", return_value=settings),
|
||||
patch("app.services.ha_discovery.mqtt_manager", manager),
|
||||
patch("app.services.ha_discovery.build_catalog", return_value=[]),
|
||||
patch("app.services.ha_discovery._legacy_thermal_entities", return_value=[legacy, second_legacy]),
|
||||
patch("app.services.ha_discovery._stale_m8_entities", return_value=[]),
|
||||
):
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.initialize_legacy_thermal_cleanup(session)
|
||||
manager.publish.side_effect = [True, False]
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.publish_discovery(session)
|
||||
assert manager.publish.call_count == 2
|
||||
with Session(disco_db) as session:
|
||||
progress = session.query(AppConfigEntry).filter_by(
|
||||
key=ha_discovery._LEGACY_THERMAL_CLEANUP_KEY
|
||||
).one()
|
||||
assert ha_discovery._legacy_discovery_topic(legacy, "homeassistant") in progress.value
|
||||
|
||||
manager.publish.reset_mock()
|
||||
manager.publish.side_effect = None
|
||||
manager.publish.return_value = True
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.publish_discovery(session)
|
||||
assert manager.publish.call_args_list == [
|
||||
((ha_discovery._legacy_discovery_topic(second_legacy, "homeassistant"), b""), {"retain": True}),
|
||||
]
|
||||
|
||||
# A fresh Session simulates a process restart: no illegal topic is sent.
|
||||
manager.publish.reset_mock()
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.publish_discovery(session)
|
||||
manager.publish.assert_not_called()
|
||||
|
||||
|
||||
def test_legacy_thermal_enumeration_failure_does_not_advance_marker(disco_db) -> None:
|
||||
"""An inventory error is pending work, never an empty successful cleanup."""
|
||||
from app.models.config import AppConfigEntry
|
||||
from app.services import ha_discovery
|
||||
|
||||
settings = _make_settings()
|
||||
manager = _make_mock_manager()
|
||||
with (
|
||||
patch("app.services.ha_discovery.build_runtime_settings", return_value=settings),
|
||||
patch("app.services.ha_discovery.mqtt_manager", manager),
|
||||
patch("app.services.ha_discovery.build_catalog", return_value=[]),
|
||||
patch("app.services.ha_discovery._legacy_thermal_entities", side_effect=RuntimeError("enumeration failed")),
|
||||
patch("app.services.ha_discovery._stale_m8_entities", return_value=[]),
|
||||
):
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.publish_discovery(session)
|
||||
with Session(disco_db) as session:
|
||||
assert session.query(AppConfigEntry).filter_by(
|
||||
key=ha_discovery._LEGACY_THERMAL_CLEANUP_KEY
|
||||
).one_or_none() is None
|
||||
|
||||
|
||||
def test_legacy_cleanup_compatibility_freeze_keeps_previous_success_progress(disco_db) -> None:
|
||||
"""Startup freezes a pre-inventory ledger and preserves its success progress."""
|
||||
from app.integrations.expose import DeviceInfo, ExposableEntity
|
||||
from app.models.config import AppConfigEntry
|
||||
from app.services import ha_discovery
|
||||
|
||||
device = DeviceInfo(identifiers=("legacy",), identity="old", name="obsolete")
|
||||
first = ExposableEntity(key="thermal_cost.old.heating_total", component="sensor", device=device,
|
||||
device_class=None, unit="", name="obsolete")
|
||||
second = ExposableEntity(key="thermal_cost.old.heating_today", component="sensor", device=device,
|
||||
device_class=None, unit="", name="obsolete")
|
||||
first_topic = ha_discovery._legacy_discovery_topic(first, "homeassistant")
|
||||
with Session(disco_db) as session:
|
||||
session.add(AppConfigEntry(
|
||||
key=ha_discovery._LEGACY_THERMAL_CLEANUP_KEY,
|
||||
value=json.dumps({"complete": False, "topics": [first_topic]}),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
))
|
||||
session.commit()
|
||||
|
||||
with (
|
||||
patch("app.services.ha_discovery.build_runtime_settings", return_value=_make_settings()),
|
||||
patch("app.services.ha_discovery._legacy_thermal_entities", return_value=[first, second]),
|
||||
):
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.initialize_legacy_thermal_cleanup(session)
|
||||
|
||||
manager = _make_mock_manager()
|
||||
with (
|
||||
patch("app.services.ha_discovery.build_runtime_settings", return_value=_make_settings()),
|
||||
patch("app.services.ha_discovery.mqtt_manager", manager),
|
||||
patch("app.services.ha_discovery.build_catalog", return_value=[]),
|
||||
patch("app.services.ha_discovery._stale_m8_entities", return_value=[]),
|
||||
patch("app.services.ha_discovery._ha_registry_bindings", return_value=None),
|
||||
):
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.publish_discovery(session)
|
||||
assert manager.publish.call_args_list == [
|
||||
((ha_discovery._legacy_discovery_topic(second, "homeassistant"), b""), {"retain": True}),
|
||||
]
|
||||
with Session(disco_db) as session:
|
||||
ledger = ha_discovery._migration_json(session, ha_discovery._LEGACY_THERMAL_CLEANUP_KEY)
|
||||
assert ledger == {
|
||||
"complete": True,
|
||||
"inventory": [first_topic, ha_discovery._legacy_discovery_topic(second, "homeassistant")],
|
||||
"topics": sorted((first_topic, ha_discovery._legacy_discovery_topic(second, "homeassistant"))),
|
||||
}
|
||||
|
||||
|
||||
def test_legacy_cleanup_startup_marks_fresh_install_complete_and_short_circuits(disco_db) -> None:
|
||||
"""A later first toggle cannot make a v1.6.1 topic after fresh startup."""
|
||||
from app.models.config import AppConfigEntry
|
||||
from app.services import ha_discovery
|
||||
|
||||
manager = _make_mock_manager()
|
||||
settings = _make_settings()
|
||||
with patch("app.services.ha_discovery._legacy_thermal_entities", return_value=[]):
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.initialize_legacy_thermal_cleanup(session)
|
||||
with Session(disco_db) as session:
|
||||
ledger = session.query(AppConfigEntry).filter_by(
|
||||
key=ha_discovery._LEGACY_THERMAL_CLEANUP_KEY
|
||||
).one()
|
||||
assert ledger.value == '{"complete": true, "inventory": [], "topics": []}'
|
||||
|
||||
# A fresh Session is equivalent to a restarted process. If a user now
|
||||
# enables a thermal entity, the completed ledger prevents re-enumeration.
|
||||
with (
|
||||
patch("app.services.ha_discovery.build_runtime_settings", return_value=settings),
|
||||
patch("app.services.ha_discovery.mqtt_manager", manager),
|
||||
patch("app.services.ha_discovery.build_catalog", return_value=[]),
|
||||
patch("app.services.ha_discovery._legacy_thermal_entities") as legacy,
|
||||
patch("app.services.ha_discovery._stale_m8_entities", return_value=[]),
|
||||
patch("app.services.ha_discovery._ha_registry_bindings", return_value=None),
|
||||
):
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.publish_discovery(session)
|
||||
legacy.assert_not_called()
|
||||
manager.publish.assert_not_called()
|
||||
|
||||
|
||||
def test_legacy_cleanup_startup_completes_empty_compat_ledger_before_later_enable(disco_db) -> None:
|
||||
"""An Alembic-head empty compat ledger cannot manufacture a later old topic."""
|
||||
from app.models.config import AppConfigEntry
|
||||
from app.models.energy import Meter
|
||||
from app.services import ha_discovery
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
with Session(disco_db) as session:
|
||||
heating = Meter(label="heating", commodity="heating", started_at=now, ended_at=None,
|
||||
reason="initial", note=None, created_at=now)
|
||||
water = Meter(label="water", commodity="hot_water", started_at=now, ended_at=None,
|
||||
reason="initial", note=None, created_at=now)
|
||||
session.add_all((heating, water))
|
||||
session.add(AppConfigEntry(
|
||||
key=ha_discovery._LEGACY_THERMAL_CLEANUP_KEY,
|
||||
value=json.dumps({"complete": False, "topics": []}),
|
||||
updated_at=now,
|
||||
))
|
||||
session.commit()
|
||||
|
||||
settings = _make_settings(ha_discovery_prefix="startup_prefix")
|
||||
with patch("app.services.ha_discovery.build_runtime_settings", return_value=settings):
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.initialize_legacy_thermal_cleanup(session)
|
||||
with Session(disco_db) as session:
|
||||
ledger = ha_discovery._migration_json(session, ha_discovery._LEGACY_THERMAL_CLEANUP_KEY)
|
||||
assert ledger == {"complete": True, "inventory": [], "topics": []}
|
||||
active_meters = session.query(Meter).filter(Meter.ended_at.is_(None)).all()
|
||||
pair = ha_discovery._thermal_cleanup_entities(
|
||||
[(next(meter for meter in active_meters if meter.commodity == "heating"),
|
||||
next(meter for meter in active_meters if meter.commodity == "hot_water"))],
|
||||
include_hot_water_total=False,
|
||||
)
|
||||
_enable_entity(session, pair[0].key)
|
||||
session.commit()
|
||||
|
||||
manager = _make_mock_manager()
|
||||
with (
|
||||
patch("app.services.ha_discovery.build_runtime_settings",
|
||||
return_value=_make_settings(ha_discovery_prefix="changed_prefix")),
|
||||
patch("app.services.ha_discovery.mqtt_manager", manager),
|
||||
patch("app.services.ha_discovery.build_catalog", return_value=[]),
|
||||
patch("app.services.ha_discovery._stale_m8_entities", return_value=[]),
|
||||
patch("app.services.ha_discovery._ha_registry_bindings", return_value=None),
|
||||
):
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.publish_discovery(session)
|
||||
manager.publish.assert_not_called()
|
||||
|
||||
|
||||
def test_legacy_cleanup_startup_propagates_inventory_failure(disco_db) -> None:
|
||||
"""Startup must fail closed instead of exposing a mutable cleanup window."""
|
||||
from app.services import ha_discovery
|
||||
|
||||
with patch("app.services.ha_discovery._legacy_thermal_entities", side_effect=RuntimeError("boom")):
|
||||
with Session(disco_db) as session:
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
ha_discovery.initialize_legacy_thermal_cleanup(session)
|
||||
|
||||
|
||||
def test_legacy_cleanup_startup_propagates_durable_write_failure(disco_db) -> None:
|
||||
"""A failed freeze write is fatal; UI must not open with an unfrozen ledger."""
|
||||
from app.services import ha_discovery
|
||||
|
||||
with (
|
||||
patch("app.services.ha_discovery._legacy_thermal_entities", return_value=[]),
|
||||
patch("app.services.ha_discovery._set_migration_json", side_effect=RuntimeError("disk full")),
|
||||
):
|
||||
with Session(disco_db) as session:
|
||||
with pytest.raises(RuntimeError, match="disk full"):
|
||||
ha_discovery.initialize_legacy_thermal_cleanup(session)
|
||||
|
||||
|
||||
def test_legacy_cleanup_startup_keeps_upgrade_with_enabled_topic_pending(disco_db) -> None:
|
||||
"""Existing enabled v1.6.1 inventory is not mistaken for a fresh install."""
|
||||
from app.integrations.expose import DeviceInfo, ExposableEntity
|
||||
from app.models.config import AppConfigEntry
|
||||
from app.services import ha_discovery
|
||||
|
||||
legacy = ExposableEntity(
|
||||
key="thermal_cost.old.heating_total", component="sensor",
|
||||
device=DeviceInfo(identifiers=("legacy",), identity="old", name="obsolete"),
|
||||
device_class=None, unit="", name="obsolete",
|
||||
)
|
||||
with patch("app.services.ha_discovery._legacy_thermal_entities", return_value=[legacy]):
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.initialize_legacy_thermal_cleanup(session)
|
||||
with Session(disco_db) as session:
|
||||
ledger = session.query(AppConfigEntry).filter_by(
|
||||
key=ha_discovery._LEGACY_THERMAL_CLEANUP_KEY
|
||||
).one()
|
||||
assert ledger.value == (
|
||||
'{"complete": false, "inventory": ["homeassistant/sensor/old/'
|
||||
'thermal_cost_old_heating_total/config"], "topics": []}'
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_cleanup_freezes_startup_upgrade_inventory_across_toggle_changes(disco_db) -> None:
|
||||
"""Alembic-head compat ledger survives UI disable and runtime prefix changes."""
|
||||
from app.models.config import AppConfigEntry
|
||||
from app.models.energy import Meter
|
||||
from app.models.expose import ExposedEntityToggle
|
||||
from app.services import ha_discovery
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
settings = _make_settings(ha_discovery_prefix="frozen_prefix")
|
||||
with Session(disco_db) as session:
|
||||
heating = Meter(label="heating", commodity="heating", started_at=now, ended_at=None,
|
||||
reason="initial", note=None, created_at=now)
|
||||
water = Meter(label="water", commodity="hot_water", started_at=now, ended_at=None,
|
||||
reason="initial", note=None, created_at=now)
|
||||
session.add_all((heating, water))
|
||||
session.flush()
|
||||
enabled = ha_discovery._thermal_cleanup_entities(
|
||||
[(heating, water)], include_hot_water_total=False
|
||||
)[:2]
|
||||
for entity in enabled:
|
||||
_enable_entity(session, entity.key)
|
||||
first_topic = ha_discovery._legacy_discovery_topic(enabled[0], "frozen_prefix")
|
||||
session.add(AppConfigEntry(
|
||||
key=ha_discovery._LEGACY_THERMAL_CLEANUP_KEY,
|
||||
value=json.dumps({"complete": False, "topics": [first_topic]}),
|
||||
updated_at=now,
|
||||
))
|
||||
session.commit()
|
||||
|
||||
with patch("app.services.ha_discovery.build_runtime_settings", return_value=settings):
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.initialize_legacy_thermal_cleanup(session)
|
||||
|
||||
with Session(disco_db) as session:
|
||||
ledger = ha_discovery._migration_json(session, ha_discovery._LEGACY_THERMAL_CLEANUP_KEY)
|
||||
inventory = ledger["inventory"]
|
||||
assert len(inventory) == 2
|
||||
assert all(topic.startswith("frozen_prefix/") for topic in inventory)
|
||||
assert ledger["topics"] == [first_topic]
|
||||
# This mirrors PUT /api/expose: persist the UI change before it invokes
|
||||
# publish_discovery in the same request.
|
||||
toggle = session.query(ExposedEntityToggle).filter_by(key=enabled[0].key).one()
|
||||
toggle.enabled = False
|
||||
session.commit()
|
||||
|
||||
manager = _make_mock_manager()
|
||||
failed_topic = inventory[-1]
|
||||
published: list[str] = []
|
||||
manager.publish.side_effect = lambda topic, _payload, **_kwargs: (
|
||||
published.append(topic) or topic != failed_topic
|
||||
)
|
||||
with (
|
||||
patch("app.services.ha_discovery.build_runtime_settings",
|
||||
return_value=_make_settings(ha_discovery_prefix="changed_prefix")),
|
||||
patch("app.services.ha_discovery.mqtt_manager", manager),
|
||||
patch("app.services.ha_discovery.build_catalog", return_value=[]),
|
||||
patch("app.services.ha_discovery._stale_m8_entities", return_value=[]),
|
||||
patch("app.services.ha_discovery._ha_registry_bindings", return_value=None),
|
||||
):
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.publish_discovery(session)
|
||||
assert published == [failed_topic]
|
||||
with Session(disco_db) as session:
|
||||
pending = ha_discovery._migration_json(session, ha_discovery._LEGACY_THERMAL_CLEANUP_KEY)
|
||||
assert pending["complete"] is False
|
||||
assert pending["inventory"] == inventory
|
||||
assert pending["topics"] == [first_topic]
|
||||
|
||||
manager.publish.reset_mock()
|
||||
manager.publish.side_effect = lambda topic, _payload, **_kwargs: published.append(topic) or True
|
||||
published.clear()
|
||||
with (
|
||||
patch("app.services.ha_discovery.build_runtime_settings",
|
||||
return_value=_make_settings(ha_discovery_prefix="changed_prefix")),
|
||||
patch("app.services.ha_discovery.mqtt_manager", manager),
|
||||
patch("app.services.ha_discovery.build_catalog", return_value=[]),
|
||||
patch("app.services.ha_discovery._legacy_thermal_entities") as enumerate_legacy,
|
||||
patch("app.services.ha_discovery._stale_m8_entities", return_value=[]),
|
||||
patch("app.services.ha_discovery._ha_registry_bindings", return_value=None),
|
||||
):
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.publish_discovery(session)
|
||||
assert published == [failed_topic]
|
||||
enumerate_legacy.assert_not_called()
|
||||
|
||||
manager.publish.reset_mock()
|
||||
with (
|
||||
patch("app.services.ha_discovery.build_runtime_settings", return_value=settings),
|
||||
patch("app.services.ha_discovery.mqtt_manager", manager),
|
||||
patch("app.services.ha_discovery.build_catalog", return_value=[]),
|
||||
patch("app.services.ha_discovery._legacy_thermal_entities") as enumerate_legacy,
|
||||
patch("app.services.ha_discovery._stale_m8_entities", return_value=[]),
|
||||
patch("app.services.ha_discovery._ha_registry_bindings", return_value=None),
|
||||
):
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.publish_discovery(session)
|
||||
enumerate_legacy.assert_not_called()
|
||||
manager.publish.assert_not_called()
|
||||
|
||||
|
||||
def test_registry_repair_waits_for_registry_observation_and_partial_catalog_recovers(disco_db) -> None:
|
||||
"""The V2 repair is per entity and never mistakes broker ACK for HA ACK."""
|
||||
from app.integrations.expose import CatalogEntry, DeviceInfo, ExposableEntity
|
||||
from app.services import ha_discovery
|
||||
|
||||
def entity(kind: str, identity: str, metric: str) -> ExposableEntity:
|
||||
return ExposableEntity(
|
||||
key=f"{kind}.{identity}.{metric}" if kind != "energy" else f"energy.{metric}",
|
||||
component="sensor",
|
||||
device=DeviceInfo(
|
||||
identifiers=(f"home-automation:{kind}:{identity}",), identity=identity, name=identity,
|
||||
provides_availability=False,
|
||||
),
|
||||
device_class=None, unit="", name=metric,
|
||||
)
|
||||
|
||||
entities = [
|
||||
*(entity("meter", f"meter-{number}", "total") for number in range(3)),
|
||||
*(entity("source", f"source-{number}", "online") for number in range(2)),
|
||||
*(entity("modbus", f"modbus-{number}", "voltage") for number in range(2)),
|
||||
entity("energy", "electricity-epoch", "import_cost_total"),
|
||||
]
|
||||
catalog = [CatalogEntry(entity=item, enabled=True) for item in entities]
|
||||
manager = _make_mock_manager()
|
||||
manager.publish.return_value = True
|
||||
settings = _make_settings()
|
||||
calls: list[tuple[str, object]] = []
|
||||
manager.publish.side_effect = lambda topic, payload, **_kwargs: calls.append((topic, payload)) or True
|
||||
|
||||
bindings: dict[str, set[str]] = {ha_discovery._unique_id(item): {"old"} for item in entities}
|
||||
with (
|
||||
patch("app.services.ha_discovery.build_runtime_settings", return_value=settings),
|
||||
patch("app.services.ha_discovery.mqtt_manager", manager),
|
||||
patch("app.services.ha_discovery.build_catalog", return_value=catalog),
|
||||
patch("app.services.ha_discovery._legacy_thermal_entities", return_value=[]),
|
||||
patch("app.services.ha_discovery._stale_m8_entities", return_value=[]),
|
||||
patch("app.services.ha_discovery._ha_registry_bindings", side_effect=lambda _settings, ids: {
|
||||
key: value for key, value in bindings.items() if key in ids
|
||||
}),
|
||||
):
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.publish_discovery(session)
|
||||
|
||||
topics = [ha_discovery._discovery_topic(item, "homeassistant") for item in entities]
|
||||
assert [payload for _topic, payload in calls[:len(entities)]] == [b""] * len(entities)
|
||||
assert [topic for topic, _payload in calls[:len(entities)]] == topics
|
||||
assert len({ha_discovery._unique_id(item) for item in entities}) == len(entities)
|
||||
|
||||
# HA confirms every tombstone; only then is each target re-added.
|
||||
calls.clear()
|
||||
bindings.clear()
|
||||
with (
|
||||
patch("app.services.ha_discovery.build_runtime_settings", return_value=settings),
|
||||
patch("app.services.ha_discovery.mqtt_manager", manager),
|
||||
patch("app.services.ha_discovery.build_catalog", return_value=catalog),
|
||||
patch("app.services.ha_discovery._stale_m8_entities", return_value=[]),
|
||||
patch("app.services.ha_discovery._ha_registry_bindings", side_effect=lambda _settings, _ids: dict(bindings)),
|
||||
):
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.publish_discovery(session)
|
||||
assert len(calls) == len(entities)
|
||||
assert all(payload not in (b"", "", None) for _topic, payload in calls)
|
||||
|
||||
|
||||
def test_registry_repair_unavailable_keeps_normal_discovery_publishing(disco_db) -> None:
|
||||
"""A broken optional HA WS link cannot leave legal configs tombstoned."""
|
||||
from app.integrations.expose import CatalogEntry, DeviceInfo, ExposableEntity
|
||||
from app.services import ha_discovery
|
||||
|
||||
entity = ExposableEntity(
|
||||
key="meter.meter-1.total", component="sensor",
|
||||
device=DeviceInfo(identifiers=("home-automation:meter:meter-1",), identity="meter-1", name="m1"),
|
||||
device_class=None, unit="", name="total",
|
||||
)
|
||||
manager = _make_mock_manager()
|
||||
with (
|
||||
patch("app.services.ha_discovery.build_runtime_settings", return_value=_make_settings()),
|
||||
patch("app.services.ha_discovery.mqtt_manager", manager),
|
||||
patch("app.services.ha_discovery.build_catalog", return_value=[CatalogEntry(entity, True)]),
|
||||
patch("app.services.ha_discovery._legacy_thermal_entities", return_value=[]),
|
||||
patch("app.services.ha_discovery._stale_m8_entities", return_value=[]),
|
||||
patch("app.services.ha_discovery._ha_registry_bindings", return_value=None),
|
||||
):
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.publish_discovery(session)
|
||||
assert manager.publish.call_args.args[1] != b""
|
||||
|
||||
def test_stale_thermal_cleanup_uses_safe_topics_and_all_fourteen_metrics(disco_db) -> None:
|
||||
"""Later thermal meter swaps clear only ended safe-format pair configs."""
|
||||
from app.models.energy import Meter
|
||||
from app.services import ha_discovery
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
with Session(disco_db) as session:
|
||||
old_heating = Meter(label="old heat", commodity="heating", started_at=now - timedelta(days=2),
|
||||
ended_at=now - timedelta(days=1), reason="meter_swap", note=None, created_at=now)
|
||||
old_water = Meter(label="old water", commodity="hot_water", started_at=now - timedelta(days=2),
|
||||
ended_at=now - timedelta(days=1), reason="meter_swap", note=None, created_at=now)
|
||||
active_heating = Meter(label="new heat", commodity="heating", started_at=now - timedelta(days=1),
|
||||
ended_at=None, reason="meter_swap", note=None, created_at=now)
|
||||
active_water = Meter(label="new water", commodity="hot_water", started_at=now - timedelta(days=1),
|
||||
ended_at=None, reason="meter_swap", note=None, created_at=now)
|
||||
session.add_all((old_heating, old_water, active_heating, active_water))
|
||||
session.commit()
|
||||
stale = ha_discovery._stale_m8_entities(session)
|
||||
|
||||
old_identity = ".".join(sorted((old_heating.uuid, old_water.uuid)))
|
||||
thermal = [item for item in stale if item.key.startswith("thermal_cost.")]
|
||||
assert len(thermal) == 14
|
||||
assert {item.key for item in thermal} == {
|
||||
f"thermal_cost.{old_identity}.{metric}_{suffix}"
|
||||
for metric in ("heating", "hot_water_heating", "hot_water_total", "water", "water_tax", "fixed", "all_in")
|
||||
for suffix in ("total", "today")
|
||||
}
|
||||
assert all("." not in ha_discovery._discovery_topic(item, "homeassistant") for item in thermal)
|
||||
assert not any(active_heating.uuid in item.key and active_water.uuid in item.key for item in thermal)
|
||||
|
||||
@@ -111,3 +111,87 @@ def test_homeassistant_client_raises_on_invalid_arguments() -> None:
|
||||
|
||||
with pytest.raises(ValueError, match="webhook_id"):
|
||||
client.trigger_webhook(webhook_id="", body={})
|
||||
|
||||
|
||||
def test_discovery_registry_bindings_reads_entity_and_device_registry(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The repair confirmation reads HA's authoritative registry bindings."""
|
||||
sent: list[dict] = []
|
||||
|
||||
class _Socket:
|
||||
replies = iter((
|
||||
'{"type":"auth_required"}',
|
||||
'{"type":"auth_ok"}',
|
||||
'{"id":1,"success":true,"result":[{"platform":"mqtt","unique_id":"u1","device_id":"d1"}]}',
|
||||
'{"id":2,"success":true,"result":[{"id":"d1","identifiers":[["mqtt","home-automation:meter:m1"]]}]}',
|
||||
))
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return None
|
||||
|
||||
def recv(self, *, timeout=None):
|
||||
assert timeout is not None
|
||||
assert timeout <= 1.5
|
||||
return next(self.replies)
|
||||
|
||||
def send(self, payload):
|
||||
sent.append(json.loads(payload))
|
||||
|
||||
monkeypatch.setattr("app.integrations.homeassistant.connect", lambda *_args, **_kwargs: _Socket())
|
||||
bindings = HomeAssistantClient(_configured_settings()).discovery_registry_bindings({"u1", "missing"})
|
||||
|
||||
assert bindings == {"u1": {"home-automation:meter:m1"}}
|
||||
assert [message.get("type") for message in sent] == [
|
||||
"auth", "config/entity_registry/list", "config/device_registry/list"
|
||||
]
|
||||
|
||||
|
||||
def test_discovery_registry_bindings_uses_one_deadline_and_ignores_non_mqtt_identifiers(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Only official ["mqtt", value] pairs participate in repair matching."""
|
||||
received_timeouts: list[float] = []
|
||||
|
||||
class _Socket:
|
||||
replies = iter((
|
||||
'{"type":"auth_required"}',
|
||||
'{"type":"auth_ok"}',
|
||||
'{"id":1,"success":true,"result":['
|
||||
'{"platform":"mqtt","unique_id":"u1","device_id":"d1"},'
|
||||
'{"platform":"mqtt","unique_id":"u2","device_id":"d2"},'
|
||||
'{"platform":"mqtt","unique_id":"u3","device_id":"d3"}]}',
|
||||
'{"id":2,"success":true,"result":['
|
||||
'{"id":"d1","identifiers":[["mqtt","expected"],["esphome","aux"]]},'
|
||||
'{"id":"d2","identifiers":[["esphome","expected"],"expected",["mqtt",3],[]]},'
|
||||
'{"id":"d3","identifiers":[["mqtt","expected"],["mqtt","old"]]}]}',
|
||||
))
|
||||
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *_args): return None
|
||||
def send(self, _payload): return None
|
||||
def recv(self, *, timeout=None):
|
||||
received_timeouts.append(timeout)
|
||||
return next(self.replies)
|
||||
|
||||
monkeypatch.setattr("app.integrations.homeassistant.connect", lambda *_args, **_kwargs: _Socket())
|
||||
bindings = HomeAssistantClient(_configured_settings()).discovery_registry_bindings({"u1", "u2", "u3"})
|
||||
|
||||
assert bindings == {"u1": {"expected"}, "u2": set(), "u3": {"expected", "old"}}
|
||||
assert len(received_timeouts) == 4
|
||||
assert all(timeout is not None and 0 < timeout <= 1.5 for timeout in received_timeouts)
|
||||
|
||||
|
||||
def test_discovery_registry_bindings_silent_socket_times_out(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class _Socket:
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *_args): return None
|
||||
def send(self, _payload): return None
|
||||
def recv(self, *, timeout=None):
|
||||
assert timeout is not None and timeout > 0
|
||||
raise TimeoutError("silent")
|
||||
|
||||
monkeypatch.setattr("app.integrations.homeassistant.connect", lambda *_args, **_kwargs: _Socket())
|
||||
with pytest.raises(HomeAssistantRequestError, match="registry query failed"):
|
||||
HomeAssistantClient(_configured_settings()).discovery_registry_bindings({"u1"})
|
||||
|
||||
@@ -257,7 +257,7 @@ def test_publish_passes_topic_payload_retain_to_paho() -> None:
|
||||
manager._connected = True
|
||||
manager._client = mock_client
|
||||
|
||||
manager.publish("test/topic", '{"key": "value"}', retain=True, qos=1)
|
||||
assert manager.publish("test/topic", '{"key": "value"}', retain=True, qos=1) is True
|
||||
|
||||
mock_client.publish.assert_called_once_with(
|
||||
"test/topic", payload='{"key": "value"}', qos=1, retain=True
|
||||
@@ -267,8 +267,7 @@ def test_publish_passes_topic_payload_retain_to_paho() -> None:
|
||||
def test_publish_is_noop_when_not_connected() -> None:
|
||||
manager = MqttManager()
|
||||
# No connect — should silently skip
|
||||
manager.publish("topic", "payload", retain=False)
|
||||
# No exception raised
|
||||
assert manager.publish("topic", "payload", retain=False) is False
|
||||
|
||||
|
||||
def test_publish_does_not_raise_on_paho_error() -> None:
|
||||
@@ -279,7 +278,17 @@ def test_publish_does_not_raise_on_paho_error() -> None:
|
||||
manager._connected = True
|
||||
|
||||
# Must not raise
|
||||
manager.publish("topic", "payload")
|
||||
assert manager.publish("topic", "payload") is False
|
||||
|
||||
|
||||
def test_publish_returns_false_when_paho_rejects_message() -> None:
|
||||
manager = MqttManager()
|
||||
mock_client = MagicMock()
|
||||
mock_client.publish.return_value.rc = 1
|
||||
manager._client = mock_client
|
||||
manager._connected = True
|
||||
|
||||
assert manager.publish("topic", "payload") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user