Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 63 additions & 4 deletions custom_components/myhome/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
)
)
Expand All @@ -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,
)
)
Expand All @@ -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(
Expand All @@ -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,
)
)
Expand All @@ -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,
)
)
Expand Down Expand Up @@ -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:
Expand Down
97 changes: 97 additions & 0 deletions tests/test_climate_season_frame.py
Original file line number Diff line number Diff line change
@@ -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
14 changes: 7 additions & 7 deletions tests/test_component_climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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##
Expand Down
Loading