diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bc962e..93e19df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to `OWNd` are documented in this file. +## [Unreleased] + +### Added +- **WHO 1 (`OWNLightingCommand`, `OWNLightingEvent`)**: + - Automatic detection of legacy pre-`09W29` BTicino `F414` coarse-only dimmers via `DIMENSION 1` / `DIMENSION 4` transition speed `240` (`0xF0`, `DIMMER_SPEED_COARSE_ONLY`) with multi-gateway plant isolation (`gateway=` scoping on `OWNMessage.parse`, `OWNEvent.parse`, `OWNEventSession.get_next`, `is_coarse_dimmer_where`, `register_coarse_dimmer_where`, `clear_coarse_dimmer_wheres`, and `OWNLightingCommand.set_brightness`) (`#134`, `MyHOME#767`). + - Canonical `libqtdevices` (`TS10_1_0_23`) coarse `WHAT 2..10` $\leftrightarrow$ fine `1..100%` conversion helpers (`COARSE_PRESET_TO_PERCENT`, `brightness_preset_to_percent`, `percent_to_brightness_preset`), `OWNLightingEvent.is_coarse_dimmer`, `OWNLightingEvent.brightness_preset_percent`, and `OWNLightingCommand.set_brightness(..., coarse=..., gateway=...)` (`#134`). + ## [2.0.0b11] - 2026-10-20 ### Added diff --git a/OWNd/connection/command_session.py b/OWNd/connection/command_session.py index 111bf47..d8f50b1 100644 --- a/OWNd/connection/command_session.py +++ b/OWNd/connection/command_session.py @@ -156,7 +156,9 @@ async def _read_command_response( for _ in range(COMMAND_RESPONSE_MAX_FRAMES): raw_response = await self._read_frame(COMMAND_TIMEOUT) try: - resulting_message = OWNMessage.parse(raw_response) + resulting_message = OWNMessage.parse( + raw_response, gateway=self._gateway + ) except (ValueError, IndexError, TypeError) as error: self._logger.warning( "%s Malformed command response %r: %s", @@ -228,7 +230,7 @@ async def _locked_send( msg_obj = ( message if isinstance(message, OWNMessage) - else OWNMessage.parse(str(message)) + else OWNMessage.parse(str(message), gateway=self._gateway) ) is_valid_frame = ( msg_obj is not None diff --git a/OWNd/connection/event_session.py b/OWNd/connection/event_session.py index 527649d..185be18 100644 --- a/OWNd/connection/event_session.py +++ b/OWNd/connection/event_session.py @@ -262,7 +262,7 @@ async def get_next( # down (and lose bus events) over a frame we could not make sense of. try: _decoded_data = data.decode() - _message = OWNMessage.parse(_decoded_data) + _message = OWNMessage.parse(_decoded_data, gateway=self._gateway) return _message if _message else _decoded_data except (UnicodeDecodeError, ValueError, IndexError, TypeError) as error: _decoded_data = data.decode(errors="replace") diff --git a/OWNd/message/__init__.py b/OWNd/message/__init__.py index ad5ed7d..99a2280 100644 --- a/OWNd/message/__init__.py +++ b/OWNd/message/__init__.py @@ -176,6 +176,8 @@ OWNHeatingEvent, ) from .lighting import ( + COARSE_PRESET_TO_PERCENT, + DIMMER_SPEED_COARSE_ONLY, LIGHTING_STATE_ANOMALY, MESSAGE_TYPE_ACTION, MESSAGE_TYPE_ILLUMINANCE, @@ -184,8 +186,13 @@ MESSAGE_TYPE_PIR_SENSITIVITY, PIR_SENSITIVITY_MAPPING, area_of_where, + brightness_preset_to_percent, + clear_coarse_dimmer_wheres, is_apl_address, + is_coarse_dimmer_where, normalize_where, + percent_to_brightness_preset, + register_coarse_dimmer_where, OWNLightingCommand, OWNLightingEvent, ) @@ -231,6 +238,8 @@ "require_non_empty_where", "_ensure_all_subsystems_registered", # Lighting (WHO 1) + "COARSE_PRESET_TO_PERCENT", + "DIMMER_SPEED_COARSE_ONLY", "LIGHTING_STATE_ANOMALY", "MESSAGE_TYPE_ACTION", "MESSAGE_TYPE_MOTION", @@ -238,6 +247,11 @@ "MESSAGE_TYPE_ILLUMINANCE", "MESSAGE_TYPE_MOTION_TIMEOUT", "PIR_SENSITIVITY_MAPPING", + "brightness_preset_to_percent", + "percent_to_brightness_preset", + "is_coarse_dimmer_where", + "register_coarse_dimmer_where", + "clear_coarse_dimmer_wheres", "is_apl_address", "area_of_where", "normalize_where", diff --git a/OWNd/message/base.py b/OWNd/message/base.py index fb99430..624d6b7 100644 --- a/OWNd/message/base.py +++ b/OWNd/message/base.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable +import contextvars import importlib import re import sys @@ -10,6 +11,14 @@ _EVENT_DISPATCH: dict[int, Callable[[str], OWNEvent]] = {} _COMMAND_DISPATCH: dict[int, Callable[[str], OWNCommand]] = {} +_PARSE_GATEWAY_CTX: contextvars.ContextVar[Any] = contextvars.ContextVar( + "ownd_parse_gateway", default=None +) + + +def get_parse_gateway() -> Any: + """Return the active gateway context during ``OWNMessage.parse`` / ``OWNEvent.parse``.""" + return _PARSE_GATEWAY_CTX.get() _WHO_SUBMODULES: tuple[str, ...] = ( "actuator_lock", @@ -120,6 +129,7 @@ def __init__(self, data: str) -> None: self._dimension: int | None = None self._dimension_param: list[str] = [] self._dimension_value: list[str] = [] + self._gateway: Any = get_parse_gateway() if match := self._STATUS.match(self._raw): self._is_valid_message = True @@ -203,7 +213,7 @@ def __init__(self, data: str) -> None: self._dimension_value = match.group("dimension_value").split("*")[1:] @classmethod - def parse(cls, data: str) -> OWNMessage | None: + def parse(cls, data: str, *, gateway: Any = None) -> OWNMessage | None: if ( cls._ACK.match(data) or cls._NACK.match(data) @@ -219,13 +229,13 @@ def parse(cls, data: str) -> OWNMessage | None: or cls._DOOR_ENTRY_SHORT.match(data) or cls._DIMENSION_REQUEST_REPLY.match(data) ): - return OWNEvent.parse(data) + return OWNEvent.parse(data, gateway=gateway) if ( cls._STATUS_REQUEST.match(data) or cls._DIMENSION_REQUEST.match(data) or cls._DIMENSION_WRITING.match(data) ): - return OWNCommand.parse(data) + return OWNCommand.parse(data, gateway=gateway) return None @property @@ -438,16 +448,20 @@ class OWNEvent(OWNMessage): """ @classmethod - def parse(cls, data: str) -> OWNEvent | None: + def parse(cls, data: str, *, gateway: Any = None) -> OWNEvent | None: _match = re.match(r"^\*#?(?P\d+)(?:\*.+)?##$", data) if _match: _who = int(_match.group("who")) _ensure_all_subsystems_registered() parser = _EVENT_DISPATCH.get(_who) - if parser is not None: - return parser(data) - return cls(data) + token = _PARSE_GATEWAY_CTX.set(gateway) + try: + if parser is not None: + return parser(data) + return cls(data) + finally: + _PARSE_GATEWAY_CTX.reset(token) return None @@ -460,23 +474,27 @@ class OWNCommand(OWNMessage): """ @classmethod - def parse(cls, data: str) -> OWNCommand | None: + def parse(cls, data: str, *, gateway: Any = None) -> OWNCommand | None: _match = re.match(r"^\*#?(?P\d+)(?:\*.+)?##$", data) if _match: _who = int(_match.group("who")) _ensure_all_subsystems_registered() parser = _COMMAND_DISPATCH.get(_who) - if parser is not None: - return parser(data) - if _who in (0, 24) or _who > 1000: - return cls(data) - if _who in (7, 9): - return ( - OWNStatusRequest(data) - if cls._STATUS_REQUEST.match(data) - else cls(data) - ) + token = _PARSE_GATEWAY_CTX.set(gateway) + try: + if parser is not None: + return parser(data) + if _who in (0, 24) or _who > 1000: + return cls(data) + if _who in (7, 9): + return ( + OWNStatusRequest(data) + if cls._STATUS_REQUEST.match(data) + else cls(data) + ) + finally: + _PARSE_GATEWAY_CTX.reset(token) return None diff --git a/OWNd/message/lighting.py b/OWNd/message/lighting.py index f385934..e3b586c 100644 --- a/OWNd/message/lighting.py +++ b/OWNd/message/lighting.py @@ -10,6 +10,7 @@ from .base import ( OWNCommand, OWNEvent, + get_parse_gateway, register_command_parser, register_event_parser, require_non_empty_where, @@ -24,9 +25,78 @@ # WHAT 19 anomaly status per MH200N bt_luci binary disassembly (findings/MH200N/what19.md) LIGHTING_STATE_ANOMALY = 19 +# Legacy coarse-only dimmer transition-speed byte (0xF0 = 240) reported in WHO 1 +# DIMENSION 1 (*#1*WHERE*1*LEVEL100*240##) by pre-09W29 BTicino F414 dimmers +# that only actuate live output on coarse WHAT 2..10 commands (MyHOME#767). +DIMMER_SPEED_COARSE_ONLY = 240 + +# Canonical BTicino libqtdevices (TS10_1_0_23, lighting_device.cpp) mapping +# between coarse WHO 1 WHAT 2..10 presets and fine 1..100 % levels. +COARSE_PRESET_TO_PERCENT: dict[int, int] = { + 2: 1, + 3: 10, + 4: 20, + 5: 30, + 6: 40, + 7: 50, + 8: 60, + 9: 75, + 10: 100, +} + +# Per-(gateway_key, normalized_where) coarse-only dimmer registry. +# ``gateway_key`` is ``None`` for unscoped parse/command calls, or a normalized +# gateway identifier (serial/MAC or host) when scoped to a specific plant. +_COARSE_DIMMER_BY_GATEWAY: dict[tuple[str | None, str], bool] = {} + PIR_SENSITIVITY_MAPPING = ["low", "medium", "high", "very high"] +def brightness_preset_to_percent(preset: int) -> int: + """Convert a coarse WHO 1 WHAT 2..10 preset to its canonical 1..100 % level. + + Matches ``libqtdevices`` ``dimmerLevelTo100`` (``2->1, 3->10, 4->20, 5->30, + 6->40, 7->50, 8->60, 9->75, 10->100``). + """ + if isinstance(preset, bool) or (isinstance(preset, float) and preset % 1 != 0): + raise ValueError("preset must be an integer between 2 and 10") + preset_int = int(preset) + if preset_int not in COARSE_PRESET_TO_PERCENT: + raise ValueError("preset must be between 2 and 10") + return COARSE_PRESET_TO_PERCENT[preset_int] + + +def percent_to_brightness_preset(level: int) -> int: + """Convert a 0..100 % level to the coarse WHO 1 WHAT value (0 or 2..10). + + Matches ``libqtdevices`` ``dimmer100LevelTo10`` (``lighting_device.cpp:240-245``): + ``<=0 -> 0``, ``1..4 -> 2``, ``5..15 -> 3``, ``16..25 -> 4``, ``26..35 -> 5``, + ``36..45 -> 6``, ``46..53 -> 7``, ``54..67 -> 8``, ``68..85 -> 9``, ``>=86 -> 10``. + """ + if isinstance(level, bool) or (isinstance(level, float) and level % 1 != 0): + raise ValueError("level must be an integer") + lvl = int(level) + if lvl <= 0: + return 0 + if lvl <= 4: + return 2 + if lvl <= 15: + return 3 + if lvl <= 25: + return 4 + if lvl <= 35: + return 5 + if lvl <= 45: + return 6 + if lvl <= 53: + return 7 + if lvl <= 67: + return 8 + if lvl <= 85: + return 9 + return 10 + + def is_apl_address(base: str) -> bool: """Check if base address is a valid OpenWebNet Point-to-Point (APL) address. @@ -96,6 +166,89 @@ def normalize_where(where: str | int | None) -> str: return f"{norm_base}#{parts[1]}" if len(parts) > 1 else norm_base +def _normalize_gateway_key(gateway: Any = None) -> str | None: + """Resolve an optional gateway identifier or ``OWNGateway`` object to a lowercase key.""" + target = get_parse_gateway() if gateway is None else gateway + if target is None: + return None + if isinstance(target, str): + stripped = target.strip().lower() + return stripped if stripped else None + for attr in ("serial", "unique_id", "host", "address"): + val = getattr(target, attr, None) + if isinstance(val, str) and val.strip(): + return val.strip().lower() + return None + + +def is_coarse_dimmer_where( + where: str | int | None, *, gateway: Any = None +) -> bool: + """Return True if ``where`` reported the 240 (0xF0) coarse-only dimmer speed. + + Learning is reactive: a WHO 1 ``DIMENSION 1`` or ``DIMENSION 4`` reply carrying + ``transition == 240`` (``*#1*WHERE*1*LEVEL100*240##``) must be observed first + (or registered via :func:`register_coarse_dimmer_where`). + + Resolution rules: + - **Scoped lookup** (``gateway`` provided or active via ``OWNMessage.parse``): + returns the state recorded for ``(gateway_key, normalize_where(where))`` if + present, otherwise falls back to the unscoped ``(None, normalize_where(where))`` + entry (defaulting to ``False``). + - **Unscoped lookup** (``gateway=None`` outside a scoped parse context): + returns the explicit ``(None, normalize_where(where))`` entry if present; + otherwise returns ``True`` if and only if at least one gateway has recorded + ``where`` and **all** gateways that have observed ``where`` agree it is a + coarse-only dimmer (``bool(scoped) and all(scoped)``). + """ + norm = normalize_where(where) + if not norm: + return False + gw_key = _normalize_gateway_key(gateway) + if gw_key is not None: + if (gw_key, norm) in _COARSE_DIMMER_BY_GATEWAY: + return _COARSE_DIMMER_BY_GATEWAY[(gw_key, norm)] + return _COARSE_DIMMER_BY_GATEWAY.get((None, norm), False) + if (None, norm) in _COARSE_DIMMER_BY_GATEWAY: + return _COARSE_DIMMER_BY_GATEWAY[(None, norm)] + scoped = [ + v + for (g, w), v in _COARSE_DIMMER_BY_GATEWAY.items() + if g is not None and w == norm + ] + return bool(scoped) and all(scoped) + + +def register_coarse_dimmer_where( + where: str | int | None, + is_coarse: bool = True, + *, + gateway: Any = None, +) -> None: + """Record or clear ``where`` as a coarse-only dimmer (speed 240 / 0xF0). + + Pass ``gateway`` (an :class:`OWNGateway` instance or gateway identifier string) + to scope the entry to a specific plant in multi-gateway deployments. + """ + norm = normalize_where(where) + if not norm: + return + gw_key = _normalize_gateway_key(gateway) + _COARSE_DIMMER_BY_GATEWAY[(gw_key, norm)] = bool(is_coarse) + + +def clear_coarse_dimmer_wheres(gateway: Any = None) -> None: + """Clear learned coarse-only dimmer WHERE addresses (all gateways, or one gateway).""" + if gateway is None: + _COARSE_DIMMER_BY_GATEWAY.clear() + return + gw_key = _normalize_gateway_key(gateway) + if gw_key is None: + return + for key in [k for k in _COARSE_DIMMER_BY_GATEWAY if k[0] == gw_key]: + del _COARSE_DIMMER_BY_GATEWAY[key] + + class OWNLightingEvent(OWNEvent): def __init__(self, data: str) -> None: super().__init__(data) @@ -106,6 +259,7 @@ def __init__(self, data: str) -> None: self._brightness: int | None = None self._brightness_preset: int | None = None self._transition: int | None = None + self._is_coarse_dimmer: bool = False self._timer: float | None = None self._blinker: float | None = None self._illuminance: int | None = None @@ -124,6 +278,12 @@ def __init__(self, data: str) -> None: self._rgb: tuple[int, int, int] | None = None self._supports_rgb: bool | None = None + full_where = ( + f"{self._where}#{'#'.join(self._where_param)}" + if self._where and self._where_param + else self._where + ) + if self._what is not None and self._what != 1000: self._state = self._what @@ -137,7 +297,9 @@ def __init__(self, data: str) -> None: ) elif self._state > 1 and self._state < 11: # Light dimmed to preset value self._brightness_preset = self._state - # self._brightness = self._state * 10 + if is_coarse_dimmer_where(full_where): + self._is_coarse_dimmer = True + self._brightness = brightness_preset_to_percent(self._state) self._human_readable_log = f"Light {self._where}{self._interface_log_text} is switched on at brightness level {self._state}." # pylint: disable=line-too-long elif self._state == 11: # Timer at 1m self._timer = 60 @@ -196,6 +358,11 @@ def __init__(self, data: str) -> None: if len(self._dimension_value) > 1 else None ) + if self._transition is not None: + self._is_coarse_dimmer = ( + self._transition == DIMMER_SPEED_COARSE_ONLY + ) + register_coarse_dimmer_where(full_where, self._is_coarse_dimmer) if self._brightness == 0: self._state = 0 self._human_readable_log = f"Light {self._where}{self._interface_log_text} is switched off." @@ -273,6 +440,13 @@ def message_type(self) -> str | None: def brightness_preset(self) -> int | None: return self._brightness_preset + @property + def brightness_preset_percent(self) -> int | None: + """Fine 1..100 % equivalent for a coarse WHAT 2..10 preset, else None.""" + if self._brightness_preset is None: + return None + return COARSE_PRESET_TO_PERCENT.get(self._brightness_preset) + @property def brightness(self) -> int | None: return self._brightness @@ -281,6 +455,11 @@ def brightness(self) -> int | None: def transition(self) -> int | None: return self._transition + @property + def is_coarse_dimmer(self) -> bool: + """True if this event indicates a coarse-only dimmer (speed 240 / 0xF0).""" + return self._is_coarse_dimmer + @property def is_on(self) -> bool | None: """True/False when the on/off state is known, None otherwise (e.g. a @@ -397,9 +576,25 @@ def supports_rgb(self) -> bool | None: class OWNLightingCommand(OWNCommand): + def __init__(self, data: str) -> None: + super().__init__(data) + self._is_coarse_dimmer: bool = False + + @property + def is_coarse_dimmer(self) -> bool: + """True if this command was built for or targets a coarse-only dimmer.""" + return self._is_coarse_dimmer + + @property + def brightness_preset(self) -> int | None: + """Coarse WHO 1 preset (2..10) for WHAT 2..10 commands, else None.""" + if self._what is not None and 2 <= self._what <= 10: + return self._what + return None + @property def brightness(self) -> int | None: - """Decoded brightness percentage (0..100) for Dimension 1/4 write commands.""" + """Decoded brightness percentage (0..100) for Dimension 1/4 writes or coarse WHAT 2..10 commands.""" if self._dimension in (1, 4) and self._dimension_value: try: raw = int(self._dimension_value[0]) @@ -407,6 +602,16 @@ def brightness(self) -> int | None: return None if 100 <= raw <= 200: return raw - 100 + if self._what is not None and 2 <= self._what <= 10: + full_where = ( + f"{self._where}#{'#'.join(self._where_param)}" + if self._where and self._where_param + else self._where + ) + if self._is_coarse_dimmer or is_coarse_dimmer_where( + full_where, gateway=self._gateway + ): + return brightness_preset_to_percent(self._what) return None @property @@ -562,6 +767,8 @@ def switch_off(cls, where: str | int, _transition: int | None = None) -> OWNLigh def set_brightness_preset(cls, where: str | int, preset: int) -> OWNLightingCommand: """Dimmer preset WHAT 2..10 (20 %..100 %), the frame libqtdevices DimmerDevice::setLevel100 sends (lighting_device.cpp:240-245).""" + if isinstance(preset, bool) or (isinstance(preset, float) and preset % 1 != 0): + raise ValueError("preset must be an integer between 2 and 10") preset = int(preset) if not 2 <= preset <= 10: raise ValueError("preset must be between 2 and 10") @@ -637,13 +844,51 @@ def get_variable_timer(cls, where: str | int) -> OWNLightingCommand: @classmethod def set_brightness( - cls, where: str | int, _level: int = 30, _transition: int = 0 + cls, + where: str | int, + _level: int = 30, + _transition: int = 0, + *, + coarse: bool | None = None, + gateway: Any = None, ) -> OWNLightingCommand: - transition_speed = _transition if 0 <= _transition <= 255 else 0 + """Build a brightness command for ``where`` at ``_level`` % (0..100). + + By default (``coarse=None``), checks :func:`is_coarse_dimmer_where` for + ``where`` (scoped to ``gateway`` when provided, or using the unscoped + all-agree rule when ``gateway=None``). If ``where`` has reported the + legacy ``SPEED = 240`` (``0xF0``) coarse-only sentinel in a prior + ``DIMENSION 1``/``4`` reply — or if ``coarse=True`` is passed explicitly — + emits ``*1*0*WHERE##`` for ``_level <= 0`` and ``*1*2..10*WHERE##`` via + :func:`percent_to_brightness_preset` for ``1..100 %``. Otherwise emits the + standard ``DIMENSION 1`` write ``*#1*WHERE*#1*<100+level>*##``. + """ + if any( + isinstance(x, bool) or (isinstance(x, float) and x % 1 != 0) + for x in (_level, _transition) + ): + raise ValueError("brightness level and transition must be integers") + use_coarse = ( + is_coarse_dimmer_where(where, gateway=gateway) + if coarse is None + else bool(coarse) + ) + trans_int = int(_transition) + transition_speed = trans_int if 0 <= trans_int <= 255 else 0 if int(_level) <= 0: return cls.switch_off( - where, transition_speed if transition_speed > 0 else None + where, + None + if use_coarse + else (transition_speed if transition_speed > 0 else None), + ) + if use_coarse: + cmd = cls.set_brightness_preset( + where, percent_to_brightness_preset(int(_level)) ) + cmd._is_coarse_dimmer = True + cmd._gateway = gateway + return cmd level = min(int(_level), 100) command_level = level + 100 message = cls(f"*#1*{where}*#1*{command_level}*{transition_speed}##") diff --git a/OWNd/transport/serial.py b/OWNd/transport/serial.py index b3f9754..27e06e8 100644 --- a/OWNd/transport/serial.py +++ b/OWNd/transport/serial.py @@ -105,7 +105,7 @@ async def _read_loop(self) -> None: pending.set_result(None) def _process_frame(self, frame: str) -> None: - parsed = OWNMessage.parse(frame) + parsed = OWNMessage.parse(frame, gateway=self.port) message = parsed or frame unsolicited = frame.startswith("*") and not frame.startswith("*#") @@ -151,7 +151,9 @@ async def send( async with self._command_lock: loop = asyncio.get_running_loop() self._pending_future = loop.create_future() - self._pending_request = OWNMessage.parse(str(message)) + self._pending_request = OWNMessage.parse( + str(message), gateway=self.port + ) self._pending_frames = [] try: frame = str(message).encode() diff --git a/README.md b/README.md index 08795fb..bd86d45 100755 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ OWNd parses OpenWebNet frames and dispatches typed commands and events across th | WHO | Subsystem | Description & Capabilities | Event / Command Classes | |:---:|:---|:---|:---| | **0** | Scenarios | Basic scenario execution (1–32) from control panels, scenario module programming and memory lock states | `OWNScenarioEvent` | -| **1** | Lighting | On/off switching, dimming level (0–100%), DALI Tunable White (Dimension 14, 2000K–6535K / mireds), status queries | `OWNLightingCommand`, `OWNLightingEvent` | +| **1** | Lighting | On/off switching, dimming level (0–100%) with automatic legacy pre-09W29 F414 coarse preset fallback (speed 240 / 0xF0), DALI Tunable White (Dimension 14, 2000K–6535K / mireds), status queries | `OWNLightingCommand`, `OWNLightingEvent` | | **2** | Automation | Shutters, blinds, motorized curtains, tilt angles, short & full replies | `OWNAutomationCommand`, `OWNAutomationEvent` | | **3** | Load Control | Priority forcing (`*3*2*#N##`, firmware-verified), priority states 0–3, priority status and central-unit measurement requests with typed voltage, current, power, and energy properties (`WHO_3.pdf` v1.0.0 & `bt_energia` register mappings) | `OWNLoadCommand`, `OWNLoadEvent` | | **4** | Thermoregulation / Climate | Multi-zone temperature readouts, target adjustments, HVAC modes (Heat/Cool/Auto/Off), local offsets, fan coil speeds, valve states, Central Unit 3550/4695 master coordination | `OWNHeatingCommand`, `OWNHeatingEvent` | diff --git a/scripts/sync_documentation.py b/scripts/sync_documentation.py index cc22103..b456838 100644 --- a/scripts/sync_documentation.py +++ b/scripts/sync_documentation.py @@ -108,7 +108,7 @@ class WhoCatalogDefinition: WhoCatalogDefinition( who="**1**", name="Lighting", - description="On/off switching, dimming level (0–100%), DALI Tunable White (Dimension 14, 2000K–6535K / mireds), status queries", + description="On/off switching, dimming level (0–100%) with automatic legacy pre-09W29 F414 coarse preset fallback (speed 240 / 0xF0), DALI Tunable White (Dimension 14, 2000K–6535K / mireds), status queries", dispatch_who_keys=(1,), ), WhoCatalogDefinition( diff --git a/tests/golden/corpus.json b/tests/golden/corpus.json index 9bc9bc4..e64fbee 100644 --- a/tests/golden/corpus.json +++ b/tests/golden/corpus.json @@ -707,6 +707,55 @@ }, "_file": "who01_lighting.yaml" }, + { + "id": "light.dim.reply.level.30.speed240.61", + "frame": "*#1*61*1*130*240##", + "direction": "dimension_response", + "who": 1, + "what": null, + "where": "61", + "dimension": 1, + "dimension_values": [ + "130", + "240" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Lighting: DIMENSION 1 report from legacy pre-09W29 BTicino F414 coarse-only dimmer 61 on MH200N FW 1.0: reports 30% (LEVEL100 130) with unimplemented transition speed byte 240 (0xF0) (@wave68runner, MyHOME#767)", + "roundtrip": true, + "builder": { + "class": "OWNLightingCommand", + "method": "report_brightness", + "args": [ + "61", + 30, + 240 + ] + }, + "_file": "who01_lighting.yaml" + }, + { + "id": "light.cmd.preset.7.61", + "frame": "*1*7*61##", + "direction": "command", + "who": 1, + "what": "7", + "where": "61", + "dimension": null, + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Lighting: Coarse dimmer preset WHAT 7 (~50% fine level / 70% 10-step scale) on legacy BTicino F414 dimmer 61 on MH200N FW 1.0 (@wave68runner, MyHOME#767)", + "roundtrip": true, + "builder": { + "class": "OWNLightingCommand", + "method": "set_brightness_preset", + "args": [ + "61", + 7 + ] + }, + "_file": "who01_lighting.yaml" + }, { "id": "cover.cmd.up.ptp.93", "frame": "*2*1*93##", diff --git a/tests/golden/frames/who01_lighting.yaml b/tests/golden/frames/who01_lighting.yaml index 4c383ce..be68d93 100644 --- a/tests/golden/frames/who01_lighting.yaml +++ b/tests/golden/frames/who01_lighting.yaml @@ -453,3 +453,37 @@ method: report_brightness args: ["99", 50, 5] +- id: light.dim.reply.level.30.speed240.61 + frame: "*#1*61*1*130*240##" + direction: dimension_response + who: 1 + what: null + where: "61" + dimension: 1 + dimension_values: ["130", "240"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Lighting: DIMENSION 1 report from legacy pre-09W29 BTicino F414 coarse-only dimmer 61 on MH200N FW 1.0: reports 30% (LEVEL100 130) with unimplemented transition speed byte 240 (0xF0) (@wave68runner, MyHOME#767)" + roundtrip: true + builder: + class: OWNLightingCommand + method: report_brightness + args: ["61", 30, 240] + +- id: light.cmd.preset.7.61 + frame: "*1*7*61##" + direction: command + who: 1 + what: "7" + where: "61" + dimension: null + source: community-plant-capture + mcp_valid: true + mcp_notes: "Lighting: Coarse dimmer preset WHAT 7 (~50% fine level / 70% 10-step scale) on legacy BTicino F414 dimmer 61 on MH200N FW 1.0 (@wave68runner, MyHOME#767)" + roundtrip: true + builder: + class: OWNLightingCommand + method: set_brightness_preset + args: ["61", 7] + + diff --git a/tests/test_who1_ts10_builders.py b/tests/test_who1_ts10_builders.py index f23afc1..7069ead 100644 --- a/tests/test_who1_ts10_builders.py +++ b/tests/test_who1_ts10_builders.py @@ -135,4 +135,213 @@ def test_lighting_dimension_properties(): assert isinstance(ev_hs, OWNLightingEvent) assert ev_hs.hs_color == (120, 50) assert ev_hs.color_temp_kelvin is None + assert ev_hs.brightness_preset_percent is None + + +def test_coarse_dimmer_conversion_and_detection(): + from OWNd.message import ( + COARSE_PRESET_TO_PERCENT, + DIMMER_SPEED_COARSE_ONLY, + OWNCommand, + OWNEvent, + OWNLightingEvent, + OWNMessage, + brightness_preset_to_percent, + clear_coarse_dimmer_wheres, + is_coarse_dimmer_where, + percent_to_brightness_preset, + register_coarse_dimmer_where, + ) + + assert DIMMER_SPEED_COARSE_ONLY == 240 + for preset, expected_pct in COARSE_PRESET_TO_PERCENT.items(): + assert brightness_preset_to_percent(preset) == expected_pct + + for bad_preset in (True, False, 7.9, float("inf"), float("-inf"), float("nan")): + with pytest.raises(ValueError, match="integer between 2 and 10"): + brightness_preset_to_percent(bad_preset) # type: ignore[arg-type] + with pytest.raises(ValueError, match="integer between 2 and 10"): + OWNLightingCommand.set_brightness_preset("61", bad_preset) # type: ignore[arg-type] + with pytest.raises(ValueError): + brightness_preset_to_percent(1) + with pytest.raises(ValueError): + brightness_preset_to_percent(11) + + for bad_level in (True, False, 50.9, float("inf"), float("-inf"), float("nan")): + with pytest.raises(ValueError, match="level must be an integer"): + percent_to_brightness_preset(bad_level) # type: ignore[arg-type] + with pytest.raises(ValueError, match="must be integers"): + OWNLightingCommand.set_brightness("61", bad_level, 0) # type: ignore[arg-type] + with pytest.raises(ValueError, match="must be integers"): + OWNLightingCommand.set_brightness("61", 50, bad_level) # type: ignore[arg-type] + + assert percent_to_brightness_preset(-5) == 0 + assert percent_to_brightness_preset(0) == 0 + for pct, expected_preset in [ + (1, 2), + (4, 2), + (5, 3), + (15, 3), + (16, 4), + (25, 4), + (26, 5), + (35, 5), + (36, 6), + (45, 6), + (46, 7), + (50, 7), + (53, 7), + (54, 8), + (67, 8), + (68, 9), + (75, 9), + (85, 9), + (86, 10), + (100, 10), + (120, 10), + ]: + assert percent_to_brightness_preset(pct) == expected_preset + + clear_coarse_dimmer_wheres() + register_coarse_dimmer_where(None, True) + register_coarse_dimmer_where("", True) + assert is_coarse_dimmer_where(None) is False + assert is_coarse_dimmer_where("") is False + assert is_coarse_dimmer_where("61") is False + + # Unlearned address keeps brightness=None on preset events and uses Dimension 1 + ev_unlearned = OWNEvent.parse("*1*5*21##") + assert isinstance(ev_unlearned, OWNLightingEvent) + assert ev_unlearned.is_coarse_dimmer is False + assert ev_unlearned.brightness_preset == 5 + assert ev_unlearned.brightness_preset_percent == 30 + assert ev_unlearned.brightness is None + assert str(OWNLightingCommand.set_brightness("21", 50, 0)) == "*#1*21*#1*150*0##" + + # Explicit coarse=True override works even on unlearned addresses + cmd_coarse_21 = OWNLightingCommand.set_brightness("21", 50, 5, coarse=True) + assert str(cmd_coarse_21) == "*1*7*21##" + assert cmd_coarse_21.is_coarse_dimmer is True + assert cmd_coarse_21.brightness_preset == 7 + assert cmd_coarse_21.brightness == 50 + cmd_preset_21 = OWNLightingCommand.set_brightness_preset("21", 7) + assert cmd_preset_21.is_coarse_dimmer is False + assert cmd_preset_21.brightness_preset == 7 + assert cmd_preset_21.brightness is None + cmd_off_21 = OWNLightingCommand.set_brightness("21", 0, 5, coarse=True) + assert str(cmd_off_21) == "*1*0*21##" + assert cmd_off_21.brightness_preset is None + + # Legacy pre-09W29 F414 reports speed 240 (0xF0) in Dimension 1 (MyHOME#767) + ev_f414_old = OWNEvent.parse("*#1*61*1*130*240##") + assert isinstance(ev_f414_old, OWNLightingEvent) + assert ev_f414_old.brightness == 30 + assert ev_f414_old.transition == 240 + assert ev_f414_old.is_coarse_dimmer is True + assert is_coarse_dimmer_where("61") is True + + # Subsequent set_brightness on 61 automatically emits coarse WHAT 2..10 frames + assert str(OWNLightingCommand.set_brightness("61", 50, 0)) == "*1*7*61##" + assert str(OWNLightingCommand.set_brightness("61", 75, 5)) == "*1*9*61##" + assert str(OWNLightingCommand.set_brightness("61", 0, 5)) == "*1*0*61##" + # Explicit coarse=False can still force Dimension 1 if desired + assert ( + str(OWNLightingCommand.set_brightness("61", 50, 0, coarse=False)) + == "*#1*61*#1*150*0##" + ) + + # Preset event on learned coarse dimmer populates canonical fine brightness % + ev_preset_61 = OWNEvent.parse("*1*7*61##") + assert isinstance(ev_preset_61, OWNLightingEvent) + assert ev_preset_61.is_coarse_dimmer is True + assert ev_preset_61.brightness_preset == 7 + assert ev_preset_61.brightness_preset_percent == 50 + assert ev_preset_61.brightness == 50 + + # Private riser address (61#4#01) is tracked independently + ev_riser = OWNEvent.parse("*#1*61#4#01*1*130*240##") + assert isinstance(ev_riser, OWNLightingEvent) + assert ev_riser.is_coarse_dimmer is True + assert is_coarse_dimmer_where("61#4#01") is True + cmd_riser = OWNLightingCommand.set_brightness("61#4#01", 30) + assert str(cmd_riser) == "*1*5*61#4#01##" + assert cmd_riser.brightness == 30 + parsed_cmd_riser = OWNCommand.parse("*1*5*61#4#01##") + assert isinstance(parsed_cmd_riser, OWNLightingCommand) + assert parsed_cmd_riser.brightness == 30 + + # Replacing dimmer at 61 with a modern F418U2 / post-09W29 F414 (speed 2) clears coarse flag + ev_f418u2 = OWNEvent.parse("*#1*61*1*200*2##") + assert isinstance(ev_f418u2, OWNLightingEvent) + assert ev_f418u2.is_coarse_dimmer is False + assert is_coarse_dimmer_where("61") is False + assert str(OWNLightingCommand.set_brightness("61", 50, 0)) == "*#1*61*#1*150*0##" + + # Multi-gateway / multi-plant isolation: identical WHERE=61 on two different gateways + from OWNd.connection import OWNGateway + + clear_coarse_dimmer_wheres() + gw_a = OWNGateway({"address": "192.168.1.40", "serialNumber": "00:03:50:AA:BB:01"}) + gw_b = OWNGateway({"address": "192.168.1.41"}) # falls back to host when serial is None + + # Blank/unidentifiable gateway objects fall back to unscoped (None) + assert is_coarse_dimmer_where("61", gateway=" ") is False + assert is_coarse_dimmer_where("61", gateway=object()) is False + clear_coarse_dimmer_wheres(gateway=" ") + + # Plant A learns WHERE=61 is coarse (speed 240) + ev_gw_a = OWNMessage.parse("*#1*61*1*130*240##", gateway=gw_a) + assert isinstance(ev_gw_a, OWNLightingEvent) + assert ev_gw_a.is_coarse_dimmer is True + assert is_coarse_dimmer_where("61", gateway=gw_a) is True + assert is_coarse_dimmer_where("61", gateway="00:03:50:aa:bb:01") is True + # Before Plant B sees WHERE=61, Plant B falls back to (None, "61") -> False, + # while unscoped lookup sees all known gateways for "61" are coarse -> True + assert is_coarse_dimmer_where("61", gateway=gw_b) is False + assert is_coarse_dimmer_where("61") is True + + # Plant B learns its own WHERE=61 is a modern fine dimmer (speed 2) + ev_gw_b = OWNEvent.parse("*#1*61*1*200*2##", gateway=gw_b) + assert isinstance(ev_gw_b, OWNLightingEvent) + assert ev_gw_b.is_coarse_dimmer is False + # Plant A's coarse state is preserved; Plant B is fine; unscoped ambiguous query is False + assert is_coarse_dimmer_where("61", gateway=gw_a) is True + assert is_coarse_dimmer_where("61", gateway=gw_b) is False + assert is_coarse_dimmer_where("61") is False + + ev_preset_a = OWNMessage.parse("*1*7*61##", gateway=gw_a) + assert isinstance(ev_preset_a, OWNLightingEvent) + assert ev_preset_a.is_coarse_dimmer is True + assert ev_preset_a.brightness == 50 + + ev_preset_b = OWNMessage.parse("*1*7*61##", gateway=gw_b) + assert isinstance(ev_preset_b, OWNLightingEvent) + assert ev_preset_b.is_coarse_dimmer is False + assert ev_preset_b.brightness is None + + assert ( + str(OWNLightingCommand.set_brightness("61", 50, 0, gateway=gw_a)) + == "*1*7*61##" + ) + assert ( + OWNLightingCommand.set_brightness("61", 50, 0, gateway=gw_a).brightness == 50 + ) + assert ( + OWNCommand.parse("*1*7*61##", gateway=gw_a).brightness == 50 # type: ignore[union-attr] + ) + assert ( + OWNCommand.parse("*1*7*61##", gateway=gw_b).brightness is None # type: ignore[union-attr] + ) + assert ( + str(OWNLightingCommand.set_brightness("61", 50, 0, gateway=gw_b)) + == "*#1*61*#1*150*0##" + ) + + # Clearing Plant A only removes Plant A's entries + clear_coarse_dimmer_wheres(gateway=gw_a) + assert is_coarse_dimmer_where("61", gateway=gw_a) is False + assert is_coarse_dimmer_where("61", gateway=gw_b) is False + + clear_coarse_dimmer_wheres() +