From 64fd13f309baf7f2e3bf1ebf7064321f675288e3 Mon Sep 17 00:00:00 2001 From: Mac Book Date: Mon, 9 Nov 2026 10:11:28 +0200 Subject: [PATCH] climate: keep the 3550 flags through its reply, hold generic #Z frames, anchor the central echo on the write Live F461 2.0.11 + F455 on one bus with a BTicino 3550 (no battery), 2026-11-09: * The 3550 answers *#4*#0## with its mode, *4*21*#0## and *4*31*#0## within 4 ms. The mode / season handlers cleared the optional flags on the mode frame, so both central entities wrote battery_fault False and, 1.5 ms later, True on every reply. WHO 4 p. 24-25: the flags (22-24, 30, 31) are optional frames after the remote control frame (20 / 21), and no frame means "clear". The flag cycle now starts on 20 / 21 and clears a flag only when the burst has settled without it; a mode frame alone no longer touches the flags. * The F455 emits a generic *4*311*#4## 59-61 ms before the zone's seasonal status (*4*210*#4##, 18 of 18; the MH201 trace of #466 the same, before *4*110*#Z##), at every quarter-hour push of the 3550; the F461 never shows it. The F455 zone 4 entity went AUTO and back every 15 minutes. A generic 310 / 311 frame for a zone of the central is now held briefly: a seasonal status that follows replaces it, and only a generic frame with none after it is the zone's state (WHO 4 p. 9). * The central echo window starts at the write time (the written future of MyHOMEGatewayHandler.send), not at the call, so a busy command pool does not push the echo outside it; a cancelled or failed write disarms it, and a repeated command is anchored on its own write. Trace-replay tests from the captured frames: tests/test_climate_central_flags_f455_poll_2026_11_09.py. --- custom_components/myhome/climate.py | 215 +++++++-- .../frames.json | 250 ++++++++++ tests/test_climate_central_discovery.py | 48 +- ...mate_central_flags_f455_poll_2026_11_09.py | 427 ++++++++++++++++++ tests/test_climate_mode_state_2026_11_08.py | 7 +- tests/test_climate_season_frame.py | 3 + 6 files changed, 912 insertions(+), 38 deletions(-) create mode 100644 tests/fixtures/traces/central_flags_f455_poll_2026_11_09/frames.json create mode 100644 tests/test_climate_central_flags_f455_poll_2026_11_09.py diff --git a/custom_components/myhome/climate.py b/custom_components/myhome/climate.py index 63cd84583..9f8100e8e 100644 --- a/custom_components/myhome/climate.py +++ b/custom_components/myhome/climate.py @@ -22,6 +22,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect, async_dispatcher_send from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import DATA_RESTORE_STATE from OWNd.message import ( CLIMATE_MODE_AUTO, @@ -133,6 +134,20 @@ def _central_diagnostic_what(message: Any) -> int | None: # frame equal to the command that arrives later, from the central unit or another client, # is still taken as a report. _CENTRAL_ECHO_WINDOW = 0.1 +# While the command is still queued or in flight (its ``written`` future not done) the echo has +# no deadline yet; past this time the gateway's command pool has given up on it +# (gateway.PACED_WRITE_TIMEOUT), so the echo is no longer expected. +_CENTRAL_ECHO_PENDING_LIMIT = 60.0 +# The 3550's status reply is its mode, then *4*20*#0## / *4*21*#0## (remote control), then +# only the optional flags that are active (*4*22..24*#0##, *4*30*#0##, *4*31*#0##; WHO 4 p. 5, +# p. 24-25). No frame says a flag is clear, so a flag that does not follow a 20 / 21 frame +# within this time is clear. Live F461 and F455, 2026-11-08 / 11-09: 31 came 0-1 ms after 21. +_CENTRAL_FLAGS_SETTLE = 1.0 +_CENTRAL_FLAG_WHATS = (22, 23, 24, 30, 31) +# A generic *4*310/311*#Z## frame waits this long for the zone's seasonal status. An F455 put its +# synthetic *4*311*#4## 59-61 ms before *4*210*#4## / *4*203*#4## / *4*202*#4## (18 of 18, live +# 2026-11-08 / 11-09); a generic frame with no seasonal status after it is the zone's state (WHO 4 p. 9). +_GENERIC_ZONE_FRAME_HOLD = 0.5 _CONDITIONING_SEASONS = (SEASON_CONDITIONING, "cooling") # How long the fan speed poll waits for the one-shot dimension 19 answer (*#4*Z*19##, @@ -638,7 +653,7 @@ def __init__( self._central_operation: str | None = None # Central unit: Home Assistant's last command and when its local echo stops being expected, # and the echo frame once seen (the same frame object can be delivered more than once). - self._central_echo: tuple[str, float] | None = None + self._central_echo: tuple[str, float | None, float] | None = None self._central_echo_seen: object | None = None self._program: int | None = None self._holiday_days: int | None = None @@ -665,6 +680,13 @@ def __init__( self._at_least_one_probe_manual: bool = False self._failure_detected: bool = False self._battery_fault: bool = False + # The optional flags seen since the last 20 / 21 frame, and the timer that clears the others. + self._central_flags_seen: set[int] = set() + self._central_flags_unsub: Any = None + # A generic *4*310/311*#Z## frame held back until the zone's seasonal status shows whether + # it was only a prelude, the timer that applies it otherwise, and the frame being applied. + self._generic_zone_frame_unsub: Any = None + self._generic_zone_frame_release: object | None = None self._attr_hvac_mode: HVACMode | None = None self._attr_hvac_action: HVACAction | None = None @@ -1174,7 +1196,10 @@ async def async_added_to_hass(self) -> None: if target_hass is not None: if self._central: self._clear_unresponsive_issue() - elif self._standalone_config is not True: + self.async_on_remove(self._cancel_central_flag_cycle) + else: + self.async_on_remove(self._cancel_generic_zone_frame) + if not self._central and self._standalone_config is not True: self.async_on_remove( async_dispatcher_connect( target_hass, @@ -1398,21 +1423,58 @@ def _hydrate_nominal_from_local(self, prev_mode: HVACMode | None) -> None: ): self._local_target_temperature = self._target_temperature + self._local_offset - def _reset_central_status_flags(self) -> None: - """Reset optional central unit (#0) flags at the start of a new mode/status burst (#819). - - On ``*#4*#0##`` (and unsolicited ``#0`` status bursts), the BTicino 3550 emits its - operating mode frame first (``*4*1101*#0##``, ``*4*102*#0##``, etc.), followed by - ``WHAT 20``/``21`` and whichever optional fault/probe flags (``WHAT 22, 23, 24, 30, 31``) - are currently active. Optional flags have no separate all-clear code and are simply - omitted once resolved. + def _begin_central_flag_cycle(self) -> None: + """Start collecting the 3550's optional flags after its remote-control frame (#819). + + On ``*#4*#0##`` (and unsolicited ``#0`` bursts) the 3550 sends its operating mode, + ``WHAT 20``/``21``, then whichever optional flags (``WHAT 22, 23, 24, 30, 31``) are + active (WHO 4 p. 24-25). A flag has no all-clear code; it is omitted once resolved. + The flags are not cleared here: clearing them at the start of the burst wrote + ``battery_fault`` False and, 2 ms later, True on every reply of a 3550 without a + battery (live F461 + F455, 2026-11-09). A flag still missing when the burst has + settled is cleared by ``_settle_central_flags``. A second copy of the same frame + (climate and sensor platform routing) does not restart an open cycle. """ - self._at_least_one_probe_off = False - self._at_least_one_probe_antifreeze = False - self._at_least_one_probe_manual = False - self._failure_detected = False - self._battery_fault = False - self._clear_fault(FaultKind.CENTRAL_BATTERY_FAULT) + if self._central_flags_unsub is not None: + return + self._central_flags_seen = set() + if self.hass is None or self.platform is None or not self.entity_id: + # Not live yet (discovery seed, restore): nothing is written in between, so there is + # no flip to avoid; the flags of this burst follow directly. + self._settle_central_flags(None) + return + self._central_flags_unsub = async_call_later(self.hass, _CENTRAL_FLAGS_SETTLE, self._settle_central_flags) + + @callback + def _settle_central_flags(self, _now: Any) -> None: + """Clear the optional central flags the last burst did not report.""" + self._central_flags_unsub = None + seen = self._central_flags_seen + before = self._central_flag_values() + self._at_least_one_probe_off = 22 in seen + self._at_least_one_probe_antifreeze = 23 in seen + self._at_least_one_probe_manual = 24 in seen + self._failure_detected = 30 in seen + self._battery_fault = 31 in seen + if not self._battery_fault: + self._clear_fault(FaultKind.CENTRAL_BATTERY_FAULT) + if self._central_flag_values() != before: + self._publish_state() + + def _central_flag_values(self) -> tuple[bool, ...]: + return ( + self._at_least_one_probe_off, + self._at_least_one_probe_antifreeze, + self._at_least_one_probe_manual, + self._failure_detected, + self._battery_fault, + ) + + @callback + def _cancel_central_flag_cycle(self) -> None: + if self._central_flags_unsub is not None: + self._central_flags_unsub() + self._central_flags_unsub = None async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" @@ -1431,13 +1493,26 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: where=self._where, mode=cmd_mode, ) - # Set before sending: the echo can arrive while send() is still awaited. - self._central_echo = (str(command), time.monotonic() + _CENTRAL_ECHO_WINDOW) + # Armed before sending, without a deadline: the echo can arrive while the frame + # waits in the command pool or while send() is still awaited. The window starts + # when the frame is written (the ``written`` future's result), not now, so a busy + # CommandWorkerPool does not push the echo out of it. + frame = str(command) + armed_at = time.monotonic() + self._central_echo = (frame, None, armed_at) try: - await self._gateway_handler.send(command) + written: Any = await self._gateway_handler.send(command) except BaseException: self._central_echo = None raise + if isinstance(written, asyncio.Future): + + def _on_written(future: "asyncio.Future[float]", armed_at: float = armed_at) -> None: + self._anchor_central_echo(armed_at, future) + + written.add_done_callback(_on_written) + else: + self._anchor_central_echo(armed_at, None) if hvac_mode in (HVACMode.HEAT, HVACMode.COOL) and self._where.removeprefix("4-") in ("#0", "0"): # *4*1*#0## / *4*0*#0## only change the season (WHO 4 p. 5). The 3550 # answers with the operating mode it is in, e.g. *4*102*#0## antifreeze, @@ -1644,6 +1719,28 @@ def _apply_zone_state(self, message: OWNHeatingEvent) -> None: ): self._show_nominal() + @callback + def _anchor_central_echo(self, armed_at: float, written: "asyncio.Future[float] | None") -> None: + """Start the echo window at the time the command was written (MyHOMEGatewayHandler.send). + + The future resolves to the monotonic time taken just before the frame went on the + socket; it is cancelled when the frame is never written, and then no echo comes. + Without a future (a gateway double, a delegated send) the window starts now. Only the + command armed at ``armed_at`` is anchored: a later command, even the same frame, keeps + waiting for its own write. + """ + echo = self._central_echo + if echo is None or echo[2] != armed_at: + return + frame = echo[0] + if written is None: + self._central_echo = (frame, time.monotonic() + _CENTRAL_ECHO_WINDOW, echo[2]) + return + if written.cancelled() or written.exception() is not None: + self._central_echo = None + return + self._central_echo = (frame, float(written.result()) + _CENTRAL_ECHO_WINDOW, echo[2]) + @callback def _is_own_central_echo(self, message: OWNHeatingEvent) -> bool: """Whether ``message`` is the gateway handing back Home Assistant's own central command. @@ -1663,8 +1760,11 @@ def _is_own_central_echo(self, message: OWNHeatingEvent) -> bool: echo = self._central_echo if echo is None: return False - frame, deadline = echo - if time.monotonic() > deadline: + frame, deadline, armed_at = echo + now = time.monotonic() + if (deadline is None and now - armed_at > _CENTRAL_ECHO_PENDING_LIMIT) or ( + deadline is not None and now > deadline + ): self._central_echo = None return False if str(message) != frame: @@ -1686,6 +1786,63 @@ def _is_central_addressed_mode_frame(self, message: OWNHeatingEvent) -> bool: return False return is_central_thermo_where(message) + def _is_generic_central_zone_frame(self, message: OWNHeatingEvent) -> bool: + """Whether a ``*4*310*#Z##`` / ``*4*311*#Z##`` frame reaches a zone of the central unit. + + WHO 4 p. 9 lists 310 / 311 (manual / automatic generic) both as the command that sets a + zone of the central and as a monitor frame. The 3550 reports its zones' state with the + season in the code: ``*4*111*#1##`` for a zone in automatic heating, also as its answer to + ``*4*311*#1##`` (MH200N, #466), ``*4*110*#3##`` (F454, #697), ``*4*210*#4##`` (F461, 0 generic + frames in 525, 2026-11-08 / 11-09). An F455 and an MH201 put a generic ``*4*311*#Z##`` on + their event session right before the zone's real status (``*4*210*#4##`` 59 ms later on the + F455, ``*4*110*#4##`` on the MH201): live F455, 2026-11-09 07:45:02, at every quarter-hour + push of the 3550, where the F461 shows ``*#4*#4*14*0240*2##`` instead. Taken as the zone's + state it turned the zone AUTO and back every 15 minutes. So the generic frame is held + (``_GENERIC_ZONE_FRAME_HOLD``): a seasonal status of the zone that follows replaces it, and + only a generic frame with none after it is taken as the zone's state. + """ + if self._central or message.message_type != MESSAGE_TYPE_MODE: + return False + if not is_central_thermo_where(message): + return False + raw = str(message) # a MESSAGE_TYPE_MODE frame is *4*WHAT*WHERE## + where = raw.split("*")[3] if raw.count("*") >= 3 else "" + if where.removeprefix("#").split("#", 1)[0] == "0": + return False + return raw.split("*")[2] in ("310", "311") + + def _hold_generic_zone_frame(self, message: OWNHeatingEvent) -> bool: + """Hold a generic ``#Z`` frame until the zone's seasonal status shows it was a prelude. + + Returns False when the entity is not live yet (nothing to hold it on); the frame is + then handled at once, as before. + """ + if self.hass is None or self.platform is None or not self.entity_id: + return False + self._cancel_generic_zone_frame() + LOGGER.debug( + "%s %s: generic mode frame for %s via the central unit; waiting for its seasonal status", + self._gateway_handler.log_id, + message, + self._display_name, + ) + + @callback + def _apply(_now: Any) -> None: + self._generic_zone_frame_unsub = None + self._generic_zone_frame_release = message + self.handle_event(message) + + self._generic_zone_frame_unsub = async_call_later(self.hass, _GENERIC_ZONE_FRAME_HOLD, _apply) + return True + + @callback + def _cancel_generic_zone_frame(self) -> None: + """Drop a held generic frame: the zone's own status came, or the entity goes away.""" + if self._generic_zone_frame_unsub is not None: + self._generic_zone_frame_unsub() + self._generic_zone_frame_unsub = None + def handle_event(self, message: OWNHeatingEvent) -> None: """Handle an event message.""" if self._central and self._is_own_central_echo(message): @@ -1705,6 +1862,13 @@ def handle_event(self, message: OWNHeatingEvent) -> None: self._display_name, ) return + if message is self._generic_zone_frame_release: + self._generic_zone_frame_release = None + elif self._is_generic_central_zone_frame(message): + if self._hold_generic_zone_frame(message): + return + elif message.message_type in (MESSAGE_TYPE_MODE, MESSAGE_TYPE_MODE_TARGET, MESSAGE_TYPE_SEASON): + self._cancel_generic_zone_frame() if message.message_type == MESSAGE_TYPE_MAIN_TEMPERATURE: LOGGER.debug( "%s %s", @@ -1800,8 +1964,6 @@ def handle_event(self, message: OWNHeatingEvent) -> None: # same mode (live F455 + F461, 2026-11-08), and a stale restored AUTO is replaced. # An AUTO the zone itself reported (a MyHomeServer1 schedule, Dimension 7 context # automatic) is not cancelled: the season frame that follows it is its season. - if self._central: - self._reset_central_status_flags() standalone_mode = self._standalone and not ( self._attr_hvac_mode == HVACMode.AUTO and not self._hvac_mode_restored ) @@ -1839,8 +2001,6 @@ def handle_event(self, message: OWNHeatingEvent) -> None: ) self._follow_season_change(previous_season) elif message.message_type == MESSAGE_TYPE_MODE: - if self._central: - self._reset_central_status_flags() self._hvac_mode_restored = False # the zone itself says what it runs in prev_mode = self._attr_hvac_mode previous_season = self._season @@ -1924,7 +2084,6 @@ def handle_event(self, message: OWNHeatingEvent) -> None: ) elif message.message_type == MESSAGE_TYPE_MODE_TARGET: if self._central: - self._reset_central_status_flags() self._central_operation = None self._hvac_mode_restored = False prev_mode = self._attr_hvac_mode @@ -2128,6 +2287,10 @@ def handle_event(self, message: OWNHeatingEvent) -> None: self._gateway_handler.log_id, getattr(message, "human_readable_log", str(message)), ) + if diag_what in (20, 21): + self._begin_central_flag_cycle() + elif diag_what in _CENTRAL_FLAG_WHATS: + self._central_flags_seen.add(diag_what) if diag_what == 20: self._remote_control = "disabled" self._report_fault(FaultKind.CENTRAL_REMOTE_CONTROL_DISABLED, code="20") diff --git a/tests/fixtures/traces/central_flags_f455_poll_2026_11_09/frames.json b/tests/fixtures/traces/central_flags_f455_poll_2026_11_09/frames.json new file mode 100644 index 000000000..6dae2fd00 --- /dev/null +++ b/tests/fixtures/traces/central_flags_f455_poll_2026_11_09/frames.json @@ -0,0 +1,250 @@ +{ + "quarter_0745": [ + [ + "07:45:00.940", + "f455", + "*#4*4*#14*0240*2##" + ], + [ + "07:45:00.943", + "f455", + "*#4*4*0*0256##" + ], + [ + "07:45:01.198", + "f455", + "*#4*4*0*0256##" + ], + [ + "07:45:01.519", + "f455", + "*#4*4*12*0240*3##" + ], + [ + "07:45:01.520", + "f455", + "*4*0*4##" + ], + [ + "07:45:01.819", + "f455", + "*#4*4*14*0240*3##" + ], + [ + "07:45:02.138", + "f455", + "*#4*4*13*00##" + ], + [ + "07:45:02.199", + "f455", + "*4*311*#4##" + ], + [ + "07:45:02.258", + "f455", + "*4*210*#4##" + ], + [ + "07:45:00.940", + "f461", + "*#4*4*#14*0240*2##" + ], + [ + "07:45:01.146", + "f461", + "*#4*4*0*0256##" + ], + [ + "07:45:01.432", + "f461", + "*#4*4*0*0256##" + ], + [ + "07:45:01.743", + "f461", + "*#4*4*12*0240*3##" + ], + [ + "07:45:01.746", + "f461", + "*4*0*4##" + ], + [ + "07:45:02.065", + "f461", + "*#4*4*14*0240*3##" + ], + [ + "07:45:02.149", + "f461", + "*#4*4*13*00##" + ], + [ + "07:45:02.268", + "f461", + "*4*210*#4##" + ], + [ + "07:45:02.270", + "f461", + "*#4*#4*14*0240*2##" + ] + ], + "central_reply_via_f461_0747": [ + [ + "07:47:42.219", + "f455", + "*4*210#0240*#0##" + ], + [ + "07:47:42.220", + "f455", + "*4*21*#0##" + ], + [ + "07:47:42.220", + "f455", + "*4*31*#0##" + ], + [ + "07:47:42.219", + "f461", + "*4*210#0240*#0##" + ], + [ + "07:47:42.223", + "f461", + "*4*21*#0##" + ], + [ + "07:47:42.223", + "f461", + "*4*31*#0##" + ] + ], + "central_reply_via_f455_0748": [ + [ + "07:48:03.562", + "f455", + "*4*210#0240*#0##" + ], + [ + "07:48:03.562", + "f455", + "*4*21*#0##" + ], + [ + "07:48:03.562", + "f455", + "*4*31*#0##" + ], + [ + "07:48:03.569", + "f461", + "*4*210#0240*#0##" + ], + [ + "07:48:03.571", + "f461", + "*4*21*#0##" + ], + [ + "07:48:03.571", + "f461", + "*4*31*#0##" + ] + ], + "quarter_0800": [ + [ + "08:00:00.897", + "f455", + "*#4*4*#14*0240*2##" + ], + [ + "08:00:00.922", + "f455", + "*#4*4*0*0256##" + ], + [ + "08:00:01.534", + "f455", + "*#4*4*0*0256##" + ], + [ + "08:00:01.832", + "f455", + "*#4*4*12*0240*3##" + ], + [ + "08:00:01.832", + "f455", + "*4*0*4##" + ], + [ + "08:00:02.131", + "f455", + "*#4*4*14*0240*3##" + ], + [ + "08:00:02.432", + "f455", + "*#4*4*13*00##" + ], + [ + "08:00:02.502", + "f455", + "*4*311*#4##" + ], + [ + "08:00:02.561", + "f455", + "*4*210*#4##" + ], + [ + "08:00:01.102", + "f461", + "*#4*4*#14*0240*2##" + ], + [ + "08:00:01.105", + "f461", + "*#4*4*0*0256##" + ], + [ + "08:00:01.740", + "f461", + "*#4*4*0*0256##" + ], + [ + "08:00:02.126", + "f461", + "*#4*4*12*0240*3##" + ], + [ + "08:00:02.129", + "f461", + "*4*0*4##" + ], + [ + "08:00:02.364", + "f461", + "*#4*4*14*0240*3##" + ], + [ + "08:00:02.441", + "f461", + "*#4*4*13*00##" + ], + [ + "08:00:02.569", + "f461", + "*4*210*#4##" + ], + [ + "08:00:02.572", + "f461", + "*#4*#4*14*0240*2##" + ] + ] +} diff --git a/tests/test_climate_central_discovery.py b/tests/test_climate_central_discovery.py index 15cbf88b9..773abe1ca 100644 --- a/tests/test_climate_central_discovery.py +++ b/tests/test_climate_central_discovery.py @@ -500,11 +500,13 @@ async def test_3550_central_unit_diagnostic_attributes_and_state_restore(hass: H ) cu.entity_id = "climate.central_unit_0" cu.async_write_ha_state = MagicMock() + cu.platform = MagicMock() # live: the flag cycle runs on its timer # 1. Replay the exact MH200N + 3550 *#4*#0## status burst from Discussion #819: # *4*1101*#0##, *4*20*#0##, *4*30*#0##, *4*31*#0##, *4*22*#0## for raw in ("*4*1101*#0##", "*4*20*#0##", "*4*30*#0##", "*4*31*#0##", "*4*22*#0##"): cu.handle_event(OWNHeatingEvent(raw)) + await _settle_burst(hass) attrs = cu.extra_state_attributes assert cu.hvac_mode == HVACMode.HEAT @@ -528,6 +530,9 @@ async def test_3550_central_unit_diagnostic_attributes_and_state_restore(hass: H # while one probe is in antifreeze (*4*23*#0##) and one is in manual (*4*24*#0##) for raw in ("*4*1101*#0##", "*4*21*#0##", "*4*23*#0##", "*4*24*#0##"): cu.handle_event(OWNHeatingEvent(raw)) + # Until the burst settles, the flags of the previous burst stay (no False / True flip). + assert cu.extra_state_attributes["battery_fault"] is True + await _settle_burst(hass) attrs2 = cu.extra_state_attributes assert attrs2["remote_control"] == "enabled" @@ -903,6 +908,7 @@ async def test_3550_central_unit_burst_and_diagnostic_edge_cases(hass: HomeAssis ) cu.entity_id = "climate.central_unit_0" cu.async_write_ha_state = MagicMock() + cu.platform = MagicMock() # live: the flag cycle runs on its timer # 1. Burst with all 7 diagnostic flags simultaneously active: burst_all = ( @@ -916,6 +922,7 @@ async def test_3550_central_unit_burst_and_diagnostic_edge_cases(hass: HomeAssis ) for raw in burst_all: cu.handle_event(OWNHeatingEvent(raw)) + await _settle_burst(hass) attrs = cu.extra_state_attributes assert attrs["remote_control"] == "disabled" @@ -925,24 +932,36 @@ async def test_3550_central_unit_burst_and_diagnostic_edge_cases(hass: HomeAssis assert attrs["failure_detected"] is True assert attrs["battery_fault"] is True - # 2. Next burst starts with MESSAGE_TYPE_SEASON (*4*1*#0##): + # 2. A season frame alone (another client's *4*1*#0##, or the 3550's mode before its + # remote-control frame) does not touch the flags: only a settled 20 / 21 burst clears them. cu.handle_event(OWNHeatingEvent("*4*1*#0##")) attrs_season = cu.extra_state_attributes assert attrs_season["remote_control"] == "disabled" - assert attrs_season["at_least_one_probe_off"] is False - assert attrs_season["at_least_one_probe_antifreeze"] is False - assert attrs_season["at_least_one_probe_manual"] is False - assert attrs_season["failure_detected"] is False - assert attrs_season["battery_fault"] is False + assert attrs_season["at_least_one_probe_off"] is True + assert attrs_season["failure_detected"] is True + assert attrs_season["battery_fault"] is True - # *4*21*#0## explicitly enables remote control + # *4*21*#0## explicitly enables remote control; no flag follows it, so the burst clears them cu.handle_event(OWNHeatingEvent("*4*21*#0##")) assert cu.extra_state_attributes["remote_control"] == "enabled" - - # 3. Burst starting with MESSAGE_TYPE_MODE_TARGET (*4*1101#10*#0## - holiday mode): - cu.handle_event(OWNHeatingEvent("*4*31*#0##")) + assert cu.extra_state_attributes["battery_fault"] is True + await _settle_burst(hass) + attrs_clear = cu.extra_state_attributes + assert attrs_clear["at_least_one_probe_off"] is False + assert attrs_clear["at_least_one_probe_antifreeze"] is False + assert attrs_clear["at_least_one_probe_manual"] is False + assert attrs_clear["failure_detected"] is False + assert attrs_clear["battery_fault"] is False + + # 3. Holiday mode burst (*4*1101#10*#0##) with the battery flag after the 20 frame + for raw in ("*4*1101#10*#0##", "*4*20*#0##", "*4*31*#0##"): + cu.handle_event(OWNHeatingEvent(raw)) + assert cu.extra_state_attributes["battery_fault"] is True + await _settle_burst(hass) assert cu.extra_state_attributes["battery_fault"] is True cu.handle_event(OWNHeatingEvent("*4*1101#10*#0##")) + cu.handle_event(OWNHeatingEvent("*4*20*#0##")) + await _settle_burst(hass) assert cu.extra_state_attributes["battery_fault"] is False # 4. Unknown WHAT code (e.g. WHAT 99): ignored without error @@ -1034,3 +1053,12 @@ def __init__(self, where: str | None) -> None: assert str(gateway.send_status_request.call_args[0][0]) == "*#4*1##" +async def _settle_burst(hass: HomeAssistant) -> None: + """Let the central unit's flag cycle (``_CENTRAL_FLAGS_SETTLE``) run out.""" + from datetime import timedelta + + from homeassistant.util import dt as dt_util + from pytest_homeassistant_custom_component.common import async_fire_time_changed + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2)) + await hass.async_block_till_done() diff --git a/tests/test_climate_central_flags_f455_poll_2026_11_09.py b/tests/test_climate_central_flags_f455_poll_2026_11_09.py new file mode 100644 index 000000000..e0902fc25 --- /dev/null +++ b/tests/test_climate_central_flags_f455_poll_2026_11_09.py @@ -0,0 +1,427 @@ +"""3550 battery flag flip, F455 zone AUTO flip, central echo window: live F461 + F455 replay, 2026-11-09. + +Same plant as tests/test_climate_mode_state_2026_11_08.py: a BTicino 3550 99-zone central unit +(#0) without a battery, zone 4 a fan coil zone of the 3550 in manual cooling, an F461 and an +F455 on the same bus, one Home Assistant entity per gateway. + +* The 3550 answers ``*#4*#0##`` with ``*4*210#0240*#0##``, ``*4*21*#0##``, ``*4*31*#0##`` within + 4 ms. Both central entities wrote ``battery_fault`` False on the mode frame and True on the + 31, 1.5 ms later, on every reply (Home Assistant history and state_changed events). +* At every quarter-hour push of the 3550 the F455 puts ``*4*311*#4##`` on its event session, + 59 ms before ``*4*210*#4##``; the F461 never shows a 311 and shows ``*#4*#4*14*0240*2##`` + instead. The F455 entity of zone 4 went AUTO and back to COOL every 15 minutes. +* The central echo window starts when the command is written, not when it is queued. + +Frames: tests/fixtures/traces/central_flags_f455_poll_2026_11_09/frames.json +([time UTC, gateway monitor, frame] per window). +""" + +import asyncio +import json +from collections.abc import Iterator +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from homeassistant.components.climate.const import HVACMode +from OWNd.message import OWNEvent + +from custom_components.myhome import climate as climate_module +from custom_components.myhome.where_grammar import is_central_unit_report + +FRAMES = json.loads( + ( + Path(__file__).parent + / "fixtures" + / "traces" + / "central_flags_f455_poll_2026_11_09" + / "frames.json" + ).read_text() +) + + +def _rx(window: str, gateway: str) -> list[str]: + return [raw for _t, gw, raw in FRAMES[window] if gw == gateway] + + +@pytest.fixture(autouse=True) +def _entity_base_init() -> Iterator[None]: + with patch("custom_components.myhome.myhome_device.Entity.__init__", return_value=None): + yield + + +@pytest.fixture +def timers() -> Iterator[list[Any]]: + """Capture the climate timers (flag cycle, held generic frame) instead of scheduling them. + + A cancelled timer leaves the list, so ``timers`` holds only the pending ones. + """ + pending: list[Any] = [] + + def _later(_hass: Any, _delay: float, action: Any) -> Any: + pending.append(action) + + def _cancel() -> None: + if action in pending: + pending.remove(action) + + return _cancel + + with patch("custom_components.myhome.climate.async_call_later", side_effect=_later): + yield pending + + +def _gateway() -> MagicMock: + gw = MagicMock() + gw.mac = "00:03:50:00:00:01" + gw.unique_id = gw.mac + gw.log_id = "[Test Gateway]" + gw.send = AsyncMock() + gw.send_status_request = AsyncMock() + gw.has_central_unit = True + return gw + + +def _climate( + gateway: MagicMock, where: str, *, central: bool = False, fan: bool = False +) -> tuple[Any, list[dict[str, Any]]]: + from custom_components.myhome.climate import MyHOMEClimate + + hass = MagicMock() + hass.data = {} + entity = MyHOMEClimate( + hass=hass, + name="Climate", + device_id=f"4-{where}", + who="4", + where=where, + heating=True, + cooling=True, + fan=fan, + standalone=False, + central=central, + manufacturer="BTicino", + model="Thermostat", + gateway=gateway, + ) + entity.platform = MagicMock() + entity.entity_id = "climate.test" + writes: list[dict[str, Any]] = [] + + def _write(*_args: Any, **_kwargs: Any) -> None: + writes.append({"hvac_mode": entity.hvac_mode, **entity.extra_state_attributes}) + + setattr(entity, "async_schedule_update_ha_state", _write) + setattr(entity, "async_write_ha_state", _write) + return entity, writes + + +def _replay(entity: Any, frames: list[str], *, twice: bool = False) -> None: + for raw in frames: + message = OWNEvent.parse(raw) + entity.handle_event(message) + if twice: # the climate and the sensor platform both route WHO 4 frames to the entity + entity.handle_event(OWNEvent.parse(raw)) + + +def _central_without_battery(timers: list[Any]) -> tuple[Any, list[dict[str, Any]]]: + central, writes = _climate(_gateway(), "#0", central=True) + _replay(central, _rx("central_reply_via_f461_0747", "f461")) + timers.pop()(None) + assert central.extra_state_attributes["battery_fault"] is True + writes.clear() + return central, writes + + +# A: the 3550's battery flag is not written False and True again on each reply. + + +@pytest.mark.parametrize( + ("window", "gateway"), + [ + ("central_reply_via_f461_0747", "f461"), + ("central_reply_via_f461_0747", "f455"), + ("central_reply_via_f455_0748", "f461"), + ("central_reply_via_f455_0748", "f455"), + ], +) +def test_battery_flag_stays_set_through_each_central_reply( + timers: list[Any], window: str, gateway: str +) -> None: + central, writes = _central_without_battery(timers) + frames = _rx(window, gateway) + assert frames == ["*4*210#0240*#0##", "*4*21*#0##", "*4*31*#0##"] + with patch.object(central, "_clear_fault") as clear_fault: + _replay(central, frames, twice=True) + assert len(timers) == 1 # the second copy of 21 does not restart the cycle + timers.pop()(None) + assert writes, "the reply is written" + assert all(w["battery_fault"] is True for w in writes) + assert central.extra_state_attributes["battery_fault"] is True + assert central.hvac_mode == HVACMode.COOL + assert all( + call.args[0] != climate_module.FaultKind.CENTRAL_BATTERY_FAULT + for call in clear_fault.call_args_list + ) + + +def test_battery_flag_clears_once_a_burst_settles_without_it(timers: list[Any]) -> None: + central, writes = _central_without_battery(timers) + _replay(central, ["*4*210#0240*#0##", "*4*21*#0##"]) + assert all(w["battery_fault"] is True for w in writes) # not cleared mid-burst + timers.pop()(None) + assert central.extra_state_attributes["battery_fault"] is False + assert writes[-1]["battery_fault"] is False + + +def test_a_mode_frame_alone_does_not_touch_the_flags(timers: list[Any]) -> None: + # Another client's season command on the bus, or the F455 handing one back, has no 20 / 21. + central, writes = _central_without_battery(timers) + _replay(central, ["*4*0*#0##", "*4*202*#0##", "*4*303*#0##"]) + assert timers == [] + assert all(w["battery_fault"] is True for w in writes) + + +def test_a_late_second_copy_of_the_remote_control_frame_keeps_the_flag(timers: list[Any]) -> None: + central, _writes = _central_without_battery(timers) + _replay(central, ["*4*210#0240*#0##", "*4*21*#0##", "*4*31*#0##", "*4*21*#0##"]) + timers.pop()(None) + assert central.extra_state_attributes["battery_fault"] is True + + +def test_flag_cycle_timer_is_cancelled_on_removal(timers: list[Any]) -> None: + central, _writes = _central_without_battery(timers) + unsub = MagicMock() + with patch("custom_components.myhome.climate.async_call_later", return_value=unsub): + _replay(central, ["*4*21*#0##"]) + central._cancel_central_flag_cycle() + unsub.assert_called_once() + central._cancel_central_flag_cycle() # idempotent + unsub.assert_called_once() + + +# B: the F455's generic *4*311*#4## is not the zone's state when its seasonal status follows. + + +@pytest.mark.parametrize("window", ["quarter_0745", "quarter_0800"]) +def test_f455_quarter_hour_push_does_not_turn_zone_4_auto(timers: list[Any], window: str) -> None: + zone4, writes = _climate(_gateway(), "4", fan=True) + zone4._attr_hvac_mode = HVACMode.COOL + frames = _rx(window, "f455") + assert frames.index("*4*311*#4##") == frames.index("*4*210*#4##") - 1 + _replay(zone4, frames, twice=True) + assert timers == [] # the held 311 was dropped by *4*210*#4## + assert zone4.hvac_mode == HVACMode.COOL + assert all(w["hvac_mode"] == HVACMode.COOL for w in writes) + assert zone4.target_temperature == 24.0 + + +def test_f461_quarter_hour_push_was_already_right(timers: list[Any]) -> None: + zone4, writes = _climate(_gateway(), "4", fan=True) + zone4._attr_hvac_mode = HVACMode.COOL + frames = _rx("quarter_0745", "f461") + assert not any(raw.startswith("*4*311*") for raw in frames) + _replay(zone4, frames) + assert timers == [] + assert all(w["hvac_mode"] == HVACMode.COOL for w in writes) + + +@pytest.mark.parametrize( + ("raw", "mode"), + [ + ("*4*210*#4##", HVACMode.COOL), + ("*4*110*#4##", HVACMode.HEAT), + ("*4*111*#4##", HVACMode.HEAT), # unchanged (#819): OWNd maps 111 to heat + ("*4*203*#4##", HVACMode.OFF), + ], +) +def test_seasonal_zone_statuses_of_the_central_still_set_the_mode( + timers: list[Any], raw: str, mode: HVACMode +) -> None: + zone4, _writes = _climate(_gateway(), "4", fan=True) + zone4._attr_hvac_mode = HVACMode.AUTO + _replay(zone4, [raw]) + assert zone4.hvac_mode == mode + assert timers == [] + + +@pytest.mark.parametrize("seasonal", ["*4*210*#4##", "*4*203*#4##", "*4*202*#4##", "*4*0*4##"]) +def test_a_seasonal_status_after_the_generic_frame_replaces_it( + timers: list[Any], seasonal: str +) -> None: + zone4, writes = _climate(_gateway(), "4", fan=True) + zone4._attr_hvac_mode = HVACMode.COOL + _replay(zone4, ["*4*311*#4##"]) + assert len(timers) == 1 and writes == [] # held, nothing written + _replay(zone4, [seasonal]) + assert timers == [] + assert all(w["hvac_mode"] != HVACMode.AUTO for w in writes) + + +def test_a_generic_frame_with_no_seasonal_status_is_the_zone_state(timers: list[Any]) -> None: + # WHO 4 p. 9: 311 is also a monitor frame; a central that reports only it still shows AUTO. + zone4, writes = _climate(_gateway(), "4", fan=True) + zone4._attr_hvac_mode = HVACMode.COOL + _replay(zone4, ["*4*311*#4##"]) + assert zone4.hvac_mode == HVACMode.COOL + timers.pop()(None) + assert zone4.hvac_mode == HVACMode.AUTO + assert writes[-1]["hvac_mode"] == HVACMode.AUTO + + +def test_a_newer_generic_frame_replaces_the_held_one(timers: list[Any]) -> None: + zone4, _writes = _climate(_gateway(), "4", fan=True) + zone4._attr_hvac_mode = HVACMode.COOL + _replay(zone4, ["*4*311*#4##", "*4*311*#4##"]) + assert len(timers) == 1 + zone4._cancel_generic_zone_frame() # entity removed + assert timers == [] + zone4._cancel_generic_zone_frame() # idempotent + assert zone4.hvac_mode == HVACMode.COOL + + +def test_a_generic_frame_before_the_entity_is_live_is_applied_at_once() -> None: + zone4, _writes = _climate(_gateway(), "4", fan=True) + zone4.platform = None + zone4._attr_hvac_mode = HVACMode.COOL + _replay(zone4, ["*4*311*#4##"]) + assert zone4.hvac_mode == HVACMode.AUTO + + +@pytest.mark.parametrize("raw", ["*4*311*#4##", "*4*310*#4##"]) +def test_generic_zone_frames_do_not_mark_a_central(raw: str) -> None: + assert is_central_unit_report(OWNEvent.parse(raw)) is False + + +def test_the_central_itself_still_takes_its_generic_frame() -> None: + # *4*311*#0## on the central entity is handled by the central path (echo filter, #880). + central, _writes = _climate(_gateway(), "#0", central=True) + central._attr_hvac_mode = HVACMode.OFF + assert central._is_generic_central_zone_frame(OWNEvent.parse("*4*311*#0##")) is False + + +# C: the central echo window starts when the command is written. + + +async def _set_central_mode(central: Any, future: Any, hvac_mode: HVACMode = HVACMode.OFF) -> None: + central._gateway_handler.send = AsyncMock(return_value=future) + await central.async_set_hvac_mode(hvac_mode) + + +async def test_echo_while_the_command_is_still_queued_is_swallowed() -> None: + central, _writes = _climate(_gateway(), "#0", central=True) + central._attr_hvac_mode = HVACMode.COOL + written: asyncio.Future[float] = asyncio.get_running_loop().create_future() + await _set_central_mode(central, written) + start = climate_module.time.monotonic() + with patch("custom_components.myhome.climate.time.monotonic", return_value=start + 5.0): + assert central._is_own_central_echo(OWNEvent.parse("*4*303*#0##")) is True + + +async def test_window_is_anchored_on_the_write_time_of_a_busy_pool() -> None: + central, _writes = _climate(_gateway(), "#0", central=True) + central._attr_hvac_mode = HVACMode.COOL + written: asyncio.Future[float] = asyncio.get_running_loop().create_future() + await _set_central_mode(central, written) + written_at = climate_module.time.monotonic() + 5.0 # the pool wrote it 5 s after the call + written.set_result(written_at) + await asyncio.sleep(0) + with patch("custom_components.myhome.climate.time.monotonic", return_value=written_at + 0.05): + assert central._is_own_central_echo(OWNEvent.parse("*4*303*#0##")) is True + with patch("custom_components.myhome.climate.time.monotonic", return_value=written_at + 0.15): + assert central._is_own_central_echo(OWNEvent.parse("*4*303*#0##")) is False + + +async def test_identical_frame_after_the_anchored_window_is_a_report() -> None: + central, _writes = _climate(_gateway(), "#0", central=True) + central._attr_hvac_mode = HVACMode.COOL + written: asyncio.Future[float] = asyncio.get_running_loop().create_future() + await _set_central_mode(central, written) + written_at = climate_module.time.monotonic() + written.set_result(written_at) + await asyncio.sleep(0) + with patch("custom_components.myhome.climate.time.monotonic", return_value=written_at + 0.121): + assert central._is_own_central_echo(OWNEvent.parse("*4*303*#0##")) is False + assert central._central_echo is None + + +async def test_a_command_never_written_disarms_the_echo() -> None: + central, _writes = _climate(_gateway(), "#0", central=True) + central._attr_hvac_mode = HVACMode.COOL + written: asyncio.Future[float] = asyncio.get_running_loop().create_future() + await _set_central_mode(central, written) + written.cancel() + await asyncio.sleep(0) + assert central._central_echo is None + assert central._is_own_central_echo(OWNEvent.parse("*4*303*#0##")) is False + + +async def test_a_failed_write_disarms_the_echo() -> None: + central, _writes = _climate(_gateway(), "#0", central=True) + central._attr_hvac_mode = HVACMode.COOL + written: asyncio.Future[float] = asyncio.get_running_loop().create_future() + await _set_central_mode(central, written) + written.set_exception(OSError("socket closed")) + await asyncio.sleep(0) + assert central._central_echo is None + + +async def test_a_pending_echo_expires_after_the_pool_gives_up() -> None: + central, _writes = _climate(_gateway(), "#0", central=True) + central._attr_hvac_mode = HVACMode.COOL + written: asyncio.Future[float] = asyncio.get_running_loop().create_future() + await _set_central_mode(central, written) + later = climate_module.time.monotonic() + climate_module._CENTRAL_ECHO_PENDING_LIMIT + 1 + with patch("custom_components.myhome.climate.time.monotonic", return_value=later): + assert central._is_own_central_echo(OWNEvent.parse("*4*303*#0##")) is False + written.cancel() + + +async def test_a_newer_command_is_not_reanchored_by_an_older_future() -> None: + central, _writes = _climate(_gateway(), "#0", central=True) + central._attr_hvac_mode = HVACMode.COOL + first: asyncio.Future[float] = asyncio.get_running_loop().create_future() + await _set_central_mode(central, first, HVACMode.OFF) + second: asyncio.Future[float] = asyncio.get_running_loop().create_future() + await _set_central_mode(central, second, HVACMode.AUTO) + first.set_result(climate_module.time.monotonic() - 10) # the OFF frame's late result + await asyncio.sleep(0) + assert central._central_echo is not None + assert central._central_echo[0] == "*4*311*#0##" + assert central._central_echo[1] is None # still waiting for its own write + second.cancel() + + +async def test_without_a_future_the_window_starts_at_the_call() -> None: + central, _writes = _climate(_gateway(), "#0", central=True) + central._attr_hvac_mode = HVACMode.COOL + await _set_central_mode(central, None) + echo = central._central_echo + assert echo is not None and echo[1] is not None + assert echo[1] - climate_module.time.monotonic() <= climate_module._CENTRAL_ECHO_WINDOW + + +async def test_the_same_command_twice_is_anchored_on_its_own_write() -> None: + central, _writes = _climate(_gateway(), "#0", central=True) + central._attr_hvac_mode = HVACMode.COOL + first: asyncio.Future[float] = asyncio.get_running_loop().create_future() + second: asyncio.Future[float] = asyncio.get_running_loop().create_future() + with patch("custom_components.myhome.climate.time.monotonic", return_value=100.0): + await _set_central_mode(central, first, HVACMode.OFF) + with patch("custom_components.myhome.climate.time.monotonic", return_value=101.0): + await _set_central_mode(central, second, HVACMode.OFF) + first.set_result(100.0) # the first OFF's write: its window ended long ago + await asyncio.sleep(0) + assert central._central_echo is not None + assert central._central_echo[1] is None # the second OFF still waits for its own write + second.set_result(106.0) + await asyncio.sleep(0) + with patch("custom_components.myhome.climate.time.monotonic", return_value=106.05): + assert central._is_own_central_echo(OWNEvent.parse("*4*303*#0##")) is True + + +def test_a_zone_entity_leaves_the_central_s_own_generic_frame_to_the_central_path() -> None: + zone4, _writes = _climate(_gateway(), "4", fan=True) + assert zone4._is_generic_central_zone_frame(OWNEvent.parse("*4*311*#0##")) is False + assert zone4._is_generic_central_zone_frame(OWNEvent.parse("*4*311*#4##")) is True diff --git a/tests/test_climate_mode_state_2026_11_08.py b/tests/test_climate_mode_state_2026_11_08.py index 3b913a87e..128cbdd34 100644 --- a/tests/test_climate_mode_state_2026_11_08.py +++ b/tests/test_climate_mode_state_2026_11_08.py @@ -111,8 +111,10 @@ def test_zone_of_the_central_still_takes_its_state_from_the_central() -> None: zone4._attr_hvac_mode = HVACMode.OFF _replay(zone4, _rx("f461_zone4_auto_in_cooling")) assert zone4.hvac_mode == HVACMode.COOL + # The generic *4*311*#4## is not the zone's state (2026-11-09: F455 / MH201 synthetic frame + # before the 3550's seasonal status); the seasonal *4*210*#4## / *4*203*#4## are. _replay(zone4, ["*4*311*#4##"]) - assert zone4.hvac_mode == HVACMode.AUTO + assert zone4.hvac_mode == HVACMode.COOL _replay(zone4, ["*4*203*#4##"]) assert zone4.hvac_mode == HVACMode.OFF @@ -461,7 +463,8 @@ async def test_identical_central_answer_after_the_echo_window_is_a_report() -> N later = climate_module.time.monotonic() + 0.121 # the 3550's first answer came after 121 ms with patch("custom_components.myhome.climate.time.monotonic", return_value=later): _replay(central, ["*4*303*#0##"]) - assert central.extra_state_attributes["battery_fault"] is False # its burst starts over + # a mode frame alone does not clear the 3550's flags: only a settled 20 / 21 burst does (2026-11-09) + assert central.extra_state_attributes["battery_fault"] is True assert central.hvac_mode == HVACMode.OFF diff --git a/tests/test_climate_season_frame.py b/tests/test_climate_season_frame.py index 894769073..de0de37fe 100644 --- a/tests/test_climate_season_frame.py +++ b/tests/test_climate_season_frame.py @@ -75,6 +75,9 @@ def _climate(mock_gateway, central=False, where="01"): def test_auto_zone_keeps_auto_on_season_frame(mock_gateway, mock_entity_base_init): climate = _climate(mock_gateway) + # AUTO as set by Home Assistant or restored: the generic *4*311*#01## is not a zone state + # (2026-11-09, F455 / MH201 synthetic frame before the seasonal status). + climate._attr_hvac_mode = HVACMode.AUTO climate.handle_event(OWNEvent.parse("*4*311*#01##")) assert climate.hvac_mode == HVACMode.AUTO climate.handle_event(_season_event("*4*1*01##", SEASON_HEATING, "01"))