diff --git a/app/integrations/expose.py b/app/integrations/expose.py index 258d641..7a5b169 100644 --- a/app/integrations/expose.py +++ b/app/integrations/expose.py @@ -58,6 +58,21 @@ class DeviceInfo: name: str """Human-readable device name (may change; triggers HA discovery re-publish).""" + provides_availability: bool = True + """Whether this device publishes an availability ("online"/"offline") heartbeat. + + When True (the default — e.g. Modbus devices, which have an ``online`` + binary_sensor driving availability), the HA Discovery config for each entity + declares an ``availability`` topic and HA marks the entity unavailable until + "online" is published. + + When False (e.g. the energy-cost device, which has only sensors and no + heartbeat source), the config omits ``availability`` so HA treats the entity + as *always available* and shows its state as soon as one arrives. Without + this, such entities would stay perpetually ``unavailable`` in HA even though + their state is being published. + """ + @dataclass class ExposableEntity: @@ -422,9 +437,13 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]: currency = latest_period.currency # --- Shared DeviceInfo (2-element identifiers — required by ha_discovery.py [1] access) --- + # provides_availability=False: the energy-cost device has only sensors and no + # online/offline heartbeat, so its entities must be "always available" in HA. + # (Otherwise HA shows them unavailable despite state being published.) device_info = DeviceInfo( identifiers=("energy-cost", "energy-cost"), name="Energy Cost", + provides_availability=False, ) # --- value_getter: current buy price --- diff --git a/app/services/ha_discovery.py b/app/services/ha_discovery.py index b317a4b..a99f834 100644 --- a/app/services/ha_discovery.py +++ b/app/services/ha_discovery.py @@ -133,14 +133,21 @@ def build_discovery_payload( "unique_id": _unique_id(entity), "name": entity.name, "state_topic": state_t, - "availability": [{"topic": avail_topic}], - "availability_mode": "all", "device": { "identifiers": list(entity.device.identifiers), "name": entity.device.name, }, } + # Only declare an availability topic for devices that actually publish an + # online/offline heartbeat (e.g. Modbus, via its "online" binary_sensor). + # Devices without a heartbeat (e.g. energy-cost) omit availability so HA + # treats their entities as always-available — otherwise HA would mark them + # ``unavailable`` forever even though their state is being published. + if entity.device.provides_availability: + config["availability"] = [{"topic": avail_topic}] + config["availability_mode"] = "all" + # Component-specific fields if entity.component == "binary_sensor": # "online" binary sensor: payload ON/OFF diff --git a/tests/test_energy_expose.py b/tests/test_energy_expose.py index 624bccb..b860387 100644 --- a/tests/test_energy_expose.py +++ b/tests/test_energy_expose.py @@ -648,6 +648,29 @@ def test_build_discovery_payload_no_index_error_for_energy_entities(energy_db) - assert "energy-cost" in config["device"]["identifiers"] +def test_energy_cost_entities_omit_availability_so_ha_shows_them(energy_db) -> None: + """The energy-cost device has no online/offline heartbeat, so its discovery + config must NOT declare an availability topic — otherwise HA marks the + entities ``unavailable`` forever even though state is being published. + """ + from app.integrations.expose import build_catalog + from app.services.ha_discovery import build_discovery_payload + + with Session(energy_db) as session: + catalog = build_catalog(session) + + energy_entries = [e for e in catalog if e.entity.key.startswith("energy.")] + assert len(energy_entries) == 4 + + for entry in energy_entries: + assert entry.entity.device.provides_availability is False + _, config = build_discovery_payload(entry.entity, "homeassistant") + assert "availability" not in config, ( + f"energy entity {entry.entity.key} must omit availability (always-available)" + ) + assert "availability_mode" not in config + + def test_energy_entity_discovery_topics_contain_correct_node_id() -> None: """Discovery topic node_id for energy entities must be 'energy-cost' (hyphens → underscores).""" from app.integrations.expose import DeviceInfo, ExposableEntity