diff --git a/custom_components/myhome/climate.py b/custom_components/myhome/climate.py index 927b52ca7..45be8fc3c 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, @@ -75,6 +81,16 @@ # A dimension 12 frame and the frame that turns the zone OFF follow within ~0.1 s (#454, #383) _PROTECTION_FRAME_WINDOW = 2.0 _ZONE_CONTEXT_MODES = {"heating": HVACMode.HEAT, "cooling": HVACMode.COOL, "automatic": HVACMode.AUTO} + +# 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). "auto" makes +# OWNd write 3 for a zone, so the plant keeps its own season. The central +# unit write stays 1/2 because OWNd's set_central_temperature has no +# generic digit. +SETPOINT_WRITE_MODE = CLIMATE_MODE_AUTO _ZONE_STATES_ON = ("setpoint", "comfort", "eco") _ZONE_STATES_OFF = ("protection", "off") @@ -328,6 +344,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 +375,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: @@ -561,7 +580,7 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: OWNHeatingCommand.set_temperature( where=self._where, temperature=self._target_temperature, - mode=CLIMATE_MODE_HEAT, + mode=SETPOINT_WRITE_MODE, standalone=self._standalone, ) ) @@ -571,7 +590,7 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: OWNHeatingCommand.set_temperature( where=self._where, temperature=self._target_temperature, - mode=CLIMATE_MODE_COOL, + mode=SETPOINT_WRITE_MODE, standalone=self._standalone, ) ) @@ -582,6 +601,8 @@ async def async_set_temperature(self, **kwargs: Any) -> None: kwargs.get("temperature", self._local_target_temperature) # type: ignore[arg-type] ) - self._local_offset if self._central: + # OWNd's central-unit builder only knows digits 1 (heat) and 2 + # (cool), so the central keeps the season-specific write. mode = "heat" if self._attr_hvac_mode != HVACMode.COOL else "cool" await self._gateway_handler.send( OWNHeatingCommand.set_central_temperature( @@ -599,7 +620,7 @@ async def async_set_temperature(self, **kwargs: Any) -> None: OWNHeatingCommand.set_temperature( where=self._where, temperature=target_temperature, - mode=CLIMATE_MODE_HEAT, + mode=SETPOINT_WRITE_MODE, standalone=self._standalone, ) ) @@ -608,7 +629,7 @@ async def async_set_temperature(self, **kwargs: Any) -> None: OWNHeatingCommand.set_temperature( where=self._where, temperature=target_temperature, - mode=CLIMATE_MODE_COOL, + mode=SETPOINT_WRITE_MODE, standalone=self._standalone, ) ) @@ -751,6 +772,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..855a82c90 --- /dev/null +++ b/tests/test_climate_season_frame.py @@ -0,0 +1,97 @@ +"""WHAT 1 / 0 (zone operation mode = season) must not flip a running zone's mode. + +Runs only with an OWNd that decodes the frame as ``hvac_season`` (OpenWebNet-HA/OWNd#94); +the pinned 2.0.0b10 reports it as ``hvac_mode`` heat / cool and keeps the old path. +""" +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from homeassistant.components.climate.const import HVACMode + +import OWNd.message as own_message +from OWNd.message import OWNEvent + +pytestmark = pytest.mark.skipif( + not hasattr(own_message, "MESSAGE_TYPE_SEASON"), + reason="OWNd without the season decoding (OpenWebNet-HA/OWNd#94)", +) + + +@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(OWNEvent.parse("*4*1*01##")) + assert climate.hvac_mode == HVACMode.AUTO + assert climate.extra_state_attributes["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(OWNEvent.parse("*4*0*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.handle_event(OWNEvent.parse("*4*1*01##")) + assert climate.hvac_mode == HVACMode.HEAT + climate.handle_event(OWNEvent.parse("*4*303*01##")) + assert climate.hvac_mode == HVACMode.OFF + climate.handle_event(OWNEvent.parse("*4*0*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(OWNEvent.parse("*4*0*#0##")) + assert climate.hvac_mode == HVACMode.COOL + climate.handle_event(OWNEvent.parse("*4*1*#0##")) + assert climate.hvac_mode == HVACMode.HEAT diff --git a/tests/test_component_climate.py b/tests/test_component_climate.py index c7acc6afc..850c8c343 100644 --- a/tests/test_component_climate.py +++ b/tests/test_component_climate.py @@ -167,15 +167,15 @@ async def test_climate_properties_and_hvac_modes(hass): climate._target_temperature = 22.0 await climate.async_set_hvac_mode(HVACMode.HEAT) gateway.send.assert_called_once() - # It sends set_temperature with mode HEAT - assert str(gateway.send.call_args[0][0]) == "*#4*1*#14*0220*1##" + # Zone setpoint writes always use the generic mode digit 3 (firmware-validated) + assert str(gateway.send.call_args[0][0]) == "*#4*1*#14*0220*3##" gateway.send.reset_mock() # Test set_hvac_mode COOL await climate.async_set_hvac_mode(HVACMode.COOL) gateway.send.assert_called_once() # If _target_temperature is set, it will send the set_temperature command for COOL too. - assert str(gateway.send.call_args[0][0]) == "*#4*1*#14*0220*2##" + assert str(gateway.send.call_args[0][0]) == "*#4*1*#14*0220*3##" gateway.send.reset_mock() async def test_climate_set_temperature(hass): @@ -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): @@ -822,7 +822,7 @@ async def test_climate_antifreeze_status_sweep_preserves_target_and_comfort_setp # Test toggling back to HEAT restores nominal comfort setpoint (17.0 °C, NOT 7.0 °C!) await climate.async_set_hvac_mode(HVACMode.HEAT) gateway_mock.send.assert_awaited_once() - assert str(gateway_mock.send.call_args[0][0]) == "*#4*2*#14*0170*1##" + assert str(gateway_mock.send.call_args[0][0]) == "*#4*2*#14*0170*3##" # Simulated bus acknowledgment of HEAT mode: target updates to nominal comfort setpoint climate.handle_event(OWNHeatingEvent("*4*1*2##")) # or *4*110*2##