Skip to content

Commit c30a43a

Browse files
committed
Apply each message before reading the state for the next one
A notification can carry several messages, and each one builds its parameter map from the state stored so far. Collecting them and applying the last threw away everything the earlier ones had added -- silently, because the frames decoded perfectly well and only the result went missing. The same held for the record of which devices have finished reporting: two finishing inside one notification cost one of them every sensor it would have had. Found by reading the code rather than by it failing, which is luck; the tests that now cover it fail against the old behaviour.
1 parent 26b71a1 commit c30a43a

2 files changed

Lines changed: 150 additions & 3 deletions

File tree

custom_components/truma_aventa/truma_ble/device.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -506,10 +506,10 @@ def _on_data(self, _sender: BleakGATTCharacteristic, data: bytearray) -> None:
506506
self._stream.pending,
507507
self._stream.dropped - before,
508508
)
509-
changed: dict[str, Any] = {}
509+
touched = False
510510
for frame in frames:
511511
try:
512-
changed.update(self._handle_frame(frame))
512+
changed = self._handle_frame(frame)
513513
except Exception:
514514
# bleak swallows anything raised in a notification callback, so
515515
# a decoding fault is indistinguishable from a silent
@@ -519,9 +519,17 @@ def _on_data(self, _sender: BleakGATTCharacteristic, data: bytearray) -> None:
519519
self._name,
520520
frame.src,
521521
)
522-
if changed:
522+
continue
523+
if not changed:
524+
continue
525+
# Applied before the next frame is read rather than merged at the
526+
# end: a frame builds its parameter map from the stored state, so
527+
# collecting several and applying the last would drop everything
528+
# the earlier ones added.
523529
self.state = self.state.with_values(changed)
530+
touched = True
524531
_LOGGER.debug("%s: state changed: %s", self._name, sorted(changed))
532+
if touched:
525533
self._notify()
526534

527535
def _schedule_command(self, payload: bytes) -> None:

tests/test_device.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
"""Tests for turning inbound frames into state.
2+
3+
A notification can carry more than one message, and each message builds its
4+
parameter map from the state stored so far. Collecting several and applying
5+
only the last therefore loses everything the earlier ones added -- silently,
6+
because the frames themselves decoded perfectly well.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import asyncio
12+
import sys
13+
from pathlib import Path
14+
from typing import Any
15+
16+
import pytest
17+
from bleak.backends.device import BLEDevice
18+
19+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
20+
21+
from custom_components.truma_aventa.truma_ble.const import (
22+
ADDR_INTERFACE,
23+
CONTROL_MBP,
24+
MBP_PARAM_DISCOVERY_RESPONSE,
25+
)
26+
from custom_components.truma_aventa.truma_ble.device import TrumaBleDevice
27+
from custom_components.truma_aventa.truma_ble.frames import build, build_mbp
28+
29+
APPLIANCE = 0x0801
30+
OURS = 0x0501
31+
32+
33+
def _answer(source: int, topic: str, parameter: str, value: Any) -> bytes:
34+
"""One parameter-discovery answer, as the appliance sends it."""
35+
return build_mbp(
36+
dest=OURS,
37+
src=source,
38+
mbp_type=MBP_PARAM_DISCOVERY_RESPONSE,
39+
body={
40+
"avail": 1,
41+
"topics": [
42+
{
43+
"tn": topic,
44+
"parameters": [{"tn": topic, "pn": parameter, "v": value}],
45+
}
46+
],
47+
},
48+
)
49+
50+
51+
def _last_message(source: int) -> bytes:
52+
"""The sentinel that ends a discovery burst."""
53+
return build_mbp(
54+
dest=OURS,
55+
src=source,
56+
mbp_type=MBP_PARAM_DISCOVERY_RESPONSE,
57+
body={"LastMessage": 1},
58+
)
59+
60+
61+
def _feed(device: TrumaBleDevice, *payloads: bytes) -> None:
62+
"""Deliver notifications the way bleak does, from inside a loop.
63+
64+
A frame naming a topic the appliance owns schedules a discovery of its
65+
own, which needs a running loop; without one the whole frame is lost to
66+
the callback's error handling.
67+
"""
68+
69+
async def run() -> None:
70+
for payload in payloads:
71+
device._on_data(None, bytearray(payload))
72+
await asyncio.sleep(0)
73+
pending = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
74+
for task in pending:
75+
task.cancel()
76+
await asyncio.gather(*pending, return_exceptions=True)
77+
78+
asyncio.run(run())
79+
80+
81+
@pytest.fixture
82+
def device() -> TrumaBleDevice:
83+
"""A device that has never connected, which is all these need."""
84+
return TrumaBleDevice(
85+
BLEDevice("FC:DE:C5:F0:A6:35", "Truma iNetX-F0A635", None),
86+
identity={"UserName": "t", "Muid": "m", "Uuid": "u"},
87+
)
88+
89+
90+
def test_two_answers_in_one_notification_both_survive(
91+
device: TrumaBleDevice,
92+
) -> None:
93+
"""The second answer must not overwrite what the first added."""
94+
_feed(
95+
device,
96+
_answer(APPLIANCE, "AirCooling", "Temp", 251)
97+
+ _answer(APPLIANCE, "AmbientLight", "Active", 1),
98+
)
99+
raw = device.state.raw
100+
assert raw["0801/AirCooling.Temp"] == 251
101+
assert raw["0801/AmbientLight.Active"] == 1
102+
103+
104+
def test_two_devices_finishing_together_are_both_recorded(
105+
device: TrumaBleDevice,
106+
) -> None:
107+
"""A completion dropped here costs that device every one of its sensors."""
108+
_feed(device, _last_message(ADDR_INTERFACE) + _last_message(APPLIANCE))
109+
assert device.state.complete == {"0101", "0801"}
110+
111+
112+
def test_parameters_are_kept_apart_by_device(device: TrumaBleDevice) -> None:
113+
"""Two devices carry the same topic; one must not overwrite the other."""
114+
_feed(
115+
device,
116+
_answer(ADDR_INTERFACE, "RoomClimate", "TgtTemp", 220),
117+
_answer(APPLIANCE, "RoomClimate", "TgtTemp", 250),
118+
)
119+
raw = device.state.raw
120+
assert raw["0101/RoomClimate.TgtTemp"] == 220
121+
assert raw["0801/RoomClimate.TgtTemp"] == 250
122+
123+
124+
def test_a_known_parameter_reaches_the_state(device: TrumaBleDevice) -> None:
125+
"""Mapped parameters land on the fields the entities read."""
126+
_feed(device, _answer(APPLIANCE, "AirCooling", "Temp", 243))
127+
assert device.state.current_temperature == 243
128+
129+
130+
def test_an_undecodable_body_does_not_stop_the_rest(device: TrumaBleDevice) -> None:
131+
"""One unreadable message must not cost the messages beside it."""
132+
broken = build(
133+
dest=OURS,
134+
src=APPLIANCE,
135+
control=CONTROL_MBP,
136+
payload=bytes([MBP_PARAM_DISCOVERY_RESPONSE, 0]) + b"\xbf\x62",
137+
)
138+
_feed(device, broken + _answer(APPLIANCE, "AmbientLight", "Active", 1))
139+
assert device.state.raw["0801/AmbientLight.Active"] == 1

0 commit comments

Comments
 (0)