From 9610e43c44b44c78142eaa1967a671332afa5558 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:23:07 +0200 Subject: [PATCH] fix(climate): turn OWNd's fan-speed ValueError into a service error (OWNd#77) OWNd#82 makes OWNHeatingCommand.set_fan_speed raise ValueError for a central unit or the general zone: the firmware forwards *#4*Z*#11*S## only for a plain zone 1..99 (gdluck's audit A2). async_set_fan_mode did not catch it. It now raises ServiceValidationError (fan_speed_zone_only, en/fr/it/nl), sends nothing and keeps the fan mode. The test patches the builder, so it passes with OWNd 2.0.0b9 and with the OWNd#82 head. Co-Authored-By: Claude Opus 5.5 --- custom_components/myhome/climate.py | 19 +++-- custom_components/myhome/strings.json | 3 + custom_components/myhome/translations/en.json | 3 + custom_components/myhome/translations/fr.json | 3 + custom_components/myhome/translations/it.json | 3 + custom_components/myhome/translations/nl.json | 3 + tests/test_ownd77_fan_speed_guard.py | 70 +++++++++++++++++++ 7 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 tests/test_ownd77_fan_speed_guard.py diff --git a/custom_components/myhome/climate.py b/custom_components/myhome/climate.py index 2997a7e75..56eb33c59 100644 --- a/custom_components/myhome/climate.py +++ b/custom_components/myhome/climate.py @@ -17,6 +17,7 @@ UnitOfTemperature, ) from homeassistant.core import HomeAssistant, State, callback +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.dispatcher import async_dispatcher_connect, async_dispatcher_send from homeassistant.helpers.entity_platform import AddEntitiesCallback from OWNd.message import ( @@ -53,6 +54,7 @@ CONF_HEATING_SUPPORT, CONF_MANUFACTURER, CONF_STANDALONE, + DOMAIN, LOGGER, signed_who4_temperature, ) @@ -482,14 +484,23 @@ async def async_set_fan_mode(self, fan_mode: str) -> None: } speed_code = fan_mode_map.get(str(fan_mode).lower()) if speed_code is not None: - self._attr_fan_mode = fan_mode - await self._gateway_handler.send( - OWNHeatingCommand.set_fan_speed( + # OWNd raises for a central unit or general zone: the firmware forwards + # *#4*Z*#11*S## only for a plain zone 1..99 (OWNd#77, A2). + try: + command = OWNHeatingCommand.set_fan_speed( where=self._where, speed=speed_code, standalone=self._standalone, ) - ) + except ValueError as err: + raise ServiceValidationError( + f"{self._display_name} has no fan speed of its own: only a zone (1-99) takes one", + translation_domain=DOMAIN, + translation_key="fan_speed_zone_only", + translation_placeholders={"name": self._display_name}, + ) from err + self._attr_fan_mode = fan_mode + await self._gateway_handler.send(command) if self.hass is not None: self.async_write_ha_state() diff --git a/custom_components/myhome/strings.json b/custom_components/myhome/strings.json index d4fa8610b..2dc8abc0e 100644 --- a/custom_components/myhome/strings.json +++ b/custom_components/myhome/strings.json @@ -421,6 +421,9 @@ "alarm_read_only": { "message": "{name} is read-only: arm and disarm through the AUX frames your central unit is programmed for" }, + "fan_speed_zone_only": { + "message": "{name} has no fan speed of its own: only a zone (1-99) takes one" + }, "command_delivery_cancelled": { "message": "{name}: direction command delivery was cancelled before reaching the bus" }, diff --git a/custom_components/myhome/translations/en.json b/custom_components/myhome/translations/en.json index d4fa8610b..2dc8abc0e 100644 --- a/custom_components/myhome/translations/en.json +++ b/custom_components/myhome/translations/en.json @@ -421,6 +421,9 @@ "alarm_read_only": { "message": "{name} is read-only: arm and disarm through the AUX frames your central unit is programmed for" }, + "fan_speed_zone_only": { + "message": "{name} has no fan speed of its own: only a zone (1-99) takes one" + }, "command_delivery_cancelled": { "message": "{name}: direction command delivery was cancelled before reaching the bus" }, diff --git a/custom_components/myhome/translations/fr.json b/custom_components/myhome/translations/fr.json index 985d00cfe..63b4502f6 100644 --- a/custom_components/myhome/translations/fr.json +++ b/custom_components/myhome/translations/fr.json @@ -375,6 +375,9 @@ "alarm_read_only": { "message": "{name} est en lecture seule : armez et désarmez via les commandes AUX programmées sur la centrale" }, + "fan_speed_zone_only": { + "message": "{name} n'a pas de vitesse de ventilation propre : seule une zone (1-99) peut en avoir une" + }, "command_delivery_cancelled": { "message": "{name} : l'envoi de la commande de direction a été annulé avant d'atteindre le bus" }, diff --git a/custom_components/myhome/translations/it.json b/custom_components/myhome/translations/it.json index 3c221609e..382660fe1 100644 --- a/custom_components/myhome/translations/it.json +++ b/custom_components/myhome/translations/it.json @@ -375,6 +375,9 @@ "alarm_read_only": { "message": "{name} è di sola lettura: inserisci e disinserisci l'allarme con i comandi AUX programmati sulla centrale" }, + "fan_speed_zone_only": { + "message": "{name} non ha una velocità del ventilatore propria: solo una zona (1-99) può averne una" + }, "command_delivery_cancelled": { "message": "{name}: l'invio del comando di direzione è stato annullato prima di raggiungere il bus" }, diff --git a/custom_components/myhome/translations/nl.json b/custom_components/myhome/translations/nl.json index c6bb08b89..d28b09f1d 100644 --- a/custom_components/myhome/translations/nl.json +++ b/custom_components/myhome/translations/nl.json @@ -375,6 +375,9 @@ "alarm_read_only": { "message": "{name} is alleen-lezen: in- en uitschakelen gebeurt via de AUX-opdrachten waarvoor de centrale is geprogrammeerd" }, + "fan_speed_zone_only": { + "message": "{name} heeft geen eigen ventilatorsnelheid: alleen een zone (1-99) kan er een krijgen" + }, "command_delivery_cancelled": { "message": "{name}: de aflevering van het richtingscommando is geannuleerd voordat het de bus bereikte" }, diff --git a/tests/test_ownd77_fan_speed_guard.py b/tests/test_ownd77_fan_speed_guard.py new file mode 100644 index 000000000..c6b8f518a --- /dev/null +++ b/tests/test_ownd77_fan_speed_guard.py @@ -0,0 +1,70 @@ +"""Fan speed on a central unit is refused with a service error (OWNd#77, A2). + +The MyHomeServer1 firmware forwards ``*#4*Z*#11*S##`` only for a plain zone 1..99; +``*#4*#0*#11*1##`` and ``*#4*#0#2*#11*1##`` produce no bus frame. OWNd (PR #82) +therefore raises ValueError for a central unit or the general zone. The entity +must turn that into a ServiceValidationError, send nothing and keep its fan mode. +The OWNd builder is patched so the test does not depend on the OWNd release. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from custom_components.myhome.climate import MyHOMEClimate +from custom_components.myhome.const import DOMAIN + + +@pytest.fixture +def mock_gateway(): + gw = MagicMock() + gw.mac = "00:03:50:11:22:77" + gw.log_id = "[OWNd#77]" + gw.send = AsyncMock() + gw.send_status_request = AsyncMock() + return gw + + +def _central(hass: HomeAssistant, gateway, where: str) -> MyHOMEClimate: + climate = MyHOMEClimate( + hass=hass, + name="Central Unit", + device_id=f"4-{where}", + who="4", + where=where, + heating=True, + cooling=True, + fan=True, + standalone=False, + central=True, + manufacturer="BTicino", + model="Central Unit (3550)", + gateway=gateway, + ) + climate.hass = hass + climate.entity_id = "climate.central_unit" + return climate + + +@pytest.mark.parametrize("where", ["#0", "#0#1"]) +async def test_fan_speed_on_central_unit_raises_service_error( + hass: HomeAssistant, mock_gateway, where: str +) -> None: + climate = _central(hass, mock_gateway, where) + before = climate.fan_mode + + with ( + patch( + "custom_components.myhome.climate.OWNHeatingCommand.set_fan_speed", + side_effect=ValueError(f"Fan speed cannot be set on central unit or general zone: {where}"), + ), + pytest.raises(ServiceValidationError) as excinfo, + ): + await climate.async_set_fan_mode("high") + + assert excinfo.value.translation_domain == DOMAIN + assert excinfo.value.translation_key == "fan_speed_zone_only" + mock_gateway.send.assert_not_awaited() + assert climate.fan_mode == before