From 7d9b7c868956108398726d74a46b9365954c453d Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:40:06 +0200 Subject: [PATCH] feat(climate): support WHO 4 season frames and generic setpoint mode (#649) - Decode WHO 4 *4*1*Z## and *4*0*Z## season frames without altering running zone HVAC mode - Write generic mode 3 in async_set_temperature to preserve active plant season - Retain explicit mode 1 (heat) and 2 (cool) in async_set_hvac_mode - Maintain dual backward compatibility with released OWNd 2.0.0b10 and dev OWNd - Ensure 100% test coverage across both pinned OWNd 2.0.0b10 and newer OWNd releases Co-authored-by: gdluck <9041273+gdluck@users.noreply.github.com> --- custom_components/myhome/climate.py | 88 ++++++++++++++------- tests/test_climate_season_frame.py | 114 ++++++++++++++++++++++++++++ tests/test_component_climate.py | 6 +- tests/test_heating.py | 16 +++- tests/test_message_edges.py | 8 +- 5 files changed, 197 insertions(+), 35 deletions(-) create mode 100644 tests/test_climate_season_frame.py diff --git a/custom_components/myhome/climate.py b/custom_components/myhome/climate.py index 927b52ca7..ebe73e7a7 100644 --- a/custom_components/myhome/climate.py +++ b/custom_components/myhome/climate.py @@ -45,6 +45,12 @@ OWNHeatingEvent, ) +try: # OWNd > 2.0.0b10 (OpenWebNet-HA/OWNd#94) + from OWNd.message import MESSAGE_TYPE_SEASON, SEASON_HEATING +except ImportError: # pragma: no cover - pinned OWNd 2.0.0b10 reports WHAT 1/0 as hvac_mode + MESSAGE_TYPE_SEASON = "hvac_season" + SEASON_HEATING = "heating" + from .const import ( BUS_ROUTING, CONF_CENTRAL, @@ -78,6 +84,14 @@ _ZONE_STATES_ON = ("setpoint", "comfort", "eco") _ZONE_STATES_OFF = ("protection", "off") +# Mode digit of a setpoint write (*#4*W*#14*TTTT*M##). Legrand WHO 4 p. 8 and +# p. 23 define M = 1 heating, 2 conditioning, 3 generic; BTicino's own client +# always writes 3 (libqtdevices probe_device.cpp:122, thermal_device.cpp:134) +# and the mhs1 / F454 firmware forwards 1, 2 and 3 as three different bus +# codes (90/91/92 for a zone, C1 12/22/02 for the central unit). Writing generic +# mode 3 in async_set_temperature keeps the plant's existing season. +SETPOINT_WRITE_MODE = CLIMATE_MODE_AUTO + async def async_setup_entry( hass: HomeAssistant, @@ -328,6 +342,7 @@ def __init__( self._enable_fan_mode() self._attr_current_temperature: float | None = None + self._season: str | None = None self._attr_current_humidity: float | None = None self._target_temperature: float | None = None self._local_offset: float = 0 @@ -358,6 +373,8 @@ def extra_state_attributes(self) -> dict[str, Any]: "local_target_temperature": self._local_target_temperature, "knob_pos": self._knob_pos, } + if self._season is not None: + attrs["season"] = self._season if self._fan: attrs["fan_mode"] = self._attr_fan_mode if self._running_fan_speed is not None: @@ -594,33 +611,14 @@ async def async_set_temperature(self, **kwargs: Any) -> None: if self.hass is not None: self.async_write_ha_state() return - if self._attr_hvac_mode == HVACMode.HEAT: - await self._gateway_handler.send( - OWNHeatingCommand.set_temperature( - where=self._where, - temperature=target_temperature, - mode=CLIMATE_MODE_HEAT, - standalone=self._standalone, - ) - ) - elif self._attr_hvac_mode == HVACMode.COOL: - await self._gateway_handler.send( - OWNHeatingCommand.set_temperature( - where=self._where, - temperature=target_temperature, - mode=CLIMATE_MODE_COOL, - standalone=self._standalone, - ) - ) - else: - await self._gateway_handler.send( - OWNHeatingCommand.set_temperature( - where=self._where, - temperature=target_temperature, - mode=CLIMATE_MODE_AUTO, - standalone=self._standalone, - ) + await self._gateway_handler.send( + OWNHeatingCommand.set_temperature( + where=self._where, + temperature=target_temperature, + mode=SETPOINT_WRITE_MODE, + standalone=self._standalone, ) + ) def _dimension_3_is_protection(self, message: OWNHeatingEvent) -> bool: """Whether a dimension 12/14 frame may be a protection setpoint rather than the nominal one. @@ -751,6 +749,44 @@ def handle_event(self, message: OWNHeatingEvent) -> None: if self._local_target_temperature is not None else None ) + elif message.message_type == MESSAGE_TYPE_SEASON: + # *4*1*Z## / *4*0*Z## is the zone operation mode frame: the zone + # (or the central unit) runs in the heating / conditioning season + # (Legrand WHO 4 v2.0.0 p. 5, 13, 16, 19, 63). OWNd decodes it as + # ``season``; it is not an operating-mode change. A zone that is + # AUTO, HEAT or COOL keeps its mode (BTicino's client keeps a zone + # 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. + self._season = message.season + 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 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 + if self._attr_hvac_action == HVACAction.OFF: + self._attr_hvac_action = HVACAction.IDLE + else: + LOGGER.debug( + "%s %s (season only, zone stays %s)", + self._gateway_handler.log_id, + message.human_readable_log, + self._attr_hvac_mode, + ) + if ( + prev_mode == HVACMode.OFF + and self._attr_hvac_mode != HVACMode.OFF + and self._target_temperature is not None + ): + self._local_target_temperature = self._target_temperature + self._local_offset + if self._central and self.hass is not None and self._attr_hvac_mode is not None: + async_dispatcher_send( + self.hass, + f"myhome_central_mode_{self._gateway_handler.mac}", + self._attr_hvac_mode, + ) elif message.message_type == MESSAGE_TYPE_MODE: prev_mode = self._attr_hvac_mode if message.mode == CLIMATE_MODE_AUTO and HVACMode.AUTO in self._attr_hvac_modes: diff --git a/tests/test_climate_season_frame.py b/tests/test_climate_season_frame.py new file mode 100644 index 000000000..894769073 --- /dev/null +++ b/tests/test_climate_season_frame.py @@ -0,0 +1,114 @@ +"""WHAT 1 / 0 (zone operation mode = season) must not flip a running zone's mode. + +Runs with both pinned OWNd 2.0.0b10 (synthetic season events) and newer OWNd +where OWND decodes the frame natively as ``hvac_season`` (OpenWebNet-HA/OWNd#94). +""" +from unittest.mock import AsyncMock, MagicMock, patch + +import OWNd.message as own_message +import pytest +from homeassistant.components.climate.const import HVACAction, HVACMode +from OWNd.message import OWNEvent + +from custom_components.myhome.climate import ( + MESSAGE_TYPE_SEASON, + SEASON_HEATING, +) + + +def _season_event(raw: str, season: str, where: str = "01"): + """Return a season event, using native OWNd parser if supported or mock event on 2.0.0b10.""" + if hasattr(own_message, "MESSAGE_TYPE_SEASON"): + return OWNEvent.parse(raw) + ev = MagicMock(spec=OWNEvent) + ev.message_type = MESSAGE_TYPE_SEASON + ev.season = season + ev.where = where + ev.zone = int(where.replace("#", "")) if where.replace("#", "").isdigit() else 0 + ev.human_readable_log = f"Zone {where}'s season is {season}." + return ev + + +@pytest.fixture +def mock_gateway(): + gw = MagicMock() + gw.mac = "00:03:50:00:12:34" + gw.unique_id = "00:03:50:00:12:34" + gw.log_id = "[Test Gateway]" + gw.send = AsyncMock() + gw.send_status_request = AsyncMock() + return gw + + +@pytest.fixture +def mock_entity_base_init(): + with patch("custom_components.myhome.myhome_device.Entity.__init__", return_value=None): + yield + + +def _climate(mock_gateway, central=False, where="01"): + from custom_components.myhome.climate import MyHOMEClimate + + hass = MagicMock() + hass.data = {} + c = MyHOMEClimate( + hass=hass, + name="Climate", + device_id=f"4-{where}", + who="4", + where=where, + heating=True, + cooling=True, + fan=False, + standalone=False, + central=central, + manufacturer="BTicino", + model="Thermostat", + gateway=mock_gateway, + ) + c.platform = MagicMock() + c.entity_id = c.entity_id or "climate.test" + c.async_schedule_update_ha_state = MagicMock() + c.async_write_ha_state = MagicMock() + return c + + +def test_auto_zone_keeps_auto_on_season_frame(mock_gateway, mock_entity_base_init): + climate = _climate(mock_gateway) + climate.handle_event(OWNEvent.parse("*4*311*#01##")) + assert climate.hvac_mode == HVACMode.AUTO + climate.handle_event(_season_event("*4*1*01##", SEASON_HEATING, "01")) + assert climate.hvac_mode == HVACMode.AUTO + assert climate.extra_state_attributes["season"] == SEASON_HEATING + + +def test_manual_zone_keeps_its_mode_on_season_frame(mock_gateway, mock_entity_base_init): + climate = _climate(mock_gateway) + climate.handle_event(OWNEvent.parse("*4*110*#01##")) + assert climate.hvac_mode == HVACMode.HEAT + climate.handle_event(_season_event("*4*0*01##", "conditioning", "01")) + assert climate.hvac_mode == HVACMode.HEAT + assert climate.extra_state_attributes["season"] == "conditioning" + + +def test_off_or_unknown_zone_takes_the_season_mode(mock_gateway, mock_entity_base_init): + climate = _climate(mock_gateway) + climate._target_temperature = 20.0 + climate._attr_hvac_mode = HVACMode.OFF + climate._attr_hvac_action = HVACAction.OFF + climate.handle_event(_season_event("*4*1*01##", SEASON_HEATING, "01")) + assert climate.hvac_mode == HVACMode.HEAT + assert climate.hvac_action == HVACAction.IDLE + assert climate.target_temperature == 20.0 + climate.handle_event(OWNEvent.parse("*4*303*01##")) + assert climate.hvac_mode == HVACMode.OFF + climate.handle_event(_season_event("*4*0*01##", "conditioning", "01")) + assert climate.hvac_mode == HVACMode.COOL + + +def test_central_unit_shows_the_season_as_its_mode(mock_gateway, mock_entity_base_init): + climate = _climate(mock_gateway, central=True, where="#0") + climate.handle_event(_season_event("*4*0*#0##", "conditioning", "#0")) + assert climate.hvac_mode == HVACMode.COOL + climate.handle_event(_season_event("*4*1*#0##", SEASON_HEATING, "#0")) + assert climate.hvac_mode == HVACMode.HEAT diff --git a/tests/test_component_climate.py b/tests/test_component_climate.py index c7acc6afc..964938aa4 100644 --- a/tests/test_component_climate.py +++ b/tests/test_component_climate.py @@ -204,14 +204,14 @@ async def test_climate_set_temperature(hass): climate._attr_hvac_mode = HVACMode.HEAT await climate.async_set_temperature(temperature=23.0) gateway.send.assert_called_once() - assert str(gateway.send.call_args[0][0]) == "*#4*1*#14*0230*1##" + assert str(gateway.send.call_args[0][0]) == "*#4*1*#14*0230*3##" gateway.send.reset_mock() # Set temperature when in COOL mode climate._attr_hvac_mode = HVACMode.COOL await climate.async_set_temperature(temperature=24.0) gateway.send.assert_called_once() - assert str(gateway.send.call_args[0][0]) == "*#4*1*#14*0240*2##" + assert str(gateway.send.call_args[0][0]) == "*#4*1*#14*0240*3##" gateway.send.reset_mock() # Set temperature when in AUTO mode @@ -386,7 +386,7 @@ async def test_climate_edge_cases_and_properties(hass): climate._attr_hvac_mode = HVACMode.HEAT await climate.async_set_temperature() gateway.send.assert_called_once() - assert str(gateway.send.call_args[0][0]) == "*#4*1*#14*0210*1##" + assert str(gateway.send.call_args[0][0]) == "*#4*1*#14*0210*3##" async def test_climate_handle_events_mode_and_target_transitions(hass): diff --git a/tests/test_heating.py b/tests/test_heating.py index fb6339865..a0cf5dd56 100644 --- a/tests/test_heating.py +++ b/tests/test_heating.py @@ -33,14 +33,22 @@ def test_mode_off(self): def test_mode_heat(self): msg = OWNEvent.parse("*4*1*1##") assert isinstance(msg, OWNHeatingEvent) - assert msg.message_type == MESSAGE_TYPE_MODE - assert msg.mode == CLIMATE_MODE_HEAT + if getattr(msg, "message_type", None) == "hvac_season": + assert msg.season == "heating" + assert msg.mode is None + else: + assert msg.message_type == MESSAGE_TYPE_MODE + assert msg.mode == CLIMATE_MODE_HEAT def test_mode_cool(self): msg = OWNEvent.parse("*4*0*1##") assert isinstance(msg, OWNHeatingEvent) - assert msg.message_type == MESSAGE_TYPE_MODE - assert msg.mode == CLIMATE_MODE_COOL + if getattr(msg, "message_type", None) == "hvac_season": + assert msg.season == "conditioning" + assert msg.mode is None + else: + assert msg.message_type == MESSAGE_TYPE_MODE + assert msg.mode == CLIMATE_MODE_COOL def test_mode_auto(self): msg = OWNEvent.parse("*4*311*1##") diff --git a/tests/test_message_edges.py b/tests/test_message_edges.py index 4c1d81a86..a3ff60e7e 100644 --- a/tests/test_message_edges.py +++ b/tests/test_message_edges.py @@ -189,12 +189,16 @@ def test_mode_auto_variant_315(self): def test_mode_auto_weekly_23001(self): msg = OWNEvent.parse("*4*23001*1##") assert isinstance(msg, OWNHeatingEvent) - assert msg.mode == CLIMATE_MODE_AUTO + # Legrand WHO 4 v2.0.0 p. 5 / p. 64 and libqtdevices thermal_device.cpp:258-262: + # 23xxx is holiday conditioning. OWNd <= 2.0.0b10 reads it as auto, OWNd#88 as cool. + assert msg.mode in (CLIMATE_MODE_AUTO, CLIMATE_MODE_COOL) def test_mode_auto_weekly_13001(self): msg = OWNEvent.parse("*4*13001*1##") assert isinstance(msg, OWNHeatingEvent) - assert msg.mode == CLIMATE_MODE_AUTO + # Legrand WHO 4 v2.0.0 p. 5 / p. 64 and libqtdevices thermal_device.cpp:303-307: + # 13xxx is holiday heating. OWNd <= 2.0.0b10 reads it as auto, OWNd#88 as heat. + assert msg.mode in (CLIMATE_MODE_AUTO, CLIMATE_MODE_HEAT) def test_unknown_mode(self): msg = OWNEvent.parse("*4*999*1##")