From 30410c69ec16c0f5f749ce63f52c3bc73ca7f5c0 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:41:43 +0200 Subject: [PATCH 1/2] feat(message,profiles): add declarative expose frame builders, query helpers, and profile flags (#129) --- OWNd/message/automation.py | 61 +- OWNd/message/base.py | 25 + OWNd/message/energy.py | 158 +++ OWNd/message/gateway.py | 141 ++- OWNd/message/heating.py | 65 +- OWNd/message/lighting.py | 46 +- OWNd/profiles.py | 7 + tests/golden/SOURCE.yaml | 54 +- tests/golden/corpus.json | 1214 ++++++++++++++++++--- tests/golden/frames/who01_lighting.yaml | 102 ++ tests/golden/frames/who02_automation.yaml | 185 ++++ tests/golden/frames/who04_thermo.yaml | 78 ++ tests/golden/frames/who13_gateway.yaml | 126 +++ tests/golden/frames/who18_energy.yaml | 143 +++ tests/test_golden_conformance.py | 16 +- tests/test_message_protocol.py | 344 ++++++ tests/test_profiles.py | 38 + 17 files changed, 2644 insertions(+), 159 deletions(-) diff --git a/OWNd/message/automation.py b/OWNd/message/automation.py index c7b88c3d..0b0f7dc6 100644 --- a/OWNd/message/automation.py +++ b/OWNd/message/automation.py @@ -176,13 +176,70 @@ def stop_shutter(cls, where: str | int) -> OWNAutomationCommand: @classmethod def set_shutter_level(cls, where: str | int, level: int = 30) -> OWNAutomationCommand: target = require_non_empty_where(where) - lvl = int(level) - if not 0 <= lvl <= 100: + if ( + isinstance(level, bool) + or (isinstance(level, float) and level % 1 != 0) + or not 0 <= int(level) <= 100 + ): raise ValueError("shutter level must be between 0 and 100") + lvl = int(level) message = cls(f"*#2*{target}*#11#001*{lvl}##") message._human_readable_log = f"Setting shutter {message._where}{message._interface_log_text} position to {lvl}%." return message + @property + def is_status_query(self) -> bool: + """True if this message is a shutter status query (*#2*WHERE##).""" + return self.is_status_request and bool(self._where) + + @property + def is_shutter_status_query(self) -> bool: + """True if this message is an advanced shutter status query (*#2*WHERE*10##).""" + return ( + self.is_dimension_request + and self._dimension == 10 + and bool(self._where) + ) + + @classmethod + def report_shutter_level( + cls, + where: str | int, + level: int, + state: int = 10, + priority: int | str = 0, + info: int = 0, + ) -> OWNAutomationCommand: + """Build a WHO 2 Dimension 10 advanced shutter status reply (*#2*WHERE*10*STATE*LEVEL*PRIORITY*INFO##).""" + target = require_non_empty_where(where) + if any( + isinstance(x, bool) or (isinstance(x, float) and x % 1 != 0) + for x in (level, state, priority, info) + ): + raise ValueError("shutter level, state, priority, and info must be integers") + lvl = int(level) + if not (0 <= lvl <= 100 or lvl == 255): + raise ValueError("shutter level must be between 0 and 100 or 255") + st = int(state) + if not 10 <= st <= 15: + raise ValueError("shutter state must be between 10 and 15") + pr = int(priority) + inf = int(info) + if not (0 <= pr <= 255 and 0 <= inf <= 255): + raise ValueError("priority and info must be between 0 and 255") + pr_str = ( + priority.strip() + if isinstance(priority, str) + and priority.strip().isdigit() + and len(priority.strip()) <= 3 + else str(pr) + ) + message = cls(f"*#2*{target}*10*{st}*{lvl}*{pr_str}*{inf}##") + message._human_readable_log = ( + f"Reporting shutter {message._where}{message._interface_log_text} position at {lvl}% (state {st})." + ) + return message + register_event_parser(2, OWNAutomationEvent) register_command_parser(2, OWNAutomationCommand) diff --git a/OWNd/message/base.py b/OWNd/message/base.py index 52b0153d..fb994303 100644 --- a/OWNd/message/base.py +++ b/OWNd/message/base.py @@ -244,6 +244,31 @@ def is_request(self) -> bool: def is_translation(self) -> bool: return self._family == "COMMAND_TRANSLATION" + @property + def is_status_request(self) -> bool: + return getattr(self, "_message_type", None) == "STATUS_REQUEST" + + @property + def is_dimension_request(self) -> bool: + return getattr(self, "_message_type", None) == "DIMENSION_REQUEST" + + @property + def is_dimension_writing(self) -> bool: + return getattr(self, "_message_type", None) == "DIMENSION_WRITING" + + @property + def is_dimension_reply(self) -> bool: + return getattr(self, "_message_type", None) == "DIMENSION_REQUEST_REPLY" + + @property + def effective_what(self) -> int | None: + """The effective 'what' ID, unwrapping Command Translation (1000#WHAT) frames.""" + what = getattr(self, "_what", None) + what_param = getattr(self, "_what_param", []) + if what == 1000 and what_param: + return int(what_param[0]) + return what + @property def is_valid(self) -> bool: return self._is_valid_message diff --git a/OWNd/message/energy.py b/OWNd/message/energy.py index c9231aba..fd10fbef 100644 --- a/OWNd/message/energy.py +++ b/OWNd/message/energy.py @@ -3,6 +3,7 @@ from __future__ import annotations import datetime +import math import re from typing import Any @@ -464,6 +465,163 @@ def get_total_consumption(cls, where: str | int) -> OWNEnergyCommand: ) return message + @property + def is_dimension_query(self) -> bool: + """True if this message is a WHO 18 dimension request (*#18*WHERE*DIM##).""" + return self.is_dimension_request and bool(self._where) + + @property + def is_auto_update_command(self) -> bool: + """True if this message is a WHO 18 Dimension 1200 streaming write (*#18*WHERE*#1200#TYPE*TIME##).""" + return ( + self.is_dimension_writing + and self._dimension == 1200 + and bool(self._where) + ) + + @property + def stream_duration(self) -> int | None: + """Decoded stream duration in minutes (0..255) for a Dimension 1200 command, or None.""" + if self._dimension == 1200 and self._dimension_value: + return max(0, min(255, _integer_value(self._dimension_value, 0))) + return None + + @classmethod + def report_active_power( + cls, where: str | int, watts: int | float + ) -> OWNEnergyCommand: + """Build a WHO 18 Dimension 113 active power report (*#18*WHERE*113*W##).""" + target = _normalize_actuator_where(where) + w = _validate_non_negative_energy(watts, "watts") + message = cls(f"*#18*{target}*113*{w}##") + message._human_readable_log = ( + f"Reporting active power of {w} W for sensor {target}." + ) + return message + + @classmethod + def report_total_energy( + cls, where: str | int, wh: int | float + ) -> OWNEnergyCommand: + """Build a WHO 18 Dimension 51 total energy report (*#18*WHERE*51*Wh##).""" + target = _normalize_actuator_where(where) + val = _validate_non_negative_energy(wh, "wh") + message = cls(f"*#18*{target}*51*{val}##") + message._human_readable_log = ( + f"Reporting total energy consumption of {val} Wh for sensor {target}." + ) + return message + + @classmethod + def report_current_month_energy( + cls, + where: str | int, + wh: int | float, + year: int | None = None, + month: int | None = None, + ) -> OWNEnergyCommand: + """Build a WHO 18 Dimension 52 monthly energy report (*#18*WHERE*52[#YY#M]*Wh##).""" + target = _normalize_actuator_where(where) + val = _validate_non_negative_energy(wh, "wh") + if (year is None) != (month is None): + raise ValueError("year and month must both be provided or both omitted") + if year is not None and month is not None: + if any( + isinstance(x, bool) or (isinstance(x, float) and x % 1 != 0) + for x in (year, month) + ): + raise ValueError("year and month must be integers") + y = int(year) + m = int(month) + if not 2000 <= y <= 2099: + raise ValueError("year must be between 2000 and 2099") + if not 1 <= m <= 12: + raise ValueError("month must be between 1 and 12") + message = cls(f"*#18*{target}*52#{y % 100:02d}#{m}*{val}##") + else: + message = cls(f"*#18*{target}*52*{val}##") + message._human_readable_log = ( + f"Reporting monthly energy consumption of {val} Wh for sensor {target}." + ) + return message + + @classmethod + def report_partial_month_energy( + cls, where: str | int, wh: int | float, month: int | None = None + ) -> OWNEnergyCommand: + """Build a WHO 18 Dimension 53 current-month partial energy report (*#18*WHERE*53[#M]*Wh##).""" + target = _normalize_actuator_where(where) + val = _validate_non_negative_energy(wh, "wh") + if month is not None: + if ( + isinstance(month, bool) + or (isinstance(month, float) and month % 1 != 0) + or not 1 <= int(month) <= 12 + ): + raise ValueError("month must be between 1 and 12") + message = cls(f"*#18*{target}*53#{int(month)}*{val}##") + else: + message = cls(f"*#18*{target}*53*{val}##") + message._human_readable_log = ( + f"Reporting partial monthly energy consumption of {val} Wh for sensor {target}." + ) + return message + + @classmethod + def report_partial_day_energy( + cls, where: str | int, wh: int | float, day: int | None = None + ) -> OWNEnergyCommand: + """Build a WHO 18 Dimension 54 current-day partial energy report (*#18*WHERE*54[#D]*Wh##).""" + target = _normalize_actuator_where(where) + val = _validate_non_negative_energy(wh, "wh") + if day is not None: + if ( + isinstance(day, bool) + or (isinstance(day, float) and day % 1 != 0) + or not 1 <= int(day) <= 31 + ): + raise ValueError("day must be between 1 and 31") + message = cls(f"*#18*{target}*54#{int(day)}*{val}##") + else: + message = cls(f"*#18*{target}*54*{val}##") + message._human_readable_log = ( + f"Reporting partial daily energy consumption of {val} Wh for sensor {target}." + ) + return message + + @classmethod + def report_streaming_state( + cls, where: str | int, duration: int, energy_type: int = 1 + ) -> OWNEnergyCommand: + """Build a WHO 18 Dimension 1200 streaming confirmation/expiry reply (*#18*WHERE*1200#TYPE*TIME##).""" + target = _normalize_actuator_where(where) + if any( + isinstance(x, bool) or (isinstance(x, float) and x % 1 != 0) + for x in (duration, energy_type) + ): + raise ValueError("duration and energy_type must be integers") + et = int(energy_type) + if not 1 <= et <= 4: + raise ValueError("energy_type must be between 1 and 4") + dur = int(duration) + if not 0 <= dur <= 255: + raise ValueError("duration must be between 0 and 255") + message = cls(f"*#18*{target}*1200#{et}*{dur}##") + message._human_readable_log = ( + f"Reporting instant power stream state ({dur} min) for sensor {target}." + ) + return message + + +def _validate_non_negative_energy(value: int | float, name: str) -> int: + """Validate that an energy or power value is a finite non-negative number.""" + if isinstance(value, bool): + raise ValueError(f"{name} must be a finite non-negative number") + fval = float(value) + if not math.isfinite(fval) or fval < 0: + raise ValueError(f"{name} must be a finite non-negative number") + return int(round(fval)) + def _normalize_stop_and_go_where(where: str | int) -> str: """Normalize Stop&Go line address to 1N format (11..19).""" diff --git a/OWNd/message/gateway.py b/OWNd/message/gateway.py index fffe2d3d..e41b99ad 100644 --- a/OWNd/message/gateway.py +++ b/OWNd/message/gateway.py @@ -426,6 +426,11 @@ def is_timezone_unconfigured(self) -> bool: """Return True if the gateway reported an unconfigured timezone (sentinel 999).""" return self._raw_timezone == "999" + @property + def is_clock_query(self) -> bool: + """True if this message is a gateway clock/date/datetime query (Dimensions 0, 1, 22).""" + return self.is_dimension_request and self._dimension in (0, 1, 22) + @staticmethod def _resolve_tzinfo(time_zone: _dt.tzinfo | str) -> _dt.tzinfo: if isinstance(time_zone, _dt.tzinfo): @@ -437,31 +442,132 @@ def _resolve_tzinfo(time_zone: _dt.tzinfo | str) -> _dt.tzinfo: ) @classmethod - def set_datetime_to_now( - cls, time_zone: _dt.tzinfo | str - ) -> OWNGatewayCommand: - timezone = cls._resolve_tzinfo(time_zone) - now = _dt.datetime.now(timezone) - timezone_offset = ( - f"0{now.strftime('%z')[1:3]}" - if now.strftime("%z")[0] == "+" - else f"1{now.strftime('%z')[1:3]}" + def _normalize_datetime( + cls, dt: _dt.datetime, time_zone: _dt.tzinfo | str | None = None + ) -> _dt.datetime: + if not isinstance(dt, _dt.datetime): + raise ValueError("dt must be a datetime.datetime instance") + is_naive = dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None + if time_zone is not None: + tz = cls._resolve_tzinfo(time_zone) + target = dt.replace(tzinfo=tz) if is_naive else dt.astimezone(tz) + else: + target = dt + if target.tzinfo is None or target.tzinfo.utcoffset(target) is None: + raise ValueError("Naive datetime/time requires an explicit time_zone") + if not 2000 <= target.year <= 2099: + raise ValueError("year must be between 2000 and 2099") + return target + + @classmethod + def _normalize_date( + cls, d: _dt.date | _dt.datetime, time_zone: _dt.tzinfo | str | None = None + ) -> _dt.date: + if not isinstance(d, _dt.date): + raise ValueError("d must be a datetime.date or datetime.datetime instance") + if isinstance(d, _dt.datetime): + return cls._normalize_datetime(d, time_zone=time_zone).date() + if time_zone is not None: + cls._resolve_tzinfo(time_zone) + if not 2000 <= d.year <= 2099: + raise ValueError("year must be between 2000 and 2099") + return d + + @classmethod + def _normalize_time( + cls, t: _dt.time | _dt.datetime, time_zone: _dt.tzinfo | str | None = None + ) -> tuple[_dt.datetime, str]: + if not isinstance(t, (_dt.time, _dt.datetime)): + raise ValueError("t must be a datetime.time or datetime.datetime instance") + dt_val = ( + t + if isinstance(t, _dt.datetime) + else _dt.datetime.combine(_dt.datetime.now(_dt.timezone.utc).date(), t) ) + target = cls._normalize_datetime(dt_val, time_zone=time_zone) + return target, cls._encode_tz_offset(target) + + @staticmethod + def _encode_tz_offset(dt: _dt.datetime) -> str: + z = dt.strftime("%z") + return f"0{z[1:3]}" if z[0] == "+" else f"1{z[1:3]}" + + @classmethod + def set_datetime( + cls, dt: _dt.datetime, time_zone: _dt.tzinfo | str | None = None + ) -> OWNGatewayCommand: + """Build a WHO 13 Dimension 22 datetime write frame (*#13**#22*H*M*S*TZ*0W*D*M*Y##).""" + target = cls._normalize_datetime(dt, time_zone=time_zone) + tz_offset = cls._encode_tz_offset(target) message = cls( - f"*#13**#22*{now.strftime('%H*%M*%S')}*{timezone_offset}*0{now.strftime('%w*%d*%m*%Y##')}" # pylint: disable=line-too-long + f"*#13**#22*{target.strftime('%H*%M*%S')}*{tz_offset}*0{target.strftime('%w*%d*%m*%Y')}##" ) message._human_readable_log = f"Setting gateway time to: {message._datetime}." return message + @classmethod + def set_date( + cls, d: _dt.date | _dt.datetime, time_zone: _dt.tzinfo | str | None = None + ) -> OWNGatewayCommand: + """Build a WHO 13 Dimension 1 date write frame (*#13**#1*0W*D*M*Y##).""" + target = cls._normalize_date(d, time_zone=time_zone) + message = cls(f"*#13**#1*0{target.strftime('%w*%d*%m*%Y')}##") + message._human_readable_log = f"Setting gateway date to: {message._date}." + return message + + @classmethod + def set_time( + cls, t: _dt.time | _dt.datetime, time_zone: _dt.tzinfo | str | None = None + ) -> OWNGatewayCommand: + """Build a WHO 13 Dimension 0 time write frame (*#13**#0*H*M*S*TZ##).""" + target, tz_offset = cls._normalize_time(t, time_zone=time_zone) + message = cls(f"*#13**#0*{target.strftime('%H*%M*%S')}*{tz_offset}##") + message._human_readable_log = f"Setting gateway time to: {message._time}." + return message + + @classmethod + def report_datetime( + cls, dt: _dt.datetime, time_zone: _dt.tzinfo | str | None = None + ) -> OWNGatewayCommand: + """Build a WHO 13 Dimension 22 datetime reply frame (*#13**22*H*M*S*TZ*0W*D*M*Y##).""" + target = cls._normalize_datetime(dt, time_zone=time_zone) + tz_offset = cls._encode_tz_offset(target) + message = cls( + f"*#13**22*{target.strftime('%H*%M*%S')}*{tz_offset}*0{target.strftime('%w*%d*%m*%Y')}##" + ) + return message + + @classmethod + def report_date( + cls, d: _dt.date | _dt.datetime, time_zone: _dt.tzinfo | str | None = None + ) -> OWNGatewayCommand: + """Build a WHO 13 Dimension 1 date reply frame (*#13**1*0W*D*M*Y##).""" + target = cls._normalize_date(d, time_zone=time_zone) + return cls(f"*#13**1*0{target.strftime('%w*%d*%m*%Y')}##") + + @classmethod + def report_time( + cls, t: _dt.time | _dt.datetime, time_zone: _dt.tzinfo | str | None = None + ) -> OWNGatewayCommand: + """Build a WHO 13 Dimension 0 time reply frame (*#13**0*H*M*S*TZ##).""" + target, tz_offset = cls._normalize_time(t, time_zone=time_zone) + return cls(f"*#13**0*{target.strftime('%H*%M*%S')}*{tz_offset}##") + + @classmethod + def set_datetime_to_now( + cls, time_zone: _dt.tzinfo | str + ) -> OWNGatewayCommand: + timezone = cls._resolve_tzinfo(time_zone) + now = _dt.datetime.now(timezone) + return cls.set_datetime(now) + @classmethod def set_date_to_today( cls, time_zone: _dt.tzinfo | str ) -> OWNGatewayCommand: timezone = cls._resolve_tzinfo(time_zone) now = _dt.datetime.now(timezone) - message = cls(f"*#13**#1*0{now.strftime('%w*%d*%m*%Y##')}") - message._human_readable_log = f"Setting gateway date to: {message._date}." - return message + return cls.set_date(now) @classmethod def set_time_to_now( @@ -469,14 +575,7 @@ def set_time_to_now( ) -> OWNGatewayCommand: timezone = cls._resolve_tzinfo(time_zone) now = _dt.datetime.now(timezone) - timezone_offset = ( - f"0{now.strftime('%z')[1:3]}" - if now.strftime("%z")[0] == "+" - else f"1{now.strftime('%z')[1:3]}" - ) - message = cls(f"*#13**#0*{now.strftime('%H*%M*%S')}*{timezone_offset}##") - message._human_readable_log = f"Setting gateway time to: {message._time}." - return message + return cls.set_time(now) @classmethod def request_time(cls) -> OWNGatewayCommand: diff --git a/OWNd/message/heating.py b/OWNd/message/heating.py index ef1db8ef..74fd51e0 100644 --- a/OWNd/message/heating.py +++ b/OWNd/message/heating.py @@ -2,11 +2,18 @@ from __future__ import annotations +from decimal import Decimal, ROUND_HALF_UP import math import re import warnings -from .base import OWNCommand, OWNEvent, register_command_parser, register_event_parser +from .base import ( + OWNCommand, + OWNEvent, + register_command_parser, + register_event_parser, + require_non_empty_where, +) from .lighting import MESSAGE_TYPE_ACTION MESSAGE_TYPE_MAIN_TEMPERATURE = "main_temperature" @@ -1305,6 +1312,62 @@ def central_status(cls, where: str = "#0") -> OWNHeatingCommand: message._human_readable_log = f"Requesting Central Unit {where} status." return message + @property + def is_temperature_query(self) -> bool: + """True if this message is a temperature dimension query (*#4*WHERE*0##).""" + return ( + self.is_dimension_request + and self._dimension == 0 + and bool(self._where) + ) + + @staticmethod + def encode_temperature(temperature: float) -> str: + """Encode a temperature in °C into WHO 4 4-digit SXXX format (tenths of a degree).""" + if isinstance(temperature, bool): + raise ValueError("temperature must be finite") + temp_val = float(temperature) + if not math.isfinite(temp_val): + raise ValueError("temperature must be finite") + if not -99.9 <= temp_val <= 99.9: + raise ValueError("temperature must be between -99.9 and 99.9") + tenths = int( + (Decimal(str(temp_val)) * Decimal(10)).quantize( + Decimal("1"), rounding=ROUND_HALF_UP + ) + ) + if tenths < 0: + return f"1{abs(tenths):03d}" + return f"0{tenths:03d}" + + @classmethod + def report_temperature( + cls, where: str | int, temperature: float + ) -> OWNHeatingCommand: + """Build a WHO 4 Dimension 0 temperature report frame (*#4*WHERE*0*TTTT##).""" + target = require_non_empty_where(where) + encoded = cls.encode_temperature(temperature) + decoded = who4_temperature(encoded) + message = cls(f"*#4*{target}*0*{encoded}##") + message._human_readable_log = ( + f"Reporting temperature {decoded}°C for {message._where}{message._interface_log_text}." + ) + return message + + @classmethod + def write_temperature( + cls, where: str | int, temperature: float + ) -> OWNHeatingCommand: + """Build a WHO 4 Dimension #0 temperature write frame (*#4*WHERE*#0*TTTT##).""" + target = require_non_empty_where(where) + encoded = cls.encode_temperature(temperature) + decoded = who4_temperature(encoded) + message = cls(f"*#4*{target}*#0*{encoded}##") + message._human_readable_log = ( + f"Writing temperature {decoded}°C for {message._where}{message._interface_log_text}." + ) + return message + register_event_parser(4, OWNHeatingEvent) register_command_parser(4, OWNHeatingCommand) diff --git a/OWNd/message/lighting.py b/OWNd/message/lighting.py index 64a748cf..f385934b 100644 --- a/OWNd/message/lighting.py +++ b/OWNd/message/lighting.py @@ -7,7 +7,13 @@ import re from typing import Any -from .base import OWNCommand, OWNEvent, register_command_parser, register_event_parser +from .base import ( + OWNCommand, + OWNEvent, + register_command_parser, + register_event_parser, + require_non_empty_where, +) MESSAGE_TYPE_ACTION = "hvac_action" MESSAGE_TYPE_MOTION = "motion_detected" @@ -649,6 +655,44 @@ def set_brightness( ) return message + @property + def is_status_query(self) -> bool: + """True if this message is a point/group/area lighting status query (*#1*WHERE##).""" + return self.is_status_request and bool(self._where) + + @property + def is_brightness_query(self) -> bool: + """True if this message is a brightness dimension query (*#1*WHERE*1## or *#1*WHERE*4##).""" + return ( + self.is_dimension_request + and self._dimension in (1, 4) + and bool(self._where) + ) + + @classmethod + def report_brightness( + cls, where: str | int, level: int, transition: int = 0 + ) -> OWNLightingCommand: + """Build a WHO 1 Dimension 1 brightness reply frame (*#1*WHERE*1*LEVEL*SPEED##).""" + target = require_non_empty_where(where) + 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") + lvl = int(level) + if not 0 <= lvl <= 100: + raise ValueError("brightness level must be between 0 and 100") + trans = int(transition) + if not 0 <= trans <= 255: + raise ValueError("transition must be between 0 and 255") + own_level = lvl + 100 + message = cls(f"*#1*{target}*1*{own_level}*{trans}##") + message._human_readable_log = ( + f"Reporting light {message._where}{message._interface_log_text} brightness at {lvl}%." + ) + return message + register_event_parser(1, OWNLightingEvent) register_command_parser(1, OWNLightingCommand) diff --git a/OWNd/profiles.py b/OWNd/profiles.py index ea11772f..f82dc6c6 100644 --- a/OWNd/profiles.py +++ b/OWNd/profiles.py @@ -86,6 +86,9 @@ class GatewayProfile: audio_measured: bool = True supports_native_transitions: bool = False supports_extended_frames: bool = False + supports_dimension_response_injection: bool = False + supports_who4_command_injection: bool = True + supports_who18_response_injection: bool = True supported_who: tuple[int, ...] = DEFAULT_SUPPORTED_WHO extra_features: tuple[str, ...] = () firmware_version: str | None = field(default=None, kw_only=True) @@ -254,6 +257,7 @@ def __init__(self) -> None: super().__init__( model_name="F453", command_queue_delay=0.15, + supports_who4_command_injection=False, ) @@ -431,6 +435,7 @@ def __init__(self) -> None: max_queue_size=100, event_keepalive_interval=90, supports_energy_instant_power=False, + supports_who18_response_injection=False, supported_who=( WHO_LIGHTING, WHO_AUTOMATION, @@ -472,6 +477,8 @@ def __init__(self) -> None: event_keepalive_interval=90, supports_energy_instant_power=False, supports_audio=True, + supports_who4_command_injection=False, + supports_who18_response_injection=False, supported_who=( WHO_LIGHTING, WHO_AUTOMATION, diff --git a/tests/golden/SOURCE.yaml b/tests/golden/SOURCE.yaml index 0f411fc9..1b8d7fd4 100644 --- a/tests/golden/SOURCE.yaml +++ b/tests/golden/SOURCE.yaml @@ -75,13 +75,13 @@ authorities: captures: - name: "Physical Bus Capture (BTicino F461 + DALI DT8)" reference: "https://github.com/OpenWebNet-HA/MyHOME/issues/273" - author: "Lyubomir Traykov (@lyubomirtraykov)" + author: "@lyubomirtraykov" date: "2026-09-10" gateway: "BTicino F461 DIN-rail Server" devices: "DALI DT8 Tunable White and RGB Ballasts" - name: "Physical Bus Capture (BTicino MH201 + Automation WHO 2)" reference: "https://github.com/OpenWebNet-HA/MyHOME/issues/304" - author: "Marco B. (@marcob79)" + author: "@marcob79" date: "2026-09-12" gateway: "BTicino MH201 Scenario Programmer" devices: "WHO=2 Automation (Covers & Shutters) with leading-zero (01) and two-digit (11) addresses" @@ -93,7 +93,7 @@ authorities: devices: "Eight shutters 11-18 (area 1): keypad point runs, a general UP (*2*0*0## + *2*1*0##) ended by one stop per actuator ~126 s later and none for WHERE 0, an area-1 stop echo (*2*0*1##)" - name: "Live Bus Test (BTicino MH200 + F422, Automation WHO 2 scope commands)" reference: "https://github.com/OpenWebNet-HA/MyHOME/issues/433" - author: "Laurens (@GreenGrassBlueOcean)" + author: "@GreenGrassBlueOcean" date: "2026-09-23" gateway: "BTicino MH200" devices: "Covers 11-16, 18, 19 behind F422 interface 02 (logical #4# addressing): area status request answered per object; area commands on the main bus (*2*2*1##) and on the private bus (*2*1*1#4#02##) moved none of them; point commands did" @@ -145,6 +145,54 @@ authorities: date: "2026-10-05" gateway: "BTicino MH200N (fw 1.0 as reported over SSDP) and MyHomeServer1 on the same SCS bus" devices: "CEN unit 0512 (A 5, PL 12): press, short release, hold (#3 repeated) and long release, emitted identically by both gateways" + - name: "Physical Bus Capture (BTicino F454 + MH202 Shared Bus, WHO 1/2/13/18)" + reference: "https://github.com/OpenWebNet-HA/MyHOME/issues/466#issuecomment-5849027587" + author: "@anotherjulien" + date: "2026-09-26" + gateway: "BTicino F454 (fw 2.0.5) and BTicino MH202 (fw 2.6.13) on the same SCS bus" + devices: "F418U2 two-channel dimmers (WHERE 14, 32), advanced shutter actuators (WHERE 31, Dimension 10), F520 energy meters (WHERE 51, 52), and gateway clock sync (Dimensions 0, 1, 22)" + - name: "Physical Bus Capture (BTicino MH200 + F414 / F418U2 Dimmers & F461 Live Plant)" + reference: "https://github.com/OpenWebNet-HA/MyHOME/issues/466#issuecomment-5849245960" + author: "@GreenGrassBlueOcean" + date: "2026-09-26" + gateway: "BTicino MH200 (fw 2.1.0) and BTicino F461 (fw 2.0.11)" + devices: "Classic F414 10-level dimmer (WHERE 99) and F418U2 dimmer (WHERE 62) Dimension 1 brightness telemetry, plus F461 WHO 13 Dimension 1 date reply" + - name: "Physical Bus Capture (MyHomeServer1 + LN-4660M2 Advanced Shutters WHO 2)" + reference: "https://github.com/OpenWebNet-HA/MyHOME/issues/445#issuecomment-5868017035" + author: "@f18m" + date: "2026-09-28" + gateway: "BTicino MyHomeServer1" + devices: "Seven LN-4660M2 advanced shutter actuators (02, 03, 04, 08, 09, 0010, 0011) emitting Dimension 10 transitional and stationary position reports" + - name: "Physical Bus Capture (BTicino MH200N Sweep, WHO 4 3-digit Probe & WHO 18 F520)" + reference: "https://github.com/OpenWebNet-HA/MyHOME/issues/466#issuecomment-5834435606" + author: "@caiosweet" + date: "2026-09-25" + gateway: "BTicino MH200N, firmware 1.1.8" + devices: "External 3-digit temperature probe 105 (*#4*105*0*0296##), F520 energy meters 51..57 (*#18*51*51*23791364##), and WHO 13 Dimension 22 datetime" + - name: "Physical Bus Capture (MyHomeServer1 Bistable Cover & Climate Sweep)" + reference: "https://github.com/OpenWebNet-HA/MyHOME/issues/466#issuecomment-5855690408" + author: "@TheDarkWizard" + date: "2026-09-27" + gateway: "BTicino MyHomeServer1, firmware 2.38.9" + devices: "Bistable cover 03 Dimension 10 position report (*#2*03*10*10*83*001*0##) and Zone 2 Dimension 0 measured temperature (*#4*2*0*0271##)" + - name: "Physical Bus Capture (MyHomeServer1 Cover Command Translation WHO 2)" + reference: "https://github.com/OpenWebNet-HA/MyHOME/issues/466#issuecomment-5927373969" + author: "@Interstellar0verdrive" + date: "2026-10-01" + gateway: "BTicino MyHomeServer1" + devices: "Cover 91 leading-idle command-translation sequence (*2*1000#0*91## -> *2*0*91## -> *2*1000#1*91## -> *2*1*91##)" + - name: "Physical Bus Capture (BTicino F454 + F520 Dimension 1200 Stream Lifecycle WHO 18)" + reference: "https://github.com/OpenWebNet-HA/MyHOME/pull/661#issuecomment-6067730589" + author: "@xtimmy86x" + date: "2026-10-08" + gateway: "BTicino F454, firmware 2.0" + devices: "F520 energy meters 51..511: Dimension 1200 stream activation (*#18*56*#1200#1*2##), confirmation (*#18*56*1200#1*2##), autonomous Dimension 113 power reports (*#18*51*113*572##), expiry (*#18*56*1200#1*0##), and Dimension 54 partial daily energy (*#18*51*54*13165##)" + - name: "Physical Bus Capture (BTicino F461 Live Plant — F411/4 Lights, F411/2 5s Shutter & Expose Bus Verification)" + reference: "https://github.com/OpenWebNet-HA/MyHOME/pull/753" + author: "@gdluck & @GreenGrassBlueOcean" + date: "2026-10-18" + gateway: "BTicino F461, firmware 2.0.11 (WHO=13 code 200, WHO=1013 134.15.5.0)" + devices: "BT-F411/4 lights (WHERE 06, 07, Group #1 Dim 15, General 0), BT-F411/2 5s timed shutter (WHERE 05) + unpopulated virtual cover (WHERE 25) command-translation bursts (*2*1000#0/1/2*W##), KW4691 Zone 2 (*#4*2*0*0249##) + Dimension #0 temperature write (*#4*50*#0*0215## ACKed in 155 ms), WHO=13 clock sync, and WHO=18 virtual energy meter (WHERE 51)" role: "Provides authentic on-wire bus frames and status replies captured from live physical hardware." community_capture_cen_plus: diff --git a/tests/golden/corpus.json b/tests/golden/corpus.json index a96b1824..9bc9bc47 100644 --- a/tests/golden/corpus.json +++ b/tests/golden/corpus.json @@ -552,6 +552,161 @@ "roundtrip": true, "_file": "who01_lighting.yaml" }, + { + "id": "light.dim.reply.level.34.speed5.12", + "frame": "*#1*12*1*134*5##", + "direction": "dimension_response", + "who": 1, + "what": null, + "where": "12", + "dimension": 1, + "dimension_values": [ + "134", + "5" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Lighting: DIMENSION 1 reply reporting 34% brightness (LEVEL100 134) at transition speed 5 on dimmer 12 (Legrand F455 FW 1.0.86 capture by @lionelser, MyHOME#466 comment 5938596974)", + "roundtrip": true, + "builder": { + "class": "OWNLightingCommand", + "method": "report_brightness", + "args": [ + "12", + 34, + 5 + ] + }, + "_file": "who01_lighting.yaml" + }, + { + "id": "light.dim.reply.off.speed0.dim1.mh202.32", + "frame": "*#1*32*1*100*0##", + "direction": "dimension_response", + "who": 1, + "what": null, + "where": "32", + "dimension": 1, + "dimension_values": [ + "100", + "0" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Lighting: DIMENSION 1 status response reporting OFF (LEVEL100 100 = 0% at transition speed 0) on BTicino MH202 FW 2.6.13 + F418U2 dimmer 32 (@anotherjulien, MyHOME#466 comments 5849027587 & 5854805415)", + "roundtrip": true, + "builder": { + "class": "OWNLightingCommand", + "method": "report_brightness", + "args": [ + "32", + 0, + 0 + ] + }, + "_file": "who01_lighting.yaml" + }, + { + "id": "light.dim.reply.level.30.speed5.dim1.32", + "frame": "*#1*32*1*130*5##", + "direction": "dimension_response", + "who": 1, + "what": null, + "where": "32", + "dimension": 1, + "dimension_values": [ + "130", + "5" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Lighting: DIMENSION 1 report after 30% write/ON command to BTicino F418U2 dimmer 32 on F454 FW 2.0.5 and MH202 FW 2.6.13: reports 30% (LEVEL100 130) with active transition speed 5 (@anotherjulien, MyHOME#466 comments 5849027587 & 5854469497)", + "roundtrip": true, + "builder": { + "class": "OWNLightingCommand", + "method": "report_brightness", + "args": [ + "32", + 30, + 5 + ] + }, + "_file": "who01_lighting.yaml" + }, + { + "id": "light.dim.req.level.99", + "frame": "*#1*99*1##", + "direction": "dimension_request", + "who": 1, + "what": null, + "where": "99", + "dimension": 1, + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Lighting: DIMENSION 1 request to BTicino F414 classic dimmer 99 on MH200 FW 2.1.0 (@GreenGrassBlueOcean, MyHOME#466 comment 5849245960)", + "roundtrip": true, + "builder": { + "class": "OWNLightingCommand", + "method": "get_brightness", + "args": [ + "99" + ] + }, + "_file": "who01_lighting.yaml" + }, + { + "id": "light.dim.write.level.50.99", + "frame": "*#1*99*#1*150*0##", + "direction": "dimension_write", + "who": 1, + "what": null, + "where": "99", + "dimension": 1, + "dimension_values": [ + "150", + "0" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Lighting: DIMENSION 1 write setting 50% (LEVEL100 150) with speed 0 on BTicino F414 classic dimmer 99 on MH200 FW 2.1.0 (@GreenGrassBlueOcean, MyHOME#466 comment 5849245960)", + "roundtrip": true, + "builder": { + "class": "OWNLightingCommand", + "method": "set_brightness", + "args": [ + "99", + 50 + ] + }, + "_file": "who01_lighting.yaml" + }, + { + "id": "light.dim.reply.level.50.speed5.99", + "frame": "*#1*99*1*150*5##", + "direction": "dimension_response", + "who": 1, + "what": null, + "where": "99", + "dimension": 1, + "dimension_values": [ + "150", + "5" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Lighting: DIMENSION 1 report after 50% write to BTicino F414 dimmer 99 on MH200 FW 2.1.0: reports 50% (LEVEL100 150) with active transition speed 5 (@GreenGrassBlueOcean, MyHOME#466 comment 5849245960)", + "roundtrip": true, + "builder": { + "class": "OWNLightingCommand", + "method": "report_brightness", + "args": [ + "99", + 50, + 5 + ] + }, + "_file": "who01_lighting.yaml" + }, { "id": "cover.cmd.up.ptp.93", "frame": "*2*1*93##", @@ -1100,131 +1255,420 @@ "_file": "who02_automation.yaml" }, { - "id": "thermo.req.temp.zone1", - "frame": "*#4*1*0##", - "direction": "dimension_request", - "who": 4, + "id": "cover.status.dimension10.level40.31", + "frame": "*#2*31*10*10*40*001*0##", + "direction": "dimension_response", + "who": 2, "what": null, - "where": "1", - "dimension": 0, - "source": "legrand-spec", + "where": "31", + "dimension": 10, + "dimension_values": [ + "10", + "40", + "001", + "0" + ], + "scope": "point", + "source": "community-plant-capture", "mcp_valid": true, - "mcp_notes": "Request measured temperature for Zone 1", + "mcp_notes": "Automation: Dimension 10 report with state 10 (stopped) at position 40%, priority bitfield 001, info 0 for shutter 31 on MH202 FW 2.6.13 and F454 FW 2.0.5 (@anotherjulien, MyHOME#466 comment 5849027587)", "roundtrip": true, - "_file": "who04_thermo.yaml" + "builder": { + "class": "OWNAutomationCommand", + "method": "report_shutter_level", + "args": [ + "31", + 40, + 10, + "001", + 0 + ] + }, + "_file": "who02_automation.yaml" }, { - "id": "thermo.resp.temp.zone1.215", - "frame": "*#4*1*0*0215##", + "id": "cover.status.dimension10.closing66.31", + "frame": "*#2*31*10*12*66*001*0##", "direction": "dimension_response", - "who": 4, + "who": 2, "what": null, - "where": "1", - "dimension": 0, + "where": "31", + "dimension": 10, "dimension_values": [ - "0215" + "12", + "66", + "001", + "0" ], - "source": "legrand-spec", + "scope": "point", + "source": "community-plant-capture", "mcp_valid": true, - "mcp_notes": "Temperature response: Zone 1 is at 21.5\u00b0C", + "mcp_notes": "Automation: Dimension 10 report with state 12 (closing) from position 66%, priority bitfield 001, info 0 for shutter 31 on F454 FW 2.0.5 and MH202 FW 2.6.13 (@anotherjulien, MyHOME#466 comments 5849027587 & 6038376669)", "roundtrip": true, - "_file": "who04_thermo.yaml" + "builder": { + "class": "OWNAutomationCommand", + "method": "report_shutter_level", + "args": [ + "31", + 66, + 12, + "001", + 0 + ] + }, + "_file": "who02_automation.yaml" }, { - "id": "thermo.cmd.setpoint.zone1.205", - "frame": "*#4*1*#14*0205*1##", - "direction": "dimension_write", - "who": 4, + "id": "cover.status.dimension10.transitional.closing.02", + "frame": "*#2*02*10*12*100*001*0##", + "direction": "dimension_response", + "who": 2, "what": null, - "where": "1", - "dimension": 14, + "where": "02", + "dimension": 10, "dimension_values": [ - "0205", - "1" + "12", + "100", + "001", + "0" ], - "source": "legrand-spec", + "scope": "point", + "source": "community-plant-capture", "mcp_valid": true, - "mcp_notes": "Set Zone 1 target temperature to 20.5\u00b0C in Heating mode", + "mcp_notes": "Automation: Dimension 10 report with state 12 (closing from initial position 100%) on LN-4660M2 advanced shutter actuator 02 captured on MyHomeServer1 (@f18m, MyHOME#445 comment 5868017035)", "roundtrip": true, "builder": { - "class": "OWNHeatingCommand", - "method": "set_temperature", + "class": "OWNAutomationCommand", + "method": "report_shutter_level", "args": [ - "1", - 20.5, - "heat", - true + "02", + 100, + 12, + "001", + 0 ] }, - "_file": "who04_thermo.yaml" + "_file": "who02_automation.yaml" }, { - "id": "thermo.cmd.mode.antifreeze.zone1", - "frame": "*4*102*#1##", - "direction": "command", - "who": 4, - "what": 102, - "where": "#1", - "source": "encyclopedia", + "id": "cover.status.dimension10.level26.0010", + "frame": "*#2*0010*10*10*26*001*0##", + "direction": "dimension_response", + "who": 2, + "what": null, + "where": "0010", + "dimension": 10, + "dimension_values": [ + "10", + "26", + "001", + "0" + ], + "scope": "point", + "source": "community-plant-capture", "mcp_valid": true, - "mcp_notes": "Set Zone 1 (through the central unit) to Antifreeze, WHAT 102 (Encyclopedia functional/who-4-temperature-control/what.md, zone setup: *4*102*#WHERE##; libqtdevices TS10_1_0_23 PROT_ANTIFREEZE 102). Corrected from *4*110*1##: 110 is manual heating (same table; libqtdevices TS10_1_0_23 WIN_MANUAL) and the MyHomeServer1 firmware forwards nothing for it (OWNd#77). The MyHomeServer1 captures in this file report an antifreeze zone as *4*102*Z##. Built by set_mode.", + "mcp_notes": "Automation: Dimension 10 position 26% report for 4-digit address 0010 from MyHomeServer1 + LN-4660M2 plant after centralized stop (@f18m, MyHOME#445 & MyHOME#466)", "roundtrip": true, "builder": { - "class": "OWNHeatingCommand", - "method": "set_mode", + "class": "OWNAutomationCommand", + "method": "report_shutter_level", "args": [ - "#1", - "antifreeze" + "0010", + 26, + 10, + "001", + 0 ] }, - "_file": "who04_thermo.yaml" + "_file": "who02_automation.yaml" }, { - "id": "thermo.cmd.mode.protection.zone1", - "frame": "*4*302*#1##", - "direction": "command", - "who": 4, - "what": 302, - "where": "#1", - "source": "encyclopedia", + "id": "cover.status.dimension10.level83.03", + "frame": "*#2*03*10*10*83*001*0##", + "direction": "dimension_response", + "who": 2, + "what": null, + "where": "03", + "dimension": 10, + "dimension_values": [ + "10", + "83", + "001", + "0" + ], + "scope": "point", + "source": "community-plant-capture", "mcp_valid": true, - "mcp_notes": "Set Zone 1 (through the central unit) to generic protection, WHAT 302 (Encyclopedia what.md zone setup: *4*302*#WHERE##; libqtdevices TS10_1_0_23 ControlledProbeDevice::setProtection sends PROT_GENERIC 302 to #zone). Corrected from *4*111*1##: 111 is programmed/automatic heating (same table; libqtdevices WIN_AUTOMATIC), which the MyHomeServer1 firmware forwards as automatic heating (OWNd#77). Built by set_mode.", + "mcp_notes": "Automation: Dimension 10 position 83% report for bistable cover 03 on MyHomeServer1 FW 2.38.9 (@TheDarkWizard, MyHOME#466 comment 5855690408)", "roundtrip": true, "builder": { - "class": "OWNHeatingCommand", - "method": "set_mode", + "class": "OWNAutomationCommand", + "method": "report_shutter_level", "args": [ - "#1", - "protection" + "03", + 83, + 10, + "001", + 0 ] }, - "_file": "who04_thermo.yaml" + "_file": "who02_automation.yaml" }, { - "id": "thermo.cmd.mode.thermal_protection.zone1", - "frame": "*4*202*#1##", - "direction": "command", - "who": 4, - "what": 202, - "where": "#1", - "source": "encyclopedia", + "id": "cover.evt.translation.stop.91", + "frame": "*2*1000#0*91##", + "direction": "event", + "who": 2, + "what": "1000#0", + "where": "91", + "scope": "point", + "source": "community-plant-capture", "mcp_valid": true, - "mcp_notes": "Set Zone 1 (through the central unit) to Thermal Protection (cooling protection), WHAT 202 (Encyclopedia functional/who-4-temperature-control/what.md, zone setup: *4*202*#WHERE##; WHO_4.pdf conditioning: thermal protection). Built by set_mode.", + "mcp_notes": "Automation: Leading-idle pushbutton command translation STOP (*2*1000#0*91## followed by *2*0*91## and *2*1000#1*91##) on MyHomeServer1 (@Interstellar0verdrive, MyHOME#466 comment 5927373969 & MyHOME#645 comment 6111193057)", "roundtrip": true, - "builder": { - "class": "OWNHeatingCommand", - "method": "set_mode", - "args": [ - "#1", - "thermal_protection" - ] - }, - "_file": "who04_thermo.yaml" + "_file": "who02_automation.yaml" }, { - "id": "thermo.resp.probe.status.neg_temp", - "frame": "*#4*6*12*1048*3##", - "direction": "dimension_response", + "id": "cover.evt.translation.up.91", + "frame": "*2*1000#1*91##", + "direction": "event", + "who": 2, + "what": "1000#1", + "where": "91", + "scope": "point", + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Automation: Pushbutton command translation UP (*2*1000#1*91## followed by *2*1*91##) on MyHomeServer1 (@Interstellar0verdrive, MyHOME#466 comment 5927373969 & MyHOME#645 comment 6111193057)", + "roundtrip": true, + "_file": "who02_automation.yaml" + }, + { + "id": "cover.evt.translation.stop.05.f461", + "frame": "*2*1000#0*05##", + "direction": "event", + "who": 2, + "what": "1000#0", + "where": "05", + "scope": "point", + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Automation: Pre-move STOP command translation (*2*1000#0*05## -> *2*0*05## -> *2*1000#1/2*05## -> *2*1/2*05##) emitted by physical BTicino F461 FW 2.0.11 for BT-F411/2 5s timed shutter at WHERE 05 (@gdluck & @GreenGrassBlueOcean, MyHOME#753)", + "roundtrip": true, + "_file": "who02_automation.yaml" + }, + { + "id": "cover.evt.translation.down.05.f461", + "frame": "*2*1000#2*05##", + "direction": "event", + "who": 2, + "what": "1000#2", + "where": "05", + "scope": "point", + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Automation: Mid-flight reversal DOWN command translation (*2*1000#2*05## followed by *2*2*05## and 5.0 s hardware auto-stop *2*0*05##) on BTicino F461 FW 2.0.11 (@gdluck & @GreenGrassBlueOcean, MyHOME#753)", + "roundtrip": true, + "_file": "who02_automation.yaml" + }, + { + "id": "cover.evt.translation.stop.virtual.25.f461", + "frame": "*2*1000#0*25##", + "direction": "event", + "who": 2, + "what": "1000#0", + "where": "25", + "scope": "point", + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Automation: Pre-move STOP command translation (*2*1000#0*25## -> *2*1000#1/2*25##) broadcast on the event monitor session by physical BTicino F461 FW 2.0.11 for unpopulated virtual cover 25 before returning NACK on cmd (@gdluck & @GreenGrassBlueOcean, MyHOME#753)", + "roundtrip": true, + "_file": "who02_automation.yaml" + }, + { + "id": "cover.cmd.level.50.virtual.25.f461", + "frame": "*#2*25*#11#001*50##", + "direction": "dimension_write", + "who": 2, + "what": null, + "where": "25", + "dimension": 11, + "dimension_values": [ + "50" + ], + "scope": "point", + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Automation: Dimension #11#001 shutter position write (50%) on virtual cover 25 ACKed in 83 ms by physical BTicino F461 FW 2.0.11 and echoed on evt as *#2*25*#11#001#1*50## (@gdluck, MyHOME#750 comment 6277050571)", + "roundtrip": true, + "builder": { + "class": "OWNAutomationCommand", + "method": "set_shutter_level", + "args": [ + "25", + 50 + ] + }, + "_file": "who02_automation.yaml" + }, + { + "id": "cover.evt.level.50.virtual.25.f461", + "frame": "*#2*25*#11#001#1*50##", + "direction": "dimension_write", + "who": 2, + "what": null, + "where": "25", + "dimension": 11, + "dimension_values": [ + "50" + ], + "scope": "point", + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Automation: Gateway event-session echo (*#2*25*#11#001#1*50##) emitted by physical BTicino F461 FW 2.0.11 (and F454/F459/F460/MH202/MyHomeServer1 in trace-corpus) when *#2*25*#11#001*50## is sent on cmd (@gdluck, MyHOME#750 comment 6277050571)", + "roundtrip": true, + "_file": "who02_automation.yaml" + }, + { + "id": "thermo.req.temp.zone1", + "frame": "*#4*1*0##", + "direction": "dimension_request", + "who": 4, + "what": null, + "where": "1", + "dimension": 0, + "source": "legrand-spec", + "mcp_valid": true, + "mcp_notes": "Request measured temperature for Zone 1", + "roundtrip": true, + "builder": { + "class": "OWNHeatingCommand", + "method": "get_temperature", + "args": [ + "1" + ] + }, + "_file": "who04_thermo.yaml" + }, + { + "id": "thermo.resp.temp.zone1.215", + "frame": "*#4*1*0*0215##", + "direction": "dimension_response", + "who": 4, + "what": null, + "where": "1", + "dimension": 0, + "dimension_values": [ + "0215" + ], + "source": "legrand-spec", + "mcp_valid": true, + "mcp_notes": "Temperature response: Zone 1 is at 21.5\u00b0C", + "roundtrip": true, + "builder": { + "class": "OWNHeatingCommand", + "method": "report_temperature", + "args": [ + "1", + 21.5 + ] + }, + "_file": "who04_thermo.yaml" + }, + { + "id": "thermo.cmd.setpoint.zone1.205", + "frame": "*#4*1*#14*0205*1##", + "direction": "dimension_write", + "who": 4, + "what": null, + "where": "1", + "dimension": 14, + "dimension_values": [ + "0205", + "1" + ], + "source": "legrand-spec", + "mcp_valid": true, + "mcp_notes": "Set Zone 1 target temperature to 20.5\u00b0C in Heating mode", + "roundtrip": true, + "builder": { + "class": "OWNHeatingCommand", + "method": "set_temperature", + "args": [ + "1", + 20.5, + "heat", + true + ] + }, + "_file": "who04_thermo.yaml" + }, + { + "id": "thermo.cmd.mode.antifreeze.zone1", + "frame": "*4*102*#1##", + "direction": "command", + "who": 4, + "what": 102, + "where": "#1", + "source": "encyclopedia", + "mcp_valid": true, + "mcp_notes": "Set Zone 1 (through the central unit) to Antifreeze, WHAT 102 (Encyclopedia functional/who-4-temperature-control/what.md, zone setup: *4*102*#WHERE##; libqtdevices TS10_1_0_23 PROT_ANTIFREEZE 102). Corrected from *4*110*1##: 110 is manual heating (same table; libqtdevices TS10_1_0_23 WIN_MANUAL) and the MyHomeServer1 firmware forwards nothing for it (OWNd#77). The MyHomeServer1 captures in this file report an antifreeze zone as *4*102*Z##. Built by set_mode.", + "roundtrip": true, + "builder": { + "class": "OWNHeatingCommand", + "method": "set_mode", + "args": [ + "#1", + "antifreeze" + ] + }, + "_file": "who04_thermo.yaml" + }, + { + "id": "thermo.cmd.mode.protection.zone1", + "frame": "*4*302*#1##", + "direction": "command", + "who": 4, + "what": 302, + "where": "#1", + "source": "encyclopedia", + "mcp_valid": true, + "mcp_notes": "Set Zone 1 (through the central unit) to generic protection, WHAT 302 (Encyclopedia what.md zone setup: *4*302*#WHERE##; libqtdevices TS10_1_0_23 ControlledProbeDevice::setProtection sends PROT_GENERIC 302 to #zone). Corrected from *4*111*1##: 111 is programmed/automatic heating (same table; libqtdevices WIN_AUTOMATIC), which the MyHomeServer1 firmware forwards as automatic heating (OWNd#77). Built by set_mode.", + "roundtrip": true, + "builder": { + "class": "OWNHeatingCommand", + "method": "set_mode", + "args": [ + "#1", + "protection" + ] + }, + "_file": "who04_thermo.yaml" + }, + { + "id": "thermo.cmd.mode.thermal_protection.zone1", + "frame": "*4*202*#1##", + "direction": "command", + "who": 4, + "what": 202, + "where": "#1", + "source": "encyclopedia", + "mcp_valid": true, + "mcp_notes": "Set Zone 1 (through the central unit) to Thermal Protection (cooling protection), WHAT 202 (Encyclopedia functional/who-4-temperature-control/what.md, zone setup: *4*202*#WHERE##; WHO_4.pdf conditioning: thermal protection). Built by set_mode.", + "roundtrip": true, + "builder": { + "class": "OWNHeatingCommand", + "method": "set_mode", + "args": [ + "#1", + "thermal_protection" + ] + }, + "_file": "who04_thermo.yaml" + }, + { + "id": "thermo.resp.probe.status.neg_temp", + "frame": "*#4*6*12*1048*3##", + "direction": "dimension_response", "who": 4, "what": null, "where": "6", @@ -1593,6 +2037,106 @@ "roundtrip": true, "_file": "who04_thermo.yaml" }, + { + "id": "thermo.capture.temp.probe105.296", + "frame": "*#4*105*0*0296##", + "direction": "dimension_response", + "who": 4, + "what": null, + "where": "105", + "dimension": 0, + "dimension_values": [ + "0296" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "External 3-digit probe 105 (sensor 1 on zone 5) reporting measured temperature 29.6 \u00b0C on MH200N FW 1.1.8 (@caiosweet, MyHOME#466 comment 5834435606)", + "roundtrip": true, + "builder": { + "class": "OWNHeatingCommand", + "method": "report_temperature", + "args": [ + "105", + 29.6 + ] + }, + "_file": "who04_thermo.yaml" + }, + { + "id": "thermo.capture.temp.zone2.271", + "frame": "*#4*2*0*0271##", + "direction": "dimension_response", + "who": 4, + "what": null, + "where": "2", + "dimension": 0, + "dimension_values": [ + "0271" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Zone 2 reporting measured temperature 27.1 \u00b0C on MyHomeServer1 FW 2.38.9 (@TheDarkWizard, MyHOME#466 comment 5855690408)", + "roundtrip": true, + "builder": { + "class": "OWNHeatingCommand", + "method": "report_temperature", + "args": [ + "2", + 27.1 + ] + }, + "_file": "who04_thermo.yaml" + }, + { + "id": "thermo.capture.temp.zone2.249.f461", + "frame": "*#4*2*0*0249##", + "direction": "dimension_response", + "who": 4, + "what": null, + "where": "2", + "dimension": 0, + "dimension_values": [ + "0249" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Zone 2 standalone KW4691 thermostat reporting measured temperature 24.9 \u00b0C on physical BTicino F461 FW 2.0.11 (@gdluck & @GreenGrassBlueOcean, MyHOME#753)", + "roundtrip": true, + "builder": { + "class": "OWNHeatingCommand", + "method": "report_temperature", + "args": [ + "2", + 24.9 + ] + }, + "_file": "who04_thermo.yaml" + }, + { + "id": "thermo.capture.write_temp.probe50.215.f461", + "frame": "*#4*50*#0*0215##", + "direction": "dimension_write", + "who": 4, + "what": null, + "where": "50", + "dimension": 0, + "dimension_values": [ + "0215" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "WHO=4 Dimension #0 temperature write (21.5 \u00b0C on virtual probe 50) ACKed in 155 ms by bt_termo on physical BTicino F461 FW 2.0.11, whereas *#4*W*0*TTTT## without #0 is parsed by F461 bt_termo as a Dimension 0 read request (@gdluck & @GreenGrassBlueOcean, MyHOME#753)", + "roundtrip": true, + "builder": { + "class": "OWNHeatingCommand", + "method": "write_temperature", + "args": [ + "50", + 21.5 + ] + }, + "_file": "who04_thermo.yaml" + }, { "id": "alarm.req.status.zone2", "frame": "*#5*2##", @@ -1745,90 +2289,286 @@ "where": "1", "source": "legrand-spec", "mcp_valid": true, - "mcp_notes": "Auxiliary: Activate AUX channel 1", + "mcp_notes": "Auxiliary: Activate AUX channel 1", + "roundtrip": true, + "_file": "who09_auxiliary.yaml" + }, + { + "id": "aux.cmd.deactivate.1", + "frame": "*9*0*1##", + "direction": "command", + "who": 9, + "what": 0, + "where": "1", + "source": "legrand-spec", + "mcp_valid": true, + "mcp_notes": "Auxiliary: Deactivate AUX channel 1", + "roundtrip": true, + "_file": "who09_auxiliary.yaml" + }, + { + "id": "aux.cmd.activate.4", + "frame": "*9*1*4##", + "direction": "command", + "who": 9, + "what": 1, + "where": "4", + "source": "openwebnet4j", + "mcp_valid": true, + "mcp_notes": "Auxiliary: Activate AUX channel 4", + "roundtrip": true, + "_file": "who09_auxiliary.yaml" + }, + { + "id": "aux.cmd.deactivate.4", + "frame": "*9*0*4##", + "direction": "command", + "who": 9, + "what": 0, + "where": "4", + "source": "legrand-spec", + "mcp_valid": true, + "mcp_notes": "Auxiliary: Deactivate AUX channel 4", + "roundtrip": true, + "_file": "who09_auxiliary.yaml" + }, + { + "id": "gateway.resp.firmware.version", + "frame": "*#13**16*1*2*3##", + "direction": "dimension_response", + "who": 13, + "what": null, + "where": "", + "dimension": 16, + "dimension_values": [ + "1", + "2", + "3" + ], + "source": "openwebnet4j", + "mcp_valid": true, + "mcp_notes": "Gateway Management: Gateway reports firmware version 1.2.3", + "roundtrip": true, + "_file": "who13_gateway.yaml" + }, + { + "id": "gateway.resp.internal.datetime", + "frame": "*#13**22*09*37*30*102*03*01*05*2019##", + "direction": "dimension_response", + "who": 13, + "what": null, + "where": "", + "dimension": 22, + "dimension_values": [ + "09", + "37", + "30", + "102", + "03", + "01", + "05", + "2019" + ], + "source": "openwebnet4j", + "mcp_valid": true, + "mcp_notes": "Gateway Management: Gateway reports internal datetime 2019-05-01 09:37:30-02:00", + "roundtrip": true, + "builder": { + "class": "OWNGatewayCommand", + "method": "report_datetime", + "args": [ + "2019-05-01T09:37:30-02:00" + ] + }, + "_file": "who13_gateway.yaml" + }, + { + "id": "gateway.req.time", + "frame": "*#13**0##", + "direction": "dimension_request", + "who": 13, + "what": null, + "where": "", + "dimension": 0, + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Gateway Management: Request gateway internal time on Dimension 0 (Legrand F455 FW 1.0.86 capture by @lionelser and BTicino MH202 FW 2.6.13 capture by @anotherjulien, MyHOME#466)", + "roundtrip": true, + "builder": { + "class": "OWNGatewayCommand", + "method": "request_time", + "args": [] + }, + "_file": "who13_gateway.yaml" + }, + { + "id": "gateway.resp.internal.time.mh202", + "frame": "*#13**0*20*54*00*002##", + "direction": "dimension_response", + "who": 13, + "what": null, + "where": "", + "dimension": 0, + "dimension_values": [ + "20", + "54", + "00", + "002" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Gateway Management: BTicino MH202 FW 2.6.13 reports internal time 20:54:00+02:00 on Dimension 0 (@anotherjulien, MyHOME#466 comment 5849027587)", "roundtrip": true, - "_file": "who09_auxiliary.yaml" + "builder": { + "class": "OWNGatewayCommand", + "method": "report_time", + "args": [ + "20:54:00+02:00" + ] + }, + "_file": "who13_gateway.yaml" }, { - "id": "aux.cmd.deactivate.1", - "frame": "*9*0*1##", - "direction": "command", - "who": 9, - "what": 0, - "where": "1", - "source": "legrand-spec", + "id": "gateway.write.internal.time.f454", + "frame": "*#13**#0*11*30*00*002##", + "direction": "dimension_write", + "who": 13, + "what": null, + "where": "", + "dimension": 0, + "dimension_values": [ + "11", + "30", + "00", + "002" + ], + "source": "community-plant-capture", "mcp_valid": true, - "mcp_notes": "Auxiliary: Deactivate AUX channel 1", + "mcp_notes": "Gateway Management: Periodic Dimension 0 time sync write 11:30:00+02:00 on shared F454 FW 2.0.5 + MH202 bus (@anotherjulien, MyHOME#466 comment 6038376669)", "roundtrip": true, - "_file": "who09_auxiliary.yaml" + "builder": { + "class": "OWNGatewayCommand", + "method": "set_time", + "args": [ + "11:30:00+02:00" + ] + }, + "_file": "who13_gateway.yaml" }, { - "id": "aux.cmd.activate.4", - "frame": "*9*1*4##", - "direction": "command", - "who": 9, - "what": 1, - "where": "4", - "source": "openwebnet4j", + "id": "gateway.resp.internal.date.f461", + "frame": "*#13**1*04*15*10*2026##", + "direction": "dimension_response", + "who": 13, + "what": null, + "where": "", + "dimension": 1, + "dimension_values": [ + "04", + "15", + "10", + "2026" + ], + "source": "community-plant-capture", "mcp_valid": true, - "mcp_notes": "Auxiliary: Activate AUX channel 4", + "mcp_notes": "Gateway Management: BTicino F461 FW 2.0.11 reports internal date Thursday (04) 2026-10-15 on Dimension 1 (@GreenGrassBlueOcean live plant capture, 2026-10-15)", "roundtrip": true, - "_file": "who09_auxiliary.yaml" + "builder": { + "class": "OWNGatewayCommand", + "method": "report_date", + "args": [ + "2026-10-15" + ] + }, + "_file": "who13_gateway.yaml" }, { - "id": "aux.cmd.deactivate.4", - "frame": "*9*0*4##", - "direction": "command", - "who": 9, - "what": 0, - "where": "4", - "source": "legrand-spec", + "id": "gateway.write.internal.date.f454", + "frame": "*#13**#1*03*07*10*2026##", + "direction": "dimension_write", + "who": 13, + "what": null, + "where": "", + "dimension": 1, + "dimension_values": [ + "03", + "07", + "10", + "2026" + ], + "source": "community-plant-capture", "mcp_valid": true, - "mcp_notes": "Auxiliary: Deactivate AUX channel 4", + "mcp_notes": "Gateway Management: Periodic Dimension 1 date sync write Wednesday (03) 2026-10-07 on shared F454 FW 2.0.5 + MH202 bus (@anotherjulien, MyHOME#466 comment 6038376669)", "roundtrip": true, - "_file": "who09_auxiliary.yaml" + "builder": { + "class": "OWNGatewayCommand", + "method": "set_date", + "args": [ + "2026-10-07" + ] + }, + "_file": "who13_gateway.yaml" }, { - "id": "gateway.resp.firmware.version", - "frame": "*#13**16*1*2*3##", + "id": "gateway.resp.internal.datetime.mh200n", + "frame": "*#13**22*16*37*03*001*05*25*09*2026##", "direction": "dimension_response", "who": 13, "what": null, "where": "", - "dimension": 16, + "dimension": 22, "dimension_values": [ - "1", - "2", - "3" + "16", + "37", + "03", + "001", + "05", + "25", + "09", + "2026" ], - "source": "openwebnet4j", + "source": "community-plant-capture", "mcp_valid": true, - "mcp_notes": "Gateway Management: Gateway reports firmware version 1.2.3", + "mcp_notes": "Gateway Management: BTicino MH200N FW 1.1.8 reports internal datetime Friday (05) 2026-09-25 16:37:03+01:00 on Dimension 22 (@caiosweet, MyHOME#466 comment 5834435606)", "roundtrip": true, + "builder": { + "class": "OWNGatewayCommand", + "method": "report_datetime", + "args": [ + "2026-09-25T16:37:03+01:00" + ] + }, "_file": "who13_gateway.yaml" }, { - "id": "gateway.resp.internal.datetime", - "frame": "*#13**22*09*37*30*102*03*01*05*2019##", - "direction": "dimension_response", + "id": "gateway.write.internal.datetime.2026", + "frame": "*#13**#22*17*33*54*002*05*11*09*2026##", + "direction": "dimension_write", "who": 13, "what": null, "where": "", "dimension": 22, "dimension_values": [ - "09", - "37", - "30", - "102", - "03", - "01", + "17", + "33", + "54", + "002", "05", - "2019" + "11", + "09", + "2026" ], - "source": "openwebnet4j", + "source": "community-plant-capture", "mcp_valid": true, - "mcp_notes": "Gateway Management: Gateway reports internal datetime 2019-05-01 09:37:30-02:00", + "mcp_notes": "Gateway Management: Set gateway internal datetime to Friday (05) 2026-09-11 17:33:54+02:00 on Dimension 22 (MyHomeServer1 plant capture, MyHOME#247)", "roundtrip": true, + "builder": { + "class": "OWNGatewayCommand", + "method": "set_datetime", + "args": [ + "2026-09-11T17:33:54+02:00" + ] + }, "_file": "who13_gateway.yaml" }, { @@ -1843,6 +2583,11 @@ "mcp_valid": true, "mcp_notes": "Gateway Management: Request gateway model / device type (physical MH200 check)", "roundtrip": true, + "builder": { + "class": "OWNGatewayCommand", + "method": "request_device_type", + "args": [] + }, "_file": "who13_gateway.yaml" }, { @@ -2170,6 +2915,215 @@ "mcp_valid": true, "mcp_notes": "Energy Management: Request energy meter 51 diagnostic/unit parameter 113", "roundtrip": true, + "builder": { + "class": "OWNEnergyCommand", + "method": "request_active_power", + "args": [ + "51" + ] + }, + "_file": "who18_energy.yaml" + }, + { + "id": "energy.cmd.start_stream.meter56.2", + "frame": "*#18*56*#1200#1*2##", + "direction": "dimension_write", + "who": 18, + "what": null, + "where": "56", + "dimension": 1200, + "dimension_values": [ + "2" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Energy Management: Start 2-minute instantaneous active power auto-update stream on F520 meter 56 (BTicino F454 FW 2.0 capture by @xtimmy86x, MyHOME#661 comment 6067730589)", + "roundtrip": true, + "builder": { + "class": "OWNEnergyCommand", + "method": "start_sending_instant_power", + "args": [ + "56", + 2 + ] + }, + "_file": "who18_energy.yaml" + }, + { + "id": "energy.resp.auto_update_interval.meter56.2", + "frame": "*#18*56*1200#1*2##", + "direction": "dimension_response", + "who": 18, + "what": null, + "where": "56", + "dimension": 1200, + "dimension_values": [ + "2" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Energy Management: F520 meter 56 confirms 2-minute active power auto-update interval active ~235 ms after *#18*56*#1200#1*2## (@xtimmy86x F454 FW 2.0 capture, MyHOME#661 comment 6067730589)", + "roundtrip": true, + "builder": { + "class": "OWNEnergyCommand", + "method": "report_streaming_state", + "args": [ + "56", + 2, + 1 + ] + }, + "_file": "who18_energy.yaml" + }, + { + "id": "energy.resp.auto_update_interval.meter56.0", + "frame": "*#18*56*1200#1*0##", + "direction": "dimension_response", + "who": 18, + "what": null, + "where": "56", + "dimension": 1200, + "dimension_values": [ + "0" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Energy Management: F520 meter 56 reports instantaneous active power auto-update expired (interval 0) ~126.18 s after activation (@xtimmy86x F454 FW 2.0 capture, MyHOME#661 comment 6067730589)", + "roundtrip": true, + "builder": { + "class": "OWNEnergyCommand", + "method": "report_streaming_state", + "args": [ + "56", + 0, + 1 + ] + }, + "_file": "who18_energy.yaml" + }, + { + "id": "energy.resp.active_power.meter51.572", + "frame": "*#18*51*113*572##", + "direction": "dimension_response", + "who": 18, + "what": null, + "where": "51", + "dimension": 113, + "dimension_values": [ + "572" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Energy Management: F520 meter 51 reports 572 W instantaneous active power on Dimension 113 during active stream (@xtimmy86x F454 FW 2.0 capture, MyHOME#661 comment 6067730589)", + "roundtrip": true, + "builder": { + "class": "OWNEnergyCommand", + "method": "report_active_power", + "args": [ + "51", + 572 + ] + }, + "_file": "who18_energy.yaml" + }, + { + "id": "energy.resp.actuator_power.channel77.161", + "frame": "*#18*77#0*113*161##", + "direction": "dimension_response", + "who": 18, + "what": null, + "where": "77", + "dimension": 113, + "dimension_values": [ + "161" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Energy Management: Local-bus actuator channel 77#0 reports 161 W active power on Dimension 113 (MyHOME#669)", + "roundtrip": true, + "builder": { + "class": "OWNEnergyCommand", + "method": "report_active_power", + "args": [ + "77#0", + 161 + ] + }, + "_file": "who18_energy.yaml" + }, + { + "id": "energy.resp.cumulative_totalizer.meter51", + "frame": "*#18*51*51*23791364##", + "direction": "dimension_response", + "who": 18, + "what": null, + "where": "51", + "dimension": 51, + "dimension_values": [ + "23791364" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Energy Management: F520 meter 51 reports 23,791,364 Wh cumulative energy totalizer on Dimension 51 (@caiosweet MH200N FW 1.1.8 capture, MyHOME#466 comment 5834435606)", + "roundtrip": true, + "builder": { + "class": "OWNEnergyCommand", + "method": "report_total_energy", + "args": [ + "51", + 23791364 + ] + }, + "_file": "who18_energy.yaml" + }, + { + "id": "energy.resp.partial_month.meter52.1", + "frame": "*#18*52*53*1##", + "direction": "dimension_response", + "who": 18, + "what": null, + "where": "52", + "dimension": 53, + "dimension_values": [ + "1" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Energy Management: F520 meter 52 reports 1 Wh current-month partial energy on Dimension 53 (@anotherjulien F454 FW 2.0.5 capture, MyHOME#466 comment 5849027587)", + "roundtrip": true, + "builder": { + "class": "OWNEnergyCommand", + "method": "report_partial_month_energy", + "args": [ + "52", + 1 + ] + }, + "_file": "who18_energy.yaml" + }, + { + "id": "energy.resp.partial_day.meter51.13165", + "frame": "*#18*51*54*13165##", + "direction": "dimension_response", + "who": 18, + "what": null, + "where": "51", + "dimension": 54, + "dimension_values": [ + "13165" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Energy Management: F520 meter 51 reports 13,165 Wh current-day partial energy on Dimension 54 (@xtimmy86x F454 FW 2.0 capture, MyHOME#661 comment 6067730589)", + "roundtrip": true, + "builder": { + "class": "OWNEnergyCommand", + "method": "report_partial_day_energy", + "args": [ + "51", + 13165 + ] + }, "_file": "who18_energy.yaml" }, { diff --git a/tests/golden/frames/who01_lighting.yaml b/tests/golden/frames/who01_lighting.yaml index 39a265e4..4c383ce9 100644 --- a/tests/golden/frames/who01_lighting.yaml +++ b/tests/golden/frames/who01_lighting.yaml @@ -351,3 +351,105 @@ mcp_valid: true mcp_notes: "Lighting: Pushbutton command translation OFF at address 12 (MyHOME#466)" roundtrip: true + +- id: light.dim.reply.level.34.speed5.12 + frame: "*#1*12*1*134*5##" + direction: dimension_response + who: 1 + what: null + where: "12" + dimension: 1 + dimension_values: ["134", "5"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Lighting: DIMENSION 1 reply reporting 34% brightness (LEVEL100 134) at transition speed 5 on dimmer 12 (Legrand F455 FW 1.0.86 capture by @lionelser, MyHOME#466 comment 5938596974)" + roundtrip: true + builder: + class: OWNLightingCommand + method: report_brightness + args: ["12", 34, 5] + +- id: light.dim.reply.off.speed0.dim1.mh202.32 + frame: "*#1*32*1*100*0##" + direction: dimension_response + who: 1 + what: null + where: "32" + dimension: 1 + dimension_values: ["100", "0"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Lighting: DIMENSION 1 status response reporting OFF (LEVEL100 100 = 0% at transition speed 0) on BTicino MH202 FW 2.6.13 + F418U2 dimmer 32 (@anotherjulien, MyHOME#466 comments 5849027587 & 5854805415)" + roundtrip: true + builder: + class: OWNLightingCommand + method: report_brightness + args: ["32", 0, 0] + +- id: light.dim.reply.level.30.speed5.dim1.32 + frame: "*#1*32*1*130*5##" + direction: dimension_response + who: 1 + what: null + where: "32" + dimension: 1 + dimension_values: ["130", "5"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Lighting: DIMENSION 1 report after 30% write/ON command to BTicino F418U2 dimmer 32 on F454 FW 2.0.5 and MH202 FW 2.6.13: reports 30% (LEVEL100 130) with active transition speed 5 (@anotherjulien, MyHOME#466 comments 5849027587 & 5854469497)" + roundtrip: true + builder: + class: OWNLightingCommand + method: report_brightness + args: ["32", 30, 5] + +- id: light.dim.req.level.99 + frame: "*#1*99*1##" + direction: dimension_request + who: 1 + what: null + where: "99" + dimension: 1 + source: community-plant-capture + mcp_valid: true + mcp_notes: "Lighting: DIMENSION 1 request to BTicino F414 classic dimmer 99 on MH200 FW 2.1.0 (@GreenGrassBlueOcean, MyHOME#466 comment 5849245960)" + roundtrip: true + builder: + class: OWNLightingCommand + method: get_brightness + args: ["99"] + +- id: light.dim.write.level.50.99 + frame: "*#1*99*#1*150*0##" + direction: dimension_write + who: 1 + what: null + where: "99" + dimension: 1 + dimension_values: ["150", "0"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Lighting: DIMENSION 1 write setting 50% (LEVEL100 150) with speed 0 on BTicino F414 classic dimmer 99 on MH200 FW 2.1.0 (@GreenGrassBlueOcean, MyHOME#466 comment 5849245960)" + roundtrip: true + builder: + class: OWNLightingCommand + method: set_brightness + args: ["99", 50] + +- id: light.dim.reply.level.50.speed5.99 + frame: "*#1*99*1*150*5##" + direction: dimension_response + who: 1 + what: null + where: "99" + dimension: 1 + dimension_values: ["150", "5"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Lighting: DIMENSION 1 report after 50% write to BTicino F414 dimmer 99 on MH200 FW 2.1.0: reports 50% (LEVEL100 150) with active transition speed 5 (@GreenGrassBlueOcean, MyHOME#466 comment 5849245960)" + roundtrip: true + builder: + class: OWNLightingCommand + method: report_brightness + args: ["99", 50, 5] + diff --git a/tests/golden/frames/who02_automation.yaml b/tests/golden/frames/who02_automation.yaml index ee1e6e81..2a263b26 100644 --- a/tests/golden/frames/who02_automation.yaml +++ b/tests/golden/frames/who02_automation.yaml @@ -431,3 +431,188 @@ class: OWNAutomationCommand method: lower_shutter args: ["#1"] + +- id: cover.status.dimension10.level40.31 + frame: "*#2*31*10*10*40*001*0##" + direction: dimension_response + who: 2 + what: null + where: "31" + dimension: 10 + dimension_values: ["10", "40", "001", "0"] + scope: point + source: community-plant-capture + mcp_valid: true + mcp_notes: "Automation: Dimension 10 report with state 10 (stopped) at position 40%, priority bitfield 001, info 0 for shutter 31 on MH202 FW 2.6.13 and F454 FW 2.0.5 (@anotherjulien, MyHOME#466 comment 5849027587)" + roundtrip: true + builder: + class: OWNAutomationCommand + method: report_shutter_level + args: ["31", 40, 10, "001", 0] + +- id: cover.status.dimension10.closing66.31 + frame: "*#2*31*10*12*66*001*0##" + direction: dimension_response + who: 2 + what: null + where: "31" + dimension: 10 + dimension_values: ["12", "66", "001", "0"] + scope: point + source: community-plant-capture + mcp_valid: true + mcp_notes: "Automation: Dimension 10 report with state 12 (closing) from position 66%, priority bitfield 001, info 0 for shutter 31 on F454 FW 2.0.5 and MH202 FW 2.6.13 (@anotherjulien, MyHOME#466 comments 5849027587 & 6038376669)" + roundtrip: true + builder: + class: OWNAutomationCommand + method: report_shutter_level + args: ["31", 66, 12, "001", 0] + +- id: cover.status.dimension10.transitional.closing.02 + frame: "*#2*02*10*12*100*001*0##" + direction: dimension_response + who: 2 + what: null + where: "02" + dimension: 10 + dimension_values: ["12", "100", "001", "0"] + scope: point + source: community-plant-capture + mcp_valid: true + mcp_notes: "Automation: Dimension 10 report with state 12 (closing from initial position 100%) on LN-4660M2 advanced shutter actuator 02 captured on MyHomeServer1 (@f18m, MyHOME#445 comment 5868017035)" + roundtrip: true + builder: + class: OWNAutomationCommand + method: report_shutter_level + args: ["02", 100, 12, "001", 0] + +- id: cover.status.dimension10.level26.0010 + frame: "*#2*0010*10*10*26*001*0##" + direction: dimension_response + who: 2 + what: null + where: "0010" + dimension: 10 + dimension_values: ["10", "26", "001", "0"] + scope: point + source: community-plant-capture + mcp_valid: true + mcp_notes: "Automation: Dimension 10 position 26% report for 4-digit address 0010 from MyHomeServer1 + LN-4660M2 plant after centralized stop (@f18m, MyHOME#445 & MyHOME#466)" + roundtrip: true + builder: + class: OWNAutomationCommand + method: report_shutter_level + args: ["0010", 26, 10, "001", 0] + +- id: cover.status.dimension10.level83.03 + frame: "*#2*03*10*10*83*001*0##" + direction: dimension_response + who: 2 + what: null + where: "03" + dimension: 10 + dimension_values: ["10", "83", "001", "0"] + scope: point + source: community-plant-capture + mcp_valid: true + mcp_notes: "Automation: Dimension 10 position 83% report for bistable cover 03 on MyHomeServer1 FW 2.38.9 (@TheDarkWizard, MyHOME#466 comment 5855690408)" + roundtrip: true + builder: + class: OWNAutomationCommand + method: report_shutter_level + args: ["03", 83, 10, "001", 0] + +- id: cover.evt.translation.stop.91 + frame: "*2*1000#0*91##" + direction: event + who: 2 + what: "1000#0" + where: "91" + scope: point + source: community-plant-capture + mcp_valid: true + mcp_notes: "Automation: Leading-idle pushbutton command translation STOP (*2*1000#0*91## followed by *2*0*91## and *2*1000#1*91##) on MyHomeServer1 (@Interstellar0verdrive, MyHOME#466 comment 5927373969 & MyHOME#645 comment 6111193057)" + roundtrip: true + +- id: cover.evt.translation.up.91 + frame: "*2*1000#1*91##" + direction: event + who: 2 + what: "1000#1" + where: "91" + scope: point + source: community-plant-capture + mcp_valid: true + mcp_notes: "Automation: Pushbutton command translation UP (*2*1000#1*91## followed by *2*1*91##) on MyHomeServer1 (@Interstellar0verdrive, MyHOME#466 comment 5927373969 & MyHOME#645 comment 6111193057)" + roundtrip: true + +- id: cover.evt.translation.stop.05.f461 + frame: "*2*1000#0*05##" + direction: event + who: 2 + what: "1000#0" + where: "05" + scope: point + source: community-plant-capture + mcp_valid: true + mcp_notes: "Automation: Pre-move STOP command translation (*2*1000#0*05## -> *2*0*05## -> *2*1000#1/2*05## -> *2*1/2*05##) emitted by physical BTicino F461 FW 2.0.11 for BT-F411/2 5s timed shutter at WHERE 05 (@gdluck & @GreenGrassBlueOcean, MyHOME#753)" + roundtrip: true + +- id: cover.evt.translation.down.05.f461 + frame: "*2*1000#2*05##" + direction: event + who: 2 + what: "1000#2" + where: "05" + scope: point + source: community-plant-capture + mcp_valid: true + mcp_notes: "Automation: Mid-flight reversal DOWN command translation (*2*1000#2*05## followed by *2*2*05## and 5.0 s hardware auto-stop *2*0*05##) on BTicino F461 FW 2.0.11 (@gdluck & @GreenGrassBlueOcean, MyHOME#753)" + roundtrip: true + +- id: cover.evt.translation.stop.virtual.25.f461 + frame: "*2*1000#0*25##" + direction: event + who: 2 + what: "1000#0" + where: "25" + scope: point + source: community-plant-capture + mcp_valid: true + mcp_notes: "Automation: Pre-move STOP command translation (*2*1000#0*25## -> *2*1000#1/2*25##) broadcast on the event monitor session by physical BTicino F461 FW 2.0.11 for unpopulated virtual cover 25 before returning NACK on cmd (@gdluck & @GreenGrassBlueOcean, MyHOME#753)" + roundtrip: true + +- id: cover.cmd.level.50.virtual.25.f461 + frame: "*#2*25*#11#001*50##" + direction: dimension_write + who: 2 + what: null + where: "25" + dimension: 11 + dimension_values: ["50"] + scope: point + source: community-plant-capture + mcp_valid: true + mcp_notes: "Automation: Dimension #11#001 shutter position write (50%) on virtual cover 25 ACKed in 83 ms by physical BTicino F461 FW 2.0.11 and echoed on evt as *#2*25*#11#001#1*50## (@gdluck, MyHOME#750 comment 6277050571)" + roundtrip: true + builder: + class: OWNAutomationCommand + method: set_shutter_level + args: ["25", 50] + +- id: cover.evt.level.50.virtual.25.f461 + frame: "*#2*25*#11#001#1*50##" + direction: dimension_write + who: 2 + what: null + where: "25" + dimension: 11 + dimension_values: ["50"] + scope: point + source: community-plant-capture + mcp_valid: true + mcp_notes: "Automation: Gateway event-session echo (*#2*25*#11#001#1*50##) emitted by physical BTicino F461 FW 2.0.11 (and F454/F459/F460/MH202/MyHomeServer1 in trace-corpus) when *#2*25*#11#001*50## is sent on cmd (@gdluck, MyHOME#750 comment 6277050571)" + roundtrip: true + + + diff --git a/tests/golden/frames/who04_thermo.yaml b/tests/golden/frames/who04_thermo.yaml index 2c1df401..72d39b58 100644 --- a/tests/golden/frames/who04_thermo.yaml +++ b/tests/golden/frames/who04_thermo.yaml @@ -9,6 +9,10 @@ mcp_valid: true mcp_notes: "Request measured temperature for Zone 1" roundtrip: true + builder: + class: OWNHeatingCommand + method: get_temperature + args: ["1"] - id: thermo.resp.temp.zone1.215 frame: "*#4*1*0*0215##" @@ -22,6 +26,10 @@ mcp_valid: true mcp_notes: "Temperature response: Zone 1 is at 21.5°C" roundtrip: true + builder: + class: OWNHeatingCommand + method: report_temperature + args: ["1", 21.5] - id: thermo.cmd.setpoint.zone1.205 frame: "*#4*1*#14*0205*1##" @@ -361,3 +369,73 @@ mcp_valid: true mcp_notes: "Zone 2 target 7.0 °C, the antifreeze value, 47 ms after *4*102*2## (xtimmy86x capture 23:39:43.838, MyHOME#429)" roundtrip: true + +- id: thermo.capture.temp.probe105.296 + frame: "*#4*105*0*0296##" + direction: dimension_response + who: 4 + what: null + where: "105" + dimension: 0 + dimension_values: ["0296"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "External 3-digit probe 105 (sensor 1 on zone 5) reporting measured temperature 29.6 °C on MH200N FW 1.1.8 (@caiosweet, MyHOME#466 comment 5834435606)" + roundtrip: true + builder: + class: OWNHeatingCommand + method: report_temperature + args: ["105", 29.6] + +- id: thermo.capture.temp.zone2.271 + frame: "*#4*2*0*0271##" + direction: dimension_response + who: 4 + what: null + where: "2" + dimension: 0 + dimension_values: ["0271"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Zone 2 reporting measured temperature 27.1 °C on MyHomeServer1 FW 2.38.9 (@TheDarkWizard, MyHOME#466 comment 5855690408)" + roundtrip: true + builder: + class: OWNHeatingCommand + method: report_temperature + args: ["2", 27.1] + +- id: thermo.capture.temp.zone2.249.f461 + frame: "*#4*2*0*0249##" + direction: dimension_response + who: 4 + what: null + where: "2" + dimension: 0 + dimension_values: ["0249"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Zone 2 standalone KW4691 thermostat reporting measured temperature 24.9 °C on physical BTicino F461 FW 2.0.11 (@gdluck & @GreenGrassBlueOcean, MyHOME#753)" + roundtrip: true + builder: + class: OWNHeatingCommand + method: report_temperature + args: ["2", 24.9] + +- id: thermo.capture.write_temp.probe50.215.f461 + frame: "*#4*50*#0*0215##" + direction: dimension_write + who: 4 + what: null + where: "50" + dimension: 0 + dimension_values: ["0215"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "WHO=4 Dimension #0 temperature write (21.5 °C on virtual probe 50) ACKed in 155 ms by bt_termo on physical BTicino F461 FW 2.0.11, whereas *#4*W*0*TTTT## without #0 is parsed by F461 bt_termo as a Dimension 0 read request (@gdluck & @GreenGrassBlueOcean, MyHOME#753)" + roundtrip: true + builder: + class: OWNHeatingCommand + method: write_temperature + args: ["50", 21.5] + + diff --git a/tests/golden/frames/who13_gateway.yaml b/tests/golden/frames/who13_gateway.yaml index 7231bcff..44cbb690 100644 --- a/tests/golden/frames/who13_gateway.yaml +++ b/tests/golden/frames/who13_gateway.yaml @@ -23,6 +23,128 @@ mcp_valid: true mcp_notes: "Gateway Management: Gateway reports internal datetime 2019-05-01 09:37:30-02:00" roundtrip: true + builder: + class: OWNGatewayCommand + method: report_datetime + args: ["2019-05-01T09:37:30-02:00"] + +- id: gateway.req.time + frame: "*#13**0##" + direction: dimension_request + who: 13 + what: null + where: "" + dimension: 0 + source: community-plant-capture + mcp_valid: true + mcp_notes: "Gateway Management: Request gateway internal time on Dimension 0 (Legrand F455 FW 1.0.86 capture by @lionelser and BTicino MH202 FW 2.6.13 capture by @anotherjulien, MyHOME#466)" + roundtrip: true + builder: + class: OWNGatewayCommand + method: request_time + args: [] + +- id: gateway.resp.internal.time.mh202 + frame: "*#13**0*20*54*00*002##" + direction: dimension_response + who: 13 + what: null + where: "" + dimension: 0 + dimension_values: ["20", "54", "00", "002"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Gateway Management: BTicino MH202 FW 2.6.13 reports internal time 20:54:00+02:00 on Dimension 0 (@anotherjulien, MyHOME#466 comment 5849027587)" + roundtrip: true + builder: + class: OWNGatewayCommand + method: report_time + args: ["20:54:00+02:00"] + +- id: gateway.write.internal.time.f454 + frame: "*#13**#0*11*30*00*002##" + direction: dimension_write + who: 13 + what: null + where: "" + dimension: 0 + dimension_values: ["11", "30", "00", "002"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Gateway Management: Periodic Dimension 0 time sync write 11:30:00+02:00 on shared F454 FW 2.0.5 + MH202 bus (@anotherjulien, MyHOME#466 comment 6038376669)" + roundtrip: true + builder: + class: OWNGatewayCommand + method: set_time + args: ["11:30:00+02:00"] + +- id: gateway.resp.internal.date.f461 + frame: "*#13**1*04*15*10*2026##" + direction: dimension_response + who: 13 + what: null + where: "" + dimension: 1 + dimension_values: ["04", "15", "10", "2026"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Gateway Management: BTicino F461 FW 2.0.11 reports internal date Thursday (04) 2026-10-15 on Dimension 1 (@GreenGrassBlueOcean live plant capture, 2026-10-15)" + roundtrip: true + builder: + class: OWNGatewayCommand + method: report_date + args: ["2026-10-15"] + +- id: gateway.write.internal.date.f454 + frame: "*#13**#1*03*07*10*2026##" + direction: dimension_write + who: 13 + what: null + where: "" + dimension: 1 + dimension_values: ["03", "07", "10", "2026"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Gateway Management: Periodic Dimension 1 date sync write Wednesday (03) 2026-10-07 on shared F454 FW 2.0.5 + MH202 bus (@anotherjulien, MyHOME#466 comment 6038376669)" + roundtrip: true + builder: + class: OWNGatewayCommand + method: set_date + args: ["2026-10-07"] + +- id: gateway.resp.internal.datetime.mh200n + frame: "*#13**22*16*37*03*001*05*25*09*2026##" + direction: dimension_response + who: 13 + what: null + where: "" + dimension: 22 + dimension_values: ["16", "37", "03", "001", "05", "25", "09", "2026"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Gateway Management: BTicino MH200N FW 1.1.8 reports internal datetime Friday (05) 2026-09-25 16:37:03+01:00 on Dimension 22 (@caiosweet, MyHOME#466 comment 5834435606)" + roundtrip: true + builder: + class: OWNGatewayCommand + method: report_datetime + args: ["2026-09-25T16:37:03+01:00"] + +- id: gateway.write.internal.datetime.2026 + frame: "*#13**#22*17*33*54*002*05*11*09*2026##" + direction: dimension_write + who: 13 + what: null + where: "" + dimension: 22 + dimension_values: ["17", "33", "54", "002", "05", "11", "09", "2026"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Gateway Management: Set gateway internal datetime to Friday (05) 2026-09-11 17:33:54+02:00 on Dimension 22 (MyHomeServer1 plant capture, MyHOME#247)" + roundtrip: true + builder: + class: OWNGatewayCommand + method: set_datetime + args: ["2026-09-11T17:33:54+02:00"] - id: gateway.req.device_type frame: "*#13**15##" @@ -35,6 +157,10 @@ mcp_valid: true mcp_notes: "Gateway Management: Request gateway model / device type (physical MH200 check)" roundtrip: true + builder: + class: OWNGatewayCommand + method: request_device_type + args: [] - id: gateway.resp.model.device_type.mh200 frame: "*#13**15*4##" diff --git a/tests/golden/frames/who18_energy.yaml b/tests/golden/frames/who18_energy.yaml index 2431cad2..3fa6bf56 100644 --- a/tests/golden/frames/who18_energy.yaml +++ b/tests/golden/frames/who18_energy.yaml @@ -59,3 +59,146 @@ mcp_valid: true mcp_notes: "Energy Management: Request energy meter 51 diagnostic/unit parameter 113" roundtrip: true + builder: + class: OWNEnergyCommand + method: request_active_power + args: ["51"] + +# Authentic BTicino F454 & F520 Dimension 1200 stream lifecycle and scalar energy +# reports (@xtimmy86x, @caiosweet, @anotherjulien, MyHOME#466 / #661 / #669) +- id: energy.cmd.start_stream.meter56.2 + frame: "*#18*56*#1200#1*2##" + direction: dimension_write + who: 18 + what: null + where: "56" + dimension: 1200 + dimension_values: ["2"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Energy Management: Start 2-minute instantaneous active power auto-update stream on F520 meter 56 (BTicino F454 FW 2.0 capture by @xtimmy86x, MyHOME#661 comment 6067730589)" + roundtrip: true + builder: + class: OWNEnergyCommand + method: start_sending_instant_power + args: ["56", 2] + +- id: energy.resp.auto_update_interval.meter56.2 + frame: "*#18*56*1200#1*2##" + direction: dimension_response + who: 18 + what: null + where: "56" + dimension: 1200 + dimension_values: ["2"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Energy Management: F520 meter 56 confirms 2-minute active power auto-update interval active ~235 ms after *#18*56*#1200#1*2## (@xtimmy86x F454 FW 2.0 capture, MyHOME#661 comment 6067730589)" + roundtrip: true + builder: + class: OWNEnergyCommand + method: report_streaming_state + args: ["56", 2, 1] + +- id: energy.resp.auto_update_interval.meter56.0 + frame: "*#18*56*1200#1*0##" + direction: dimension_response + who: 18 + what: null + where: "56" + dimension: 1200 + dimension_values: ["0"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Energy Management: F520 meter 56 reports instantaneous active power auto-update expired (interval 0) ~126.18 s after activation (@xtimmy86x F454 FW 2.0 capture, MyHOME#661 comment 6067730589)" + roundtrip: true + builder: + class: OWNEnergyCommand + method: report_streaming_state + args: ["56", 0, 1] + +- id: energy.resp.active_power.meter51.572 + frame: "*#18*51*113*572##" + direction: dimension_response + who: 18 + what: null + where: "51" + dimension: 113 + dimension_values: ["572"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Energy Management: F520 meter 51 reports 572 W instantaneous active power on Dimension 113 during active stream (@xtimmy86x F454 FW 2.0 capture, MyHOME#661 comment 6067730589)" + roundtrip: true + builder: + class: OWNEnergyCommand + method: report_active_power + args: ["51", 572] + +- id: energy.resp.actuator_power.channel77.161 + frame: "*#18*77#0*113*161##" + direction: dimension_response + who: 18 + what: null + where: "77" + dimension: 113 + dimension_values: ["161"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Energy Management: Local-bus actuator channel 77#0 reports 161 W active power on Dimension 113 (MyHOME#669)" + roundtrip: true + builder: + class: OWNEnergyCommand + method: report_active_power + args: ["77#0", 161] + +- id: energy.resp.cumulative_totalizer.meter51 + frame: "*#18*51*51*23791364##" + direction: dimension_response + who: 18 + what: null + where: "51" + dimension: 51 + dimension_values: ["23791364"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Energy Management: F520 meter 51 reports 23,791,364 Wh cumulative energy totalizer on Dimension 51 (@caiosweet MH200N FW 1.1.8 capture, MyHOME#466 comment 5834435606)" + roundtrip: true + builder: + class: OWNEnergyCommand + method: report_total_energy + args: ["51", 23791364] + +- id: energy.resp.partial_month.meter52.1 + frame: "*#18*52*53*1##" + direction: dimension_response + who: 18 + what: null + where: "52" + dimension: 53 + dimension_values: ["1"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Energy Management: F520 meter 52 reports 1 Wh current-month partial energy on Dimension 53 (@anotherjulien F454 FW 2.0.5 capture, MyHOME#466 comment 5849027587)" + roundtrip: true + builder: + class: OWNEnergyCommand + method: report_partial_month_energy + args: ["52", 1] + +- id: energy.resp.partial_day.meter51.13165 + frame: "*#18*51*54*13165##" + direction: dimension_response + who: 18 + what: null + where: "51" + dimension: 54 + dimension_values: ["13165"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Energy Management: F520 meter 51 reports 13,165 Wh current-day partial energy on Dimension 54 (@xtimmy86x F454 FW 2.0 capture, MyHOME#661 comment 6067730589)" + roundtrip: true + builder: + class: OWNEnergyCommand + method: report_partial_day_energy + args: ["51", 13165] + diff --git a/tests/test_golden_conformance.py b/tests/test_golden_conformance.py index 02ef1cba..73431321 100644 --- a/tests/test_golden_conformance.py +++ b/tests/test_golden_conformance.py @@ -3,6 +3,7 @@ Tests parser extraction fidelity, roundtrip string serialization, and builder factory parity against OWNd. """ +import datetime import json from pathlib import Path from typing import Any, Dict, List @@ -12,6 +13,8 @@ OWNAutomationCommand, OWNCenCommand, OWNCenPlusCommand, + OWNEnergyCommand, + OWNGatewayCommand, OWNHeatingCommand, OWNLightingCommand, OWNMessage, @@ -160,6 +163,8 @@ def test_golden_frame_builder_parity(fixture: Dict[str, Any]): "OWNAutomationCommand": OWNAutomationCommand, "OWNCenCommand": OWNCenCommand, "OWNCenPlusCommand": OWNCenPlusCommand, + "OWNEnergyCommand": OWNEnergyCommand, + "OWNGatewayCommand": OWNGatewayCommand, "OWNHeatingCommand": OWNHeatingCommand, } cls = class_map.get(builder["class"]) @@ -168,7 +173,16 @@ def test_golden_frame_builder_parity(fixture: Dict[str, Any]): method = getattr(cls, builder["method"], None) assert method is not None, f"Method {builder['method']} not found on {cls.__name__}" - built_message = method(*builder["args"]) + args = list(builder["args"]) + if cls is OWNGatewayCommand and args and isinstance(args[0], str): + if builder["method"] in ("set_datetime", "report_datetime"): + args[0] = datetime.datetime.fromisoformat(args[0]) + elif builder["method"] in ("set_date", "report_date"): + args[0] = datetime.date.fromisoformat(args[0]) + elif builder["method"] in ("set_time", "report_time"): + args[0] = datetime.time.fromisoformat(args[0]) + + built_message = method(*args) assert str(built_message) == fixture["frame"], ( f"Builder {builder['class']}.{builder['method']}({builder['args']}) produced " f"'{str(built_message)}', expected '{fixture['frame']}'" diff --git a/tests/test_message_protocol.py b/tests/test_message_protocol.py index 103f9351..ede36ab6 100644 --- a/tests/test_message_protocol.py +++ b/tests/test_message_protocol.py @@ -511,3 +511,347 @@ def test_flash_accepts_bundled_v2_keyword_alias() -> None: message = OWNLightingCommand.flash("21", _freqency=1.0) assert str(message) == "*1*21*21##" + + +def test_own_message_helper_properties_and_effective_what() -> None: + from OWNd.message import ( + OWNAutomationCommand, + OWNEnergyCommand, + OWNGatewayCommand, + OWNHeatingCommand, + OWNMessage, + ) + import datetime as dt + + status_req = OWNMessage.parse("*#1*99##") + assert status_req is not None + assert status_req.is_status_request is True + assert status_req.is_dimension_request is False + assert status_req.is_dimension_writing is False + assert status_req.is_dimension_reply is False + assert status_req.effective_what is None + assert isinstance(status_req, OWNLightingCommand) + assert status_req.is_status_query is True + assert status_req.is_brightness_query is False + + dim_req = OWNMessage.parse("*#1*99*1##") + assert dim_req is not None + assert dim_req.is_dimension_request is True + assert isinstance(dim_req, OWNLightingCommand) + assert dim_req.is_brightness_query is True + assert dim_req.is_status_query is False + + dim_write = OWNMessage.parse("*#1*99*#1*150*0##") + assert dim_write is not None + assert dim_write.is_dimension_writing is True + + dim_reply = OWNMessage.parse("*#1*99*1*150*0##") + assert dim_reply is not None + assert dim_reply.is_dimension_reply is True + + trans_on = OWNMessage.parse("*1*1000#1*99##") + assert trans_on is not None + assert trans_on.is_translation is True + assert trans_on.what == 1000 + assert trans_on.effective_what == 1 + + direct_on = OWNMessage.parse("*1*1*99##") + assert direct_on is not None + assert direct_on.is_translation is False + assert direct_on.effective_what == 1 + + # Lighting report_brightness + rep_b = OWNLightingCommand.report_brightness("99", 75, 0) + assert str(rep_b) == "*#1*99*1*175*0##" + assert "75%" in rep_b.human_readable_log + + # Automation queries & report_shutter_level + shutter_q = OWNAutomationCommand("*#2*98##") + assert shutter_q.is_status_query is True + assert shutter_q.is_shutter_status_query is False + shutter_dim_q = OWNAutomationCommand("*#2*98*10##") + assert shutter_dim_q.is_shutter_status_query is True + rep_shutter = OWNAutomationCommand.report_shutter_level("98", 60, state=10) + assert str(rep_shutter) == "*#2*98*10*10*60*0*0##" + rep_shutter_bitfield = OWNAutomationCommand.report_shutter_level( + "31", 40, state=10, priority="001" + ) + assert str(rep_shutter_bitfield) == "*#2*31*10*10*40*001*0##" + rep_shutter_unk = OWNAutomationCommand.report_shutter_level("98", 255) + assert str(rep_shutter_unk) == "*#2*98*10*10*255*0*0##" + + # Heating encode_temperature, report_temperature, write_temperature + assert OWNHeatingCommand.encode_temperature(18.5) == "0185" + assert OWNHeatingCommand.encode_temperature(-3.2) == "1032" + assert OWNHeatingCommand.encode_temperature(0.0) == "0000" + assert OWNHeatingCommand.encode_temperature(-0.02) == "0000" + rep_temp = OWNHeatingCommand.report_temperature("000", -3.2) + assert str(rep_temp) == "*#4*000*0*1032##" + wr_temp = OWNHeatingCommand.write_temperature("000", -3.2) + assert str(wr_temp) == "*#4*000*#0*1032##" + assert "-3.2°C" in wr_temp.human_readable_log + ev_temp_000 = OWNHeatingEvent("*#4*000*0*1032##") + assert ev_temp_000.unique_id == "4-#0" + assert ev_temp_000.main_temperature == -3.2 + ev_temp_101 = OWNHeatingEvent("*#4*101*0*1032##") + assert ev_temp_101.unique_id == "4-101" + assert ev_temp_101.probe_temperature == -3.2 + assert ev_temp_101.secondary_temperature == [1, -3.2] + temp_q = OWNHeatingCommand("*#4*000*0##") + assert temp_q.is_temperature_query is True + + # Energy report builders & queries + en_q = OWNEnergyCommand("*#18*51*113##") + assert en_q.is_dimension_query is True + assert en_q.is_auto_update_command is False + assert en_q.stream_duration is None + en_stream = OWNEnergyCommand.start_sending_instant_power("51", duration=65) + assert en_stream.is_auto_update_command is True + assert en_stream.stream_duration == 65 + assert str(OWNEnergyCommand.report_active_power("51", 640)) == "*#18*51*113*640##" + assert str(OWNEnergyCommand.report_total_energy("51", 12345)) == "*#18*51*51*12345##" + assert str(OWNEnergyCommand.report_current_month_energy("51", 500)) == "*#18*51*52*500##" + assert str(OWNEnergyCommand.report_current_month_energy("51", 500, 2026, 10)) == "*#18*51*52#26#10*500##" + assert str(OWNEnergyCommand.report_partial_month_energy("51", 300)) == "*#18*51*53*300##" + assert str(OWNEnergyCommand.report_partial_month_energy("51", 300, 10)) == "*#18*51*53#10*300##" + assert str(OWNEnergyCommand.report_partial_day_energy("51", 120)) == "*#18*51*54*120##" + assert str(OWNEnergyCommand.report_partial_day_energy("51", 120, 18)) == "*#18*51*54#18*120##" + assert str(OWNEnergyCommand.report_streaming_state("51", 65, 1)) == "*#18*51*1200#1*65##" + + # Gateway clock builders & queries + clk_q = OWNGatewayCommand("*#13**22##") + assert clk_q.is_clock_query is True + sample_dt = dt.datetime(2026, 10, 18, 14, 30, 0, tzinfo=dt.timezone.utc) + assert str(OWNGatewayCommand.set_datetime(sample_dt, "Europe/Brussels")) == "*#13**#22*16*30*00*002*00*18*10*2026##" + assert str(OWNGatewayCommand.report_datetime(sample_dt, "Europe/Brussels")) == "*#13**22*16*30*00*002*00*18*10*2026##" + assert str(OWNGatewayCommand.set_date(sample_dt, "Europe/Brussels")) == "*#13**#1*00*18*10*2026##" + assert str(OWNGatewayCommand.report_date(sample_dt, "Europe/Brussels")) == "*#13**1*00*18*10*2026##" + assert str(OWNGatewayCommand.set_time(sample_dt, "Europe/Brussels")) == "*#13**#0*16*30*00*002##" + assert str(OWNGatewayCommand.report_time(sample_dt, "Europe/Brussels")) == "*#13**0*16*30*00*002##" + # Naive time with explicit time_zone (string or tzinfo) and aware time with/without time_zone + from zoneinfo import ZoneInfo + + naive_t = dt.time(12, 0, 0) + aware_t = dt.time(12, 0, 0, tzinfo=dt.timezone.utc) + zone_t = dt.time(12, 0, 0, tzinfo=ZoneInfo("Europe/Brussels")) + assert str(OWNGatewayCommand.set_time(aware_t)) == "*#13**#0*12*00*00*000##" + assert ( + str(OWNGatewayCommand.set_time(aware_t, dt.timezone(dt.timedelta(hours=2)))) + == "*#13**#0*14*00*00*002##" + ) + assert str(OWNGatewayCommand.set_time(zone_t)).startswith("*#13**#0*12*00*00*00") + assert str(OWNGatewayCommand.set_time(naive_t, dt.timezone.utc)) == "*#13**#0*12*00*00*000##" + assert str(OWNGatewayCommand.report_time(naive_t, "America/New_York")).startswith("*#13**0*12*00*00*10") + + +def test_expose_builders_infeasible_inputs() -> None: + import datetime as dt + + from OWNd.message import ( + OWNAutomationCommand, + OWNEnergyCommand, + OWNGatewayCommand, + OWNHeatingCommand, + OWNLightingCommand, + ) + + # 1. WHO 1 report_brightness + for bad_where in ("", " ", "11*1"): + with pytest.raises(ValueError, match="non-empty address"): + OWNLightingCommand.report_brightness(bad_where, 50) + for bad_int in (True, False, 50.9, 0.5, float("inf"), float("-inf"), float("nan")): + with pytest.raises(ValueError, match="must be integers"): + OWNLightingCommand.report_brightness("11", bad_int) # type: ignore[arg-type] + with pytest.raises(ValueError, match="must be integers"): + OWNLightingCommand.report_brightness("11", 50, bad_int) # type: ignore[arg-type] + for bad_lvl in (-1, 101): + with pytest.raises(ValueError, match="brightness level must be between 0 and 100"): + OWNLightingCommand.report_brightness("11", bad_lvl) + for bad_trans in (-1, 256): + with pytest.raises(ValueError, match="transition must be between 0 and 255"): + OWNLightingCommand.report_brightness("11", 50, bad_trans) + + # 2. WHO 2 set_shutter_level & report_shutter_level + for bad_set_lvl in (True, 50.9, float("inf"), -1, 101): + with pytest.raises(ValueError, match="shutter level must be between 0 and 100"): + OWNAutomationCommand.set_shutter_level("21", bad_set_lvl) # type: ignore[arg-type] + for bad_where in ("", "21*1"): + with pytest.raises(ValueError, match="non-empty address"): + OWNAutomationCommand.report_shutter_level(bad_where, 50) + for bool_args in ( + (True, 10, 0, 0), + (50, True, 0, 0), + (50, 10, True, 0), + (50, 10, 0, True), + (50.9, 10, 0, 0), + (50, 10.5, 0, 0), + (50, 10, 0.5, 0), + (50, 10, 0, 1.5), + (float("inf"), 10, 0, 0), + (50, float("inf"), 0, 0), + (50, 10, float("inf"), 0), + (50, 10, 0, float("inf")), + ): + with pytest.raises(ValueError, match="must be integers"): + OWNAutomationCommand.report_shutter_level("21", *bool_args) # type: ignore[arg-type] + for bad_lvl in (-1, 101, 254, 256): + with pytest.raises(ValueError, match="shutter level must be between 0 and 100 or 255"): + OWNAutomationCommand.report_shutter_level("21", bad_lvl) + for bad_state in (9, 16): + with pytest.raises(ValueError, match="shutter state must be between 10 and 15"): + OWNAutomationCommand.report_shutter_level("21", 50, state=bad_state) + for bad_pr, bad_inf in ((-1, 0), (256, 0), (0, -1), (0, 256)): + with pytest.raises(ValueError, match="priority and info must be between 0 and 255"): + OWNAutomationCommand.report_shutter_level( + "21", 50, state=10, priority=bad_pr, info=bad_inf + ) + assert ( + str(OWNAutomationCommand.report_shutter_level("31", 40, state=10, priority="00001")) + == "*#2*31*10*10*40*1*0##" + ) + + # 3. WHO 4 encode_temperature, report_temperature, write_temperature + assert OWNHeatingCommand.encode_temperature(20.25) == "0203" + assert OWNHeatingCommand.encode_temperature(20.35) == "0204" + assert OWNHeatingCommand.encode_temperature(-20.25) == "1203" + for non_finite in (True, False, float("nan"), float("inf"), float("-inf")): + with pytest.raises(ValueError, match="temperature must be finite"): + OWNHeatingCommand.encode_temperature(non_finite) + for out_of_range in (-100.0, -150.0, 100.0, 150.0): + with pytest.raises(ValueError, match="temperature must be between -99.9 and 99.9"): + OWNHeatingCommand.encode_temperature(out_of_range) + for bad_where in ("", "000*1"): + with pytest.raises(ValueError, match="non-empty address"): + OWNHeatingCommand.report_temperature(bad_where, 20.0) + with pytest.raises(ValueError, match="non-empty address"): + OWNHeatingCommand.write_temperature(bad_where, 20.0) + + # 4. WHO 18 energy report builders + for bad_val in (True, False, -1, -0.1, float("nan"), float("inf"), float("-inf")): + with pytest.raises(ValueError, match="must be a finite non-negative number"): + OWNEnergyCommand.report_active_power("51", bad_val) + with pytest.raises(ValueError, match="must be a finite non-negative number"): + OWNEnergyCommand.report_total_energy("51", bad_val) + with pytest.raises(ValueError, match="must be a finite non-negative number"): + OWNEnergyCommand.report_current_month_energy("51", bad_val) + with pytest.raises(ValueError, match="must be a finite non-negative number"): + OWNEnergyCommand.report_partial_month_energy("51", bad_val) + with pytest.raises(ValueError, match="must be a finite non-negative number"): + OWNEnergyCommand.report_partial_day_energy("51", bad_val) + + with pytest.raises(ValueError, match="both be provided or both omitted"): + OWNEnergyCommand.report_current_month_energy("51", 100, year=2026, month=None) + with pytest.raises(ValueError, match="both be provided or both omitted"): + OWNEnergyCommand.report_current_month_energy("51", 100, year=None, month=10) + for bad_ym in ( + (True, 10), + (2026, False), + (2026.5, 10), + (2026, 10.5), + (float("inf"), 10), + (2026, float("inf")), + ): + with pytest.raises(ValueError, match="year and month must be integers"): + OWNEnergyCommand.report_current_month_energy("51", 100, year=bad_ym[0], month=bad_ym[1]) # type: ignore[arg-type] + for bad_year in (1999, 2100): + with pytest.raises(ValueError, match="year must be between 2000 and 2099"): + OWNEnergyCommand.report_current_month_energy("51", 100, year=bad_year, month=10) + for bad_month in (0, 13): + with pytest.raises(ValueError, match="month must be between 1 and 12"): + OWNEnergyCommand.report_current_month_energy("51", 100, year=2026, month=bad_month) + with pytest.raises(ValueError, match="month must be between 1 and 12"): + OWNEnergyCommand.report_partial_month_energy("51", 100, month=bad_month) + for bad_pm in (True, 10.5, float("inf")): + with pytest.raises(ValueError, match="month must be between 1 and 12"): + OWNEnergyCommand.report_partial_month_energy("51", 100, month=bad_pm) # type: ignore[arg-type] + for bad_day in (0, 32, True, 18.5, float("inf")): + with pytest.raises(ValueError, match="day must be between 1 and 31"): + OWNEnergyCommand.report_partial_day_energy("51", 100, day=bad_day) # type: ignore[arg-type] + + for bad_de in ( + (True, 1), + (60, False), + (60.5, 1), + (60, 1.5), + (float("inf"), 1), + (60, float("inf")), + ): + with pytest.raises(ValueError, match="duration and energy_type must be integers"): + OWNEnergyCommand.report_streaming_state("51", bad_de[0], bad_de[1]) # type: ignore[arg-type] + for bad_et in (0, 5): + with pytest.raises(ValueError, match="energy_type must be between 1 and 4"): + OWNEnergyCommand.report_streaming_state("51", 60, energy_type=bad_et) + for bad_dur in (-1, 256): + with pytest.raises(ValueError, match="duration must be between 0 and 255"): + OWNEnergyCommand.report_streaming_state("51", bad_dur, energy_type=1) + + # 5. WHO 13 gateway clock builders + for bad_dt in ("2026-01-01", dt.date(2026, 1, 1), dt.time(12, 0)): + with pytest.raises(ValueError, match="dt must be a datetime.datetime instance"): + OWNGatewayCommand.set_datetime(bad_dt) # type: ignore[arg-type] + with pytest.raises(ValueError, match="dt must be a datetime.datetime instance"): + OWNGatewayCommand.report_datetime(bad_dt) # type: ignore[arg-type] + naive_dt = dt.datetime(2026, 10, 18, 12, 0, 0) + with pytest.raises(ValueError, match="Naive datetime/time requires an explicit time_zone"): + OWNGatewayCommand.set_datetime(naive_dt) + with pytest.raises(ValueError, match="Naive datetime/time requires an explicit time_zone"): + OWNGatewayCommand.report_datetime(naive_dt) + with pytest.raises(ValueError, match="Naive datetime/time requires an explicit time_zone"): + OWNGatewayCommand.set_date(naive_dt) + with pytest.raises(ValueError, match="Naive datetime/time requires an explicit time_zone"): + OWNGatewayCommand.report_date(naive_dt) + with pytest.raises(ValueError, match="Naive datetime/time requires an explicit time_zone"): + OWNGatewayCommand.set_time(naive_dt) + with pytest.raises(ValueError, match="Naive datetime/time requires an explicit time_zone"): + OWNGatewayCommand.report_time(naive_dt) + with pytest.raises(ValueError, match="Naive datetime/time requires an explicit time_zone"): + OWNGatewayCommand.set_time(dt.time(12, 0, 0)) + with pytest.raises(ValueError, match="Naive datetime/time requires an explicit time_zone"): + OWNGatewayCommand.report_time(dt.time(12, 0, 0)) + with pytest.raises(ValueError, match="time_zone must be a datetime.tzinfo"): + OWNGatewayCommand.set_datetime(naive_dt, 123) # type: ignore[arg-type] + with pytest.raises(ValueError, match="time_zone must be a datetime.tzinfo"): + OWNGatewayCommand.set_date(dt.date(2026, 10, 18), 123) # type: ignore[arg-type] + assert ( + str(OWNGatewayCommand.set_datetime(naive_dt, dt.timezone.utc)) + == "*#13**#22*12*00*00*000*00*18*10*2026##" + ) + assert ( + str(OWNGatewayCommand.set_date(naive_dt, dt.timezone.utc)) + == "*#13**#1*00*18*10*2026##" + ) + assert ( + str(OWNGatewayCommand.set_date(dt.date(2026, 10, 18), dt.timezone.utc)) + == "*#13**#1*00*18*10*2026##" + ) + assert ( + str(OWNGatewayCommand.set_time(naive_dt, dt.timezone.utc)) + == "*#13**#0*12*00*00*000##" + ) + for bad_year_dt in ( + dt.datetime(1999, 12, 31, 12, 0, tzinfo=dt.timezone.utc), + dt.datetime(2100, 1, 1, 12, 0, tzinfo=dt.timezone.utc), + ): + with pytest.raises(ValueError, match="year must be between 2000 and 2099"): + OWNGatewayCommand.set_datetime(bad_year_dt) + with pytest.raises(ValueError, match="year must be between 2000 and 2099"): + OWNGatewayCommand.report_datetime(bad_year_dt) + + for bad_d in ("2026-01-01", dt.time(12, 0), 12345): + with pytest.raises(ValueError, match="d must be a datetime.date"): + OWNGatewayCommand.set_date(bad_d) # type: ignore[arg-type] + with pytest.raises(ValueError, match="d must be a datetime.date"): + OWNGatewayCommand.report_date(bad_d) # type: ignore[arg-type] + for bad_year_d in (dt.date(1999, 12, 31), dt.date(2100, 1, 1)): + with pytest.raises(ValueError, match="year must be between 2000 and 2099"): + OWNGatewayCommand.set_date(bad_year_d) + with pytest.raises(ValueError, match="year must be between 2000 and 2099"): + OWNGatewayCommand.report_date(bad_year_d) + + for bad_t in ("12:00:00", dt.date(2026, 1, 1), 12345): + with pytest.raises(ValueError, match="t must be a datetime.time"): + OWNGatewayCommand.set_time(bad_t) # type: ignore[arg-type] + with pytest.raises(ValueError, match="t must be a datetime.time"): + OWNGatewayCommand.report_time(bad_t) # type: ignore[arg-type] + + diff --git a/tests/test_profiles.py b/tests/test_profiles.py index c2895ed1..56bb68a1 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -618,3 +618,41 @@ def test_h4684_profile(name: str) -> None: assert not profile.supports_who(WHO_SCENARIO) assert not profile.supports_who(WHO_SOUND_DIFFUSION) assert not profile.supports_who(WHO_CEN_PLUS) + + +def test_profile_injection_capability_flags() -> None: + """Verify dimension/command injection capability flags across gateway profiles.""" + for model in ( + "F452", + "F452V", + "F453AV", + "F454", + "F455", + "F459", + "F460", + "F461", + "MH200", + "MH201", + "MH202", + "H4890", + "H4684", + "MyHomeServer1", + ): + p = get_gateway_profile(model) + assert p.supports_who4_command_injection is True + assert p.supports_dimension_response_injection is False + + for model in ("MH200N", "F453"): + p = get_gateway_profile(model) + assert p.supports_who4_command_injection is False + assert p.supports_dimension_response_injection is False + + for model in ("F454", "F455", "F461", "MH202", "MyHomeServer1"): + p = get_gateway_profile(model) + assert p.supports_who18_response_injection is True + + for model in ("MH200", "MH200N"): + p = get_gateway_profile(model) + assert p.supports_who18_response_injection is False + + From e374f3c46823fda40b8bb7c72390c0649bc80c2c Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:50:28 +0200 Subject: [PATCH 2/2] fix(profiles,gateway): mark unverified injection flags None and reject sub-hour TZ offsets Review follow-up (#129, pullrequestreview-5586521653): - supports_dimension_response_injection, supports_who4_command_injection and supports_who18_response_injection are now bool | None, None meaning no capture or published oracle verdict exists. Only F461Profile sets them, from the live 2026-10-18 capture and the MyHOME#750 plant check: reply forms not injected (WHO 1/2 NACK, WHO 4/13 answered as reads), *#4*W*#0*TTTT## ACKed, WHO 18 reply forms ACKed. - Drop the F453/MH200N WHO 4 and MH200/MH200N WHO 18 overrides: no published oracle verdict backs them. - _encode_tz_offset raises ValueError for offsets that are not whole hours (Asia/Kolkata, Asia/Kathmandu, America/St_Johns) instead of truncating. Co-Authored-By: Claude Opus 5.5 --- OWNd/message/gateway.py | 5 +++++ OWNd/profiles.py | 31 ++++++++++++++++++++++++------- tests/test_message_protocol.py | 8 ++++++++ tests/test_profiles.py | 29 ++++++++++++----------------- 4 files changed, 49 insertions(+), 24 deletions(-) diff --git a/OWNd/message/gateway.py b/OWNd/message/gateway.py index e41b99ad..64f3a247 100644 --- a/OWNd/message/gateway.py +++ b/OWNd/message/gateway.py @@ -490,6 +490,11 @@ def _normalize_time( @staticmethod def _encode_tz_offset(dt: _dt.datetime) -> str: z = dt.strftime("%z") + # The TZ field (0HH / 1HH) carries whole hours only. + if z[3:] != "00": + raise ValueError( + f"UTC offset {z[:3]}:{z[3:5]} is not a whole number of hours" + ) return f"0{z[1:3]}" if z[0] == "+" else f"1{z[1:3]}" @classmethod diff --git a/OWNd/profiles.py b/OWNd/profiles.py index f82dc6c6..c255a47e 100644 --- a/OWNd/profiles.py +++ b/OWNd/profiles.py @@ -86,9 +86,16 @@ class GatewayProfile: audio_measured: bool = True supports_native_transitions: bool = False supports_extended_frames: bool = False - supports_dimension_response_injection: bool = False - supports_who4_command_injection: bool = True - supports_who18_response_injection: bool = True + # How the command session treats frames that inject state for a virtual + # device. None means unverified: no capture or published oracle verdict + # exists for this family. Only F461Profile sets them, from live captures. + # Reply-form frames *#1*W*1*..##, *#2*W*10*..##, *#4*W*0*T##, *#13**D*..##. + supports_dimension_response_injection: bool | None = None + # WHO 4 Dimension #0 temperature writes (*#4*W*#0*TTTT##). + supports_who4_command_injection: bool | None = None + # WHO 18 reply-form frames (*#18*W*DIM*VAL##). True means acknowledged on + # the command session, not that the value is shown to reach the bus. + supports_who18_response_injection: bool | None = None supported_who: tuple[int, ...] = DEFAULT_SUPPORTED_WHO extra_features: tuple[str, ...] = () firmware_version: str | None = field(default=None, kw_only=True) @@ -257,7 +264,6 @@ def __init__(self) -> None: super().__init__( model_name="F453", command_queue_delay=0.15, - supports_who4_command_injection=False, ) @@ -381,6 +387,17 @@ class F461Profile(GatewayProfile): (`020011`) does not package the ``bt_energia`` translator and strictly NACKs all WHO 3 requests without bus activity (`results/F461/020011/oracle/full/energy-ts10.tsv`). WHO 3 is therefore excluded from ``supported_who``. + + Injection flags, from the live F461 2.0.11 capture of 2026-10-18 + (MyHOME ``tests/fixtures/traces/f461_live_2026-10-18``) and the plant + check in MyHOME#750 (comment 6277050571): + - Reply-form frames are not injected. ``*#1*08*1*150*0##`` and + ``*#2*25*10*10*50*0*0##`` are NACKed; ``*#4*2*0*0999##`` and + ``*#13**0*22*51*00*999##`` are answered as reads with the real zone 2 + temperature and the gateway's own time. + - ``*#4*50*#0*0215##`` is ACKed (0.155 s). + - ``*#18*51*113*450##``, ``*#18*51*51*12345##`` and ``*#18*51*54*320##`` + are ACKed (1.27-1.40 s) with no event after the ACK. """ def __init__(self) -> None: @@ -394,6 +411,9 @@ def __init__(self) -> None: supports_hmac=True, supports_native_transitions=True, supports_extended_frames=True, + supports_dimension_response_injection=False, + supports_who4_command_injection=True, + supports_who18_response_injection=True, supported_who=tuple( w for w in DEFAULT_SUPPORTED_WHO if w != WHO_LOAD_CONTROL ), @@ -435,7 +455,6 @@ def __init__(self) -> None: max_queue_size=100, event_keepalive_interval=90, supports_energy_instant_power=False, - supports_who18_response_injection=False, supported_who=( WHO_LIGHTING, WHO_AUTOMATION, @@ -477,8 +496,6 @@ def __init__(self) -> None: event_keepalive_interval=90, supports_energy_instant_power=False, supports_audio=True, - supports_who4_command_injection=False, - supports_who18_response_injection=False, supported_who=( WHO_LIGHTING, WHO_AUTOMATION, diff --git a/tests/test_message_protocol.py b/tests/test_message_protocol.py index ede36ab6..2bac3575 100644 --- a/tests/test_message_protocol.py +++ b/tests/test_message_protocol.py @@ -642,6 +642,14 @@ def test_own_message_helper_properties_and_effective_what() -> None: assert str(OWNGatewayCommand.set_time(zone_t)).startswith("*#13**#0*12*00*00*00") assert str(OWNGatewayCommand.set_time(naive_t, dt.timezone.utc)) == "*#13**#0*12*00*00*000##" assert str(OWNGatewayCommand.report_time(naive_t, "America/New_York")).startswith("*#13**0*12*00*00*10") + # The TZ field carries whole hours only: half- and quarter-hour offsets raise + for tz_name in ("Asia/Kolkata", "Asia/Kathmandu", "America/St_Johns"): + with pytest.raises(ValueError, match="whole number of hours"): + OWNGatewayCommand.set_datetime(sample_dt, tz_name) + with pytest.raises(ValueError, match="whole number of hours"): + OWNGatewayCommand.report_time(naive_t, tz_name) + with pytest.raises(ValueError, match="whole number of hours"): + OWNGatewayCommand.set_time_to_now(tz_name) def test_expose_builders_infeasible_inputs() -> None: diff --git a/tests/test_profiles.py b/tests/test_profiles.py index 56bb68a1..83ca9fc2 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -621,38 +621,33 @@ def test_h4684_profile(name: str) -> None: def test_profile_injection_capability_flags() -> None: - """Verify dimension/command injection capability flags across gateway profiles.""" + """Only the F461 has injection evidence; every other family is unverified.""" + f461 = get_gateway_profile("F461") + assert f461.supports_dimension_response_injection is False + assert f461.supports_who4_command_injection is True + assert f461.supports_who18_response_injection is True + for model in ( "F452", "F452V", + "F453", "F453AV", "F454", "F455", "F459", "F460", - "F461", "MH200", + "MH200N", "MH201", "MH202", "H4890", "H4684", "MyHomeServer1", + "unknown-model", ): p = get_gateway_profile(model) - assert p.supports_who4_command_injection is True - assert p.supports_dimension_response_injection is False - - for model in ("MH200N", "F453"): - p = get_gateway_profile(model) - assert p.supports_who4_command_injection is False - assert p.supports_dimension_response_injection is False - - for model in ("F454", "F455", "F461", "MH202", "MyHomeServer1"): - p = get_gateway_profile(model) - assert p.supports_who18_response_injection is True - - for model in ("MH200", "MH200N"): - p = get_gateway_profile(model) - assert p.supports_who18_response_injection is False + assert p.supports_dimension_response_injection is None + assert p.supports_who4_command_injection is None + assert p.supports_who18_response_injection is None