From 6557ac478fea6e56a2207f38f9256c53d1bd003a Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:12:18 +0200 Subject: [PATCH 1/3] feat(climate): support WHO 4 holiday plan and vacation program attributes --- custom_components/myhome/climate.py | 42 ++++- tests/test_climate_holiday_program.py | 256 ++++++++++++++++++++++++++ 2 files changed, 292 insertions(+), 6 deletions(-) create mode 100644 tests/test_climate_holiday_program.py diff --git a/custom_components/myhome/climate.py b/custom_components/myhome/climate.py index 27270752b..9a226db81 100644 --- a/custom_components/myhome/climate.py +++ b/custom_components/myhome/climate.py @@ -363,6 +363,9 @@ def __init__( self._attr_native_current_temperature: float | None = None self._season: str | None = None + self._program: int | None = None + self._holiday_days: int | None = None + self._scenario: int | None = None self._attr_current_humidity: float | None = None self._target_temperature: float | None = None self._local_offset: float = 0 @@ -406,6 +409,12 @@ def extra_state_attributes(self) -> dict[str, Any]: } if self._season is not None: attrs["season"] = self._season + if self._program is not None: + attrs["program"] = self._program + if self._holiday_days is not None: + attrs["holiday_days"] = self._holiday_days + if self._scenario is not None: + attrs["scenario"] = self._scenario if self._fan: attrs["fan_mode"] = self._attr_fan_mode if self._running_fan_speed is not None: @@ -438,6 +447,21 @@ async def async_restore_last_state(self, last_state: State | None) -> None: self._target_temperature = float(target_temp) except (ValueError, TypeError): pass + if "program" in last_state.attributes: + try: + self._program = int(last_state.attributes["program"]) + except (ValueError, TypeError): + pass + if "holiday_days" in last_state.attributes: + try: + self._holiday_days = int(last_state.attributes["holiday_days"]) + except (ValueError, TypeError): + pass + if "scenario" in last_state.attributes: + try: + self._scenario = int(last_state.attributes["scenario"]) + except (ValueError, TypeError): + pass if "fan_mode" in last_state.attributes or bool( last_state.attributes.get("supported_features", 0) & ClimateEntityFeature.FAN_MODE ): @@ -714,6 +738,7 @@ def _apply_zone_state(self, message: OWNHeatingEvent) -> None: self._attr_hvac_mode = HVACMode.OFF self._attr_hvac_action = HVACAction.OFF self._actuator_states.clear() + self._holiday_days = None return if state not in _ZONE_STATES_ON: return @@ -842,6 +867,9 @@ def handle_event(self, message: OWNHeatingEvent) -> None: ) elif message.message_type == MESSAGE_TYPE_MODE: prev_mode = self._attr_hvac_mode + 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: LOGGER.debug( "%s %s", @@ -892,6 +920,9 @@ def handle_event(self, message: OWNHeatingEvent) -> None: self._attr_hvac_mode, ) elif message.message_type == MESSAGE_TYPE_MODE_TARGET: + 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: LOGGER.debug( "%s %s", @@ -929,12 +960,11 @@ def handle_event(self, message: OWNHeatingEvent) -> None: self._attr_hvac_mode = HVACMode.OFF self._attr_hvac_action = HVACAction.OFF self._actuator_states.clear() - self._target_temperature = message.set_temperature - self._local_target_temperature = ( - self._target_temperature + self._local_offset - if self._target_temperature is not None - else None - ) + if message.set_temperature is not None and message.set_temperature >= 0: + self._target_temperature = message.set_temperature + 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, diff --git a/tests/test_climate_holiday_program.py b/tests/test_climate_holiday_program.py new file mode 100644 index 000000000..c19af93b2 --- /dev/null +++ b/tests/test_climate_holiday_program.py @@ -0,0 +1,256 @@ +"""Test WHO 4 holiday plan and multi-day vacation program handling. + +Verifies: +1. Holiday plan parameters (115#1102 / 215#2102) and multi-day vacation + frames (13DDD#PPPP / 23DDD#PPPP / 33DDD#PPPP) expose `program` and + `holiday_days` in extra_state_attributes. +2. Holiday parameters are never misinterpreted as negative target temperatures + (legacy -10.2 °C bug). +3. Normal manual, setpoint, and off frames clear active holiday attributes. +4. Freedompro / Norbert transition: switching plant season while the central unit + drops into Antifreeze (*4*102*...##) is correctly tracked by zone state. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import OWNd.message as own_message +import pytest +from homeassistant.components.climate.const import HVACAction, HVACMode +from homeassistant.core import State +from OWNd.message import OWNEvent + +from custom_components.myhome.climate import ( + CLIMATE_MODE_HEAT, + MESSAGE_TYPE_MODE_TARGET, + MESSAGE_TYPE_SEASON, +) + + +@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 _parse_or_mock(raw: str, mode: str, program: int | None = None, holiday_days: int | None = None): + """Parse raw frame if current OWNd supports it, or supply expected typed event.""" + try: + ev = OWNEvent.parse(raw) + if getattr(ev, "program", None) is not None or getattr(ev, "holiday_days", None) is not None: + return ev + except Exception: + pass + ev = MagicMock(spec=OWNEvent) + ev.message_type = "hvac_mode" + ev.mode = mode + ev.program = program + ev.holiday_days = holiday_days + ev.scenario = None + ev.where = "#0" + ev.zone = 0 + ev.human_readable_log = f"Zone #0 mode is {mode}, program={program}, holiday_days={holiday_days}." + return ev + + +def test_holiday_plan_heating_program(mock_gateway, mock_entity_base_init): + """*4*115#1102*#0## sets HEAT mode, exposes program=2, and does NOT corrupt setpoint.""" + climate = _climate(mock_gateway, central=True, where="#0") + climate._target_temperature = 20.5 + climate._local_target_temperature = 20.5 + + event = _parse_or_mock("*4*115#1102*#0##", mode="heat", program=2) + climate.handle_event(event) + + assert climate.hvac_mode == HVACMode.HEAT + assert climate.target_temperature == 20.5 + assert climate.extra_state_attributes.get("program") == 2 + assert climate.extra_state_attributes.get("holiday_days") is None + + +def test_holiday_plan_cooling_program(mock_gateway, mock_entity_base_init): + """*4*215#2102*#0## sets COOL mode and exposes program=2.""" + climate = _climate(mock_gateway, central=True, where="#0") + + event = _parse_or_mock("*4*215#2102*#0##", mode="cool", program=2) + climate.handle_event(event) + + assert climate.hvac_mode == HVACMode.COOL + assert climate.extra_state_attributes.get("program") == 2 + assert climate.extra_state_attributes.get("holiday_days") is None + + +def test_multi_day_vacation_frames(mock_gateway, mock_entity_base_init): + """Multi-day vacation frames expose holiday_days and program across seasons.""" + climate = _climate(mock_gateway, central=True, where="#0") + + # Heating vacation 5 days, return program 2 + ev1 = _parse_or_mock("*4*13005#3102*#0##", mode="heat", program=2, holiday_days=5) + climate.handle_event(ev1) + assert climate.hvac_mode == HVACMode.HEAT + assert climate.extra_state_attributes.get("holiday_days") == 5 + assert climate.extra_state_attributes.get("program") == 2 + + # Conditioning vacation 3 days, return program 1 + ev2 = _parse_or_mock("*4*23003#3101*#0##", mode="cool", program=1, holiday_days=3) + climate.handle_event(ev2) + assert climate.hvac_mode == HVACMode.COOL + assert climate.extra_state_attributes.get("holiday_days") == 3 + assert climate.extra_state_attributes.get("program") == 1 + + # Generic vacation 10 days, return program 3 + ev3 = _parse_or_mock("*4*33010#3103*#0##", mode="auto", program=3, holiday_days=10) + climate.handle_event(ev3) + assert climate.hvac_mode == HVACMode.AUTO + assert climate.extra_state_attributes.get("holiday_days") == 10 + assert climate.extra_state_attributes.get("program") == 3 + + +def test_holiday_cleared_on_normal_mode_or_off(mock_gateway, mock_entity_base_init): + """Normal mode frames clear holiday_days and program.""" + climate = _climate(mock_gateway, central=True, where="#0") + + # Set vacation + ev_vac = _parse_or_mock("*4*13005#3102*#0##", mode="heat", program=2, holiday_days=5) + climate.handle_event(ev_vac) + assert climate.extra_state_attributes.get("holiday_days") == 5 + assert climate.extra_state_attributes.get("program") == 2 + + # Switch to manual heat + ev_manual = OWNEvent.parse("*4*110*#0##") + climate.handle_event(ev_manual) + assert climate.hvac_mode == HVACMode.HEAT + assert climate.extra_state_attributes.get("holiday_days") is None + assert climate.extra_state_attributes.get("program") is None + + # Switch to off + ev_off = OWNEvent.parse("*4*303*#0##") + climate.handle_event(ev_off) + assert climate.hvac_mode == HVACMode.OFF + assert climate.extra_state_attributes.get("holiday_days") is None + assert climate.extra_state_attributes.get("program") is None + + +async def test_restore_last_state_preserves_holiday_attributes(mock_gateway, mock_entity_base_init): + """Restoring state restores program and holiday_days.""" + climate = _climate(mock_gateway, central=True, where="#0") + + last_state = State( + "climate.test", + "heat", + attributes={"program": 2, "holiday_days": 5, "scenario": 1, "temperature": 21.0}, + ) + await climate.async_restore_last_state(last_state) + + assert climate.hvac_mode == HVACMode.HEAT + assert climate.extra_state_attributes.get("program") == 2 + assert climate.extra_state_attributes.get("holiday_days") == 5 + assert climate.extra_state_attributes.get("scenario") == 1 + + +def test_negative_setpoint_rejected_on_mode_target(mock_gateway, mock_entity_base_init): + """Negative target temperature on mode_target is rejected and nominal kept.""" + climate = _climate(mock_gateway) + climate._target_temperature = 21.0 + climate._local_target_temperature = 21.0 + + ev = MagicMock(spec=OWNEvent) + ev.message_type = MESSAGE_TYPE_MODE_TARGET + ev.mode = CLIMATE_MODE_HEAT + ev.set_temperature = -10.2 + ev.program = None + ev.holiday_days = None + ev.scenario = None + ev.human_readable_log = "Corrupted temperature" + + climate.handle_event(ev) + assert climate.hvac_mode == HVACMode.HEAT + assert climate.target_temperature == 21.0 # preserved nominal! + + +def test_season_transition_and_antifreeze_scenario(mock_gateway, mock_entity_base_init): + """Norbert's transition: central unit switches season, zones enter antifreeze.""" + central = _climate(mock_gateway, central=True, where="#0") + zone1 = _climate(mock_gateway, central=False, where="01") + + # Zone 1 is initially running in manual heat at 21.0 °C + zone1._target_temperature = 21.0 + zone1.handle_event(OWNEvent.parse("*4*110*01##")) + assert zone1.hvac_mode == HVACMode.HEAT + + # Plant season switches to summer (Conditioning) + if hasattr(own_message, "MESSAGE_TYPE_SEASON"): + central_season_event = OWNEvent.parse("*4*0*#0##") + else: + central_season_event = MagicMock(spec=OWNEvent) + central_season_event.message_type = MESSAGE_TYPE_SEASON + central_season_event.season = "conditioning" + central_season_event.where = "#0" + central_season_event.zone = 0 + central_season_event.human_readable_log = "Central season is conditioning" + + central.handle_event(central_season_event) + assert central.hvac_mode == HVACMode.COOL + assert central.extra_state_attributes["season"] == "conditioning" + + # Central unit puts zone 1 into Antifreeze (*4*102*01##) + zone1.handle_event(OWNEvent.parse("*4*102*01##")) + assert zone1.hvac_mode == HVACMode.OFF + assert zone1.hvac_action == HVACAction.OFF + assert zone1.target_temperature == 21.0 # nominal setpoint preserved! + + # Central unit puts zone 1 into thermal protection (*4*202*01##) + zone1.handle_event(OWNEvent.parse("*4*202*01##")) + assert zone1.hvac_mode == HVACMode.OFF + + # Zone 1 receives summer operating state + if hasattr(own_message, "MESSAGE_TYPE_SEASON"): + zone_summer_event = OWNEvent.parse("*4*0*01##") + else: + zone_summer_event = MagicMock(spec=OWNEvent) + zone_summer_event.message_type = MESSAGE_TYPE_SEASON + zone_summer_event.season = "conditioning" + zone_summer_event.where = "01" + zone_summer_event.zone = 1 + zone_summer_event.human_readable_log = "Zone 1 season is conditioning" + + zone1.handle_event(zone_summer_event) + assert zone1.hvac_mode == HVACMode.COOL + assert zone1.extra_state_attributes["season"] == "conditioning" From bff7073ec20bb70170be96fa9017d75707bbc860 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:58:58 +0200 Subject: [PATCH 2/3] test(climate): cover invalid holiday attribute handling in state restoration --- tests/test_climate_holiday_program.py | 29 ++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/tests/test_climate_holiday_program.py b/tests/test_climate_holiday_program.py index c19af93b2..c27c5cdae 100644 --- a/tests/test_climate_holiday_program.py +++ b/tests/test_climate_holiday_program.py @@ -70,11 +70,16 @@ def _climate(mock_gateway, central=False, where="01"): return c -def _parse_or_mock(raw: str, mode: str, program: int | None = None, holiday_days: int | None = None): +def _parse_or_mock( + raw: str, mode: str, program: int | None = None, holiday_days: int | None = None +): """Parse raw frame if current OWNd supports it, or supply expected typed event.""" try: ev = OWNEvent.parse(raw) - if getattr(ev, "program", None) is not None or getattr(ev, "holiday_days", None) is not None: + if ( + getattr(ev, "program", None) is not None + or getattr(ev, "holiday_days", None) is not None + ): return ev except Exception: pass @@ -86,7 +91,9 @@ def _parse_or_mock(raw: str, mode: str, program: int | None = None, holiday_days ev.scenario = None ev.where = "#0" ev.zone = 0 - ev.human_readable_log = f"Zone #0 mode is {mode}, program={program}, holiday_days={holiday_days}." + ev.human_readable_log = ( + f"Zone #0 mode is {mode}, program={program}, holiday_days={holiday_days}." + ) return ev @@ -185,6 +192,22 @@ async def test_restore_last_state_preserves_holiday_attributes(mock_gateway, moc assert climate.extra_state_attributes.get("scenario") == 1 +async def test_restore_last_state_invalid_holiday_attributes(mock_gateway, mock_entity_base_init): + """Restoring invalid holiday attributes handles ValueError/TypeError gracefully.""" + climate = _climate(mock_gateway, central=True, where="#0") + + last_state = State( + "climate.test", + "heat", + attributes={"program": "invalid", "holiday_days": "invalid", "scenario": "invalid"}, + ) + await climate.async_restore_last_state(last_state) + + assert climate.extra_state_attributes.get("program") is None + assert climate.extra_state_attributes.get("holiday_days") is None + assert climate.extra_state_attributes.get("scenario") is None + + def test_negative_setpoint_rejected_on_mode_target(mock_gateway, mock_entity_base_init): """Negative target temperature on mode_target is rejected and nominal kept.""" climate = _climate(mock_gateway) From b7077e50512006d85d05c77243b56acf1b995c51 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:44:17 +0200 Subject: [PATCH 3/3] fix(climate): clear program and scenario with holiday_days on zone OFF state --- custom_components/myhome/climate.py | 2 ++ tests/test_climate_holiday_program.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/custom_components/myhome/climate.py b/custom_components/myhome/climate.py index 9a226db81..f6ed2ef60 100644 --- a/custom_components/myhome/climate.py +++ b/custom_components/myhome/climate.py @@ -739,6 +739,8 @@ def _apply_zone_state(self, message: OWNHeatingEvent) -> None: self._attr_hvac_action = HVACAction.OFF self._actuator_states.clear() self._holiday_days = None + self._program = None + self._scenario = None return if state not in _ZONE_STATES_ON: return diff --git a/tests/test_climate_holiday_program.py b/tests/test_climate_holiday_program.py index c27c5cdae..802be4278 100644 --- a/tests/test_climate_holiday_program.py +++ b/tests/test_climate_holiday_program.py @@ -175,6 +175,21 @@ def test_holiday_cleared_on_normal_mode_or_off(mock_gateway, mock_entity_base_in assert climate.extra_state_attributes.get("program") is None +def test_zone_state_off_clears_all_holiday_attributes(mock_gateway, mock_entity_base_init): + """A dimension-7 OFF/protection zone state clears program, holiday_days and scenario.""" + climate = _climate(mock_gateway, central=False, where="01") + climate._program = 2 + climate._holiday_days = 5 + climate._scenario = 3 + + climate._apply_zone_state(MagicMock(zone_state="off")) + + assert climate.hvac_mode == HVACMode.OFF + assert climate.extra_state_attributes.get("program") is None + assert climate.extra_state_attributes.get("holiday_days") is None + assert climate.extra_state_attributes.get("scenario") is None + + async def test_restore_last_state_preserves_holiday_attributes(mock_gateway, mock_entity_base_init): """Restoring state restores program and holiday_days.""" climate = _climate(mock_gateway, central=True, where="#0")