M8-R15: fix HA discovery identities and thermal totals
This commit is contained in:
+44
-16
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user