From 460e0ef1475eb48253744d6d92d5f34efd13f408 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sun, 8 Nov 2026 18:17:51 +0200 Subject: [PATCH 1/6] fix(climate): take zone and central mode from status frames only Live F461 2.0.11 + F455 on one bus with a BTicino 3550 central unit (#0), zone 2 standalone and zone 4 a zone of the 3550 (2026-11-08): every set_hvac_mode returned HTTP 200, but the mode shown was wrong. - Standalone zones ignore mode / season frames in the #Z form (zone via the central unit, WHO 4 p. 6, 9, 65): the F455 emits *4*311*#2## after each status report of zone 2, which showed the heating zone as AUTO. Zones of a central unit keep every #Z state the 3550 reports. - A standalone zone takes HEAT / COOL from its *4*1*Z## / *4*0*Z## answer to Home Assistant's own mode 1 / 2 setpoint write (*#4*2*#14*0265*2## -> *4*0*2##); unsolicited season frames stay season-only. - Central 102 / 202 are antifreeze / thermal protection in their season (WHO 4 p. 5, 64), shown as HEAT / COOL with a central_operation attribute, not OFF. HEAT / COOL on #0 only change the season, so the mode shown waits for the 3550's answer instead of being set optimistically. - The central drops the gateway's echo of Home Assistant's own command (the F455 returns *4*0*#0## 1 ms after sending it), which reset the 3550's battery flag and turned the action OFF -> IDLE. The same echo object can arrive twice (climate and sensor platforms), so it is recognised by identity. - HEAT in conditioning / COOL in heating on a zone of a central unit with a reported season, and AUTO on a standalone zone of a plant with a central unit, raise ServiceValidationError instead of a silent no-op. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01DN9oUd4ipuDSAE8mb2BjSq --- custom_components/myhome/climate.py | 166 +++++++-- custom_components/myhome/strings.json | 6 + custom_components/myhome/translations/en.json | 6 + custom_components/myhome/translations/fr.json | 6 + custom_components/myhome/translations/it.json | 6 + custom_components/myhome/translations/nl.json | 6 + .../climate_mode_2026_11_08/frames.json | 184 ++++++++++ tests/test_climate_central_discovery.py | 10 +- tests/test_climate_central_season_699.py | 16 +- tests/test_climate_mode_state_2026_11_08.py | 323 ++++++++++++++++++ tests/test_issue_303.py | 4 +- tests/test_phase2_architecture.py | 10 +- 12 files changed, 713 insertions(+), 30 deletions(-) create mode 100644 tests/fixtures/traces/climate_mode_2026_11_08/frames.json create mode 100644 tests/test_climate_mode_state_2026_11_08.py diff --git a/custom_components/myhome/climate.py b/custom_components/myhome/climate.py index 54ca170dc..57b75a991 100644 --- a/custom_components/myhome/climate.py +++ b/custom_components/myhome/climate.py @@ -118,6 +118,16 @@ def _central_diagnostic_what(message: Any) -> int | None: _ZONE_CONTEXT_MODES = {"heating": HVACMode.HEAT, "cooling": HVACMode.COOL, "automatic": HVACMode.AUTO} _ZONE_STATES_ON = ("setpoint", "comfort", "eco") _ZONE_STATES_OFF = ("protection", "off") +# Central unit protection modes (WHO 4 p. 5 and p. 64): the plant keeps its season and +# regulates to the protection setpoint; it is not switched off. +_CENTRAL_PROTECTION = {"102": ("antifreeze", HVACMode.HEAT), "202": ("thermal_protection", HVACMode.COOL)} +# An F455 hands Home Assistant's own central command back on the event session within +# milliseconds (live, 2026-11-08); a 3550 reply is a different frame. +_CENTRAL_ECHO_WINDOW = 2.0 +# A standalone thermostat answers a setpoint write with mode 1 / 2 with its zone operation +# mode frame within ~0.1 s (live F461, 2026-11-08). +_SEASON_WRITE_WINDOW = 10.0 +_CONDITIONING_SEASONS = (SEASON_CONDITIONING, "cooling") # How long the fan speed poll waits for the one-shot dimension 19 answer (*#4*Z*19##, # 0.11 s on the live F461) before asking as it did before. @@ -616,6 +626,16 @@ def __init__( self._attr_native_current_temperature: float | None = None self._season: str | None = None + # Whether the season came from a frame since start-up, not from the restored state. + self._season_reported = False + # Central unit: the protection mode last reported (102 antifreeze, 202 thermal protection). + 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_seen: object | None = None + # Standalone zone: the season a setpoint write with mode 1 / 2 asked for, and until when. + self._season_write: tuple[str, float] | None = None self._program: int | None = None self._holiday_days: int | None = None self._scenario: int | None = None @@ -760,6 +780,8 @@ def extra_state_attributes(self) -> dict[str, Any]: if self._interface is not None: attrs["Int"] = self._interface if self._central: + if self._central_operation is not None: + attrs["central_operation"] = self._central_operation if self._remote_control is not None: attrs["remote_control"] = self._remote_control attrs["at_least_one_probe_off"] = self._at_least_one_probe_off @@ -1125,6 +1147,7 @@ def _remember_season(self, message: Any) -> None: season = getattr(message, "season", None) if isinstance(season, str): self._season = season + self._season_reported = True def _raise_unresponsive_issue(self) -> None: self._report_fault(FaultKind.UNRESPONSIVE) @@ -1304,12 +1327,19 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: } cmd_mode = mode_map.get(hvac_mode) if cmd_mode: - await self._gateway_handler.send( - OWNHeatingCommand.set_central_mode( - where=self._where, - mode=cmd_mode, - ) + command = OWNHeatingCommand.set_central_mode( + 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) + await self._gateway_handler.send(command) + if hvac_mode in (HVACMode.HEAT, HVACMode.COOL): + # *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, + # or nothing when the season does not change (live F461 and F455, + # 2026-11-08), so the mode shown comes from that answer. + return self._attr_hvac_mode = hvac_mode if self.hass is not None: self.async_write_ha_state() @@ -1357,6 +1387,16 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: ) ) return + if self._standalone: + # *4*311*#Z## asks the central unit to put zone Z on its program (WHO 4 + # p. 9). A standalone thermostat is not one of its zones: the live F461 + # (3550 on the bus, zone 2 standalone) ACKs the frame and nothing answers. + raise ServiceValidationError( + f"{self._display_name} is a standalone thermostat: the central unit has no program for it", + translation_domain=DOMAIN, + translation_key="auto_standalone_zone", + translation_placeholders={"name": self._display_name}, + ) cmd = OWNHeatingCommand.set_mode( where=self._where, mode=CLIMATE_MODE_AUTO, @@ -1364,25 +1404,31 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: ) if cmd is not None: await self._gateway_handler.send(cmd) - elif hvac_mode == HVACMode.HEAT: - nominal = self._effective_nominal_temperature() - if nominal is not None: - await self._gateway_handler.send( - OWNHeatingCommand.set_temperature( - where=self._where, - temperature=nominal, - mode=CLIMATE_MODE_HEAT, - standalone=self._standalone, - ) + elif hvac_mode in (HVACMode.HEAT, HVACMode.COOL): + heat = hvac_mode == HVACMode.HEAT + under_central = not self._standalone and getattr(self._gateway_handler, "has_central_unit", False) is True + if under_central and self._season_reported and self._season is not None and ( + self._season in _CONDITIONING_SEASONS if heat else self._season == SEASON_HEATING + ): + # A zone of a central unit runs in the central's season. The live 3550 + # (2026-11-08, plant in conditioning) answers nothing to a heating setpoint + # for its zone 4; the season is changed on the central unit. + raise ServiceValidationError( + f"{self._display_name} follows the central unit, which is in the {self._season} season", + translation_domain=DOMAIN, + translation_key="zone_mode_outside_central_season", + translation_placeholders={"name": self._display_name, "season": self._season}, ) - elif hvac_mode == HVACMode.COOL: nominal = self._effective_nominal_temperature() if nominal is not None: + if self._standalone: + season = SEASON_HEATING if heat else SEASON_CONDITIONING + self._season_write = (season, time.monotonic() + _SEASON_WRITE_WINDOW) await self._gateway_handler.send( OWNHeatingCommand.set_temperature( where=self._where, temperature=nominal, - mode=CLIMATE_MODE_COOL, + mode=CLIMATE_MODE_HEAT if heat else CLIMATE_MODE_COOL, standalone=self._standalone, ) ) @@ -1492,12 +1538,72 @@ def _apply_zone_state(self, message: OWNHeatingEvent) -> None: self._show_nominal() @callback + def _is_own_central_echo(self, message: OWNHeatingEvent) -> bool: + """Whether ``message`` is the gateway handing back Home Assistant's own central command. + + An F455 returns ``*4*0*#0##`` on the event session 1 ms after Home Assistant sent it, + whether or not the 3550 answers (live, 2026-11-08); an F461 does not. The echo is not + the central unit's state: taken as a season frame it cleared the battery flag the 3550 + had just reported and turned the action from OFF to IDLE. + + The climate and sensor platforms both route WHO 4 frames to the zone, so the one + echo frame can reach this entity twice: it is recognised by identity the second time. + """ + if message is self._central_echo_seen: + return True + echo = self._central_echo + if echo is None: + return False + frame, deadline = echo + if time.monotonic() > deadline: + self._central_echo = None + return False + if str(message) != frame: + return False + self._central_echo = None + self._central_echo_seen = message + return True + + def _is_central_addressed_mode_frame(self, message: OWNHeatingEvent) -> bool: + """Whether a mode / season frame for a standalone zone is in the ``#Z`` (via central unit) form. + + ``*4*what*#Z##`` is a zone of the central unit (WHO 4 p. 6, p. 9, p. 65). A standalone + thermostat answers in the plain ``Z`` form. An F455 emits ``*4*311*#2##`` after every + status report of the standalone zone 2 (live, 2026-11-08: 8 of 8, none on the F461 + monitoring the same bus), which OWNd decodes as zone 2 in automatic. + """ + if self._central or not self._standalone: + return False + if message.message_type not in (MESSAGE_TYPE_MODE, MESSAGE_TYPE_MODE_TARGET, MESSAGE_TYPE_SEASON): + return False + return is_central_thermo_where(message) + + def _answers_season_write(self, message: OWNHeatingEvent) -> bool: + """Whether a season frame is the standalone zone's answer to Home Assistant's mode 1 / 2 setpoint write.""" + write = self._season_write + if write is None or self._central: + return False + self._season_write = None + season, deadline = write + return time.monotonic() <= deadline and message.season == season + def handle_event(self, message: OWNHeatingEvent) -> None: """Handle an event message.""" + if self._central and self._is_own_central_echo(message): + LOGGER.debug("%s %s: own command echoed by the gateway, not a central unit report", self._gateway_handler.log_id, message) + return if is_central_thermo_where(message): self._gateway_handler.has_central_unit = True if self._poll_health.frame_seen(): self._clear_unresponsive_issue() + if self._is_central_addressed_mode_frame(message): + LOGGER.debug( + "%s %s: %s is a standalone thermostat, a frame for it via the central unit is not its state", + self._gateway_handler.log_id, + message, + self._display_name, + ) + return if message.message_type == MESSAGE_TYPE_MAIN_TEMPERATURE: LOGGER.debug( "%s %s", @@ -1582,14 +1688,18 @@ def handle_event(self, message: OWNHeatingEvent) -> None: # in automatic on it, libqtdevices probe_device.cpp:240-253); a # zone that was OFF or not yet known is running again, so it takes # the season's mode. For a central unit the season is the plant - # mode Home Assistant shows. + # mode Home Assistant shows. A standalone thermostat answers a setpoint + # write with mode 1 / 2 with this frame in the season it now runs in + # (live F461, *#4*2*#14*0265*2## -> *4*0*2##): that answer is its mode. if self._central: self._reset_central_status_flags() + answers_write = self._answers_season_write(message) previous_season = self._season self._season = message.season + self._season_reported = True season_mode = HVACMode.HEAT if message.season == SEASON_HEATING else HVACMode.COOL prev_mode = self._attr_hvac_mode - if self._central or self._attr_hvac_mode in (None, HVACMode.OFF): + if self._central or answers_write or self._attr_hvac_mode in (None, HVACMode.OFF): if season_mode in self._attr_hvac_modes: LOGGER.debug("%s %s", self._gateway_handler.log_id, message.human_readable_log) self._attr_hvac_mode = season_mode @@ -1628,11 +1738,26 @@ def handle_event(self, message: OWNHeatingEvent) -> None: raw_what = str(message).split("*")[2] if str(message).startswith("*4*") else "" if raw_what in ("0", "1") and not self._central: self._season = SEASON_HEATING if raw_what == "1" else "cooling" + self._season_reported = True self._follow_season_change(previous_season) self._program = getattr(message, "program", None) self._holiday_days = getattr(message, "holiday_days", None) self._scenario = getattr(message, "scenario", None) - if message.mode == CLIMATE_MODE_AUTO and HVACMode.AUTO in self._attr_hvac_modes: + protection = _CENTRAL_PROTECTION.get(raw_what) if self._central else None + if self._central: + self._central_operation = protection[0] if protection is not None else None + if protection is not None: + # *4*102*#0## / *4*202*#0## is the central unit in antifreeze / thermal + # protection (WHO 4 p. 5, p. 64): the plant keeps its season and holds the + # protection setpoint (live 3550: zone 4 at 7.0 / 35.0 °C), it is not off. + # BTicino's client shows it as a status of the season (libqtdevices + # thermal_device.cpp WIN_PROTECTION / SUM_PROTECTION). + LOGGER.debug("%s %s (%s)", self._gateway_handler.log_id, message.human_readable_log, protection[0]) + if protection[1] in self._attr_hvac_modes: + self._attr_hvac_mode = protection[1] + if self._attr_hvac_action == HVACAction.OFF: + self._attr_hvac_action = HVACAction.IDLE + elif message.mode == CLIMATE_MODE_AUTO and HVACMode.AUTO in self._attr_hvac_modes: LOGGER.debug( "%s %s", self._gateway_handler.log_id, @@ -1685,6 +1810,7 @@ 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 prev_mode = self._attr_hvac_mode self._remember_season(message) self._program = getattr(message, "program", None) diff --git a/custom_components/myhome/strings.json b/custom_components/myhome/strings.json index 15129d369..f97f95caf 100644 --- a/custom_components/myhome/strings.json +++ b/custom_components/myhome/strings.json @@ -600,6 +600,12 @@ "fan_speed_zone_only": { "message": "{name} has no fan speed of its own: only a zone (1-99) takes one" }, + "auto_standalone_zone": { + "message": "{name} is a standalone thermostat: the central unit has no program for it" + }, + "zone_mode_outside_central_season": { + "message": "{name} follows the central unit, which is in the {season} season: change the season on the central unit" + }, "command_delivery_cancelled": { "message": "{name}: direction command delivery was cancelled before reaching the bus" }, diff --git a/custom_components/myhome/translations/en.json b/custom_components/myhome/translations/en.json index 15129d369..f97f95caf 100644 --- a/custom_components/myhome/translations/en.json +++ b/custom_components/myhome/translations/en.json @@ -600,6 +600,12 @@ "fan_speed_zone_only": { "message": "{name} has no fan speed of its own: only a zone (1-99) takes one" }, + "auto_standalone_zone": { + "message": "{name} is a standalone thermostat: the central unit has no program for it" + }, + "zone_mode_outside_central_season": { + "message": "{name} follows the central unit, which is in the {season} season: change the season on the central unit" + }, "command_delivery_cancelled": { "message": "{name}: direction command delivery was cancelled before reaching the bus" }, diff --git a/custom_components/myhome/translations/fr.json b/custom_components/myhome/translations/fr.json index 12e4dbcc2..47b70538b 100644 --- a/custom_components/myhome/translations/fr.json +++ b/custom_components/myhome/translations/fr.json @@ -536,6 +536,12 @@ "fan_speed_zone_only": { "message": "{name} n'a pas de vitesse de ventilation propre : seule une zone (1-99) peut en avoir une" }, + "auto_standalone_zone": { + "message": "{name} est un thermostat autonome : la centrale n'a pas de programme pour lui" + }, + "zone_mode_outside_central_season": { + "message": "{name} suit la centrale, qui est en saison {season} : changez la saison sur la centrale" + }, "command_delivery_cancelled": { "message": "{name} : l'envoi de la commande de direction a été annulé avant d'atteindre le bus" }, diff --git a/custom_components/myhome/translations/it.json b/custom_components/myhome/translations/it.json index 719143fac..ca5f04117 100644 --- a/custom_components/myhome/translations/it.json +++ b/custom_components/myhome/translations/it.json @@ -536,6 +536,12 @@ "fan_speed_zone_only": { "message": "{name} non ha una velocità del ventilatore propria: solo una zona (1-99) può averne una" }, + "auto_standalone_zone": { + "message": "{name} è un termostato autonomo: la centrale non ha un programma per esso" + }, + "zone_mode_outside_central_season": { + "message": "{name} segue la centrale, che è nella stagione {season}: cambia la stagione sulla centrale" + }, "command_delivery_cancelled": { "message": "{name}: l'invio del comando di direzione è stato annullato prima di raggiungere il bus" }, diff --git a/custom_components/myhome/translations/nl.json b/custom_components/myhome/translations/nl.json index f99819348..c8e925e23 100644 --- a/custom_components/myhome/translations/nl.json +++ b/custom_components/myhome/translations/nl.json @@ -536,6 +536,12 @@ "fan_speed_zone_only": { "message": "{name} heeft geen eigen ventilatorsnelheid: alleen een zone (1-99) kan er een krijgen" }, + "auto_standalone_zone": { + "message": "{name} is een zelfstandige thermostaat: de centrale heeft er geen programma voor" + }, + "zone_mode_outside_central_season": { + "message": "{name} volgt de centrale, die in het seizoen {season} staat: wijzig het seizoen op de centrale" + }, "command_delivery_cancelled": { "message": "{name}: de aflevering van het richtingscommando is geannuleerd voordat het de bus bereikte" }, diff --git a/tests/fixtures/traces/climate_mode_2026_11_08/frames.json b/tests/fixtures/traces/climate_mode_2026_11_08/frames.json new file mode 100644 index 000000000..fdc2080b5 --- /dev/null +++ b/tests/fixtures/traces/climate_mode_2026_11_08/frames.json @@ -0,0 +1,184 @@ +{ + "_source": "Live F461 2.0.11 and F455 on the same SCS bus with a BTicino 3550 99-zone central unit (#0), zone 2 standalone thermostat, zone 4 under the 3550; 2026-11-08 UTC. Frames as seen by Home Assistant (tx = sent by that gateway's integration, rx = received on its event session). Only WHO 4 frames are kept.", + "f455_zone2_heat": [ + [ + "14:26:29.114", + "tx", + "*#4*2*#14*0265*1##" + ], + [ + "14:26:29.209", + "rx", + "*4*1*2##" + ], + [ + "14:26:29.259", + "rx", + "*#4*2*12*0265*3##" + ], + [ + "14:26:29.309", + "rx", + "*#4*2*0*0239##" + ], + [ + "14:26:29.839", + "rx", + "*#4*2*12*0265*3##" + ], + [ + "14:26:29.840", + "rx", + "*4*1*2##" + ], + [ + "14:26:30.119", + "rx", + "*#4*2*14*0265*3##" + ], + [ + "14:26:30.439", + "rx", + "*#4*2*13*00##" + ], + [ + "14:26:30.519", + "rx", + "*4*311*#2##" + ] + ], + "f461_zone2_heat_to_cool": [ + [ + "14:09:07.158", + "tx", + "*#4*2*#14*0265*2##" + ], + [ + "14:09:07.255", + "rx", + "*4*0*2##" + ], + [ + "14:09:07.311", + "rx", + "*#4*2*12*0265*3##" + ], + [ + "14:09:07.367", + "rx", + "*#4*2*0*0240##" + ] + ], + "f461_zone4_auto_in_cooling": [ + [ + "14:20:35.911", + "tx", + "*4*311*#4##" + ], + [ + "14:20:36.015", + "rx", + "*4*210*#4##" + ], + [ + "14:20:36.797", + "rx", + "*4*0*4##" + ], + [ + "14:20:36.798", + "rx", + "*#4*4*12*0205*3##" + ] + ], + "f461_central_heat": [ + [ + "14:24:48.141", + "tx", + "*4*1*#0##" + ], + [ + "14:24:48.374", + "rx", + "*4*102*#0##" + ], + [ + "14:24:48.376", + "rx", + "*4*21*#0##" + ], + [ + "14:24:48.377", + "rx", + "*4*31*#0##" + ] + ], + "f461_central_heat_second_press": [ + [ + "14:25:08.340", + "tx", + "*4*1*#0##" + ] + ], + "f461_central_cool": [ + [ + "14:25:28.545", + "tx", + "*4*0*#0##" + ], + [ + "14:25:28.783", + "rx", + "*4*202*#0##" + ], + [ + "14:25:28.784", + "rx", + "*4*21*#0##" + ], + [ + "14:25:28.784", + "rx", + "*4*31*#0##" + ] + ], + "f455_central_cool": [ + [ + "14:35:26.501", + "tx", + "*4*0*#0##" + ], + [ + "14:35:26.501", + "rx", + "*4*0*#0##" + ], + [ + "14:35:26.730", + "rx", + "*4*202*#0##" + ], + [ + "14:35:26.731", + "rx", + "*4*21*#0##" + ], + [ + "14:35:26.731", + "rx", + "*4*31*#0##" + ] + ], + "f455_central_cool_second_press": [ + [ + "14:35:47.471", + "tx", + "*4*0*#0##" + ], + [ + "14:35:47.472", + "rx", + "*4*0*#0##" + ] + ] +} diff --git a/tests/test_climate_central_discovery.py b/tests/test_climate_central_discovery.py index 9b93e7bf1..15cbf88b9 100644 --- a/tests/test_climate_central_discovery.py +++ b/tests/test_climate_central_discovery.py @@ -306,16 +306,22 @@ def _record_central_mode(mode: HVACMode) -> None: gateway.send_status_request.assert_called_once() assert str(gateway.send_status_request.call_args[0][0]) == "*#4*#0##" - # 2. Command path: setting mode via HA emits central commands and dispatches signal + # 2. Command path: setting mode via HA emits central commands and dispatches signal. + # HEAT / COOL only change the season: the mode shown comes from the 3550's answer + # (antifreeze / thermal protection, live 3550 2026-11-08). await cu.async_set_hvac_mode(HVACMode.HEAT) sent_cmd = gateway.send.call_args[0][0] assert str(sent_cmd) == "*4*1*#0##" + assert cu.hvac_mode is None + cu.handle_event(OWNHeatingEvent("*4*102*#0##")) assert cu.hvac_mode == HVACMode.HEAT assert central_mode_events[-1] == HVACMode.HEAT await cu.async_set_hvac_mode(HVACMode.COOL) sent_cmd = gateway.send.call_args[0][0] assert str(sent_cmd) == "*4*0*#0##" + assert cu.hvac_mode == HVACMode.HEAT + cu.handle_event(OWNHeatingEvent("*4*202*#0##")) assert cu.hvac_mode == HVACMode.COOL assert central_mode_events[-1] == HVACMode.COOL @@ -332,6 +338,8 @@ def _record_central_mode(mode: HVACMode) -> None: assert central_mode_events[-1] == HVACMode.OFF # 3. Autonomous operating mode broadcast events update HVACMode and dispatch signal + # (later than the 2 s in which a gateway's echo of the last command is expected) + cu._central_echo = None cu.handle_event(OWNHeatingEvent("*4*110*#0##")) # Manual heating status event assert cu.hvac_mode == HVACMode.HEAT assert central_mode_events[-1] == HVACMode.HEAT diff --git a/tests/test_climate_central_season_699.py b/tests/test_climate_central_season_699.py index ee99ab6ac..bcc497613 100644 --- a/tests/test_climate_central_season_699.py +++ b/tests/test_climate_central_season_699.py @@ -48,13 +48,18 @@ def _zone(where="#0", central=True): @_NEEDS_OWND_SEASON @pytest.mark.parametrize( - ("raw", "season"), - [("*4*202*#0##", "conditioning"), ("*4*102*#0##", "heating"), ("*4*103*#0##", "heating")], + ("raw", "season", "mode"), + [ + # 102 / 202 are antifreeze / thermal protection: the central keeps the season's mode. + ("*4*202*#0##", "conditioning", HVACMode.COOL), + ("*4*102*#0##", "heating", HVACMode.HEAT), + ("*4*103*#0##", "heating", HVACMode.OFF), + ], ) -def test_central_off_frame_sets_the_season(raw, season): +def test_central_off_frame_sets_the_season(raw, season, mode): central = _zone() central.handle_event(OWNEvent.parse(raw)) - assert central.hvac_mode == HVACMode.OFF + assert central.hvac_mode == mode assert central.extra_state_attributes["season"] == season @@ -65,7 +70,8 @@ def test_season_switch_sequence_from_the_f454_plant(): assert central.extra_state_attributes["season"] == "conditioning" central.handle_event(OWNEvent.parse("*4*102*#0##")) assert central.extra_state_attributes["season"] == "heating" - assert central.hvac_mode == HVACMode.OFF + assert central.hvac_mode == HVACMode.HEAT + assert central.extra_state_attributes["central_operation"] == "antifreeze" @_NEEDS_OWND_SEASON diff --git a/tests/test_climate_mode_state_2026_11_08.py b/tests/test_climate_mode_state_2026_11_08.py new file mode 100644 index 000000000..308f10630 --- /dev/null +++ b/tests/test_climate_mode_state_2026_11_08.py @@ -0,0 +1,323 @@ +"""Climate mode state from status frames only: live F461 + F455 replay, 2026-11-08. + +One SCS bus with a BTicino 3550 99-zone central unit (#0), zone 2 a standalone +thermostat and zone 4 a zone of the 3550, reached through an F461 2.0.11 and an F455. +Every service call returned HTTP 200, yet Home Assistant showed the wrong mode: + +* the F455 emits ``*4*311*#2##`` after each status report of zone 2, which read as AUTO; +* a standalone zone's HEAT <-> COOL answer ``*4*0*2##`` / ``*4*1*2##`` was taken as season only; +* the 3550 answers HEAT / COOL on #0 with antifreeze / thermal protection (102 / 202), + which read as OFF, and the optimistic HEAT / COOL stayed on the second press; +* the F455 hands Home Assistant's own ``*4*0*#0##`` back, which cleared the battery flag; +* HEAT on a zone of a central in the conditioning season and AUTO on a standalone zone + are not answered at all. + +Frames: tests/fixtures/traces/climate_mode_2026_11_08/frames.json. +""" + +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 HVACAction, HVACMode +from homeassistant.exceptions import ServiceValidationError +from OWNd.message import OWNEvent + +FRAMES = json.loads( + (Path(__file__).parent / "fixtures" / "traces" / "climate_mode_2026_11_08" / "frames.json").read_text() +) + + +def _rx(trace: str) -> list[str]: + return [raw for _t, direction, raw in FRAMES[trace] if direction == "rx"] + + +def _tx(trace: str) -> list[str]: + return [raw for _t, direction, raw in FRAMES[trace] if direction == "tx"] + + +@pytest.fixture(autouse=True) +def _entity_base_init() -> Iterator[None]: + with patch("custom_components.myhome.myhome_device.Entity.__init__", return_value=None): + yield + + +def _gateway(has_central_unit: bool = True) -> 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 = has_central_unit + return gw + + +def _climate(gateway: MagicMock, where: str, *, standalone: bool | None, central: bool = False) -> 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=False, + standalone=standalone, + central=central, + manufacturer="BTicino", + model="Thermostat", + gateway=gateway, + ) + entity.platform = MagicMock() + entity.entity_id = "climate.test" + setattr(entity, "async_schedule_update_ha_state", MagicMock()) + setattr(entity, "async_write_ha_state", MagicMock()) + return entity + + +def _replay(entity: Any, frames: list[str]) -> None: + for raw in frames: + entity.handle_event(OWNEvent.parse(raw)) + + +def _sent(gateway: MagicMock) -> list[str]: + return [str(call.args[0]) for call in gateway.send.await_args_list] + + +# F1: a standalone zone's state comes from its plain-Z frames, not from the #Z form. + + +def test_f455_trailing_311_does_not_turn_the_standalone_zone_auto() -> None: + zone2 = _climate(_gateway(), "2", standalone=True) + zone2._attr_hvac_mode = HVACMode.OFF + zone2._attr_hvac_action = HVACAction.OFF + _replay(zone2, _rx("f455_zone2_heat")) + assert zone2.hvac_mode == HVACMode.HEAT + assert zone2.target_temperature == 26.5 + + +def test_zone_of_the_central_still_takes_its_state_from_the_central() -> None: + # *4*what*#Z## from the 3550 is the zone's state (WHO 4 p. 65): it must survive. + zone4 = _climate(_gateway(), "4", standalone=False) + zone4._attr_hvac_mode = HVACMode.OFF + _replay(zone4, _rx("f461_zone4_auto_in_cooling")) + assert zone4.hvac_mode == HVACMode.COOL + _replay(zone4, ["*4*311*#4##"]) + assert zone4.hvac_mode == HVACMode.AUTO + _replay(zone4, ["*4*203*#4##"]) + assert zone4.hvac_mode == HVACMode.OFF + + +# F2: the standalone zone's answer to Home Assistant's own mode 1 / 2 setpoint write is its mode. + + +async def test_standalone_heat_to_cool_shows_cool_from_the_thermostat_answer() -> None: + gateway = _gateway() + zone2 = _climate(gateway, "2", standalone=True) + zone2._target_temperature = 26.5 + _replay(zone2, ["*4*1*2##", "*#4*2*12*0265*3##"]) + zone2._attr_hvac_mode = HVACMode.HEAT + await zone2.async_set_hvac_mode(HVACMode.COOL) + assert _sent(gateway) == _tx("f461_zone2_heat_to_cool") + for raw in _rx("f461_zone2_heat_to_cool"): + frame = OWNEvent.parse(raw) + zone2.handle_event(frame) + zone2.handle_event(frame) # delivered by the climate and the sensor platform + assert zone2.hvac_mode == HVACMode.COOL + assert zone2.extra_state_attributes["season"] == "conditioning" + + +def test_unsolicited_season_frame_keeps_a_running_standalone_zone() -> None: + zone2 = _climate(_gateway(), "2", standalone=True) + zone2._attr_hvac_mode = HVACMode.HEAT + _replay(zone2, ["*4*0*2##"]) + assert zone2.hvac_mode == HVACMode.HEAT + + +async def test_season_answer_for_the_other_season_is_not_taken_as_the_mode() -> None: + gateway = _gateway() + zone2 = _climate(gateway, "2", standalone=True) + zone2._target_temperature = 26.5 + zone2._attr_hvac_mode = HVACMode.HEAT + await zone2.async_set_hvac_mode(HVACMode.COOL) + _replay(zone2, ["*4*1*2##"]) + assert zone2.hvac_mode == HVACMode.HEAT + _replay(zone2, ["*4*0*2##"]) # the pending write was answered: later frames are season only + assert zone2.hvac_mode == HVACMode.HEAT + + +# F3: 102 / 202 on #0 are protection in their season, and HEAT / COOL on #0 waits for the 3550. + + +async def test_central_heat_shows_the_antifreeze_the_3550_answers() -> None: + gateway = _gateway() + central = _climate(gateway, "#0", standalone=None, central=True) + _replay(central, _rx("f461_central_cool")) + assert central.hvac_mode == HVACMode.COOL + assert central.extra_state_attributes["central_operation"] == "thermal_protection" + + await central.async_set_hvac_mode(HVACMode.HEAT) + assert _sent(gateway) == _tx("f461_central_heat") + assert central.hvac_mode == HVACMode.COOL # nothing reported yet + _replay(central, _rx("f461_central_heat")) + assert central.hvac_mode == HVACMode.HEAT + assert central.hvac_action != HVACAction.OFF + attributes = central.extra_state_attributes + assert attributes["central_operation"] == "antifreeze" + assert attributes["season"] == "heating" + assert attributes["battery_fault"] is True + assert attributes["remote_control"] == "enabled" + + await central.async_set_hvac_mode(HVACMode.HEAT) # second press: the 3550 sends nothing + assert _sent(gateway)[-1:] == _tx("f461_central_heat_second_press") + assert central.hvac_mode == HVACMode.HEAT + assert central.extra_state_attributes["central_operation"] == "antifreeze" + + +def test_central_protection_does_not_switch_the_zones_off() -> None: + gateway = _gateway() + central = _climate(gateway, "#0", standalone=None, central=True) + with patch("custom_components.myhome.climate.async_dispatcher_send") as dispatch: + _replay(central, _rx("f461_central_cool")) + assert [call.args[2] for call in dispatch.call_args_list] == [HVACMode.COOL] + + +def test_central_operation_clears_on_a_manual_mode_report() -> None: + central = _climate(_gateway(), "#0", standalone=None, central=True) + _replay(central, _rx("f461_central_heat")) + assert central.extra_state_attributes["central_operation"] == "antifreeze" + _replay(central, ["*4*110#0215*#0##"]) + assert "central_operation" not in central.extra_state_attributes + assert central.hvac_mode == HVACMode.HEAT + + +async def test_central_off_and_auto_stay_immediate() -> None: + gateway = _gateway() + central = _climate(gateway, "#0", standalone=None, central=True) + central._attr_hvac_mode = HVACMode.HEAT + await central.async_set_hvac_mode(HVACMode.OFF) + assert central.hvac_mode == HVACMode.OFF + await central.async_set_hvac_mode(HVACMode.AUTO) + assert central.hvac_mode == HVACMode.AUTO + assert _sent(gateway) == ["*4*303*#0##", "*4*311*#0##"] + + +# F6: the F455's echo of Home Assistant's own #0 command is not a 3550 report. + + +async def test_f455_echo_of_our_central_command_changes_nothing() -> None: + gateway = _gateway() + central = _climate(gateway, "#0", standalone=None, central=True) + first, second = _rx("f455_central_cool"), _rx("f455_central_cool_second_press") + + async def send_and_echo(command: Any) -> None: + # The F455 returns the frame before send() is done (1 ms, live). The climate and the + # sensor platforms both route WHO 4 frames, so the entity gets the same object twice. + echo = OWNEvent.parse(str(command)) + central.handle_event(echo) + central.handle_event(echo) + + gateway.send.side_effect = send_and_echo + central._attr_hvac_mode = HVACMode.HEAT + central._attr_hvac_action = HVACAction.IDLE + await central.async_set_hvac_mode(HVACMode.COOL) + assert first[0] == second[0] == _tx("f455_central_cool")[0] + assert central.hvac_mode == HVACMode.HEAT # the echo is not the season report + _replay(central, first[1:]) + assert central.hvac_mode == HVACMode.COOL + assert central.extra_state_attributes["battery_fault"] is True + + await central.async_set_hvac_mode(HVACMode.COOL) # second press, only the echo comes back + attributes = central.extra_state_attributes + assert attributes["battery_fault"] is True + assert attributes["central_operation"] == "thermal_protection" + assert central.hvac_mode == HVACMode.COOL + + +async def test_only_one_echo_is_dropped_and_only_while_expected() -> None: + gateway = _gateway() + central = _climate(gateway, "#0", standalone=None, central=True) + await central.async_set_hvac_mode(HVACMode.COOL) + _replay(central, ["*4*0*#0##"]) # the echo + assert central.extra_state_attributes.get("season") is None + _replay(central, ["*4*0*#0##"]) # a second identical frame is a report + assert central.hvac_mode == HVACMode.COOL + + await central.async_set_hvac_mode(HVACMode.HEAT) + with patch("custom_components.myhome.climate.time.monotonic", return_value=float("inf")): + _replay(central, ["*4*1*#0##"]) # too late to be the echo + assert central.hvac_mode == HVACMode.HEAT + + +# F4: a zone of the central unit runs in the central's season. + + +async def test_heat_on_a_zone_of_the_central_in_conditioning_is_refused() -> None: + gateway = _gateway() + zone4 = _climate(gateway, "4", standalone=False) + _replay(zone4, _rx("f461_zone4_auto_in_cooling")) + zone4._target_temperature = 20.5 + with pytest.raises(ServiceValidationError) as err: + await zone4.async_set_hvac_mode(HVACMode.HEAT) + assert err.value.translation_key == "zone_mode_outside_central_season" + gateway.send.assert_not_awaited() + assert zone4.hvac_mode == HVACMode.COOL + await zone4.async_set_hvac_mode(HVACMode.COOL) + assert _sent(gateway) == ["*#4*#4*#14*0205*2##"] + + +async def test_zone_of_the_central_with_unknown_season_is_not_restricted() -> None: + gateway = _gateway() + zone4 = _climate(gateway, "4", standalone=False) + zone4._target_temperature = 20.5 + await zone4.async_set_hvac_mode(HVACMode.HEAT) + assert _sent(gateway) == ["*#4*#4*#14*0205*1##"] + + +async def test_standalone_zone_is_not_season_restricted() -> None: + gateway = _gateway() + zone2 = _climate(gateway, "2", standalone=True) + _replay(zone2, ["*4*0*2##"]) + zone2._target_temperature = 26.5 + await zone2.async_set_hvac_mode(HVACMode.HEAT) + assert _sent(gateway) == ["*#4*2*#14*0265*1##"] + + +# F5: AUTO is the central unit's program for its zones; a standalone thermostat has none. + + +async def test_auto_on_a_standalone_zone_of_a_plant_with_a_central_is_refused() -> None: + gateway = _gateway(has_central_unit=True) + zone2 = _climate(gateway, "2", standalone=True) + zone2._attr_hvac_mode = HVACMode.HEAT + with pytest.raises(ServiceValidationError) as err: + await zone2.async_set_hvac_mode(HVACMode.AUTO) + assert err.value.translation_key == "auto_standalone_zone" + gateway.send.assert_not_awaited() + assert zone2.hvac_mode == HVACMode.HEAT + + +async def test_auto_on_a_standalone_zone_without_a_central_keeps_the_setpoint_fallback() -> None: + gateway = _gateway(has_central_unit=False) + zone2 = _climate(gateway, "2", standalone=True) + zone2._target_temperature = 26.5 + await zone2.async_set_hvac_mode(HVACMode.AUTO) + assert _sent(gateway) == ["*#4*2*#14*0265*3##"] + + +async def test_restored_season_alone_does_not_restrict_a_zone_of_the_central() -> None: + # After a restart the season comes from the stored state until the zone reports again. + gateway = _gateway() + zone4 = _climate(gateway, "4", standalone=False) + zone4._season = "conditioning" + zone4._target_temperature = 20.5 + await zone4.async_set_hvac_mode(HVACMode.HEAT) + assert _sent(gateway) == ["*#4*#4*#14*0205*1##"] diff --git a/tests/test_issue_303.py b/tests/test_issue_303.py index fe79941f1..feadedd1a 100644 --- a/tests/test_issue_303.py +++ b/tests/test_issue_303.py @@ -574,8 +574,10 @@ async def test_central_unit_async_set_hvac_mode(hass: HomeAssistant, mock_gatewa for mode in [HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO, HVACMode.OFF]: mock_gateway.send.reset_mock() + before = central.hvac_mode await central.async_set_hvac_mode(mode) - assert central.hvac_mode == mode + # HEAT / COOL only change the season; the 3550's answer sets the mode shown. + assert central.hvac_mode == (before if mode in (HVACMode.HEAT, HVACMode.COOL) else mode) mock_gateway.send.assert_awaited_once() # Invalid mode does nothing diff --git a/tests/test_phase2_architecture.py b/tests/test_phase2_architecture.py index 0b65375a8..480f950e0 100644 --- a/tests/test_phase2_architecture.py +++ b/tests/test_phase2_architecture.py @@ -216,14 +216,15 @@ async def test_p4_central_unit_3550_initialization_and_commands(hass: HomeAssist assert gateway.send.call_count == 1 sent_cmd = gateway.send.call_args[0][0] assert str(sent_cmd) == "*4*1*#0##" - assert cu99._attr_hvac_mode == HVACMode.HEAT + # Only the season changes; the 3550's answer (*4*102*#0## etc.) sets the mode shown. + assert cu99._attr_hvac_mode is None # Set master cool mode -> *4*0*#0## gateway.send.reset_mock() await cu99.async_set_hvac_mode(HVACMode.COOL) sent_cmd = gateway.send.call_args[0][0] assert str(sent_cmd) == "*4*0*#0##" - assert cu99._attr_hvac_mode == HVACMode.COOL + assert cu99._attr_hvac_mode is None # Set master off mode -> *4*303*#0## gateway.send.reset_mock() @@ -358,8 +359,11 @@ async def test_p4_master_seasonal_mode_propagation_to_subordinate_zones(hass: Ho zone1._attr_hvac_mode = HVACMode.HEAT zone2_standalone._attr_hvac_mode = HVACMode.HEAT - # Switch Central Unit to COOL + # Switch Central Unit to COOL; the 3550 answers with its conditioning mode + from OWNd.message import OWNHeatingEvent + await cu.async_set_hvac_mode(HVACMode.COOL) + cu.handle_event(OWNHeatingEvent.parse("*4*202*#0##")) # Subordinate Zone 1 follows master mode switch to COOL assert zone1._attr_hvac_mode == HVACMode.COOL From d3d96c35d53dee486e2827955ba51dbe120e451d Mon Sep 17 00:00:00 2001 From: gdluck Date: Sun, 8 Nov 2026 18:38:56 +0200 Subject: [PATCH 2/6] fix(climate): a standalone zone's own season frame replaces a restored mode A standalone thermostat reports its mode in the plain Z form only as season (*4*1*Z## / *4*0*Z##), protection or off (WHO 4 p. 63), never as automatic. While its mode is still the one Home Assistant restored, that season frame is the mode it runs in. Live F455, 2026-11-08: after a restart zone 2 came back as AUTO, saved earlier from the F455's own *4*311*#2## (now ignored for standalone zones). The zone then reported *4*1*2##, which was taken as season only, so the stale AUTO stayed until a mode write. Zones of a central unit, and zones that have reported since start-up, are unchanged. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01DN9oUd4ipuDSAE8mb2BjSq --- custom_components/myhome/climate.py | 7 +++- .../climate_mode_2026_11_08/frames.json | 32 +++++++++++++++++++ tests/test_climate_mode_state_2026_11_08.py | 24 ++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/custom_components/myhome/climate.py b/custom_components/myhome/climate.py index e446d770e..51122ec37 100644 --- a/custom_components/myhome/climate.py +++ b/custom_components/myhome/climate.py @@ -1792,12 +1792,17 @@ def handle_event(self, message: OWNHeatingEvent) -> None: if self._central: self._reset_central_status_flags() answers_write = self._answers_season_write(message) + # A standalone thermostat reports its mode in the plain Z form only as season, + # protection or off (WHO 4 p. 63), never as automatic: while its mode is still the + # one Home Assistant restored, its own season frame is the mode it runs in (a stale + # AUTO saved from an F455 *4*311*#Z## otherwise stays after a restart, 2026-11-08). + replaces_restored = self._standalone and self._hvac_mode_restored previous_season = self._season self._season = message.season self._season_reported = True season_mode = HVACMode.HEAT if message.season == SEASON_HEATING else HVACMode.COOL prev_mode = self._attr_hvac_mode - if self._central or answers_write or self._attr_hvac_mode in (None, HVACMode.OFF): + if self._central or answers_write or replaces_restored or self._attr_hvac_mode in (None, HVACMode.OFF): if season_mode in self._attr_hvac_modes: LOGGER.debug("%s %s", self._gateway_handler.log_id, message.human_readable_log) self._attr_hvac_mode = season_mode diff --git a/tests/fixtures/traces/climate_mode_2026_11_08/frames.json b/tests/fixtures/traces/climate_mode_2026_11_08/frames.json index fdc2080b5..72ce2f7bc 100644 --- a/tests/fixtures/traces/climate_mode_2026_11_08/frames.json +++ b/tests/fixtures/traces/climate_mode_2026_11_08/frames.json @@ -180,5 +180,37 @@ "rx", "*4*0*#0##" ] + ], + "f455_zone2_after_restart": [ + [ + "16:28:49.863", + "rx", + "*#4*2*0*0238##" + ], + [ + "16:28:50.183", + "rx", + "*#4*2*12*0265*3##" + ], + [ + "16:28:50.184", + "rx", + "*4*1*2##" + ], + [ + "16:28:50.493", + "rx", + "*#4*2*14*0265*3##" + ], + [ + "16:28:50.833", + "rx", + "*#4*2*13*00##" + ], + [ + "16:28:50.893", + "rx", + "*4*311*#2##" + ] ] } diff --git a/tests/test_climate_mode_state_2026_11_08.py b/tests/test_climate_mode_state_2026_11_08.py index 308f10630..34615880a 100644 --- a/tests/test_climate_mode_state_2026_11_08.py +++ b/tests/test_climate_mode_state_2026_11_08.py @@ -321,3 +321,27 @@ async def test_restored_season_alone_does_not_restrict_a_zone_of_the_central() - zone4._target_temperature = 20.5 await zone4.async_set_hvac_mode(HVACMode.HEAT) assert _sent(gateway) == ["*#4*#4*#14*0205*1##"] + + +# A stale AUTO restored after a restart: the standalone zone's own season frame replaces it. + + +async def test_restored_auto_on_a_standalone_zone_is_replaced_by_its_season_frame() -> None: + from homeassistant.core import State + + zone2 = _climate(_gateway(), "2", standalone=True) + await zone2.async_restore_last_state(State("climate.test", "auto", {"temperature": 26.5, "season": "heating"})) + assert zone2.hvac_mode == HVACMode.AUTO + _replay(zone2, _rx("f455_zone2_after_restart")) # ends with the F455's *4*311*#2## + assert zone2.hvac_mode == HVACMode.HEAT + _replay(zone2, ["*4*0*2##"]) # reported since the restart: season frames are season only again + assert zone2.hvac_mode == HVACMode.HEAT + + +async def test_restored_mode_of_a_zone_of_the_central_is_kept_on_a_season_frame() -> None: + from homeassistant.core import State + + zone4 = _climate(_gateway(), "4", standalone=False) + await zone4.async_restore_last_state(State("climate.test", "auto", {"temperature": 20.5})) + _replay(zone4, ["*4*0*4##"]) + assert zone4.hvac_mode == HVACMode.AUTO From 52934c36f595583f8fe7f66c76dd4942c054ac66 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sun, 8 Nov 2026 19:08:16 +0200 Subject: [PATCH 3/6] fix(climate): a standalone zone's plain season frame is its mode A standalone thermostat has no program: in the plain Z form it reports its mode only as *4*1*Z## / *4*0*Z## (heating / conditioning), protection or off (WHO 4 p. 63). So that frame now sets HEAT / COOL whoever changed the zone, instead of only right after this entity's own setpoint write or while its mode is the restored one. OFF and protection still come from their own frames; zones of a central unit keep the season-only handling. Live, 2026-11-08: the zone 2 thermostat switched to cool from the F455's entity (*#4*2*#14*0265*2## -> *4*0*2##). The F461 on the same bus saw the same *4*0*2## but, not having sent the write, kept HEAT. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01DN9oUd4ipuDSAE8mb2BjSq --- custom_components/myhome/climate.py | 36 +++-------- .../climate_mode_2026_11_08/frames.json | 59 +++++++++++++++++++ tests/test_climate_mode_state_2026_11_08.py | 43 +++++++++----- 3 files changed, 97 insertions(+), 41 deletions(-) diff --git a/custom_components/myhome/climate.py b/custom_components/myhome/climate.py index 51122ec37..a6324c3ca 100644 --- a/custom_components/myhome/climate.py +++ b/custom_components/myhome/climate.py @@ -127,9 +127,6 @@ def _central_diagnostic_what(message: Any) -> int | None: # An F455 hands Home Assistant's own central command back on the event session within # milliseconds (live, 2026-11-08); a 3550 reply is a different frame. _CENTRAL_ECHO_WINDOW = 2.0 -# A standalone thermostat answers a setpoint write with mode 1 / 2 with its zone operation -# mode frame within ~0.1 s (live F461, 2026-11-08). -_SEASON_WRITE_WINDOW = 10.0 _CONDITIONING_SEASONS = (SEASON_CONDITIONING, "cooling") # How long the fan speed poll waits for the one-shot dimension 19 answer (*#4*Z*19##, @@ -637,8 +634,6 @@ def __init__( # 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_seen: object | None = None - # Standalone zone: the season a setpoint write with mode 1 / 2 asked for, and until when. - self._season_write: tuple[str, float] | None = None self._program: int | None = None self._holiday_days: int | None = None self._scenario: int | None = None @@ -1517,9 +1512,6 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: ) nominal = self._effective_nominal_temperature() if nominal is not None: - if self._standalone: - season = SEASON_HEATING if heat else SEASON_CONDITIONING - self._season_write = (season, time.monotonic() + _SEASON_WRITE_WINDOW) await self._gateway_handler.send( OWNHeatingCommand.set_temperature( where=self._where, @@ -1674,15 +1666,6 @@ def _is_central_addressed_mode_frame(self, message: OWNHeatingEvent) -> bool: return False return is_central_thermo_where(message) - def _answers_season_write(self, message: OWNHeatingEvent) -> bool: - """Whether a season frame is the standalone zone's answer to Home Assistant's mode 1 / 2 setpoint write.""" - write = self._season_write - if write is None or self._central: - return False - self._season_write = None - season, deadline = write - return time.monotonic() <= deadline and message.season == season - def handle_event(self, message: OWNHeatingEvent) -> None: """Handle an event message.""" if self._central and self._is_own_central_echo(message): @@ -1786,23 +1769,22 @@ def handle_event(self, message: OWNHeatingEvent) -> None: # in automatic on it, libqtdevices probe_device.cpp:240-253); a # zone that was OFF or not yet known is running again, so it takes # the season's mode. For a central unit the season is the plant - # mode Home Assistant shows. A standalone thermostat answers a setpoint - # write with mode 1 / 2 with this frame in the season it now runs in - # (live F461, *#4*2*#14*0265*2## -> *4*0*2##): that answer is its mode. + # mode Home Assistant shows. + # A standalone thermostat has no program: in the plain Z form it reports its mode + # only as this frame, protection (102 / 202) or off (303) (WHO 4 p. 63), never as + # automatic or manual. So for it this frame is the mode it runs in, whoever changed + # it: Home Assistant (*#4*2*#14*0265*2## -> *4*0*2##), another gateway on the bus, + # a touch screen or the thermostat itself. Two gateways on one bus then show the + # same mode (live F455 + F461, 2026-11-08), and a stale restored AUTO is replaced. if self._central: self._reset_central_status_flags() - answers_write = self._answers_season_write(message) - # A standalone thermostat reports its mode in the plain Z form only as season, - # protection or off (WHO 4 p. 63), never as automatic: while its mode is still the - # one Home Assistant restored, its own season frame is the mode it runs in (a stale - # AUTO saved from an F455 *4*311*#Z## otherwise stays after a restart, 2026-11-08). - replaces_restored = self._standalone and self._hvac_mode_restored + standalone_mode = self._standalone previous_season = self._season self._season = message.season self._season_reported = True season_mode = HVACMode.HEAT if message.season == SEASON_HEATING else HVACMode.COOL prev_mode = self._attr_hvac_mode - if self._central or answers_write or replaces_restored or self._attr_hvac_mode in (None, HVACMode.OFF): + if self._central or standalone_mode or self._attr_hvac_mode in (None, HVACMode.OFF): if season_mode in self._attr_hvac_modes: LOGGER.debug("%s %s", self._gateway_handler.log_id, message.human_readable_log) self._attr_hvac_mode = season_mode diff --git a/tests/fixtures/traces/climate_mode_2026_11_08/frames.json b/tests/fixtures/traces/climate_mode_2026_11_08/frames.json index 72ce2f7bc..7b4f792d4 100644 --- a/tests/fixtures/traces/climate_mode_2026_11_08/frames.json +++ b/tests/fixtures/traces/climate_mode_2026_11_08/frames.json @@ -212,5 +212,64 @@ "rx", "*4*311*#2##" ] + ], + "f455_zone2_cool_from_f455_ui": [ + [ + "16:50:09.019", + "tx", + "*#4*2*#14*0265*2##" + ], + [ + "16:50:09.116", + "rx", + "*4*0*2##" + ], + [ + "16:50:09.156", + "rx", + "*#4*2*12*0265*3##" + ], + [ + "16:50:10.466", + "rx", + "*4*311*#2##" + ] + ], + "f461_zone2_cool_from_f455_ui": [ + [ + "16:50:09.071", + "rx", + "*#4*2*#14*0265*2##" + ], + [ + "16:50:09.078", + "tx", + "*#4*2##" + ], + [ + "16:50:09.340", + "rx", + "*4*0*2##" + ], + [ + "16:50:09.341", + "rx", + "*#4*2*12*0265*3##" + ], + [ + "16:50:22.785", + "tx", + "*#4*2*#14*0280*3##" + ], + [ + "16:50:22.902", + "rx", + "*#4*2*12*0280*3##" + ], + [ + "16:50:22.903", + "rx", + "*4*0*2##" + ] ] } diff --git a/tests/test_climate_mode_state_2026_11_08.py b/tests/test_climate_mode_state_2026_11_08.py index 34615880a..e81ebccb5 100644 --- a/tests/test_climate_mode_state_2026_11_08.py +++ b/tests/test_climate_mode_state_2026_11_08.py @@ -5,7 +5,8 @@ Every service call returned HTTP 200, yet Home Assistant showed the wrong mode: * the F455 emits ``*4*311*#2##`` after each status report of zone 2, which read as AUTO; -* a standalone zone's HEAT <-> COOL answer ``*4*0*2##`` / ``*4*1*2##`` was taken as season only; +* a standalone zone's ``*4*0*2##`` / ``*4*1*2##`` was taken as season only, so HEAT <-> COOL was not + shown, and the other gateway's entity kept the old mode; * the 3550 answers HEAT / COOL on #0 with antifreeze / thermal protection (102 / 202), which read as OFF, and the optimistic HEAT / COOL stayed on the second press; * the F455 hands Home Assistant's own ``*4*0*#0##`` back, which cleared the battery flag; @@ -135,23 +136,37 @@ async def test_standalone_heat_to_cool_shows_cool_from_the_thermostat_answer() - assert zone2.extra_state_attributes["season"] == "conditioning" -def test_unsolicited_season_frame_keeps_a_running_standalone_zone() -> None: +def test_unsolicited_season_frame_is_the_standalone_zone_mode() -> None: + # Plain-Z 0 / 1 is a standalone thermostat's operation mode (WHO 4 p. 63), whoever changed it. zone2 = _climate(_gateway(), "2", standalone=True) zone2._attr_hvac_mode = HVACMode.HEAT _replay(zone2, ["*4*0*2##"]) + assert zone2.hvac_mode == HVACMode.COOL + _replay(zone2, ["*4*1*2##"]) assert zone2.hvac_mode == HVACMode.HEAT + _replay(zone2, ["*4*303*2##"]) # off wins through its own frame + assert zone2.hvac_mode == HVACMode.OFF -async def test_season_answer_for_the_other_season_is_not_taken_as_the_mode() -> None: - gateway = _gateway() - zone2 = _climate(gateway, "2", standalone=True) - zone2._target_temperature = 26.5 - zone2._attr_hvac_mode = HVACMode.HEAT - await zone2.async_set_hvac_mode(HVACMode.COOL) - _replay(zone2, ["*4*1*2##"]) - assert zone2.hvac_mode == HVACMode.HEAT - _replay(zone2, ["*4*0*2##"]) # the pending write was answered: later frames are season only - assert zone2.hvac_mode == HVACMode.HEAT +async def test_both_gateways_show_cool_after_the_f455_switches_the_standalone_zone() -> None: + f455, f461 = _gateway(), _gateway() + zone2_f455 = _climate(f455, "2", standalone=True) + zone2_f461 = _climate(f461, "2", standalone=True) + for zone in (zone2_f455, zone2_f461): + zone._target_temperature = 26.5 + zone._attr_hvac_mode = HVACMode.HEAT + await zone2_f455.async_set_hvac_mode(HVACMode.COOL) + assert _sent(f455) == _tx("f455_zone2_cool_from_f455_ui") + _replay(zone2_f455, _rx("f455_zone2_cool_from_f455_ui")) + _replay(zone2_f461, _rx("f461_zone2_cool_from_f455_ui")) # incl. a later mode 3 setpoint change + assert zone2_f455.hvac_mode == zone2_f461.hvac_mode == HVACMode.COOL + + +def test_zone_of_the_central_keeps_its_mode_on_a_season_frame() -> None: + zone4 = _climate(_gateway(), "4", standalone=False) + zone4._attr_hvac_mode = HVACMode.AUTO + _replay(zone4, ["*4*0*4##"]) + assert zone4.hvac_mode == HVACMode.AUTO # F3: 102 / 202 on #0 are protection in their season, and HEAT / COOL on #0 waits for the 3550. @@ -334,8 +349,8 @@ async def test_restored_auto_on_a_standalone_zone_is_replaced_by_its_season_fram assert zone2.hvac_mode == HVACMode.AUTO _replay(zone2, _rx("f455_zone2_after_restart")) # ends with the F455's *4*311*#2## assert zone2.hvac_mode == HVACMode.HEAT - _replay(zone2, ["*4*0*2##"]) # reported since the restart: season frames are season only again - assert zone2.hvac_mode == HVACMode.HEAT + _replay(zone2, ["*4*0*2##"]) # the zone's own report keeps being its mode + assert zone2.hvac_mode == HVACMode.COOL async def test_restored_mode_of_a_zone_of_the_central_is_kept_on_a_season_frame() -> None: From 15cbd48ba811aad62e43333e83d3a5b752908e27 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sun, 8 Nov 2026 19:13:34 +0200 Subject: [PATCH 4/6] test(climate): 102 / 202 on the central are protection in the trace replays The F453AV (#619) and F454 (#629) trace replays asserted the old reading of *4*102*#0## / *4*202*#0## as OFF. The central unit now shows the season's mode with central_operation antifreeze / thermal_protection (WHO 4 p. 5, p. 64). The #619 replays also check that a zone the central put in protection is still OFF (#383). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01DN9oUd4ipuDSAE8mb2BjSq --- tests/test_issue_619_who4_trace.py | 18 ++++++++++++++---- .../test_issue_629_f454_central_unit_trace.py | 8 +++++--- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/tests/test_issue_619_who4_trace.py b/tests/test_issue_619_who4_trace.py index e0c469969..72a08f1fc 100644 --- a/tests/test_issue_619_who4_trace.py +++ b/tests/test_issue_619_who4_trace.py @@ -176,7 +176,8 @@ async def test_f453av_antifreeze_on_replay(hass: HomeAssistant) -> None: Verifies that setting Central Unit #0 to Antifreeze via *4*102*#0##: 1. Replays 112 authentic frames cleanly without errors. - 2. Sets Central Unit #0 to HVACMode.OFF (antifreeze protection). + 2. Shows Central Unit #0 in the heating season in antifreeze (102 is protection, + not off: WHO 4 p. 5, p. 64), while the zones it puts in antifreeze are OFF (#383). 3. Replays subordinate zone frames *4*102*#Z## and *4*102*Z##. 4. Leaves 0 device health faults. """ @@ -189,7 +190,11 @@ async def test_f453av_antifreeze_on_replay(hass: HomeAssistant) -> None: state_0 = hass.states.get("climate.climate_zone_0") assert state_0 is not None - assert state_0.state == HVACMode.OFF + assert state_0.state == HVACMode.HEAT + assert state_0.attributes["central_operation"] == "antifreeze" + state_3 = hass.states.get("climate.climate_zone_3") + assert state_3 is not None + assert state_3.state == HVACMode.OFF @pytest.mark.asyncio @@ -198,7 +203,8 @@ async def test_f453av_one_zone_to_cool_replay(hass: HomeAssistant) -> None: Verifies that switching from Winter to Summer season into Thermal Protection: 1. Replays 63 authentic frames cleanly without errors. - 2. Emits *4*202*#0## putting Central Unit #0 into protection. + 2. Emits *4*202*#0## putting Central Unit #0 into thermal protection, shown in the + conditioning season (202 is protection, not off), while its zones are OFF (#383). 3. Propagates cooling setpoints *#4*Z*12*0350*3## to zones. 4. Leaves 0 device health faults. """ @@ -211,7 +217,11 @@ async def test_f453av_one_zone_to_cool_replay(hass: HomeAssistant) -> None: state_0 = hass.states.get("climate.climate_zone_0") assert state_0 is not None - assert state_0.state == HVACMode.OFF + assert state_0.state == HVACMode.COOL + assert state_0.attributes["central_operation"] == "thermal_protection" + state_3 = hass.states.get("climate.climate_zone_3") + assert state_3 is not None + assert state_3.state == HVACMode.OFF @pytest.mark.asyncio diff --git a/tests/test_issue_629_f454_central_unit_trace.py b/tests/test_issue_629_f454_central_unit_trace.py index d71a1e990..bd60b1a0e 100644 --- a/tests/test_issue_629_f454_central_unit_trace.py +++ b/tests/test_issue_629_f454_central_unit_trace.py @@ -145,7 +145,7 @@ async def test_f454_central_unit_plain_status_replay(hass: HomeAssistant) -> Non Verifies that: 1. *#4*#0## requests status of central unit #0. - 2. Central unit responds with *4*202*#0## setting HVACMode.OFF on Climate Zone 0. + 2. Central unit responds with *4*202*#0## (thermal protection): Climate Zone 0 shows COOL. 3. Operational flags *4*21*#0##, *4*22*#0##, *4*24*#0## parse and process cleanly. 4. Subordinate zone frames *4*210*#5## and *4*210*#12## set HVACMode.COOL. """ @@ -208,10 +208,12 @@ async def test_f454_central_unit_plain_status_replay(hass: HomeAssistant) -> Non await hass.async_block_till_done() assert replayed == 7 - # Central unit #0 was set to OFF via *4*202*#0## + # Central unit #0 reported thermal protection via *4*202*#0##: the conditioning season, + # not off (WHO 4 p. 5, p. 64) state_0 = hass.states.get("climate.climate_zone_0") assert state_0 is not None, "Expected climate entity for Central Unit 0" - assert state_0.state == HVACMode.OFF + assert state_0.state == HVACMode.COOL + assert state_0.attributes["central_operation"] == "thermal_protection" # Zones 5 and 12 were set to COOL via *4*210*#5## and *4*210*#12## state_5 = hass.states.get("climate.climate_zone_5") From f5248f9b8a2f276247f1c20f7737642b49079d4d Mon Sep 17 00:00:00 2001 From: gdluck Date: Sun, 8 Nov 2026 19:30:42 +0200 Subject: [PATCH 5/6] test(climate): cover a central in protection after OFF *4*303*#0## then *4*102*#0## (live 3550 answers): the central shows HEAT with central_operation antifreeze, and its action leaves OFF. Restores the 100 % coverage of climate.py. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01DN9oUd4ipuDSAE8mb2BjSq --- tests/test_climate_mode_state_2026_11_08.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_climate_mode_state_2026_11_08.py b/tests/test_climate_mode_state_2026_11_08.py index e81ebccb5..894eda4a9 100644 --- a/tests/test_climate_mode_state_2026_11_08.py +++ b/tests/test_climate_mode_state_2026_11_08.py @@ -360,3 +360,15 @@ async def test_restored_mode_of_a_zone_of_the_central_is_kept_on_a_season_frame( await zone4.async_restore_last_state(State("climate.test", "auto", {"temperature": 20.5})) _replay(zone4, ["*4*0*4##"]) assert zone4.hvac_mode == HVACMode.AUTO + + +def test_central_off_then_antifreeze_is_heat_and_no_longer_action_off() -> None: + # Live 3550: *4*303*#0## answers OFF; a later *4*1*#0## is answered with antifreeze. + central = _climate(_gateway(), "#0", standalone=None, central=True) + _replay(central, ["*4*303*#0##"]) + assert central.hvac_mode == HVACMode.OFF + assert central.hvac_action == HVACAction.OFF + _replay(central, ["*4*102*#0##"]) + assert central.hvac_mode == HVACMode.HEAT + assert central.hvac_action == HVACAction.IDLE + assert central.extra_state_attributes["central_operation"] == "antifreeze" From 1a56d3c774da344364b9cf0159c145610784f0f1 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sun, 8 Nov 2026 20:54:16 +0200 Subject: [PATCH 6/6] fix(climate): address the #880 review (central detection, echo, 4-zone, schedules) - A central unit is known from #0 / #0#N frames, a #Z dimension, or a #Z zone status with its season (WHO 4 p. 65, p. 9: 110/210/111/211/103/203/ 102/202), in the dispatcher and in the climate entity. The generic *4*311*#Z## / *4*310*#Z## no longer count: the F455 makes up *4*311*#2## for a standalone thermostat, so a standalone-only plant was taken for one with a central unit. The live 3550 is still found from its *4*210*#4##. - The central's echo drop applies only within 100 ms of the command (F455 echo after 1 ms; the 3550's first answer after 121 ms), to every copy of the frame inside that window; a failed send disarms it. - Central OFF / AUTO clear central_operation, OFF sets the action to off. - HEAT / COOL wait for the 3550's answer only on #0; a 4-zone central (#0#1..#0#4), which is not polled, keeps the mode it was set to. - A standalone zone's plain season frame no longer cancels an AUTO the zone itself reported (Dimension 7 automatic context); Dimension 7 also ends the restored-mode state. - central_operation is restored after a restart; *4*302*#0## is the protection of the season the plant is in (WHO 4 p. 5). - The season errors are two translation keys without a season placeholder. Co-Authored-By: Claude Opus 5.5 --- custom_components/myhome/climate.py | 52 ++++- custom_components/myhome/gateway_events.py | 4 +- custom_components/myhome/strings.json | 7 +- custom_components/myhome/translations/en.json | 7 +- custom_components/myhome/translations/fr.json | 7 +- custom_components/myhome/translations/it.json | 7 +- custom_components/myhome/translations/nl.json | 7 +- custom_components/myhome/where_grammar.py | 29 +++ tests/test_climate_mode_state_2026_11_08.py | 213 +++++++++++++++++- 9 files changed, 307 insertions(+), 26 deletions(-) diff --git a/custom_components/myhome/climate.py b/custom_components/myhome/climate.py index a6324c3ca..4cce11e86 100644 --- a/custom_components/myhome/climate.py +++ b/custom_components/myhome/climate.py @@ -86,6 +86,7 @@ canonical_thermo_zone, is_actuator_where, is_central_thermo_where, + is_central_unit_report, is_probe, is_pump, parse_thermo_actuator_where, @@ -124,9 +125,14 @@ def _central_diagnostic_what(message: Any) -> int | None: # Central unit protection modes (WHO 4 p. 5 and p. 64): the plant keeps its season and # regulates to the protection setpoint; it is not switched off. _CENTRAL_PROTECTION = {"102": ("antifreeze", HVACMode.HEAT), "202": ("thermal_protection", HVACMode.COOL)} -# An F455 hands Home Assistant's own central command back on the event session within -# milliseconds (live, 2026-11-08); a 3550 reply is a different frame. -_CENTRAL_ECHO_WINDOW = 2.0 +# 302 is generic protection (WHO 4 p. 5): the protection of the season the plant is in. +_CENTRAL_GENERIC_PROTECTION = "302" +# An F455 hands Home Assistant's own central command back on the event session 1 ms after +# it was sent; the 3550's answers came 121 ms (*4*203*#0## to *4*303*#0##) or later on the +# F461 and F455 (live, 2026-11-08). Only a frame inside this window can be the echo, so a +# 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 _CONDITIONING_SEASONS = (SEASON_CONDITIONING, "cooling") # How long the fan speed poll waits for the one-shot dimension 19 answer (*#4*Z*19##, @@ -805,6 +811,9 @@ async def async_restore_last_state(self, last_state: State | None) -> None: restored_rc = last_state.attributes.get("remote_control") if restored_rc in ("enabled", "disabled"): self._remote_control = str(restored_rc) + restored_operation = last_state.attributes.get("central_operation") + if restored_operation in ("antifreeze", "thermal_protection"): + self._central_operation = str(restored_operation) self._at_least_one_probe_off = bool( last_state.attributes.get("at_least_one_probe_off", False) ) @@ -1424,13 +1433,22 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: ) # Set before sending: the echo can arrive while send() is still awaited. self._central_echo = (str(command), time.monotonic() + _CENTRAL_ECHO_WINDOW) - await self._gateway_handler.send(command) - if hvac_mode in (HVACMode.HEAT, HVACMode.COOL): + try: + await self._gateway_handler.send(command) + except BaseException: + self._central_echo = None + raise + 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, # or nothing when the season does not change (live F461 and F455, - # 2026-11-08), so the mode shown comes from that answer. + # 2026-11-08), so the mode shown comes from that answer. A 4-zone + # central (#0#1..#0#4) is not polled and its reports are routed to the + # zone, so it keeps the mode it was set to. return + self._central_operation = None + if hvac_mode == HVACMode.OFF: + self._attr_hvac_action = HVACAction.OFF self._attr_hvac_mode = hvac_mode if self.hass is not None: self.async_write_ha_state() @@ -1507,8 +1525,8 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: raise ServiceValidationError( f"{self._display_name} follows the central unit, which is in the {self._season} season", translation_domain=DOMAIN, - translation_key="zone_mode_outside_central_season", - translation_placeholders={"name": self._display_name, "season": self._season}, + translation_key="zone_heat_in_conditioning_season" if heat else "zone_cool_in_heating_season", + translation_placeholders={"name": self._display_name}, ) nominal = self._effective_nominal_temperature() if nominal is not None: @@ -1613,6 +1631,7 @@ def _apply_zone_state(self, message: OWNHeatingEvent) -> None: mode = _ZONE_CONTEXT_MODES.get(getattr(message, "zone_context", None) or "") if mode is not None and mode in self._attr_hvac_modes: self._attr_hvac_mode = mode + self._hvac_mode_restored = False # the zone reported the mode it runs in if self._attr_hvac_action == HVACAction.OFF: self._attr_hvac_action = HVACAction.IDLE temperature = message.set_temperature if state == "setpoint" else None @@ -1635,7 +1654,9 @@ def _is_own_central_echo(self, message: OWNHeatingEvent) -> bool: had just reported and turned the action from OFF to IDLE. The climate and sensor platforms both route WHO 4 frames to the zone, so the one - echo frame can reach this entity twice: it is recognised by identity the second time. + echo frame can reach this entity twice, as the same object (live) or as another copy: + every frame equal to the command inside the window is the echo, and the same object + stays recognised after it. """ if message is self._central_echo_seen: return True @@ -1648,7 +1669,6 @@ def _is_own_central_echo(self, message: OWNHeatingEvent) -> bool: return False if str(message) != frame: return False - self._central_echo = None self._central_echo_seen = message return True @@ -1671,7 +1691,9 @@ def handle_event(self, message: OWNHeatingEvent) -> None: if self._central and self._is_own_central_echo(message): LOGGER.debug("%s %s: own command echoed by the gateway, not a central unit report", self._gateway_handler.log_id, message) return - if is_central_thermo_where(message): + # A central unit's own report first: on a zero-config plant its *4*111*#1## makes zone 1 + # a zone of the central before the standalone check below looks at it. + if is_central_unit_report(message): self._gateway_handler.has_central_unit = True if self._poll_health.frame_seen(): self._clear_unresponsive_issue() @@ -1776,9 +1798,13 @@ def handle_event(self, message: OWNHeatingEvent) -> None: # it: Home Assistant (*#4*2*#14*0265*2## -> *4*0*2##), another gateway on the bus, # a touch screen or the thermostat itself. Two gateways on one bus then show the # 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 + standalone_mode = self._standalone and not ( + self._attr_hvac_mode == HVACMode.AUTO and not self._hvac_mode_restored + ) previous_season = self._season self._season = message.season self._season_reported = True @@ -1831,6 +1857,8 @@ def handle_event(self, message: OWNHeatingEvent) -> None: self._holiday_days = getattr(message, "holiday_days", None) self._scenario = getattr(message, "scenario", None) protection = _CENTRAL_PROTECTION.get(raw_what) if self._central else None + if self._central and raw_what == _CENTRAL_GENERIC_PROTECTION and self._season is not None: + protection = _CENTRAL_PROTECTION["102" if self._season == SEASON_HEATING else "202"] if self._central: self._central_operation = protection[0] if protection is not None else None if protection is not None: diff --git a/custom_components/myhome/gateway_events.py b/custom_components/myhome/gateway_events.py index 986bf6cce..6cf374e13 100644 --- a/custom_components/myhome/gateway_events.py +++ b/custom_components/myhome/gateway_events.py @@ -45,7 +45,7 @@ LOGGER, ) from .topology import peer_handles_who -from .where_grammar import is_central_thermo_where, is_probe, where_param +from .where_grammar import is_central_unit_report, is_probe, where_param if TYPE_CHECKING: from .gateway import MyHOMEGatewayHandler @@ -306,7 +306,7 @@ async def process_message(self, message: Any) -> None: or isinstance(message, OWNHeatingEvent) ): if not message.is_translation: - if isinstance(message, OWNHeatingEvent) and is_central_thermo_where(message): + if isinstance(message, OWNHeatingEvent) and is_central_unit_report(message): self.handler.has_central_unit = True if isinstance(message, OWNLightingEvent) and self._is_active_for_who(1): if ( diff --git a/custom_components/myhome/strings.json b/custom_components/myhome/strings.json index f97f95caf..852aa5095 100644 --- a/custom_components/myhome/strings.json +++ b/custom_components/myhome/strings.json @@ -603,8 +603,11 @@ "auto_standalone_zone": { "message": "{name} is a standalone thermostat: the central unit has no program for it" }, - "zone_mode_outside_central_season": { - "message": "{name} follows the central unit, which is in the {season} season: change the season on the central unit" + "zone_heat_in_conditioning_season": { + "message": "{name} follows the central unit, which is in the conditioning season: change the season on the central unit" + }, + "zone_cool_in_heating_season": { + "message": "{name} follows the central unit, which is in the heating season: change the season on the central unit" }, "command_delivery_cancelled": { "message": "{name}: direction command delivery was cancelled before reaching the bus" diff --git a/custom_components/myhome/translations/en.json b/custom_components/myhome/translations/en.json index f97f95caf..852aa5095 100644 --- a/custom_components/myhome/translations/en.json +++ b/custom_components/myhome/translations/en.json @@ -603,8 +603,11 @@ "auto_standalone_zone": { "message": "{name} is a standalone thermostat: the central unit has no program for it" }, - "zone_mode_outside_central_season": { - "message": "{name} follows the central unit, which is in the {season} season: change the season on the central unit" + "zone_heat_in_conditioning_season": { + "message": "{name} follows the central unit, which is in the conditioning season: change the season on the central unit" + }, + "zone_cool_in_heating_season": { + "message": "{name} follows the central unit, which is in the heating season: change the season on the central unit" }, "command_delivery_cancelled": { "message": "{name}: direction command delivery was cancelled before reaching the bus" diff --git a/custom_components/myhome/translations/fr.json b/custom_components/myhome/translations/fr.json index 47b70538b..3582b7a81 100644 --- a/custom_components/myhome/translations/fr.json +++ b/custom_components/myhome/translations/fr.json @@ -539,8 +539,11 @@ "auto_standalone_zone": { "message": "{name} est un thermostat autonome : la centrale n'a pas de programme pour lui" }, - "zone_mode_outside_central_season": { - "message": "{name} suit la centrale, qui est en saison {season} : changez la saison sur la centrale" + "zone_heat_in_conditioning_season": { + "message": "{name} suit la centrale, qui est en saison de climatisation : changez la saison sur la centrale" + }, + "zone_cool_in_heating_season": { + "message": "{name} suit la centrale, qui est en saison de chauffage : changez la saison sur la centrale" }, "command_delivery_cancelled": { "message": "{name} : l'envoi de la commande de direction a été annulé avant d'atteindre le bus" diff --git a/custom_components/myhome/translations/it.json b/custom_components/myhome/translations/it.json index ca5f04117..a8663c0d6 100644 --- a/custom_components/myhome/translations/it.json +++ b/custom_components/myhome/translations/it.json @@ -539,8 +539,11 @@ "auto_standalone_zone": { "message": "{name} è un termostato autonomo: la centrale non ha un programma per esso" }, - "zone_mode_outside_central_season": { - "message": "{name} segue la centrale, che è nella stagione {season}: cambia la stagione sulla centrale" + "zone_heat_in_conditioning_season": { + "message": "{name} segue la centrale, che è nella stagione di condizionamento: cambia la stagione sulla centrale" + }, + "zone_cool_in_heating_season": { + "message": "{name} segue la centrale, che è nella stagione di riscaldamento: cambia la stagione sulla centrale" }, "command_delivery_cancelled": { "message": "{name}: l'invio del comando di direzione è stato annullato prima di raggiungere il bus" diff --git a/custom_components/myhome/translations/nl.json b/custom_components/myhome/translations/nl.json index c8e925e23..d8bb90ad2 100644 --- a/custom_components/myhome/translations/nl.json +++ b/custom_components/myhome/translations/nl.json @@ -539,8 +539,11 @@ "auto_standalone_zone": { "message": "{name} is een zelfstandige thermostaat: de centrale heeft er geen programma voor" }, - "zone_mode_outside_central_season": { - "message": "{name} volgt de centrale, die in het seizoen {season} staat: wijzig het seizoen op de centrale" + "zone_heat_in_conditioning_season": { + "message": "{name} volgt de centrale, die in het koelseizoen staat: wijzig het seizoen op de centrale" + }, + "zone_cool_in_heating_season": { + "message": "{name} volgt de centrale, die in het stookseizoen staat: wijzig het seizoen op de centrale" }, "command_delivery_cancelled": { "message": "{name}: de aflevering van het richtingscommando is geannuleerd voordat het de bus bereikte" diff --git a/custom_components/myhome/where_grammar.py b/custom_components/myhome/where_grammar.py index 6dcd63a8b..8259c01f4 100644 --- a/custom_components/myhome/where_grammar.py +++ b/custom_components/myhome/where_grammar.py @@ -201,6 +201,7 @@ def _sound_step_delta(self: Any) -> int | None: "is_actuator_where", "is_apl_address", "is_central_thermo_where", + "is_central_unit_report", "is_probe", "is_pump", "is_valid_hs_tuple", @@ -290,6 +291,34 @@ def is_central_thermo_where(message: Any) -> bool: return raw.startswith("#") and head.isdigit() and not is_probe(head) +# Zone operation mode by central unit with its season (WHO 4 p. 65, 1.9.4; p. 9): 110 / 210 +# manual, 111 / 211 automatic, 103 / 203 off, 102 / 202 protection. The generic 310 / 311 +# (p. 9 lists them on the monitor session too) are also the command text, and an F455 +# makes up *4*311*#Z## for a standalone thermostat; the live 3550 reported its zone 4 as +# *4*210*#4##, also in its quarter-hour push, so a central unit is still found. +_CENTRAL_ZONE_STATUS_WHATS = frozenset({"110", "210", "111", "211", "103", "203", "102", "202"}) + + +def is_central_unit_report(message: Any) -> bool: + """Whether a WHO=4 frame shows that the plant has a central unit. + + ``#0`` and ``#0#N`` are the central unit itself (WHO 4 p. 6, p. 64). A ``#Z`` frame counts + when it is a dimension or a zone status with its season (WHO 4 p. 65). A generic one does + not: an F455 puts ``*4*311*#2##`` on its event session after every status of a standalone + thermostat, on a plant with or without a central unit (live, 2026-11-08). + """ + if not is_central_thermo_where(message): + return False + raw = str(getattr(message, "where", None) or "").strip().removeprefix("4-") + if raw.removeprefix("#").split("#", 1)[0] == "0": + return True + frame = str(message) + if not frame.startswith("*4*"): + return True + what = frame.split("*")[2].split("#", 1)[0] + return what in _CENTRAL_ZONE_STATUS_WHATS + + def canonical_point_spelling(where: str) -> str: """``0004`` -> ``04``, silently; anything else as typed. diff --git a/tests/test_climate_mode_state_2026_11_08.py b/tests/test_climate_mode_state_2026_11_08.py index 894eda4a9..3b913a87e 100644 --- a/tests/test_climate_mode_state_2026_11_08.py +++ b/tests/test_climate_mode_state_2026_11_08.py @@ -263,7 +263,13 @@ async def test_only_one_echo_is_dropped_and_only_while_expected() -> None: await central.async_set_hvac_mode(HVACMode.COOL) _replay(central, ["*4*0*#0##"]) # the echo assert central.extra_state_attributes.get("season") is None - _replay(central, ["*4*0*#0##"]) # a second identical frame is a report + _replay(central, ["*4*0*#0##"]) # another copy of it, e.g. from the second routing + assert central.extra_state_attributes.get("season") is None + from custom_components.myhome import climate as climate_module + + later = climate_module.time.monotonic() + 0.121 + with patch("custom_components.myhome.climate.time.monotonic", return_value=later): + _replay(central, ["*4*0*#0##"]) # an identical frame after the window is a report assert central.hvac_mode == HVACMode.COOL await central.async_set_hvac_mode(HVACMode.HEAT) @@ -282,7 +288,7 @@ async def test_heat_on_a_zone_of_the_central_in_conditioning_is_refused() -> Non zone4._target_temperature = 20.5 with pytest.raises(ServiceValidationError) as err: await zone4.async_set_hvac_mode(HVACMode.HEAT) - assert err.value.translation_key == "zone_mode_outside_central_season" + assert err.value.translation_key == "zone_heat_in_conditioning_season" gateway.send.assert_not_awaited() assert zone4.hvac_mode == HVACMode.COOL await zone4.async_set_hvac_mode(HVACMode.COOL) @@ -372,3 +378,206 @@ def test_central_off_then_antifreeze_is_heat_and_no_longer_action_off() -> None: assert central.hvac_mode == HVACMode.HEAT assert central.hvac_action == HVACAction.IDLE assert central.extra_state_attributes["central_operation"] == "antifreeze" + + +# PR #880 review: the F455's synthetic *4*311*#Z## is not a central unit report. + + +def _dispatcher(gateway: MagicMock) -> Any: + from custom_components.myhome.gateway_events import GatewayEventDispatcher + + gateway.hass = MagicMock() + gateway.host = "gateway.test" + gateway.generate_events = False + gateway._correlate_shared_bus_traffic = MagicMock() + gateway._bridge_to_primary = MagicMock() + gateway.health_owner = MagicMock(return_value=None) + return GatewayEventDispatcher(gateway) + + +async def test_f455_synthetic_311_does_not_make_a_standalone_only_plant_central() -> None: + gateway = _gateway(has_central_unit=False) + dispatcher = _dispatcher(gateway) + zone2 = _climate(gateway, "2", standalone=None) # zero-config: follows the plant + for raw in _rx("f455_zone2_after_restart"): + await dispatcher.process_message(OWNEvent.parse(raw)) + zone2.handle_event(OWNEvent.parse(raw)) + assert gateway.has_central_unit is False + assert zone2._standalone is True + assert zone2.hvac_mode == HVACMode.HEAT + await zone2.async_set_hvac_mode(HVACMode.COOL) + assert _sent(gateway) == ["*#4*2*#14*0265*2##"] + + +async def test_auto_fallback_survives_the_f455_trailing_311() -> None: + gateway = _gateway(has_central_unit=False) + dispatcher = _dispatcher(gateway) + zone2 = _climate(gateway, "2", standalone=True) + for raw in _rx("f455_zone2_after_restart"): + await dispatcher.process_message(OWNEvent.parse(raw)) + zone2.handle_event(OWNEvent.parse(raw)) + assert gateway.has_central_unit is False + await zone2.async_set_hvac_mode(HVACMode.AUTO) + assert _sent(gateway) == ["*#4*2*#14*0265*3##"] + + +@pytest.mark.parametrize( + ("raw", "central"), + [ + ("*4*311*#2##", False), # generic automatic (WHO 4 p. 9), F455 synthetic + ("*4*310*#2##", False), # generic manual (WHO 4 p. 9) + ("*4*303*#2##", False), # generic off, not a zone status (WHO 4 p. 65) + ("*4*111*#2##", True), # zone automatic by central unit (WHO 4 p. 65) + ("*4*210*#4##", True), # zone manual conditioning by central unit, live 3550 + ("*4*203*#4##", True), # zone off conditioning by central unit, live 3550 + ("*4*102*#0##", True), # the central unit itself (WHO 4 p. 64) + ("*4*303*#0#1##", True), # 4-zone central unit + ("*#4*#4*14*0205*2##", True), # a dimension of a zone of the central + ("*#4*2*0*0238##", False), # a standalone thermostat's temperature + ("*#4*101*0*0205##", False), # a probe (WHO 4 p. 6) + ("*#4*2#1*20*0##", False), # an actuator of zone 2 + ], +) +async def test_central_unit_detection_by_frame(raw: str, central: bool) -> None: + gateway = _gateway(has_central_unit=False) + await _dispatcher(gateway).process_message(OWNEvent.parse(raw)) + assert gateway.has_central_unit is central + + +# PR #880 review: a gateway that does not echo still gets the 3550's identical answer. + + +async def test_identical_central_answer_after_the_echo_window_is_a_report() -> None: + gateway = _gateway() + central = _climate(gateway, "#0", standalone=None, central=True) + _replay(central, _rx("f461_central_heat")) + central._battery_fault = True + assert central.extra_state_attributes["central_operation"] == "antifreeze" + await central.async_set_hvac_mode(HVACMode.OFF) + assert central.hvac_action == HVACAction.OFF + assert "central_operation" not in central.extra_state_attributes + from custom_components.myhome import climate as climate_module + + 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 + assert central.hvac_mode == HVACMode.OFF + + +async def test_failed_send_disarms_the_echo() -> None: + gateway = _gateway() + gateway.send.side_effect = OSError("gateway gone") + central = _climate(gateway, "#0", standalone=None, central=True) + with pytest.raises(OSError): + await central.async_set_hvac_mode(HVACMode.COOL) + assert central._central_echo is None + + +# PR #880 review: a 4-zone central unit (#0#1..#0#4) is not polled, it keeps HEAT / COOL. + + +@pytest.mark.parametrize("hvac_mode", [HVACMode.HEAT, HVACMode.COOL]) +async def test_four_zone_central_keeps_the_mode_it_is_set_to(hvac_mode: HVACMode) -> None: + gateway = _gateway() + central = _climate(gateway, "#0#1", standalone=None, central=True) + await central.async_set_hvac_mode(hvac_mode) + assert central.hvac_mode == hvac_mode + assert _sent(gateway) == [f"*4*{1 if hvac_mode == HVACMode.HEAT else 0}*#0#1##"] + + +# PR #880 review: an AUTO the zone itself reports is not cancelled by its season frame. + + +def test_myhomeserver1_schedule_auto_survives_the_trailing_season_frame() -> None: + zone1 = _climate(_gateway(has_central_unit=False), "1", standalone=None) + _replay(zone1, ["*#4*1*7*3*1*0210##", "*4*1*1##"]) + assert zone1.hvac_mode == HVACMode.AUTO + _replay(zone1, ["*#4*1*7*1*1*0210##", "*4*0*1##"]) # manual context, then conditioning + assert zone1.hvac_mode == HVACMode.COOL + + +async def test_restored_auto_then_schedule_auto_is_kept() -> None: + from homeassistant.core import State + + zone1 = _climate(_gateway(has_central_unit=False), "1", standalone=None) + await zone1.async_restore_last_state(State("climate.test", "auto", {"temperature": 21.0})) + _replay(zone1, ["*#4*1*7*3*1*0210##", "*4*1*1##"]) + assert zone1.hvac_mode == HVACMode.AUTO + + +# PR #880 review: central_operation survives a restart; 302 is the protection of the season. + + +async def test_central_operation_is_restored() -> None: + from homeassistant.core import State + + central = _climate(_gateway(), "#0", standalone=None, central=True) + await central.async_restore_last_state( + State("climate.test", "heat", {"central_operation": "antifreeze", "season": "heating"}) + ) + assert central.extra_state_attributes["central_operation"] == "antifreeze" + assert central.hvac_mode == HVACMode.HEAT + + +@pytest.mark.parametrize( + ("season_frame", "mode", "operation"), + [("*4*1*#0##", HVACMode.HEAT, "antifreeze"), ("*4*0*#0##", HVACMode.COOL, "thermal_protection")], +) +def test_central_generic_protection_follows_the_season(season_frame: str, mode: HVACMode, operation: str) -> None: + central = _climate(_gateway(), "#0", standalone=None, central=True) + _replay(central, [season_frame, "*4*302*#0##"]) + assert central.hvac_mode == mode + assert central.extra_state_attributes["central_operation"] == operation + + +# PR #880 review: the season is in the translated text, not an English placeholder. + + +@pytest.mark.parametrize("language", ["strings", "en", "fr", "it", "nl"]) +def test_season_errors_are_translated_without_a_season_placeholder(language: str) -> None: + base = Path(__file__).parents[1] / "custom_components" / "myhome" + path = base / "strings.json" if language == "strings" else base / "translations" / f"{language}.json" + exceptions = json.loads(path.read_text())["exceptions"] + assert "zone_mode_outside_central_season" not in exceptions + for key in ("zone_heat_in_conditioning_season", "zone_cool_in_heating_season", "auto_standalone_zone"): + assert "{season}" not in exceptions[key]["message"] + assert "{name}" in exceptions[key]["message"] + + +async def test_cool_on_a_zone_of_the_central_in_heating_is_refused() -> None: + gateway = _gateway() + zone4 = _climate(gateway, "4", standalone=False) + _replay(zone4, ["*4*1*4##"]) + zone4._target_temperature = 20.5 + with pytest.raises(ServiceValidationError) as err: + await zone4.async_set_hvac_mode(HVACMode.COOL) + assert err.value.translation_key == "zone_cool_in_heating_season" + gateway.send.assert_not_awaited() + + +async def test_echo_delivered_as_two_copies_changes_nothing() -> None: + # The second routing may hand the entity another object for the same frame. + gateway = _gateway() + central = _climate(gateway, "#0", standalone=None, central=True) + _replay(central, _rx("f455_central_cool")) + assert central.extra_state_attributes["battery_fault"] is True + + async def send_and_echo(command: Any) -> None: + central.handle_event(OWNEvent.parse(str(command))) + central.handle_event(OWNEvent.parse(str(command))) + + gateway.send.side_effect = send_and_echo + await central.async_set_hvac_mode(HVACMode.COOL) + assert central.extra_state_attributes["battery_fault"] is True + assert central.extra_state_attributes["central_operation"] == "thermal_protection" + + +def test_zone_of_the_central_found_from_the_3550_quarter_hour_push() -> None: + # Live 3550 push (14:45:00): no #0 frame, the zone 4 status in the #Z form. + gateway = _gateway(has_central_unit=False) + zone4 = _climate(gateway, "4", standalone=None) + _replay(zone4, ["*#4*4*12*0205*3##", "*4*0*4##", "*#4*4*14*0205*3##", "*4*210*#4##"]) + assert gateway.has_central_unit is True + assert zone4._standalone is False + assert zone4.hvac_mode == HVACMode.COOL