Skip to content

Commit 6bf1ae5

Browse files
committed
Expose every parameter the bus reports as a sensor
Parameter discovery returns everything each device knows about itself -- 88 values on the air conditioning alone, from measured temperatures and mains presence to error codes and firmware numbers. Only a handful were reachable. Each parameter now gets a sensor. Two things make that bearable rather than overwhelming: Parameters are keyed by the device that reported them. Several devices carry the same topic, and a bare Topic.Parameter let the interface's copy silently overwrite the appliance's. Addresses that share an identity are folded together. The interface here answers on three addresses with identical parameters and one UniqueID; without folding, this platform would create a thousand entities where two hundred say the same thing. Grouping waits until a device has finished reporting, because what identifies it arrives partway through its own answers. Writes stay with the climate and light entities. The bus exposes System.FactoryReset and DeviceManagement.Delete like any other parameter, and a generic writable entity over that surface is a foot-gun.
1 parent 172c76c commit 6bf1ae5

6 files changed

Lines changed: 227 additions & 5 deletions

File tree

custom_components/truma_aventa/const.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,9 @@
1111
MANUFACTURER: Final = "Truma"
1212
DEFAULT_MODEL: Final = "Aventa"
1313

14-
PLATFORMS: Final = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.LIGHT]
14+
PLATFORMS: Final = [
15+
Platform.BINARY_SENSOR,
16+
Platform.CLIMATE,
17+
Platform.LIGHT,
18+
Platform.SENSOR,
19+
]

custom_components/truma_aventa/diagnostics.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,15 @@
1414
from .coordinator import TrumaConfigEntry
1515

1616
#: Identifiers that say which appliance this is, or which client it trusts.
17+
#: Parameters are keyed "ADDR/Topic.Parameter"; redaction matches the tail.
1718
TO_REDACT = {"MobileIdentity.Muid", "MobileIdentity.Uuid", "Identify.SerialNr"}
1819

1920

21+
def _redacted(key: str, value: Any) -> Any:
22+
"""Hide anything that identifies the appliance or its owner."""
23+
return "**redacted**" if key.rpartition("/")[2] in TO_REDACT else value
24+
25+
2026
async def async_get_config_entry_diagnostics(
2127
hass: HomeAssistant, entry: TrumaConfigEntry
2228
) -> dict[str, Any]:
@@ -38,7 +44,7 @@ async def async_get_config_entry_diagnostics(
3844
"light_step": state.light_step,
3945
},
4046
"parameters": {
41-
key: ("**redacted**" if key in TO_REDACT else value)
47+
key: _redacted(key, value)
4248
for key, value in sorted(state.raw.items())
4349
},
4450
}

