Skip to content
Merged
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
88 changes: 62 additions & 26 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 @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
114 changes: 114 additions & 0 deletions tests/test_climate_season_frame.py
Original file line number Diff line number Diff line change
@@ -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
6 changes: 3 additions & 3 deletions tests/test_component_climate.py
Original file line number Diff line number Diff line change
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
16 changes: 12 additions & 4 deletions tests/test_heating.py
Original file line number Diff line number Diff line change
Expand Up @@ -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##")
Expand Down
8 changes: 6 additions & 2 deletions tests/test_message_edges.py
Original file line number Diff line number Diff line change
Expand Up @@ -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##")
Expand Down
Loading