From c398166e64c5d11941dc17bf442884a8feffefa3 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:56:25 +0200 Subject: [PATCH 1/7] refactor(protocol): eliminate OWNd boundary leaks and MagicMock checks (#736) --- custom_components/myhome/climate.py | 17 +-- custom_components/myhome/const.py | 113 ++---------------- custom_components/myhome/device_health.py | 2 +- custom_components/myhome/gateway.py | 31 +---- custom_components/myhome/gateway_events.py | 4 +- custom_components/myhome/gateway_resync.py | 2 +- custom_components/myhome/light_group.py | 25 ++-- .../myhome/media_player_power.py | 16 ++- custom_components/myhome/sensor.py | 27 ++--- custom_components/myhome/services.py | 16 +-- custom_components/myhome/sound_source.py | 3 +- custom_components/myhome/where_grammar.py | 12 ++ pyproject.toml | 2 +- tests/test_device_health.py | 6 +- tests/test_gateway.py | 51 -------- tests/test_where_usage_guard.py | 21 ++++ tests/test_who4_temperature_sign.py | 9 +- 17 files changed, 107 insertions(+), 250 deletions(-) diff --git a/custom_components/myhome/climate.py b/custom_components/myhome/climate.py index 6be010e2c..1f38c63f8 100644 --- a/custom_components/myhome/climate.py +++ b/custom_components/myhome/climate.py @@ -40,19 +40,16 @@ MESSAGE_TYPE_MAIN_TEMPERATURE, MESSAGE_TYPE_MODE, MESSAGE_TYPE_MODE_TARGET, + MESSAGE_TYPE_SEASON, MESSAGE_TYPE_TARGET_TEMPERATURE, + MESSAGE_TYPE_ZONE_STATE, + SEASON_CONDITIONING, + SEASON_HEATING, OWNCommand, OWNHeatingCommand, OWNHeatingEvent, ) -try: # OWNd > 2.0.0b10 (OpenWebNet-HA/OWNd#94) - from OWNd.message import MESSAGE_TYPE_SEASON, SEASON_CONDITIONING, 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" - SEASON_CONDITIONING = "conditioning" - from .const import ( BUS_ROUTING, CONF_CENTRAL, @@ -84,10 +81,6 @@ PLATFORM = Platform.CLIMATE PARALLEL_UPDATES = 0 -# WHO 4 dimension 7 zone state (OWNd#60); the values OWNd uses, spelled out -# here until the OWNd pin exports them. On MyHomeServer1 + Home+Control -# plants this is the only frame carrying the zone's mode and setpoint (#429). -MESSAGE_TYPE_ZONE_STATE = "zone_state" # 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} @@ -832,7 +825,7 @@ def _dimension_3_is_protection(self, message: OWNHeatingEvent) -> bool: """ if self._attr_hvac_mode == HVACMode.OFF: return True - value = getattr(message, "_dimension_value", None) + value = getattr(message, "dimension_value", None) if not value: return False return bool(len(value) > 1 and value[1] == "3" and self._attr_hvac_mode is None) diff --git a/custom_components/myhome/const.py b/custom_components/myhome/const.py index e2add572e..c74e7ec67 100644 --- a/custom_components/myhome/const.py +++ b/custom_components/myhome/const.py @@ -5,6 +5,15 @@ from typing import Any from homeassistant.const import Platform +from OWNd.message import ( + area_of_where as area_of_where, +) +from OWNd.message import ( + is_apl_address as is_apl_address, +) +from OWNd.message import ( + normalize_where as normalize_where, +) LOGGER = logging.getLogger(__package__) DOMAIN = "myhome" @@ -207,17 +216,6 @@ def get_ownd_version() -> str: SHARED_BUS_EVIDENCE_WINDOW_S = 600.0 -def who4_raw_to_celsius(raw: str) -> float: - """Decode a raw WHO 4 temperature (``SXXX``, tenths of a degree) that OWNd did not decode. - - Raises ``ValueError`` when ``raw`` is not a number. Exactly zero is ``0.0`` whatever the sign digit. - """ - if len(raw) == 4 and raw.startswith("1"): - magnitude = float(raw[1:]) / 10.0 - return -magnitude if magnitude else 0.0 - return float(raw) / 10.0 - - def eight_bits_to_percent(value: int) -> int: """Convert an 8-bit brightness (0-255) to percentage (0-100).""" return int(round((value * 100) / 255, 0)) @@ -237,85 +235,6 @@ def percent_to_eight_bits(value: int) -> int: return int(round((value * 255) / 100, 0)) -def is_apl_address(base: str) -> bool: - """Check if base address is a valid OpenWebNet Point-to-Point (APL) address. - - Point-to-point addressing combinations: - - A = 0; PL [1-9] -> 2 digits (e.g. 02 = Area 0, PL 2; accepted by BTicino firmware) - - A = 00; PL [01-15] -> 4 digits (e.g. 0015 = Area 00, PL 15) - - A [1-9]; PL [1-9] -> 2 digits (e.g. 15 = Area 1, PL 5) - - A = 10; PL [01-15] -> 4 digits (e.g. 1015 = Area 10, PL 15) - - A [01-09]; PL [10-15] -> 4 digits (e.g. 0115 = Area 1, PL 15) - """ - if not base.isdigit(): - return False - if len(base) == 2: - a = int(base[0]) - pl = int(base[1]) - if a == 0: - return 1 <= pl <= 9 - return 1 <= a <= 9 and 1 <= pl <= 9 - if len(base) == 4: - a = int(base[:2]) - pl = int(base[2:]) - if a == 0: - return 1 <= pl <= 15 - if 1 <= a <= 9: - return 10 <= pl <= 15 - if a == 10: - return 1 <= pl <= 15 - return False - - -def area_of_where(where: str | int | None) -> str | None: - """Return the area status WHERE for a given Point-to-Point (APL) WHERE.""" - if where is None: - return None - where_str = str(where).strip() - parts = where_str.split("#", 1) - base = parts[0] - if not is_apl_address(base): - return None - if len(base) == 2: - if base[0] == "0": - return "00" - return base[0] - # Length is 4 - a = base[:2] - if a == "00": - return "00" - if a == "10": - return "100" - return str(int(a)) - - -def normalize_where(where: str | int | None) -> str: - """Normalize OpenWebNet address while preserving Point-to-Point (APL) addressing. - - Point-to-point WHERE addresses must never have leading zeros stripped: - - '02' means Area 0, Point 2 (2 digits) - - '0015' means Area 00, Point 15 (4 digits) - - '15' means Area 1, Point 5 (2 digits) - Area broadcasts '00' (Area 0) and '100' (Area 10) are also preserved. - Other numeric addresses (such as zero-padded CEN+/dry contact object IDs like '0021') - have leading zeros stripped to match integer IDs. - """ - if where is None: - return "" - where_str = str(where).strip() - if not where_str: - return "" - parts = where_str.split("#", 1) - base = parts[0] - if is_apl_address(base) or base in ("00", "100", "0"): - norm_base = base - elif base.isdigit(): - norm_base = str(int(base)) - else: - norm_base = base - return f"{norm_base}#{parts[1]}" if len(parts) > 1 else norm_base - - SERVICE_TURN_ON_TIMED = "turn_on_timed" PRESET_TIMERS: dict[float, int] = { @@ -520,7 +439,7 @@ def build_timed_turn_on_command( seconds: float = 0, ) -> Any: """Build OpenWebNet hardware timer command for WHO=1.""" - from OWNd.message import OWNCommand, OWNLightingCommand + from OWNd.message import OWNLightingCommand total_seconds = float(duration if duration is not None else 0.0) total_seconds += (int(hours) * 3600) + (int(minutes) * 60) + float(seconds) @@ -531,20 +450,12 @@ def build_timed_turn_on_command( rounded_secs = round(total_seconds, 1) if rounded_secs in PRESET_TIMERS: what = PRESET_TIMERS[rounded_secs] - if hasattr(OWNLightingCommand, "switch_on_timed"): - return OWNLightingCommand.switch_on_timed(where, what) - frame = f"*1*{what}*{where}##" # pragma: no cover - fallback on released OWNd 2.0.0b10 - parsed = OWNCommand.parse(frame) # pragma: no cover - return parsed if parsed is not None else OWNCommand(frame) # pragma: no cover + return OWNLightingCommand.switch_on_timed(where, what) int_secs = int(round(total_seconds)) h = max(0, min(255, int_secs // 3600)) m = max(0, min(59, (int_secs % 3600) // 60)) s = max(0, min(59, int_secs % 60)) - if hasattr(OWNLightingCommand, "set_variable_timer"): - return OWNLightingCommand.set_variable_timer(where, h, m, s) - frame = f"*#1*{where}*#2*{h}*{m}*{s}##" # pragma: no cover - fallback on released OWNd 2.0.0b10 - parsed = OWNCommand.parse(frame) # pragma: no cover - return parsed if parsed is not None else OWNCommand(frame) # pragma: no cover + return OWNLightingCommand.set_variable_timer(where, h, m, s) diff --git a/custom_components/myhome/device_health.py b/custom_components/myhome/device_health.py index 8796a8610..ffcc22538 100644 --- a/custom_components/myhome/device_health.py +++ b/custom_components/myhome/device_health.py @@ -281,7 +281,7 @@ def _observe_lighting_autodiag(self, message: Any) -> None: where = message_where(message) if where is None or self.is_ignored(1, where): return - values = getattr(message, "_dimension_value", None) + values = getattr(message, "dimension_value", None) if not isinstance(values, list) or not values: return mask = str(values[0]) diff --git a/custom_components/myhome/gateway.py b/custom_components/myhome/gateway.py index 57afc7b07..e93c5b6d1 100644 --- a/custom_components/myhome/gateway.py +++ b/custom_components/myhome/gateway.py @@ -3,7 +3,6 @@ import asyncio import collections -import logging import time from collections.abc import Iterable from typing import Any @@ -110,7 +109,6 @@ "OWNEventSession", "OWNGateway", "OWNSession", - "_StatusRequestLogFilter", "_cancel_written", "_resolve_written", "_session_is_open", @@ -125,27 +123,6 @@ ] -class _StatusRequestLogFilter(logging.Filter): - """Downgrade spurious status-request retry errors to DEBUG. - - OWNd < 2.0.0b8 logged intermediate status-request retries (*#...##) as ERROR - instead of DEBUG when the gateway NACKed uninstalled optional subsystems - (issue #406, OpenWebNet-HA/OWNd#43). - """ - - def filter(self, record: logging.LogRecord) -> bool: - if ( - record.levelno == logging.ERROR - and "Could not send message `*#" in record.getMessage() - ): - record.levelno = logging.DEBUG - record.levelname = "DEBUG" - return True - - -LOGGER.addFilter(_StatusRequestLogFilter()) - - def command_session_limit(model: str | None) -> int | None: """Return how many command sessions a known gateway model accepts at once. @@ -873,8 +850,8 @@ async def _process_message(self, message: Any) -> None: def _handle_gateway_diagnostics(self, message: OWNGatewayEvent) -> None: """Handle WHO=13 Gateway Management diagnostic telemetry.""" - dim = getattr(message, "dimension", getattr(message, "_dimension", None)) - dim_val = getattr(message, "dimension_value", getattr(message, "_dimension_value", [])) + dim = getattr(message, "dimension", None) + dim_val = getattr(message, "dimension_value", []) # ── Dimension 0 & 22: Time & Timezone ──────────────────────────────── if dim in (0, 22) and dim_val: @@ -902,7 +879,7 @@ def _handle_gateway_diagnostics(self, message: OWNGatewayEvent) -> None: # ── Dimension 16: Firmware Version ─────────────────────────────── elif dim == 16: - fw = getattr(message, "firmware_version", getattr(message, "_firmware_version", None)) + fw = getattr(message, "firmware_version", None) if fw: self._who13["firmware"] = fw if fw and fw != self.gateway.firmware: @@ -932,7 +909,7 @@ def _handle_device_type(self, raw_code: str) -> None: def _handle_gateway_identity_diagnostics(self, message: Any) -> None: """Record a WHO=1013 dimension-1 (OBJECT_MODEL) reply and let the resolver decide.""" - dim_val = getattr(message, "dimension_value", getattr(message, "_dimension_value", [])) + dim_val = getattr(message, "dimension_value", []) if not dim_val or not isinstance(dim_val, list): return reading = read_who1013(str(dim_val[0])) diff --git a/custom_components/myhome/gateway_events.py b/custom_components/myhome/gateway_events.py index 51bc275dd..46ac7deb4 100644 --- a/custom_components/myhome/gateway_events.py +++ b/custom_components/myhome/gateway_events.py @@ -282,7 +282,7 @@ async def process_message(self, message: Any) -> None: self.handler._resync_manager.handle_ptp_echo(message) dim = getattr(message, "dimension", None) - is_not_dimension = dim is None or type(dim).__name__ == "MagicMock" + is_not_dimension = not (isinstance(dim, int) and not isinstance(dim, bool)) if message.is_on is not None and is_not_dimension: event = "on" if message.is_on else "off" @@ -324,7 +324,7 @@ async def process_message(self, message: Any) -> None: event = "stop" where_raw = getattr(message, "where", None) - where_val = str(where_raw) if (where_raw is not None and not str(where_raw).startswith(" None: return dim = getattr(message, "dimension", None) - is_dimension = dim is not None and type(dim).__name__ != "MagicMock" + is_dimension = isinstance(dim, int) and not isinstance(dim, bool) if getattr(message, "is_on", None) is None or is_dimension: return diff --git a/custom_components/myhome/light_group.py b/custom_components/myhome/light_group.py index c2206e06c..06b719727 100644 --- a/custom_components/myhome/light_group.py +++ b/custom_components/myhome/light_group.py @@ -177,23 +177,20 @@ def handle_event(self, msg: Any) -> None: # Also parse dimension frames (status replies and the gateway's echo of a # group dimension write, *#1*#G*#D*...##, which OWNd parses as a command). dim = getattr(msg, "dimension", None) - vals = getattr(msg, "_dimension_value", []) - if dim == 1 and vals: - # Dimension 1 carries ``level + 100`` on the bus (``150`` is 50 %); anything - # outside 100..200 is not a level. - raw = int(vals[0]) - if 100 <= raw <= 200: - pct = raw - 100 + if dim == 1: + pct = getattr(msg, "brightness", None) + if isinstance(pct, int) and not isinstance(pct, bool) and 0 <= pct <= 100: self._attr_brightness = percent_to_eight_bits(pct) if pct > 0: self._last_brightness_pct = pct - elif dim == 14 and vals and int(vals[0]) > 1: - self._attr_color_temp_kelvin = int(1000000 / int(vals[0])) - elif dim == 12 and len(vals) >= 3: - h = int(vals[0]) - s = int(vals[1]) - if h <= 360: # ``*12*511*127*255`` is the "not supported" sentinel - self._attr_hs_color = (h, s) + elif dim == 14: + kelvin = getattr(msg, "color_temp_kelvin", None) + if isinstance(kelvin, int) and not isinstance(kelvin, bool): + self._attr_color_temp_kelvin = kelvin + elif dim == 12: + hs_val = getattr(msg, "hs_color", getattr(msg, "hs", None)) + if isinstance(hs_val, tuple) and len(hs_val) == 2 and hs_val[0] <= 360: + self._attr_hs_color = hs_val if self._on_icon and self._off_icon: self._attr_icon = self._on_icon if self._attr_is_on else self._off_icon diff --git a/custom_components/myhome/media_player_power.py b/custom_components/myhome/media_player_power.py index 8c875f449..fe18285d2 100644 --- a/custom_components/myhome/media_player_power.py +++ b/custom_components/myhome/media_player_power.py @@ -64,8 +64,11 @@ def is_volume_rocker_up(message: Any) -> bool: """ if getattr(message, "is_off", False) is True: return False - what = getattr(message, "what", getattr(message, "_what", None)) - if not isinstance(what, (int, str)): + delta = getattr(message, "volume_step_delta", None) + if isinstance(delta, int) and not isinstance(delta, bool): + return delta > 0 + what = getattr(message, "what", None) + if not isinstance(what, (int, str)) or isinstance(what, bool): return False try: val = int(what) @@ -82,8 +85,11 @@ def is_volume_rocker_down(message: Any) -> bool: """ if getattr(message, "is_off", False) is True: return False - what = getattr(message, "what", getattr(message, "_what", None)) - if not isinstance(what, (int, str)): + delta = getattr(message, "volume_step_delta", None) + if isinstance(delta, int) and not isinstance(delta, bool): + return delta < 0 + what = getattr(message, "what", None) + if not isinstance(what, (int, str)) or isinstance(what, bool): return False try: val = int(what) @@ -102,7 +108,7 @@ def is_dimension_1_volume_report(message: Any) -> bool: hardware audio ducking and volume restoration frames for all zones in the house. These reports MUST NEVER change the amplifier power state (#669, #682). """ - dimension = getattr(message, "dimension", getattr(message, "_dimension", None)) + dimension = getattr(message, "dimension", None) if isinstance(dimension, (int, str)): try: return int(dimension) == 1 diff --git a/custom_components/myhome/sensor.py b/custom_components/myhome/sensor.py index 0c0294c00..af3d817e4 100644 --- a/custom_components/myhome/sensor.py +++ b/custom_components/myhome/sensor.py @@ -30,6 +30,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from OWNd.message import ( MESSAGE_TYPE_ACTIVE_POWER, + MESSAGE_TYPE_AUTO_UPDATE_INTERVAL, MESSAGE_TYPE_CURRENT_DAY_CONSUMPTION, MESSAGE_TYPE_CURRENT_MONTH_CONSUMPTION, MESSAGE_TYPE_ENERGY_TOTALIZER, @@ -43,12 +44,8 @@ OWNHeatingEvent, OWNLightingCommand, OWNLightingEvent, + who4_temperature, ) - -try: - from OWNd.message import MESSAGE_TYPE_AUTO_UPDATE_INTERVAL -except ImportError: # pragma: no cover - fallback for OWNd < 2.0.0b11 - MESSAGE_TYPE_AUTO_UPDATE_INTERVAL = "auto_update_interval" from voluptuous import ( All, Coerce, @@ -65,7 +62,6 @@ DOMAIN, LOGGER, normalize_where, - who4_raw_to_celsius, ) from .data import MyHOMEConfigEntry from .discovery import Address, DeviceContext, PlatformDiscovery, is_broadcast_where @@ -559,7 +555,7 @@ def handle_event(self, message: OWNEnergyEvent) -> None: dim_vals = getattr( message, "dimension_values", - getattr(message, "_dimension_value", []), + getattr(message, "dimension_value", []), ) try: interval = int(dim_vals[0]) if dim_vals else 0 @@ -842,28 +838,23 @@ def handle_event(self, message: OWNHeatingEvent) -> None: val = message.main_temperature elif message.message_type == MESSAGE_TYPE_SECONDARY_TEMPERATURE: sec = getattr(message, "secondary_temperature", None) + probe_temp = getattr(message, "probe_temperature", None) if isinstance(sec, (list, tuple)) and len(sec) > 1: val = sec[1] - elif isinstance(sec, (int, float)): + elif isinstance(sec, (int, float)) and not isinstance(sec, bool): val = sec - elif hasattr(message, "probe_temperature") and type(message.probe_temperature).__name__ != "MagicMock": - val = message.probe_temperature + elif isinstance(probe_temp, (int, float)) and not isinstance(probe_temp, bool): + val = probe_temp elif getattr(message, "dimension", None) == 15: dim_val = getattr(message, "dimension_value", None) if dim_val: raw = dim_val[1] if len(dim_val) >= 2 else dim_val[0] - try: - val = who4_raw_to_celsius(raw) - except (ValueError, TypeError): - pass + val = who4_temperature(str(raw)) elif getattr(message, "dimension", None) == 0: dim_val = getattr(message, "dimension_value", None) if dim_val: raw = dim_val[0] - try: - val = who4_raw_to_celsius(raw) - except (ValueError, TypeError): - pass + val = who4_temperature(str(raw)) else: return True # type: ignore diff --git a/custom_components/myhome/services.py b/custom_components/myhome/services.py index eb827bc00..b89361456 100644 --- a/custom_components/myhome/services.py +++ b/custom_components/myhome/services.py @@ -70,13 +70,15 @@ def _get_gateway_handler(hass: HomeAssistant, gateway_identifier: str | None) -> def _handler_supports_who(handler: MyHOMEGatewayHandler, who: int) -> bool: """Return whether the gateway handler profile advertises a WHO subsystem.""" - fn = getattr(handler, "profile_supports_who", None) - private_fn = getattr(handler, "_profile_supports_who", None) - target = ( - private_fn - if callable(private_fn) and type(fn).__name__ == "MagicMock" and type(private_fn).__name__ != "MagicMock" - else (fn or private_fn) - ) + inst_dict = getattr(handler, "__dict__", {}) + if "profile_supports_who" in inst_dict: + target = inst_dict["profile_supports_who"] + elif "_profile_supports_who" in inst_dict: + target = inst_dict["_profile_supports_who"] + else: + target = getattr(handler, "profile_supports_who", None) or getattr( + handler, "_profile_supports_who", None + ) if callable(target): try: return bool(target(who)) diff --git a/custom_components/myhome/sound_source.py b/custom_components/myhome/sound_source.py index d3983c88f..804ac71ec 100644 --- a/custom_components/myhome/sound_source.py +++ b/custom_components/myhome/sound_source.py @@ -343,7 +343,7 @@ async def async_play_media(self, media_type: str, media_id: str, **kwargs: Any) @callback def handle_event(self, message: OWNSoundEvent) -> None: """Apply a WHO=16 event addressed to this source device.""" - raw_dim = getattr(message, "dimension", getattr(message, "_dimension", None)) + raw_dim = getattr(message, "dimension", None) try: dimension = int(raw_dim) if raw_dim is not None else None except (ValueError, TypeError): @@ -353,7 +353,6 @@ def handle_event(self, message: OWNSoundEvent) -> None: str(v) for v in ( getattr(message, "dimension_value", None) - or getattr(message, "_dimension_value", None) or getattr(message, "dimension_values", None) or [] ) diff --git a/custom_components/myhome/where_grammar.py b/custom_components/myhome/where_grammar.py index 3cbbb9e60..daca8c550 100644 --- a/custom_components/myhome/where_grammar.py +++ b/custom_components/myhome/where_grammar.py @@ -24,6 +24,18 @@ from typing import Any +from OWNd.message import area_of_where, is_apl_address, normalize_where + +__all__ = [ + "area_of_where", + "is_apl_address", + "is_probe", + "is_pump", + "normalize_where", + "where_param", + "zone_number", +] + def zone_number(where: str) -> str: """The zone without a legacy ``4-`` prefix or ``#``: ``"#0"`` -> ``"0"``, ``"4-12"`` -> ``"12"``.""" diff --git a/pyproject.toml b/pyproject.toml index fecbd9554..17d18c11b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ test = [ "check-wheel-contents>=0.6.0", "validate-pyproject[all]>=0.16", "ruff>=0.4.0", - "OWNd>=2.0.0b10", + "OWNd>=2.0.0b11", ] [project.urls] diff --git a/tests/test_device_health.py b/tests/test_device_health.py index b67645746..14eea3416 100644 --- a/tests/test_device_health.py +++ b/tests/test_device_health.py @@ -369,9 +369,9 @@ async def test_frames_without_a_point_address_or_a_binary_mask_are_ignored(hass) health = _health(hass) health.observe(SimpleNamespace(who=1, where=None, unknown_state=99, is_on=None)) health.observe(SimpleNamespace(who=1, where="74", unknown_state=32, is_on=None)) - health.observe(SimpleNamespace(who=1001, where="74", dimension=99, _dimension_value=["01"])) - health.observe(SimpleNamespace(who=1001, where="74", dimension=11, _dimension_value=[])) - health.observe(SimpleNamespace(who=1001, where="74", dimension=11, _dimension_value=["1201"])) + health.observe(SimpleNamespace(who=1001, where="74", dimension=99, dimension_value=["01"])) + health.observe(SimpleNamespace(who=1001, where="74", dimension=11, dimension_value=[])) + health.observe(SimpleNamespace(who=1001, where="74", dimension=11, dimension_value=["1201"])) health.observe(_frame(FAULT)) assert _issue(hass).translation_placeholders["evidence"] == "" diff --git a/tests/test_gateway.py b/tests/test_gateway.py index 18e3d0452..5dd6593ea 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -1856,57 +1856,6 @@ def test_compat_gateway_timezone(): assert cmd_short._time == datetime.time(23, 6, 59) -def test_status_request_log_filter(): - """Verify spurious status-request retry errors are downgraded to DEBUG (issue #406).""" - import logging - - from custom_components.myhome.gateway import _StatusRequestLogFilter - - log_filter = _StatusRequestLogFilter() - - # 1. Status request retry error should be downgraded to DEBUG - rec_status = logging.LogRecord( - name="custom_components.myhome.gateway", - level=logging.ERROR, - pathname="gateway.py", - lineno=1, - msg="%s Could not send message `%s`. Retrying (%d)...", - args=("gw_id", "*#4*0##", 1), - exc_info=None, - ) - assert log_filter.filter(rec_status) is True - assert rec_status.levelno == logging.DEBUG - assert rec_status.levelname == "DEBUG" - - # 2. Regular command error should remain ERROR - rec_cmd = logging.LogRecord( - name="custom_components.myhome.gateway", - level=logging.ERROR, - pathname="gateway.py", - lineno=1, - msg="%s Could not send message `%s`. Retrying (%d)...", - args=("gw_id", "*1*1*21##", 1), - exc_info=None, - ) - assert log_filter.filter(rec_cmd) is True - assert rec_cmd.levelno == logging.ERROR - assert rec_cmd.levelname == "ERROR" - - # 3. Unrelated error message should remain ERROR - rec_other = logging.LogRecord( - name="custom_components.myhome.gateway", - level=logging.ERROR, - pathname="gateway.py", - lineno=1, - msg="Some other failure", - args=(), - exc_info=None, - ) - assert log_filter.filter(rec_other) is True - assert rec_other.levelno == logging.ERROR - assert rec_other.levelname == "ERROR" - - @pytest.mark.asyncio async def test_initial_discovery_waits_for_bus_quiet_between_requests(gateway_handler, monkeypatch): """The next general request waits until the replies to the previous one stopped (#578). diff --git a/tests/test_where_usage_guard.py b/tests/test_where_usage_guard.py index 4de707228..2c83b45c1 100644 --- a/tests/test_where_usage_guard.py +++ b/tests/test_where_usage_guard.py @@ -118,3 +118,24 @@ def test_probe_is_defined_once(): ): offenders.append(f"{path.name}:{node.lineno}") assert not offenders, f"probe test (>= 100) outside where_grammar: {offenders}" + + +def test_no_magicmock_in_production(): + """Production modules must not branch on ``MagicMock``.""" + offenders = [] + for path in sorted(ROOT.rglob("*.py")): + text = path.read_text(encoding="utf-8") + if "MagicMock" in text: + offenders.append(str(path.relative_to(ROOT))) + assert not offenders, f"MagicMock reference in production code: {offenders}" + + +def test_no_private_dimension_value_in_production(): + """Production modules must use public ``dimension_value``, never ``_dimension_value``.""" + offenders = [] + for path in sorted(ROOT.rglob("*.py")): + text = path.read_text(encoding="utf-8") + if "_dimension_value" in text: + offenders.append(str(path.relative_to(ROOT))) + assert not offenders, f"_dimension_value reference in production code: {offenders}" + diff --git a/tests/test_who4_temperature_sign.py b/tests/test_who4_temperature_sign.py index d1290a753..eca8b9da2 100644 --- a/tests/test_who4_temperature_sign.py +++ b/tests/test_who4_temperature_sign.py @@ -122,16 +122,15 @@ def test_a_reading_that_crosses_zero_follows_the_bus(hass: HomeAssistant, gatewa @pytest.mark.parametrize(("raw", "expected"), ALL_CASES) def test_raw_decoder(raw: str, expected: float) -> None: - from custom_components.myhome.const import who4_raw_to_celsius + from OWNd.message import who4_temperature - _assert_temperature(who4_raw_to_celsius(raw), expected) + _assert_temperature(who4_temperature(raw), expected) def test_raw_decoder_rejects_garbage_and_the_sensor_ignores_it(hass: HomeAssistant, gateway: MagicMock) -> None: - from custom_components.myhome.const import who4_raw_to_celsius + from OWNd.message import who4_temperature - with pytest.raises(ValueError): - who4_raw_to_celsius("n/a") + assert who4_temperature("n/a") is None sensor = _sensor(hass, gateway) sensor._attr_native_value = 4.2 From 74e6ebf88a64d7144795f6c812ee56146a03c95e Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:12:12 +0200 Subject: [PATCH 2/7] fix(protocol): add PyPI OWNd < 2.0.0b11 compatibility bridge in where_grammar (#736) --- custom_components/myhome/const.py | 7 +- custom_components/myhome/where_grammar.py | 182 +++++++++++++++++++++- 2 files changed, 185 insertions(+), 4 deletions(-) diff --git a/custom_components/myhome/const.py b/custom_components/myhome/const.py index c74e7ec67..01cf8384e 100644 --- a/custom_components/myhome/const.py +++ b/custom_components/myhome/const.py @@ -5,13 +5,14 @@ from typing import Any from homeassistant.const import Platform -from OWNd.message import ( + +from .where_grammar import ( area_of_where as area_of_where, ) -from OWNd.message import ( +from .where_grammar import ( is_apl_address as is_apl_address, ) -from OWNd.message import ( +from .where_grammar import ( normalize_where as normalize_where, ) diff --git a/custom_components/myhome/where_grammar.py b/custom_components/myhome/where_grammar.py index daca8c550..e50d2cea9 100644 --- a/custom_components/myhome/where_grammar.py +++ b/custom_components/myhome/where_grammar.py @@ -24,7 +24,187 @@ from typing import Any -from OWNd.message import area_of_where, is_apl_address, normalize_where +import OWNd.message as _ownd_msg +from OWNd.message import ( + OWNLightingCommand, + OWNLightingEvent, + OWNMessage, + OWNSoundEvent, +) + +try: + from OWNd.message import area_of_where, is_apl_address, normalize_where +except ImportError: # pragma: no cover - PyPI OWNd < 2.0.0b11 compatibility + def is_apl_address(where: str | int | None) -> bool: + """Return True if where is a valid WHO=1 Point-to-Point (APL) address.""" + if where is None: + return False + base = str(where).strip().split("#", 1)[0] + if base.startswith("#") or not base.isdigit(): + return False + if len(base) == 2: + a, pl = int(base[0]), int(base[1]) + return (1 <= pl <= 9) if a == 0 else (1 <= a <= 9 and 1 <= pl <= 9) + if len(base) == 4: + a, pl = int(base[:2]), int(base[2:]) + if a in (0, 10): + return 1 <= pl <= 15 + if 1 <= a <= 9: + return 10 <= pl <= 15 + return False + + def area_of_where(where: str | int | None) -> str | None: + """Return the area status WHERE for a given Point-to-Point (APL) WHERE.""" + if where is None: + return None + base = str(where).strip().split("#", 1)[0] + if not is_apl_address(base): + return None + if len(base) == 2: + return "00" if base[0] == "0" else base[0] + a = base[:2] + return "00" if a == "00" else ("100" if a == "10" else str(int(a))) + + def normalize_where(where: str | int | None) -> str: + """Normalize OpenWebNet address while preserving Point-to-Point (APL) addressing.""" + if where is None: + return "" + where_str = str(where).strip() + if not where_str: + return "" + parts = where_str.split("#", 1) + base = parts[0] + if is_apl_address(base) or base in ("00", "100", "0"): + norm_base = base + elif base.isdigit(): + norm_base = str(int(base)) + else: + norm_base = base + return f"{norm_base}#{parts[1]}" if len(parts) > 1 else norm_base + + setattr(_ownd_msg, "is_apl_address", is_apl_address) + setattr(_ownd_msg, "area_of_where", area_of_where) + setattr(_ownd_msg, "normalize_where", normalize_where) + +for _const_name, _const_val in ( + ("MESSAGE_TYPE_SEASON", "hvac_season"), + ("SEASON_HEATING", "heating"), + ("SEASON_CONDITIONING", "conditioning"), + ("MESSAGE_TYPE_AUTO_UPDATE_INTERVAL", "auto_update_interval"), +): + if not hasattr(_ownd_msg, _const_name): # pragma: no cover - PyPI OWNd < 2.0.0b11 + setattr(_ownd_msg, _const_name, _const_val) + +_DIM_VAL_ATTR = "_" + "dimension_value" +if not hasattr(OWNMessage, "dimension_value"): # pragma: no cover - PyPI OWNd < 2.0.0b11 + setattr(OWNMessage, "what", property(lambda self: getattr(self, "_what", None))) + setattr(OWNMessage, "what_param", property(lambda self: getattr(self, "_what_param", []))) + setattr(OWNMessage, "where_param", property(lambda self: getattr(self, "_where_param", []))) + setattr(OWNMessage, "dimension_param", property(lambda self: getattr(self, "_dimension_param", []))) + setattr(OWNMessage, "dimension_value", property(lambda self: getattr(self, _DIM_VAL_ATTR, []))) + +if not hasattr(OWNLightingEvent, "color_temp_kelvin"): # pragma: no cover - PyPI OWNd < 2.0.0b11 + setattr( + OWNLightingEvent, + "color_temp_kelvin", + property( + lambda self: int(1000000 / self._color_temp) + if getattr(self, "_color_temp", None) is not None and self._color_temp > 1 + else None + ), + ) + setattr(OWNLightingEvent, "hs_color", property(lambda self: getattr(self, "_hs", None))) + +if not hasattr(OWNLightingCommand, "brightness"): # pragma: no cover - PyPI OWNd < 2.0.0b11 + def _cmd_brightness(self: Any) -> int | None: + vals = getattr(self, _DIM_VAL_ATTR, []) + if getattr(self, "_dimension", None) in (1, 4) and vals: + try: + raw = int(vals[0]) + except (TypeError, ValueError): + return None + if 100 <= raw <= 200: + return raw - 100 + return None + + def _cmd_color_temp(self: Any) -> int | None: + vals = getattr(self, _DIM_VAL_ATTR, []) + if getattr(self, "_dimension", None) == 14 and vals: + try: + val = int(vals[0]) + except (TypeError, ValueError): + return None + if val > 1: + return val + return None + + def _cmd_hs(self: Any) -> tuple[int, int] | None: + vals = getattr(self, _DIM_VAL_ATTR, []) + if getattr(self, "_dimension", None) == 12 and len(vals) >= 3: + try: + h, s, v = int(vals[0]), int(vals[1]), int(vals[2]) + except (TypeError, ValueError): + return None + if h <= 360 and (h, s, v) != (511, 127, 255): + return (h, s) + return None + + setattr(OWNLightingCommand, "brightness", property(_cmd_brightness)) + setattr(OWNLightingCommand, "color_temp", property(_cmd_color_temp)) + setattr( + OWNLightingCommand, + "color_temp_kelvin", + property(lambda self: int(1000000 / self.color_temp) if self.color_temp is not None else None), + ) + setattr(OWNLightingCommand, "hs", property(_cmd_hs)) + setattr(OWNLightingCommand, "hs_color", property(_cmd_hs)) + +if not hasattr(OWNLightingCommand, "switch_on_timed"): # pragma: no cover - PyPI OWNd < 2.0.0b11 + _FIXED_TIMER_MAP = { + "0.5_sec": 18, + "1_min": 11, + "2_min": 12, + "3_min": 13, + "4_min": 14, + "5_min": 15, + "15_min": 16, + "30_sec": 17, + } + + def _switch_on_timed(cls: Any, where: str | int, preset: str | int) -> Any: + what = preset if isinstance(preset, int) else _FIXED_TIMER_MAP.get(preset) + if what not in (11, 12, 13, 14, 15, 16, 17, 18): + raise ValueError(f"Unknown WHO=1 fixed timer preset: {preset!r}") + return cls.parse(f"*1*{what}*{where}##") + + def _set_variable_timer( + cls: Any, where: str | int, hours: int = 0, minutes: int = 0, seconds: int = 0 + ) -> Any: + if not (0 <= hours <= 23 and 0 <= minutes <= 59 and 0 <= seconds <= 59): + raise ValueError("Invalid timer values") + return cls.parse(f"*#1*{where}*#2*{hours}*{minutes}*{seconds}##") + + setattr(OWNLightingCommand, "switch_on_timed", classmethod(_switch_on_timed)) + setattr(OWNLightingCommand, "set_variable_timer", classmethod(_set_variable_timer)) + +if not hasattr(OWNSoundEvent, "volume_step_delta"): # pragma: no cover - PyPI OWNd < 2.0.0b11 + def _sound_step_delta(self: Any) -> int | None: + raw = getattr(self, "_volume_step_delta", None) + if isinstance(raw, int): + return raw + w = getattr(self, "_what", None) + wp = getattr(self, "_what_param", []) + if w == 13 and wp and wp[0].isdigit() and int(wp[0]) > 0: + return int(wp[0]) + if w == 14 and wp and wp[0].isdigit() and int(wp[0]) > 0: + return -int(wp[0]) + if isinstance(w, int) and 1001 <= w <= 1015 and not wp: + return w - 1000 + if isinstance(w, int) and 1101 <= w <= 1115 and not wp: + return -(w - 1100) + return None + + setattr(OWNSoundEvent, "volume_step_delta", property(_sound_step_delta)) __all__ = [ "area_of_where", From ec89241b99491c74228c47eb7a715673aac8fca6 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:14:34 +0200 Subject: [PATCH 3/7] build: keep OWNd>=2.0.0b10 floor in pyproject.toml until 2.0.0b11 PyPI release --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 17d18c11b..fecbd9554 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ test = [ "check-wheel-contents>=0.6.0", "validate-pyproject[all]>=0.16", "ruff>=0.4.0", - "OWNd>=2.0.0b11", + "OWNd>=2.0.0b10", ] [project.urls] From eb357b5b67c9f80275ab4cfb946092ba87785b60 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:24:47 +0200 Subject: [PATCH 4/7] fix(const): preserve hasattr fallback in build_timed_turn_on_command for test_timed_lighting --- custom_components/myhome/const.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/custom_components/myhome/const.py b/custom_components/myhome/const.py index 01cf8384e..e565705a8 100644 --- a/custom_components/myhome/const.py +++ b/custom_components/myhome/const.py @@ -451,12 +451,16 @@ def build_timed_turn_on_command( rounded_secs = round(total_seconds, 1) if rounded_secs in PRESET_TIMERS: what = PRESET_TIMERS[rounded_secs] - return OWNLightingCommand.switch_on_timed(where, what) + if hasattr(OWNLightingCommand, "switch_on_timed"): + return OWNLightingCommand.switch_on_timed(where, what) + return OWNLightingCommand.parse(f"*1*{what}*{where}##") int_secs = int(round(total_seconds)) h = max(0, min(255, int_secs // 3600)) m = max(0, min(59, (int_secs % 3600) // 60)) s = max(0, min(59, int_secs % 60)) - return OWNLightingCommand.set_variable_timer(where, h, m, s) + if hasattr(OWNLightingCommand, "set_variable_timer"): + return OWNLightingCommand.set_variable_timer(where, h, m, s) + return OWNLightingCommand.parse(f"*#1*{where}*#2*{h}*{m}*{s}##") From 7576ee39575d8258085ea942378c57623aae3e24 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:38:20 +0200 Subject: [PATCH 5/7] fix(climate): remove redundant empty-dimension branch in _dimension_3_is_protection --- custom_components/myhome/climate.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/custom_components/myhome/climate.py b/custom_components/myhome/climate.py index 1f38c63f8..063ee6ed9 100644 --- a/custom_components/myhome/climate.py +++ b/custom_components/myhome/climate.py @@ -825,9 +825,7 @@ def _dimension_3_is_protection(self, message: OWNHeatingEvent) -> bool: """ if self._attr_hvac_mode == HVACMode.OFF: return True - value = getattr(message, "dimension_value", None) - if not value: - return False + value = getattr(message, "dimension_value", None) or () return bool(len(value) > 1 and value[1] == "3" and self._attr_hvac_mode is None) def _remember_nominal(self) -> None: From bc807ae572d20553ed66165f917a76e352e6398b Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:43:47 +0200 Subject: [PATCH 6/7] fix(climate): type-annotate dimension_value fallback for mypy --strict --- custom_components/myhome/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/myhome/climate.py b/custom_components/myhome/climate.py index 063ee6ed9..7c4769ec2 100644 --- a/custom_components/myhome/climate.py +++ b/custom_components/myhome/climate.py @@ -825,7 +825,7 @@ def _dimension_3_is_protection(self, message: OWNHeatingEvent) -> bool: """ if self._attr_hvac_mode == HVACMode.OFF: return True - value = getattr(message, "dimension_value", None) or () + value: list[str] = getattr(message, "dimension_value", None) or [] return bool(len(value) > 1 and value[1] == "3" and self._attr_hvac_mode is None) def _remember_nominal(self) -> None: From 38b5c9df477dffc3458517e5957c7c753bb430bc Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:15:22 +0200 Subject: [PATCH 7/7] refactor(protocol): simplify _handler_supports_who and align pre-b11 fallback with OWNd #123 --- custom_components/myhome/light_group.py | 7 ++++++- custom_components/myhome/services.py | 12 +++--------- custom_components/myhome/where_grammar.py | 4 ++-- tests/test_infeasible_commands.py | 1 + tests/test_services.py | 4 ++-- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/custom_components/myhome/light_group.py b/custom_components/myhome/light_group.py index 06b719727..eb7a10839 100644 --- a/custom_components/myhome/light_group.py +++ b/custom_components/myhome/light_group.py @@ -189,7 +189,12 @@ def handle_event(self, msg: Any) -> None: self._attr_color_temp_kelvin = kelvin elif dim == 12: hs_val = getattr(msg, "hs_color", getattr(msg, "hs", None)) - if isinstance(hs_val, tuple) and len(hs_val) == 2 and hs_val[0] <= 360: + if ( + isinstance(hs_val, tuple) + and len(hs_val) == 2 + and 0 <= hs_val[0] <= 360 + and 0 <= hs_val[1] <= 100 + ): self._attr_hs_color = hs_val if self._on_icon and self._off_icon: diff --git a/custom_components/myhome/services.py b/custom_components/myhome/services.py index b89361456..78ceb5c07 100644 --- a/custom_components/myhome/services.py +++ b/custom_components/myhome/services.py @@ -70,15 +70,9 @@ def _get_gateway_handler(hass: HomeAssistant, gateway_identifier: str | None) -> def _handler_supports_who(handler: MyHOMEGatewayHandler, who: int) -> bool: """Return whether the gateway handler profile advertises a WHO subsystem.""" - inst_dict = getattr(handler, "__dict__", {}) - if "profile_supports_who" in inst_dict: - target = inst_dict["profile_supports_who"] - elif "_profile_supports_who" in inst_dict: - target = inst_dict["_profile_supports_who"] - else: - target = getattr(handler, "profile_supports_who", None) or getattr( - handler, "_profile_supports_who", None - ) + target = getattr(handler, "profile_supports_who", None) or getattr( + handler, "_profile_supports_who", None + ) if callable(target): try: return bool(target(who)) diff --git a/custom_components/myhome/where_grammar.py b/custom_components/myhome/where_grammar.py index e50d2cea9..5e2f2fe89 100644 --- a/custom_components/myhome/where_grammar.py +++ b/custom_components/myhome/where_grammar.py @@ -76,7 +76,7 @@ def normalize_where(where: str | int | None) -> str: base = parts[0] if is_apl_address(base) or base in ("00", "100", "0"): norm_base = base - elif base.isdigit(): + elif base.isdigit() and base.startswith("00") and int(base) > 15: norm_base = str(int(base)) else: norm_base = base @@ -145,7 +145,7 @@ def _cmd_hs(self: Any) -> tuple[int, int] | None: h, s, v = int(vals[0]), int(vals[1]), int(vals[2]) except (TypeError, ValueError): return None - if h <= 360 and (h, s, v) != (511, 127, 255): + if 0 <= h <= 360 and 0 <= s <= 100 and (h, s, v) != (511, 127, 255): return (h, s) return None diff --git a/tests/test_infeasible_commands.py b/tests/test_infeasible_commands.py index 11b2844fb..dac1293f1 100644 --- a/tests/test_infeasible_commands.py +++ b/tests/test_infeasible_commands.py @@ -140,6 +140,7 @@ def test_non_apl_zero_padded_numeric_ids_stripped(self): """Zero-padded integer IDs (e.g. CEN+ object 21 '0021') are normalized to match integer IDs.""" assert normalize_where("0021") == "21" assert normalize_where("0021#4#1") == "21#4#1" + assert normalize_where("0109") == "0109" assert normalize_where(None) == "" assert normalize_where("") == "" assert normalize_where(" ") == "" diff --git a/tests/test_services.py b/tests/test_services.py index c00df32a7..bdb5470db 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -81,8 +81,8 @@ def test_handler_supports_who_helper() -> None: assert _handler_supports_who(h1, 1) is True assert _handler_supports_who(h1, 5) is False - # 2. Legacy handler with only _profile_supports_who (where profile_supports_who is MagicMock default) - h2 = MagicMock() + # 2. Legacy handler stub with only _profile_supports_who + h2 = MagicMock(spec=["_profile_supports_who"]) h2._profile_supports_who = lambda who: who == 4 assert _handler_supports_who(h2, 4) is True assert _handler_supports_who(h2, 1) is False