custom_components/truma_aventa/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,5 @@
3030
"bleak-retry-connector>=3.5.0",
3131
"cbor2>=5.6.0"
3232
],
33-
"version": "0.8.2"
33+
"version": "0.9.0"
3434
}
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
"""Sensor platform: every parameter the bus reports.
2+
3+
Parameter discovery returns everything each device on the bus knows about
4+
itself. The climate and light entities model the handful a user acts on;
5+
these sensors expose the rest as it arrives, so nothing the appliance reports
6+
is invisible.
7+
8+
One device answers on several addresses -- on this system the interface
9+
answers on three, all reporting the same parameters with the same
10+
``Identify.UniqueID``. Addresses that share an identity are folded into one
11+
Home Assistant device, which is the difference between a couple of hundred
12+
entities and a thousand.
13+
14+
Entities appear as their parameters do; a device that only speaks up later
15+
still gets its sensors without a reload.
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import re
21+
from typing import Any, Final
22+
23+
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
24+
from homeassistant.const import EntityCategory, UnitOfTemperature
25+
from homeassistant.core import HomeAssistant, callback
26+
from homeassistant.helpers.device_registry import DeviceInfo
27+
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
28+
from homeassistant.helpers.update_coordinator import CoordinatorEntity
29+
30+
from .const import DOMAIN, MANUFACTURER
31+
from .coordinator import TrumaConfigEntry, TrumaCoordinator
32+
33+
#: Parameters are keyed "ADDR/Topic.Parameter" by the protocol layer.
34+
_KEY: Final = re.compile(r"^([0-9A-F]{4})/([^./]+)\.([^./]+)$")
35+
36+
#: Temperatures arrive in tenths of a degree. Only parameters seen carrying a
37+
#: real temperature are converted; the appliance also reports raw sensor
38+
#: readings, which use -299 to mean "nothing connected" and are left alone.
39+
_TEMPERATURES: Final = frozenset(
40+
{
41+
"AirCooling.Temp",
42+
"AirCooling.TgtTemp",
43+
"AirHeating.Temp",
44+
"AirHeating.TgtTemp",
45+
"RoomClimate.TgtTemp",
46+
"Temperature.Internal",
47+
}
48+
)
49+
50+
#: What identifies a device across the addresses it answers on, best first.
51+
_IDENTITY_PARAMETERS: Final = ("Identify.UniqueID", "Identify.SerialNr")
52+
53+
#: A state may not exceed 255 characters.
54+
_MAX_STATE: Final = 255
55+
56+
57+
def _readable(value: Any) -> str | int | float | None:
58+
"""Render a parameter value as something a state can hold."""
59+
if value is None or isinstance(value, (int, float)):
60+
return value
61+
if isinstance(value, (bytes, bytearray)):
62+
return value.hex()[:_MAX_STATE]
63+
return str(value)[:_MAX_STATE]
64+
65+
66+
def _identity(raw: dict[str, Any], address: str) -> str:
67+
"""What to call the device answering on this address."""
68+
for parameter in _IDENTITY_PARAMETERS:
69+
if value := raw.get(f"{address}/{parameter}"):
70+
return str(value)
71+
return f"address-{address}"
72+
73+
74+
def _group_addresses(
75+
raw: dict[str, Any], complete: frozenset[str]
76+
) -> dict[str, list[str]]:
77+
"""Map each device identity to the addresses it answers on.
78+
79+
Only addresses that have finished reporting are grouped: what identifies a
80+
device arrives partway through its answers, and grouping it earlier would
81+
file the same parameter first under an address and then under an identity.
82+
"""
83+
groups: dict[str, list[str]] = {}
84+
for key in raw:
85+
if (match := _KEY.match(key)) is None:
86+
continue
87+
address = match.group(1)
88+
if address not in complete:
89+
continue
90+
addresses = groups.setdefault(_identity(raw, address), [])
91+
if address not in addresses:
92+
addresses.append(address)
93+
for addresses in groups.values():
94+
addresses.sort()
95+
return groups
96+
97+
98+
def _parameters(raw: dict[str, Any], addresses: list[str]) -> set[str]:
99+
"""Every "Topic.Parameter" reported by any address of one device."""
100+
found: set[str] = set()
101+
for key in raw:
102+
if (match := _KEY.match(key)) is not None and match.group(1) in addresses:
103+
found.add(f"{match.group(2)}.{match.group(3)}")
104+
return found
105+
106+
107+
async def async_setup_entry(
108+
hass: HomeAssistant,
109+
entry: TrumaConfigEntry,
110+
async_add_entities: AddConfigEntryEntitiesCallback,
111+
) -> None:
112+
"""Set up one sensor per reported parameter, and more as they appear."""
113+
coordinator = entry.runtime_data
114+
known: set[tuple[str, str]] = set()
115+
116+
@callback
117+
def _async_add_known() -> None:
118+
data = coordinator.data
119+
raw = data.raw
120+
fresh: list[TrumaParameterSensor] = []
121+
for identity, addresses in _group_addresses(raw, data.complete).items():
122+
for parameter in sorted(_parameters(raw, addresses)):
123+
if (identity, parameter) in known:
124+
continue
125+
known.add((identity, parameter))
126+
fresh.append(
127+
TrumaParameterSensor(coordinator, identity, addresses, parameter)
128+
)
129+
if fresh:
130+
async_add_entities(fresh)
131+
132+
_async_add_known()
133+
entry.async_on_unload(coordinator.async_add_listener(_async_add_known))
134+
135+
136+
class TrumaParameterSensor(CoordinatorEntity[TrumaCoordinator], SensorEntity):
137+
"""One parameter of one device on the bus."""
138+
139+
_attr_has_entity_name = True
140+
_attr_entity_category = EntityCategory.DIAGNOSTIC
141+
142+
def __init__(
143+
self,
144+
coordinator: TrumaCoordinator,
145+
identity: str,
146+
addresses: list[str],
147+
parameter: str,
148+
) -> None:
149+
"""Initialise the entity."""
150+
super().__init__(coordinator)
151+
self._addresses = list(addresses)
152+
self._parameter = parameter
153+
topic, _, name = parameter.partition(".")
154+
self._attr_name = f"{topic} {name}"
155+
self._attr_unique_id = f"{coordinator.key}_{identity}_{parameter}"
156+
if parameter in _TEMPERATURES:
157+
self._attr_device_class = SensorDeviceClass.TEMPERATURE
158+
self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
159+
self._attr_suggested_display_precision = 1
160+
self._attr_device_info = self._build_device_info(coordinator, identity)
161+
162+
def _build_device_info(
163+
self, coordinator: TrumaCoordinator, identity: str
164+
) -> DeviceInfo:
165+
"""Describe the bus device this parameter belongs to."""
166+
name = self._first("Identify.Name")
167+
major = self._first("Identify.SwMaj")
168+
minor = self._first("Identify.SwMin")
169+
serial = self._first("Identify.SerialNr")
170+
return DeviceInfo(
171+
identifiers={(DOMAIN, f"{coordinator.key}:{identity}")},
172+
manufacturer=MANUFACTURER,
173+
model=str(name) if name else None,
174+
name=str(name) if name else f"Truma 0x{self._addresses[0]}",
175+
serial_number=str(serial) if serial else None,
176+
sw_version=(
177+
f"{major}.{minor}" if major is not None and minor is not None else None
178+
),
179+
via_device=(DOMAIN, coordinator.key),
180+
)
181+
182+
def _first(self, parameter: str) -> Any:
183+
"""The value from whichever of this device's addresses reports it."""
184+
raw = self.coordinator.data.raw
185+
for address in self._addresses:
186+
if (value := raw.get(f"{address}/{parameter}")) is not None:
187+
return value
188+
return None
189+
190+
@property
191+
def available(self) -> bool:
192+
"""Only available while the appliance is connected."""
193+
return super().available and self.coordinator.available
194+
195+
@property
196+
def native_value(self) -> str | int | float | None:
197+
"""The parameter's current value."""
198+
value = self._first(self._parameter)
199+
if self._attr_device_class is SensorDeviceClass.TEMPERATURE:
200+
return value / 10 if isinstance(value, (int, float)) else None
201+
return _readable(value)

