|
| 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