diff --git a/custom_components/myhome/climate.py b/custom_components/myhome/climate.py index 679191c28..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, @@ -121,6 +122,18 @@ 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)} +# 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##, # 0.11 s on the live F461) before asking as it did before. @@ -619,6 +632,14 @@ 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 self._program: int | None = None self._holiday_days: int | None = None self._scenario: int | None = None @@ -767,6 +788,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 @@ -788,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) ) @@ -1134,6 +1160,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) @@ -1400,12 +1427,28 @@ 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) + 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. 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() @@ -1453,6 +1496,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, @@ -1460,25 +1513,28 @@ 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_heat_in_conditioning_season" if heat else "zone_cool_in_heating_season", + translation_placeholders={"name": self._display_name}, ) - elif hvac_mode == HVACMode.COOL: 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_COOL, + mode=CLIMATE_MODE_HEAT if heat else CLIMATE_MODE_COOL, standalone=self._standalone, ) ) @@ -1575,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 @@ -1588,12 +1645,66 @@ 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, 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 + 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_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 handle_event(self, message: OWNHeatingEvent) -> None: """Handle an event message.""" - if is_central_thermo_where(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 + # 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() + 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", @@ -1681,13 +1792,25 @@ def handle_event(self, message: OWNHeatingEvent) -> None: # 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 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. + # 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 + ) 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 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 @@ -1728,11 +1851,28 @@ 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 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: + # *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, @@ -1785,6 +1925,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 self._hvac_mode_restored = False prev_mode = self._attr_hvac_mode self._remember_season(message) 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 15129d369..852aa5095 100644 --- a/custom_components/myhome/strings.json +++ b/custom_components/myhome/strings.json @@ -600,6 +600,15 @@ "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_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 15129d369..852aa5095 100644 --- a/custom_components/myhome/translations/en.json +++ b/custom_components/myhome/translations/en.json @@ -600,6 +600,15 @@ "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_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 12e4dbcc2..3582b7a81 100644 --- a/custom_components/myhome/translations/fr.json +++ b/custom_components/myhome/translations/fr.json @@ -536,6 +536,15 @@ "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_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 719143fac..a8663c0d6 100644 --- a/custom_components/myhome/translations/it.json +++ b/custom_components/myhome/translations/it.json @@ -536,6 +536,15 @@ "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_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 f99819348..d8bb90ad2 100644 --- a/custom_components/myhome/translations/nl.json +++ b/custom_components/myhome/translations/nl.json @@ -536,6 +536,15 @@ "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_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/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..7b4f792d4 --- /dev/null +++ b/tests/fixtures/traces/climate_mode_2026_11_08/frames.json @@ -0,0 +1,275 @@ +{ + "_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##" + ] + ], + "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##" + ] + ], + "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_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..3b913a87e --- /dev/null +++ b/tests/test_climate_mode_state_2026_11_08.py @@ -0,0 +1,583 @@ +"""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 ``*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; +* 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_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_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. + + +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##"]) # 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) + 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_heat_in_conditioning_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##"] + + +# 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##"]) # 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 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 + + +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" + + +# 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 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_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") 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