custom_components/truma_aventa/truma_ble/device.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -569,7 +569,10 @@ def _handle_frame(self, frame: Frame) -> dict[str, Any]:
569569
seen = 0
570570
for topic, parameter, value in _walk_parameters(body):
571571
seen += 1
572-
raw[f"{topic}.{parameter}"] = value
572+
# Keyed by the device that reported it: several devices on the bus
573+
# carry the same topic, and a bare "Topic.Parameter" lets the
574+
# interface's copy overwrite the appliance's.
575+
raw[f"{frame.src:04X}/{topic}.{parameter}"] = value
573576
# The appliance identifies itself by owning these topics; its
574577
# address is not fixed and is not the one the reference lists.
575578
if (
@@ -583,6 +586,8 @@ def _handle_frame(self, frame: Frame) -> dict[str, Any]:
583586
changed[field] = value
584587
if raw != self.state.raw:
585588
changed["raw"] = raw
589+
if "LastMessage" in body:
590+
changed["complete"] = self.state.complete | {f"{frame.src:04X}"}
586591
if "LastMessage" in body and _LOGGER.isEnabledFor(logging.DEBUG):
587592
# The sentinel ends a discovery burst, which is the one moment the
588593
# full inventory of what this appliance reports is known.

custom_components/truma_aventa/truma_ble/models.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,14 @@ class TrumaState:
5454
serial_number: str | None = None
5555
software_version: str | None = None
5656

57-
#: Every parameter seen, keyed "Topic.Parameter", for diagnostics.
57+
#: Every parameter seen, keyed "ADDR/Topic.Parameter".
5858
raw: dict[str, Any] = field(default_factory=dict)
5959

60+
#: Addresses whose parameter discovery has run to its end. Until a device
61+
#: has finished reporting, what identifies it may still be missing, and
62+
#: entities built on a half-known identity would be built twice.
63+
complete: frozenset[str] = frozenset()
64+
6065
def with_values(self, values: dict[str, Any]) -> TrumaState:
6166
"""Return a copy with ``values`` applied."""
6267
return replace(self, **values)

0 commit comments

Comments
 (0)