M8-R15: fix HA discovery identities and thermal totals
frontend / frontend (push) Successful in 47s
pytest / test (push) Successful in 4m1s
docker-image / build-and-push (push) Successful in 1m38s

This commit is contained in:
2026-08-28 01:20:52 +02:00
parent 8180082f90
commit 018f13d73d
13 changed files with 1284 additions and 136 deletions
+44 -16
View File
@@ -45,12 +45,15 @@ class DeviceInfo:
"""Grouping metadata that maps an entity to a logical HA device.
``identifiers`` corresponds to ``device.identifiers`` in HA Discovery
config — a stable tuple used to group entities under one device card.
config. HA merges device cards if *any* identifier overlaps, so every
logical device deliberately has exactly one, globally namespaced value.
``identity`` is an independent internal seed for MQTT topics and unique
ids; it must never be inferred from the HA identifier list.
``name`` is the human-readable device name (friendly_name).
"""
identifiers: tuple[str, ...]
"""Stable identifiers for HA device grouping (e.g. ``("modbus", "<uuid>")``).
"""Stable HA identifiers, normally a one-item tuple.
These must not change over the device lifetime; they anchor the HA device
card even when friendly_name changes.
@@ -59,6 +62,14 @@ class DeviceInfo:
name: str
"""Human-readable device name (may change; triggers HA discovery re-publish)."""
identity: Optional[str] = None
"""Stable internal topic/unique-id identity, separate from HA identifiers.
The fallback preserves compatibility for third-party providers still using
the historic ``("kind", "uuid")`` construction while first-party providers
use this field explicitly.
"""
provides_availability: bool = True
"""Whether this device publishes an availability ("online"/"offline") heartbeat.
@@ -85,6 +96,11 @@ class DeviceInfo:
)
"""Return whether the source behind this device is currently usable."""
@property
def internal_identity(self) -> str:
"""Return the topic/unique-id seed without exposing HA grouping details."""
return self.identity or self.identifiers[-1]
@dataclass
class ExposableEntity:
@@ -276,8 +292,9 @@ def _modbus_provider(session: Session) -> list[ExposableEntity]:
for device in devices:
device_info = DeviceInfo(
identifiers=("modbus", device.uuid),
identifiers=(f"home-automation:modbus:{device.uuid}",),
name=device.friendly_name,
identity=device.uuid,
)
# Load the profile to get metric metadata.
@@ -407,9 +424,10 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
HA device identity (换表 → 新 sensor)
--------------------------------------
``identifiers[1]`` is set to the active meter's **uuid** (not the fixed
string ``"energy-cost"``). ``ha_discovery.py`` uses ``identifiers[1]`` as
the MQTT node_id and as part of the ``unique_id`` for every entity.
The independent internal identity is set to the active meter's **uuid**.
``ha_discovery.py`` uses that internal identity as the MQTT node_id and as
part of the ``unique_id`` for every entity; HA receives one separate,
namespaced device identifier.
Declaring a new active electricity meter produces a new uuid → new node_id /
unique_id → HA creates a brand-new sensor, cleanly isolating post-swap data.
@@ -460,9 +478,9 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
DeviceInfo identifiers
----------------------
**Two-element tuple** ``("energy-cost", meter.uuid)`` so that
``ha_discovery.py``'s ``entity.device.identifiers[1]`` resolves to the
meter uuid (used as the MQTT node_id and unique_id seed throughout).
One-element namespaced tuple ``("home-automation:energy-cost:<uuid>",)``.
The meter uuid used for MQTT topics and unique IDs is carried independently
in ``DeviceInfo.identity``.
"""
from app.models.energy import EnergyCostPeriod, Meter # local import to avoid circular
from sqlalchemy import select
@@ -499,14 +517,15 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
currency = latest_period.currency
# --- Shared DeviceInfo anchored to the active meter's uuid ---
# identifiers[1] = meter.uuid drives the MQTT node_id and unique_id in
# ha_discovery.py. Swapping the meter produces a new uuid → new HA sensor.
# The internal identity is meter.uuid, while the HA identifier is a single
# namespaced value. Swapping the meter produces a new HA sensor/card.
# 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", active_meter.uuid),
identifiers=(f"home-automation:energy-cost:{active_meter.uuid}",),
name=active_meter.label,
identity=active_meter.uuid,
provides_availability=False,
)
@@ -967,7 +986,8 @@ def _m8_energy_provider(session: Session) -> list[ExposableEntity]:
for source in sources:
source_info = DeviceInfo(
identifiers=("meter-source", source.uuid), name=source.name,
identifiers=(f"home-automation:meter-source:{source.uuid}",), name=source.name,
identity=source.uuid,
availability_id=source.uuid,
availability_getter=lambda sess, source_id=source.id: _source_online_by_id(sess, source_id),
)
@@ -994,7 +1014,8 @@ def _m8_energy_provider(session: Session) -> list[ExposableEntity]:
continue
binding, channel, source = bound
info = DeviceInfo(
identifiers=("meter", meter.uuid), name=meter.label,
identifiers=(f"home-automation:meter:{meter.uuid}",), name=meter.label,
identity=meter.uuid,
# Keep this opaque and Meter-anchored. In particular, do not use a
# source UUID here: two channels of one source can be independently
# stale/invalid and must not overwrite each other's availability.
@@ -1021,15 +1042,20 @@ def _m8_energy_provider(session: Session) -> list[ExposableEntity]:
heating, hot_water = active_by_commodity.get("heating"), active_by_commodity.get("hot_water")
if heating is not None and hot_water is not None:
# Keep the v1.6.1 canonical identity/key seed stable. Discovery topic
# segments are sanitised independently by ha_discovery, so this dot is
# never emitted in a topic while existing toggle rows remain usable.
identity = ".".join(sorted((heating.uuid, hot_water.uuid)))
currency = _thermal_currency(session)
cost_info = DeviceInfo(
identifiers=("thermal-cost", identity), name="Thermal Energy Cost",
identifiers=(f"home-automation:thermal-cost:{identity}",), name="Thermal Energy Cost",
identity=identity,
provides_availability=False,
)
labels = {
"heating": "Heating", "hot_water_heating": "Hot Water Heating", "water": "Water",
"water_tax": "Water Tax", "fixed": "Fixed", "all_in": "All-in",
"water_tax": "Water Tax", "hot_water_total": "Hot Water",
"fixed": "Fixed", "all_in": "All-in",
}
for suffix, window in (("total", None), ("today", "today")):
for metric, label in labels.items():
@@ -1203,6 +1229,8 @@ def _thermal_cost_getter(metric: str, window: str | None) -> Callable[[Session],
return result["breakdown"]["hot_water"]
if metric == "water_tax":
return result["breakdown"]["hot_water_tax"]
if metric == "hot_water_total":
return result["breakdown"]["hot_water_heating"] + result["breakdown"]["hot_water"]
return result["breakdown"][metric]
return _getter