From cfdedc43177be845f6d98f71ef7a1f13718f6bb8 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:14:49 +0200 Subject: [PATCH 1/3] feat(heating): parse WHO 4 dimension 7 zone state, log dimension 5 and the #7 write echo On MyHomeServer1 + Home+Control plants the zone's operating state and setpoint only travel as *#4*Z*7**[*TTTT]## (MyHOME#429, two plants). Replies to *#4*Z## never include it. OWNd parsed these with no message type, so consumers dropped them. - Dimension 7 status: MESSAGE_TYPE_ZONE_STATE with zone_context (generic/heating/cooling/automatic), zone_state (setpoint/protection/ comfort/eco/off) and set_temperature for the setpoint state, following the MyHOME_Suite ScenarioDevices templates (Encyclopedia who-4-temperature-control/dimensions.md). Unknown codes keep no message type and log the raw values. - The *#4*Z*#7*...## write echo (MyHomeServer1 runs its schedule this way) gets a readable log and no message type: the status that follows is authoritative. - Dimension 5 (local control) status and write get a log only; the meaning of the value is not published. Closes #58. Co-Authored-By: Claude Opus 5.5 --- OWNd/message.py | 106 +++++++++++++++++++++++++++++++++++++++++ tests/test_heating.py | 108 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 214 insertions(+) diff --git a/OWNd/message.py b/OWNd/message.py index 69a4fe4..fb507fc 100755 --- a/OWNd/message.py +++ b/OWNd/message.py @@ -31,6 +31,7 @@ MESSAGE_TYPE_ILLUMINANCE = "illuminance_value" MESSAGE_TYPE_MOTION_TIMEOUT = "motion_timeout" MESSAGE_TYPE_FAN_SPEED = "fan_speed" +MESSAGE_TYPE_ZONE_STATE = "zone_state" CLIMATE_MODE_OFF = "off" CLIMATE_MODE_HEAT = "heat" @@ -44,9 +45,66 @@ LOCAL_CONTROL_OVERRIDE = "local_override" LOCAL_CONTROL_UNKNOWN = "unknown" +# WHO 4 DIMENSION 7, *#4*ZONE*7*CONTEXT*STATE[*TTTT]##: not in the public WHO 4 +# document; the values follow the MyHOME_Suite ScenarioDevices templates. +ZONE_CONTEXT_GENERIC = "generic" +ZONE_CONTEXT_HEATING = "heating" +ZONE_CONTEXT_COOLING = "cooling" +ZONE_CONTEXT_AUTOMATIC = "automatic" + +ZONE_STATE_SETPOINT = "setpoint" +ZONE_STATE_PROTECTION = "protection" +ZONE_STATE_COMFORT = "comfort" +ZONE_STATE_ECO = "eco" +ZONE_STATE_OFF = "off" + +_ZONE_CONTEXTS = { + "0": ZONE_CONTEXT_GENERIC, + "1": ZONE_CONTEXT_HEATING, + "2": ZONE_CONTEXT_COOLING, + "3": ZONE_CONTEXT_AUTOMATIC, +} +_ZONE_STATES = { + "1": ZONE_STATE_SETPOINT, + "2": ZONE_STATE_PROTECTION, + "3": ZONE_STATE_COMFORT, + "4": ZONE_STATE_ECO, + "5": ZONE_STATE_OFF, +} + PIR_SENSITIVITY_MAPPING = ["low", "medium", "high", "very high"] +def _zone_state( + values: list[str], +) -> tuple[str | None, str | None, float | None]: + """Decode DIMENSION 7 values into (context, state, setpoint temperature). + + Unknown context or state codes decode to None; the temperature is only + read for the setpoint state. + """ + context = _ZONE_CONTEXTS.get(values[0]) if values else None + state = _ZONE_STATES.get(values[1]) if len(values) > 1 else None + temperature = None + if ( + state == ZONE_STATE_SETPOINT + and len(values) > 2 + and re.fullmatch(r"\d{4}", values[2]) + ): + temperature = float(f"{values[2][1:3]}.{values[2][-1]}") + return context, state, temperature + + +def _zone_state_text(values: list[str]) -> str | None: + """'heating setpoint at 17.0°C', 'cooling protection', or None if unknown.""" + context, state, temperature = _zone_state(values) + if context is None or state is None: + return None + if temperature is not None: + return f"{context} {state} at {temperature}°C" + return f"{context} {state}" + + def _validate_gateway_clock_values( dimension: int | None, values: list[str] ) -> None: @@ -819,6 +877,8 @@ def __init__(self, data: str) -> None: self._mode = None self._mode_name = None + self._zone_context: str | None = None + self._zone_state: str | None = None self._set_temperature = None self._local_offset = None self._local_offset_raw = None @@ -919,6 +979,25 @@ def __init__(self, data: str) -> None: ) self._human_readable_log = f"Zone {self._zone}'s secondary sensor {self._sensor} is reporting a temperature of {self._secondary_temperature}°C." # pylint: disable=line-too-long + elif self._dimension == 5 and self._dimension_value: # Local control + # MyHOME_Suite writes *#4*Z*#5*val##; the meaning of val is not + # published, so only log it. + self._human_readable_log = f"Zone {self._zone}'s local control (dimension 5) is {self._dimension_value[0]}." # pylint: disable=line-too-long + + elif self._dimension == 7 and self._dimension_value: # Zone state + # MyHomeServer1 / Home+Control plants carry the zone's operating + # state and setpoint here, and never in the reply to *#4*Z##. + self._zone_context, self._zone_state, temperature = _zone_state( + self._dimension_value + ) + text = _zone_state_text(self._dimension_value) + if text is None: + self._human_readable_log = f"Zone {self._zone} reports an unknown zone state {'*'.join(self._dimension_value)}." # pylint: disable=line-too-long + else: + self._type = MESSAGE_TYPE_ZONE_STATE + self._set_temperature = temperature + self._human_readable_log = f"Zone {self._zone} is in {text}." + elif self._dimension == 11: # Fan speed self._type = MESSAGE_TYPE_FAN_SPEED _fan_mode = int(self._dimension_value[0]) @@ -1138,6 +1217,19 @@ def zone(self) -> int: def mode(self) -> str | None: return self._mode_name + @property + def zone_context(self) -> str | None: + """DIMENSION 7 thermal context (ZONE_CONTEXT_*), else None.""" + return self._zone_context + + @property + def zone_state(self) -> str | None: + """DIMENSION 7 operating state (ZONE_STATE_*), else None. + + For ZONE_STATE_SETPOINT the temperature is in set_temperature. + """ + return self._zone_state + def is_active(self) -> bool | None: return self._is_active @@ -2234,6 +2326,20 @@ def set_shutter_level(cls, where: str | int, level: int = 30) -> OWNAutomationCo class OWNHeatingCommand(OWNCommand): + def __init__(self, data: str) -> None: + super().__init__(data) + # A dimension write seen on the event session: MyHomeServer1 runs its + # schedule by writing DIMENSION 7 and re-asserts DIMENSION 5. The + # dimension 7 status that follows is the authoritative state. + if self._message_type == "DIMENSION_WRITING" and self._dimension_value: + if self._dimension == 7: + text = _zone_state_text(self._dimension_value) + if text is None: + text = f"unknown zone state {'*'.join(self._dimension_value)}" + self._human_readable_log = f"Setting zone {self._where} to {text}." + elif self._dimension == 5: + self._human_readable_log = f"Setting zone {self._where}'s local control (dimension 5) to {self._dimension_value[0]}." # pylint: disable=line-too-long + @classmethod def status(cls, where: str | int) -> OWNHeatingCommand: message = cls(f"*#4*{where}##") diff --git a/tests/test_heating.py b/tests/test_heating.py index 8f3af37..e650f40 100644 --- a/tests/test_heating.py +++ b/tests/test_heating.py @@ -1,5 +1,7 @@ """Regression tests for WHO 4 heating messages.""" +import pytest + from OWNd.message import ( LOCAL_CONTROL_NORMAL, LOCAL_CONTROL_OFF, @@ -11,8 +13,19 @@ MESSAGE_TYPE_LOCAL_TARGET_TEMPERATURE, MESSAGE_TYPE_TARGET_TEMPERATURE, MESSAGE_TYPE_SECONDARY_TEMPERATURE, + MESSAGE_TYPE_ZONE_STATE, OWNHeatingCommand, OWNHeatingEvent, + OWNMessage, + ZONE_CONTEXT_AUTOMATIC, + ZONE_CONTEXT_COOLING, + ZONE_CONTEXT_GENERIC, + ZONE_CONTEXT_HEATING, + ZONE_STATE_COMFORT, + ZONE_STATE_ECO, + ZONE_STATE_OFF, + ZONE_STATE_PROTECTION, + ZONE_STATE_SETPOINT, ) @@ -133,3 +146,98 @@ def test_where_zero_with_parameter_stays_zone_zero() -> None: event = OWNHeatingEvent(frame) assert event.zone == 0, frame assert event.unique_id == "4-#0", frame + + +# DIMENSION 7 zone state (OWNd#58). Frames from MyHOME#429 captures: +# xtimmy86x (MyHomeServer1, 7 zones, heating) and TheDarkWizard +# (MyHomeServer1 + Home+Control, 4 zones, heating/cooling). +@pytest.mark.parametrize( + ("frame", "zone", "context", "state", "temperature", "log"), + [ + ("*#4*2*7*1*1*0170##", 2, ZONE_CONTEXT_HEATING, ZONE_STATE_SETPOINT, 17.0, + "Zone 2 is in heating setpoint at 17.0°C."), + ("*#4*1*7*2*1*0225##", 1, ZONE_CONTEXT_COOLING, ZONE_STATE_SETPOINT, 22.5, + "Zone 1 is in cooling setpoint at 22.5°C."), + ("*#4*4*7*1*2##", 4, ZONE_CONTEXT_HEATING, ZONE_STATE_PROTECTION, None, + "Zone 4 is in heating protection."), + ], +) +def test_dimension_7_zone_state_captures( + frame: str, zone: int, context: str, state: str, temperature: float | None, log: str +) -> None: + event = OWNMessage.parse(frame) + + assert isinstance(event, OWNHeatingEvent) + assert event.message_type == MESSAGE_TYPE_ZONE_STATE + assert event.zone == zone + assert event.zone_context == context + assert event.zone_state == state + assert event.set_temperature == temperature + assert event.human_readable_log == log + + +@pytest.mark.parametrize( + ("raw", "context"), + [("0", ZONE_CONTEXT_GENERIC), ("1", ZONE_CONTEXT_HEATING), + ("2", ZONE_CONTEXT_COOLING), ("3", ZONE_CONTEXT_AUTOMATIC)], +) +@pytest.mark.parametrize( + ("raw_state", "state"), + [("2", ZONE_STATE_PROTECTION), ("3", ZONE_STATE_COMFORT), + ("4", ZONE_STATE_ECO), ("5", ZONE_STATE_OFF)], +) +def test_dimension_7_follows_the_scenario_devices_table( + raw: str, context: str, raw_state: str, state: str +) -> None: + # Encyclopedia who-4-temperature-control/dimensions.md, MyHOME_Suite + # ScenarioDevices DIMENSION 7 templates. + event = OWNHeatingEvent(f"*#4*3*7*{raw}*{raw_state}##") + + assert event.message_type == MESSAGE_TYPE_ZONE_STATE + assert (event.zone_context, event.zone_state) == (context, state) + assert event.set_temperature is None + + +@pytest.mark.parametrize("frame", ["*#4*3*7*9*1*0200##", "*#4*3*7*1*9##", "*#4*3*7*1##"]) +def test_dimension_7_unknown_values_have_no_message_type(frame: str) -> None: + event = OWNHeatingEvent(frame) + + assert event.message_type is None + assert event.set_temperature is None + assert "unknown zone state" in event.human_readable_log + + +def test_dimension_7_setpoint_without_a_valid_temperature() -> None: + event = OWNHeatingEvent("*#4*3*7*1*1*20##") + + assert event.message_type == MESSAGE_TYPE_ZONE_STATE + assert event.zone_state == ZONE_STATE_SETPOINT + assert event.set_temperature is None + assert event.human_readable_log == "Zone 3 is in heating setpoint." + + +def test_dimension_7_write_echo_has_a_log() -> None: + # MyHomeServer1 runs its schedule by writing dimension 7 (xtimmy86x trace). + command = OWNMessage.parse("*#4*2*#7*1*1*0200##") + + assert isinstance(command, OWNHeatingCommand) + assert command.human_readable_log == "Setting zone 2 to heating setpoint at 20.0°C." + # MyHOME_Suite templates end non-setpoint writes with an empty value. + assert OWNHeatingCommand("*#4*2*#7*2*3*##").human_readable_log == "Setting zone 2 to cooling comfort." + assert OWNHeatingCommand("*#4*2*#7*9*9##").human_readable_log == "Setting zone 2 to unknown zone state 9*9." + + +def test_dimension_5_status_and_write_have_a_log() -> None: + # Re-asserted on every zone every ~794 s by MyHomeServer1 (MyHOME#429). + event = OWNMessage.parse("*#4*2*5*0##") + command = OWNMessage.parse("*#4*2*#5*0##") + + assert isinstance(event, OWNHeatingEvent) + assert event.message_type is None + assert event.human_readable_log == "Zone 2's local control (dimension 5) is 0." + assert isinstance(command, OWNHeatingCommand) + assert command.human_readable_log == "Setting zone 2's local control (dimension 5) to 0." + + +def test_heating_builders_keep_their_own_log() -> None: + assert OWNHeatingCommand.status("2").human_readable_log == "Requesting climate status update for 2." From 33fdaa173f67fedb3fce96a42ab0c48358e393f4 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:21:29 +0200 Subject: [PATCH 2/3] test(golden): WHO 4 dimension 7 and 5 captures from the MyHOME#429 plants Verbatim frames from the traces the two #429 reporters contributed: - @TheDarkWizard, MyHomeServer1 2.87.13 + Home+Control, 4 KM4691 zones: cooling setpoint 26.0, the 35.0 protection value sent just before it, cooling protection with *4*202*2##, and heating protection on the heating-only zone 4. - @xtimmy86x, F454 on a MyHomeServer1 plant, 13 h passive trace: MyHomeServer1's *#4*2*#7*1*1*0170## program write, the zone's answer, and the dimension 5 re-assert with its answer. Provenance goes in SOURCE.yaml; corpus.json is regenerated by tools/golden/validate_corpus.py (102 fixtures; the README count was stale at 58). test_heating checks that every dimension 7 capture in the corpus decodes to a zone state. Co-Authored-By: Claude Opus 5.5 --- tests/golden/README.md | 4 +- tests/golden/SOURCE.yaml | 12 ++ tests/golden/corpus.json | 159 ++++++++++++++++++++++++++ tests/golden/frames/who04_thermo.yaml | 122 ++++++++++++++++++++ tests/test_heating.py | 28 +++++ 5 files changed, 323 insertions(+), 2 deletions(-) diff --git a/tests/golden/README.md b/tests/golden/README.md index f7e6a7a..559007b 100644 --- a/tests/golden/README.md +++ b/tests/golden/README.md @@ -20,13 +20,13 @@ Every other source (`legrand-spec`, `encyclopedia`, `openwebnet4j`, `public-read - A `builder:` block proves the builder matches the fixture, not that either is right. When both are written in the same change from the same reading, the test only compares the code with itself. Prefer builder parity against a capture or an independent source such as `openwebnet4j`. - When a capture arrives for a frame that so far exists only as a spec-derived fixture, add the capture and keep the spec entry only if it agrees. -## Supported Subsystems Catalog (58 Fixtures) +## Supported Subsystems Catalog (102 Fixtures) - **Signaling (`who00_signaling.yaml`)**: Gateway ACK (`*#*1##`) and NACK (`*#*0##`). - **WHO=0 Scenarios (`who00_scenario.yaml`)**: Basic scenario execution and stop. - **WHO=1 Lighting (`who01_lighting.yaml`)**: Point-to-point ON/OFF, status requests, local bus routing (`0311#4#01`), group broadcast, speed transitions (`*1*1#5*12##`), and dimension writes. - **WHO=2 Automation (`who02_automation.yaml`)**: Shutter UP/DOWN/STOP, private bus routing (`21#4#1`), absolute position percentages, and slat tilt angles. Records tagged `scope` pin OWNd's general / area / group classification; an MH201 capture (MyHOME#433) adds an area-1 stop echo (`*2*0*1##`) and the per-actuator end-of-run stop that closes a general UP: a scope command never gets a stop of its own (WHO_2.pdf §3.0.1). A group run from the MyHOME MH201 plant fixture (`*2*0*#1##`, `*2*2*#1##`) shows the same rule for groups, with each position-reporting actuator also echoing the command right away. -- **WHO=4 Thermoregulation (`who04_thermo.yaml`)**: Measured temperature queries (`21.5°C`), setpoint writes, Antifreeze/Protection modes, and negative temperature probe status (`-4.8°C`). +- **WHO=4 Thermoregulation (`who04_thermo.yaml`)**: Measured temperature queries (`21.5°C`), setpoint writes, Antifreeze/Protection modes, and negative temperature probe status (`-4.8°C`). Two MyHomeServer1 + Home+Control plants (MyHOME#429) add dimension 7, which carries the zone state and setpoint on those plants and is not in `WHO_4 2.pdf`: cooling and heating setpoints, protection (the zone is OFF, with `*4*202*Z##`), the 35.0 °C protection value sent just before a real setpoint, MyHomeServer1's `*#4*Z*#7*...##` program writes, and the dimension 5 re-assert. - **WHO=5 Burglar Alarm (`who05_alarm.yaml`)**: Status requests and silent alarm events across zones and central units. - **WHO=9 Auxiliary (`who09_auxiliary.yaml`)**: Activation and deactivation of AUX relay channels. - **WHO=13 Gateway Management (`who13_gateway.yaml`)**: Firmware versions and gateway internal datetime responses. diff --git a/tests/golden/SOURCE.yaml b/tests/golden/SOURCE.yaml index 7c2f750..0439067 100644 --- a/tests/golden/SOURCE.yaml +++ b/tests/golden/SOURCE.yaml @@ -98,6 +98,18 @@ authorities: date: "2026-09-16" gateway: "BTicino MH201" devices: "Position-reporting covers 19, 0110-0115 in group 1 (MyHOME tests/fixtures/plants/mh201_physical_plant): a group stop and DOWN (*2*0*#1##, *2*2*#1##) echoed per object within 1.6 s, then one end-of-run stop per actuator 5.4-24.3 s later and none for #1" + - name: "Physical Bus Capture (MyHomeServer1 + Home+Control, WHO 4 heating and cooling)" + reference: "https://github.com/OpenWebNet-HA/MyHOME/issues/429" + author: "@TheDarkWizard" + date: "2026-09-23" + gateway: "BTicino MyHomeServer1, firmware 2.87.13" + devices: "Four zones, one KM4691 thermostat each driving an F430R8; zone 4 is heating-only. Card exports of OFF / manual / cooling transitions: dimension 7 carries the zone state and setpoint, never the reply to *#4*Z##" + - name: "Passive Bus Trace (F454 on a MyHomeServer1 + Home+Control plant, WHO 4 schedule)" + reference: "https://github.com/OpenWebNet-HA/MyHOME/issues/429#issuecomment-5813061888" + author: "@xtimmy86x" + date: "2026-09-24" + gateway: "BTicino F454, firmware 2.0 (Home Assistant's gateway); MyHomeServer1 runs the program on the same bus" + devices: "Seven heating zones: 13 h trace in which MyHomeServer1 writes *#4*Z*#7*1*1*TTTT## at each program step (06:00, 09:00), each zone answers with the dimension 7 status, and *#4*Z*#5*0## is re-asserted every ~794 s" 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 e322dea..65148ec 100644 --- a/tests/golden/corpus.json +++ b/tests/golden/corpus.json @@ -1291,6 +1291,165 @@ }, "_file": "who04_thermo.yaml" }, + { + "id": "thermo.capture.dim7.cooling_setpoint.z2", + "frame": "*#4*2*7*2*1*0260##", + "direction": "dimension_response", + "who": 4, + "what": null, + "where": "2", + "dimension": 7, + "dimension_values": [ + "2", + "1", + "0260" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Zone 2 cooling setpoint 26.0 \u00b0C: the Home+Control app switched the zone from OFF to manual at 26 \u00b0C (MyHomeServer1 capture 18:11:35, MyHOME#429 comment 5800423418)", + "roundtrip": true, + "_file": "who04_thermo.yaml" + }, + { + "id": "thermo.capture.dim7.cooling_setpoint_35.z2", + "frame": "*#4*2*7*2*1*0350##", + "direction": "dimension_response", + "who": 4, + "what": null, + "where": "2", + "dimension": 7, + "dimension_values": [ + "2", + "1", + "0350" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Zone 2 cooling setpoint 35.0 \u00b0C, the plant's cooling protection value, sent ~230 ms before the real setpoint (*#4*2*7*2*1*0260##) when the zone leaves OFF (MyHomeServer1 capture 18:11:34, MyHOME#429)", + "roundtrip": true, + "_file": "who04_thermo.yaml" + }, + { + "id": "thermo.capture.dim7.cooling_protection.z2", + "frame": "*#4*2*7*2*2##", + "direction": "dimension_response", + "who": 4, + "what": null, + "where": "2", + "dimension": 7, + "dimension_values": [ + "2", + "2" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Zone 2 cooling protection: the zone was switched OFF in Home+Control; followed by *4*202*2## (MyHomeServer1 capture 18:13:48, MyHOME#429)", + "roundtrip": true, + "_file": "who04_thermo.yaml" + }, + { + "id": "thermo.capture.what202.cooling_off.z2", + "frame": "*4*202*2##", + "direction": "event", + "who": 4, + "what": 202, + "where": "2", + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Zone 2 cooling OFF, 67 ms after *#4*2*7*2*2## (MyHomeServer1 capture 18:13:48, MyHOME#429)", + "roundtrip": true, + "_file": "who04_thermo.yaml" + }, + { + "id": "thermo.capture.dim7.heating_protection.z4", + "frame": "*#4*4*7*1*2##", + "direction": "dimension_response", + "who": 4, + "what": null, + "where": "4", + "dimension": 7, + "dimension_values": [ + "1", + "2" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Zone 4 heating protection: a heating-only bathroom zone while the plant runs in cooling, repeated ~2 s after every change on another zone (MyHomeServer1 capture, MyHOME#429 comment 5800919197)", + "roundtrip": true, + "_file": "who04_thermo.yaml" + }, + { + "id": "thermo.capture.dim7.write.heating_setpoint.z2", + "frame": "*#4*2*#7*1*1*0170##", + "direction": "dimension_write", + "who": 4, + "what": null, + "where": "2", + "dimension": 7, + "dimension_values": [ + "1", + "1", + "0170" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "MyHomeServer1 running its weekly program: writes zone 2 heating setpoint 17.0 \u00b0C (Night) on the bus, seen on the event session of an F454 on the same plant (xtimmy86x 13 h passive trace 00:18:52, MyHOME#429 comment 5813061888)", + "roundtrip": true, + "_file": "who04_thermo.yaml" + }, + { + "id": "thermo.capture.dim7.heating_setpoint.z2", + "frame": "*#4*2*7*1*1*0170##", + "direction": "dimension_response", + "who": 4, + "what": null, + "where": "2", + "dimension": 7, + "dimension_values": [ + "1", + "1", + "0170" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Zone 2 answers the dimension 7 write with heating setpoint 17.0 \u00b0C, 48 ms later; the program step carries no preset name (xtimmy86x trace 00:18:52, MyHOME#429)", + "roundtrip": true, + "_file": "who04_thermo.yaml" + }, + { + "id": "thermo.capture.dim5.write.z2", + "frame": "*#4*2*#5*0##", + "direction": "dimension_write", + "who": 4, + "what": null, + "where": "2", + "dimension": 5, + "dimension_values": [ + "0" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "MyHomeServer1 re-asserts dimension 5 (local control, value meaning unpublished) on every zone every ~794 s (xtimmy86x trace 00:17:51, MyHOME#429)", + "roundtrip": true, + "_file": "who04_thermo.yaml" + }, + { + "id": "thermo.capture.dim5.status.z2", + "frame": "*#4*2*5*0##", + "direction": "dimension_response", + "who": 4, + "what": null, + "where": "2", + "dimension": 5, + "dimension_values": [ + "0" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Zone 2 answers the dimension 5 write, 46 ms later (xtimmy86x trace 00:17:51, MyHOME#429)", + "roundtrip": true, + "_file": "who04_thermo.yaml" + }, { "id": "alarm.req.status.zone2", "frame": "*#5*2##", diff --git a/tests/golden/frames/who04_thermo.yaml b/tests/golden/frames/who04_thermo.yaml index 8f66b88..2fd2ae2 100644 --- a/tests/golden/frames/who04_thermo.yaml +++ b/tests/golden/frames/who04_thermo.yaml @@ -168,3 +168,125 @@ class: OWNHeatingCommand method: set_central_mode args: ["#0#1", "heat"] + +# ── DIMENSION 7 zone state and DIMENSION 5, MyHomeServer1 + Home+Control (MyHOME#429, OWNd#58) ── +# Dimension 7 is not in WHO_4 2.pdf; openwebnet-mcp accepts the grammar and +# warns that it is undocumented. Meaning per the MyHOME_Suite ScenarioDevices +# templates (Encyclopedia who-4-temperature-control/dimensions.md): +# *#4*ZONE*7*CONTEXT*STATE[*TTTT]##, context 1 heating / 2 cooling, +# state 1 setpoint / 2 protection. + +- id: thermo.capture.dim7.cooling_setpoint.z2 + frame: "*#4*2*7*2*1*0260##" + direction: dimension_response + who: 4 + what: null + where: "2" + dimension: 7 + dimension_values: ["2", "1", "0260"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Zone 2 cooling setpoint 26.0 °C: the Home+Control app switched the zone from OFF to manual at 26 °C (MyHomeServer1 capture 18:11:35, MyHOME#429 comment 5800423418)" + roundtrip: true + +- id: thermo.capture.dim7.cooling_setpoint_35.z2 + frame: "*#4*2*7*2*1*0350##" + direction: dimension_response + who: 4 + what: null + where: "2" + dimension: 7 + dimension_values: ["2", "1", "0350"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Zone 2 cooling setpoint 35.0 °C, the plant's cooling protection value, sent ~230 ms before the real setpoint (*#4*2*7*2*1*0260##) when the zone leaves OFF (MyHomeServer1 capture 18:11:34, MyHOME#429)" + roundtrip: true + +- id: thermo.capture.dim7.cooling_protection.z2 + frame: "*#4*2*7*2*2##" + direction: dimension_response + who: 4 + what: null + where: "2" + dimension: 7 + dimension_values: ["2", "2"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Zone 2 cooling protection: the zone was switched OFF in Home+Control; followed by *4*202*2## (MyHomeServer1 capture 18:13:48, MyHOME#429)" + roundtrip: true + +- id: thermo.capture.what202.cooling_off.z2 + frame: "*4*202*2##" + direction: event + who: 4 + what: 202 + where: "2" + source: community-plant-capture + mcp_valid: true + mcp_notes: "Zone 2 cooling OFF, 67 ms after *#4*2*7*2*2## (MyHomeServer1 capture 18:13:48, MyHOME#429)" + roundtrip: true + +- id: thermo.capture.dim7.heating_protection.z4 + frame: "*#4*4*7*1*2##" + direction: dimension_response + who: 4 + what: null + where: "4" + dimension: 7 + dimension_values: ["1", "2"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Zone 4 heating protection: a heating-only bathroom zone while the plant runs in cooling, repeated ~2 s after every change on another zone (MyHomeServer1 capture, MyHOME#429 comment 5800919197)" + roundtrip: true + +- id: thermo.capture.dim7.write.heating_setpoint.z2 + frame: "*#4*2*#7*1*1*0170##" + direction: dimension_write + who: 4 + what: null + where: "2" + dimension: 7 + dimension_values: ["1", "1", "0170"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "MyHomeServer1 running its weekly program: writes zone 2 heating setpoint 17.0 °C (Night) on the bus, seen on the event session of an F454 on the same plant (xtimmy86x 13 h passive trace 00:18:52, MyHOME#429 comment 5813061888)" + roundtrip: true + +- id: thermo.capture.dim7.heating_setpoint.z2 + frame: "*#4*2*7*1*1*0170##" + direction: dimension_response + who: 4 + what: null + where: "2" + dimension: 7 + dimension_values: ["1", "1", "0170"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Zone 2 answers the dimension 7 write with heating setpoint 17.0 °C, 48 ms later; the program step carries no preset name (xtimmy86x trace 00:18:52, MyHOME#429)" + roundtrip: true + +- id: thermo.capture.dim5.write.z2 + frame: "*#4*2*#5*0##" + direction: dimension_write + who: 4 + what: null + where: "2" + dimension: 5 + dimension_values: ["0"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "MyHomeServer1 re-asserts dimension 5 (local control, value meaning unpublished) on every zone every ~794 s (xtimmy86x trace 00:17:51, MyHOME#429)" + roundtrip: true + +- id: thermo.capture.dim5.status.z2 + frame: "*#4*2*5*0##" + direction: dimension_response + who: 4 + what: null + where: "2" + dimension: 5 + dimension_values: ["0"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Zone 2 answers the dimension 5 write, 46 ms later (xtimmy86x trace 00:17:51, MyHOME#429)" + roundtrip: true diff --git a/tests/test_heating.py b/tests/test_heating.py index e650f40..b79cbd5 100644 --- a/tests/test_heating.py +++ b/tests/test_heating.py @@ -1,5 +1,8 @@ """Regression tests for WHO 4 heating messages.""" +import json +from pathlib import Path + import pytest from OWNd.message import ( @@ -241,3 +244,28 @@ def test_dimension_5_status_and_write_have_a_log() -> None: def test_heating_builders_keep_their_own_log() -> None: assert OWNHeatingCommand.status("2").human_readable_log == "Requesting climate status update for 2." + + +def _golden_dimension_7_status_captures() -> list[str]: + corpus = json.loads((Path(__file__).parent / "golden" / "corpus.json").read_text(encoding="utf-8")) + records = corpus["frames"] if isinstance(corpus, dict) else corpus + return [ + r["frame"] for r in records + if r.get("who") == 4 and r.get("dimension") == 7 + and r.get("direction") == "dimension_response" and r.get("source") == "community-plant-capture" + ] + + +def test_golden_corpus_has_dimension_7_captures() -> None: + assert len(_golden_dimension_7_status_captures()) >= 5 + + +@pytest.mark.parametrize("frame", _golden_dimension_7_status_captures()) +def test_every_golden_dimension_7_capture_is_a_zone_state(frame: str) -> None: + event = OWNMessage.parse(frame) + + assert isinstance(event, OWNHeatingEvent) + assert event.message_type == MESSAGE_TYPE_ZONE_STATE + assert event.zone_context in (ZONE_CONTEXT_HEATING, ZONE_CONTEXT_COOLING) + assert event.zone_state in (ZONE_STATE_SETPOINT, ZONE_STATE_PROTECTION) + assert (event.set_temperature is not None) == (event.zone_state == ZONE_STATE_SETPOINT) From 922e718feddfa1ed1a796104272c6c37c596a60f Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:28:21 +0200 Subject: [PATCH 3/3] fix(heating): set zone_* only with the zone_state type; antifreeze capture Review follow-up on #60: - zone_context / zone_state are now assigned together with MESSAGE_TYPE_ZONE_STATE, so a frame with one unknown code (*#4*3*7*9*1*0200##) no longer exposes zone_state == "setpoint" while message_type is None. - The ScenarioDevices table test now covers state 1 (setpoint) in all four contexts. - zone_state documents that "protection" is the wire name for both antifreeze (heating, *4*102*Z##) and thermal protection (cooling, *4*202*Z##). - The capture test used *#4*1*7*2*1*0225##, which neither #429 nor #58 quotes verbatim; replaced by TheDarkWizard's *#4*2*7*2*1*0260##, and added xtimmy86x's *#4*2*7*1*2##. - Golden corpus: xtimmy86x's zone 2 OFF sequence on a heating plant (*#4*2*7*1*2##, *4*102*2##, *#4*2*12*0070*3##, MyHOME#429 comment 5803483452), validated with openwebnet-mcp. 105 fixtures. Co-Authored-By: Claude Opus 5.5 --- OWNd/message.py | 11 ++++-- tests/golden/README.md | 4 +-- tests/golden/SOURCE.yaml | 6 ++++ tests/golden/corpus.json | 49 +++++++++++++++++++++++++++ tests/golden/frames/who04_thermo.yaml | 37 ++++++++++++++++++++ tests/test_heating.py | 17 ++++++---- 6 files changed, 113 insertions(+), 11 deletions(-) diff --git a/OWNd/message.py b/OWNd/message.py index fb507fc..364807d 100755 --- a/OWNd/message.py +++ b/OWNd/message.py @@ -987,14 +987,16 @@ def __init__(self, data: str) -> None: elif self._dimension == 7 and self._dimension_value: # Zone state # MyHomeServer1 / Home+Control plants carry the zone's operating # state and setpoint here, and never in the reply to *#4*Z##. - self._zone_context, self._zone_state, temperature = _zone_state( - self._dimension_value - ) + # The zone_* properties are only set with the message type, so a + # half-known frame never looks like a valid state. + context, state, temperature = _zone_state(self._dimension_value) text = _zone_state_text(self._dimension_value) if text is None: self._human_readable_log = f"Zone {self._zone} reports an unknown zone state {'*'.join(self._dimension_value)}." # pylint: disable=line-too-long else: self._type = MESSAGE_TYPE_ZONE_STATE + self._zone_context = context + self._zone_state = state self._set_temperature = temperature self._human_readable_log = f"Zone {self._zone} is in {text}." @@ -1227,6 +1229,9 @@ def zone_state(self) -> str | None: """DIMENSION 7 operating state (ZONE_STATE_*), else None. For ZONE_STATE_SETPOINT the temperature is in set_temperature. + ZONE_STATE_PROTECTION is the wire name for both cases: MyHOME_Suite + calls it antifreeze in the heating context (the zone also reports + *4*102*Z##) and thermal protection in cooling (*4*202*Z##). """ return self._zone_state diff --git a/tests/golden/README.md b/tests/golden/README.md index 559007b..759a830 100644 --- a/tests/golden/README.md +++ b/tests/golden/README.md @@ -20,13 +20,13 @@ Every other source (`legrand-spec`, `encyclopedia`, `openwebnet4j`, `public-read - A `builder:` block proves the builder matches the fixture, not that either is right. When both are written in the same change from the same reading, the test only compares the code with itself. Prefer builder parity against a capture or an independent source such as `openwebnet4j`. - When a capture arrives for a frame that so far exists only as a spec-derived fixture, add the capture and keep the spec entry only if it agrees. -## Supported Subsystems Catalog (102 Fixtures) +## Supported Subsystems Catalog (105 Fixtures) - **Signaling (`who00_signaling.yaml`)**: Gateway ACK (`*#*1##`) and NACK (`*#*0##`). - **WHO=0 Scenarios (`who00_scenario.yaml`)**: Basic scenario execution and stop. - **WHO=1 Lighting (`who01_lighting.yaml`)**: Point-to-point ON/OFF, status requests, local bus routing (`0311#4#01`), group broadcast, speed transitions (`*1*1#5*12##`), and dimension writes. - **WHO=2 Automation (`who02_automation.yaml`)**: Shutter UP/DOWN/STOP, private bus routing (`21#4#1`), absolute position percentages, and slat tilt angles. Records tagged `scope` pin OWNd's general / area / group classification; an MH201 capture (MyHOME#433) adds an area-1 stop echo (`*2*0*1##`) and the per-actuator end-of-run stop that closes a general UP: a scope command never gets a stop of its own (WHO_2.pdf §3.0.1). A group run from the MyHOME MH201 plant fixture (`*2*0*#1##`, `*2*2*#1##`) shows the same rule for groups, with each position-reporting actuator also echoing the command right away. -- **WHO=4 Thermoregulation (`who04_thermo.yaml`)**: Measured temperature queries (`21.5°C`), setpoint writes, Antifreeze/Protection modes, and negative temperature probe status (`-4.8°C`). Two MyHomeServer1 + Home+Control plants (MyHOME#429) add dimension 7, which carries the zone state and setpoint on those plants and is not in `WHO_4 2.pdf`: cooling and heating setpoints, protection (the zone is OFF, with `*4*202*Z##`), the 35.0 °C protection value sent just before a real setpoint, MyHomeServer1's `*#4*Z*#7*...##` program writes, and the dimension 5 re-assert. +- **WHO=4 Thermoregulation (`who04_thermo.yaml`)**: Measured temperature queries (`21.5°C`), setpoint writes, Antifreeze/Protection modes, and negative temperature probe status (`-4.8°C`). Two MyHomeServer1 + Home+Control plants (MyHOME#429) add dimension 7, which carries the zone state and setpoint on those plants and is not in `WHO_4 2.pdf`: cooling and heating setpoints, protection (the zone is OFF: `*4*202*Z##` in cooling, `*4*102*Z##` antifreeze at 7.0 °C in heating), the 35.0 °C protection value sent just before a real setpoint, MyHomeServer1's `*#4*Z*#7*...##` program writes, and the dimension 5 re-assert. - **WHO=5 Burglar Alarm (`who05_alarm.yaml`)**: Status requests and silent alarm events across zones and central units. - **WHO=9 Auxiliary (`who09_auxiliary.yaml`)**: Activation and deactivation of AUX relay channels. - **WHO=13 Gateway Management (`who13_gateway.yaml`)**: Firmware versions and gateway internal datetime responses. diff --git a/tests/golden/SOURCE.yaml b/tests/golden/SOURCE.yaml index 0439067..7be14bd 100644 --- a/tests/golden/SOURCE.yaml +++ b/tests/golden/SOURCE.yaml @@ -110,6 +110,12 @@ authorities: date: "2026-09-24" gateway: "BTicino F454, firmware 2.0 (Home Assistant's gateway); MyHomeServer1 runs the program on the same bus" devices: "Seven heating zones: 13 h trace in which MyHomeServer1 writes *#4*Z*#7*1*1*TTTT## at each program step (06:00, 09:00), each zone answers with the dimension 7 status, and *#4*Z*#5*0## is re-asserted every ~794 s" + - name: "Passive Bus Trace (MyHomeServer1 + Home+Control plant, WHO 4 manual / program / OFF)" + reference: "https://github.com/OpenWebNet-HA/MyHOME/issues/429#issuecomment-5803483452" + author: "@xtimmy86x" + date: "2026-09-23" + gateway: "BTicino MyHomeServer1 as reported by the MyHOME 2.0.0b13 diagnostic bundle (OWNd 2.0.0b8)" + devices: "Seven heating zones: zone 2 switched OFF in Home+Control reports *#4*2*7*1*2##, *4*102*2## and a 7.0 °C antifreeze target on dimension 12" 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 65148ec..9182ea7 100644 --- a/tests/golden/corpus.json +++ b/tests/golden/corpus.json @@ -1450,6 +1450,55 @@ "roundtrip": true, "_file": "who04_thermo.yaml" }, + { + "id": "thermo.capture.dim7.heating_protection.z2", + "frame": "*#4*2*7*1*2##", + "direction": "dimension_response", + "who": 4, + "what": null, + "where": "2", + "dimension": 7, + "dimension_values": [ + "1", + "2" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Zone 2 heating protection on a heating-only plant: the zone was switched OFF in Home+Control; MyHOME_Suite calls this antifreeze in the heating context (xtimmy86x capture 23:39:43.745, MyHOME#429 comment 5803483452)", + "roundtrip": true, + "_file": "who04_thermo.yaml" + }, + { + "id": "thermo.capture.what102.antifreeze.z2", + "frame": "*4*102*2##", + "direction": "event", + "who": 4, + "what": 102, + "where": "2", + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Zone 2 antifreeze, 46 ms after *#4*2*7*1*2## (xtimmy86x capture 23:39:43.791, MyHOME#429)", + "roundtrip": true, + "_file": "who04_thermo.yaml" + }, + { + "id": "thermo.capture.dim12.antifreeze_target.z2", + "frame": "*#4*2*12*0070*3##", + "direction": "dimension_response", + "who": 4, + "what": null, + "where": "2", + "dimension": 12, + "dimension_values": [ + "0070", + "3" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Zone 2 target 7.0 \u00b0C, the antifreeze value, 47 ms after *4*102*2## (xtimmy86x capture 23:39:43.838, MyHOME#429)", + "roundtrip": true, + "_file": "who04_thermo.yaml" + }, { "id": "alarm.req.status.zone2", "frame": "*#5*2##", diff --git a/tests/golden/frames/who04_thermo.yaml b/tests/golden/frames/who04_thermo.yaml index 2fd2ae2..02bfa0a 100644 --- a/tests/golden/frames/who04_thermo.yaml +++ b/tests/golden/frames/who04_thermo.yaml @@ -290,3 +290,40 @@ mcp_valid: true mcp_notes: "Zone 2 answers the dimension 5 write, 46 ms later (xtimmy86x trace 00:17:51, MyHOME#429)" roundtrip: true + +- id: thermo.capture.dim7.heating_protection.z2 + frame: "*#4*2*7*1*2##" + direction: dimension_response + who: 4 + what: null + where: "2" + dimension: 7 + dimension_values: ["1", "2"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Zone 2 heating protection on a heating-only plant: the zone was switched OFF in Home+Control; MyHOME_Suite calls this antifreeze in the heating context (xtimmy86x capture 23:39:43.745, MyHOME#429 comment 5803483452)" + roundtrip: true + +- id: thermo.capture.what102.antifreeze.z2 + frame: "*4*102*2##" + direction: event + who: 4 + what: 102 + where: "2" + source: community-plant-capture + mcp_valid: true + mcp_notes: "Zone 2 antifreeze, 46 ms after *#4*2*7*1*2## (xtimmy86x capture 23:39:43.791, MyHOME#429)" + roundtrip: true + +- id: thermo.capture.dim12.antifreeze_target.z2 + frame: "*#4*2*12*0070*3##" + direction: dimension_response + who: 4 + what: null + where: "2" + dimension: 12 + dimension_values: ["0070", "3"] + source: community-plant-capture + 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 diff --git a/tests/test_heating.py b/tests/test_heating.py index b79cbd5..3e80e81 100644 --- a/tests/test_heating.py +++ b/tests/test_heating.py @@ -159,10 +159,12 @@ def test_where_zero_with_parameter_stays_zone_zero() -> None: [ ("*#4*2*7*1*1*0170##", 2, ZONE_CONTEXT_HEATING, ZONE_STATE_SETPOINT, 17.0, "Zone 2 is in heating setpoint at 17.0°C."), - ("*#4*1*7*2*1*0225##", 1, ZONE_CONTEXT_COOLING, ZONE_STATE_SETPOINT, 22.5, - "Zone 1 is in cooling setpoint at 22.5°C."), + ("*#4*2*7*2*1*0260##", 2, ZONE_CONTEXT_COOLING, ZONE_STATE_SETPOINT, 26.0, + "Zone 2 is in cooling setpoint at 26.0°C."), ("*#4*4*7*1*2##", 4, ZONE_CONTEXT_HEATING, ZONE_STATE_PROTECTION, None, "Zone 4 is in heating protection."), + ("*#4*2*7*1*2##", 2, ZONE_CONTEXT_HEATING, ZONE_STATE_PROTECTION, None, + "Zone 2 is in heating protection."), ], ) def test_dimension_7_zone_state_captures( @@ -186,19 +188,20 @@ def test_dimension_7_zone_state_captures( ) @pytest.mark.parametrize( ("raw_state", "state"), - [("2", ZONE_STATE_PROTECTION), ("3", ZONE_STATE_COMFORT), - ("4", ZONE_STATE_ECO), ("5", ZONE_STATE_OFF)], + [("1", ZONE_STATE_SETPOINT), ("2", ZONE_STATE_PROTECTION), + ("3", ZONE_STATE_COMFORT), ("4", ZONE_STATE_ECO), ("5", ZONE_STATE_OFF)], ) def test_dimension_7_follows_the_scenario_devices_table( raw: str, context: str, raw_state: str, state: str ) -> None: # Encyclopedia who-4-temperature-control/dimensions.md, MyHOME_Suite # ScenarioDevices DIMENSION 7 templates. - event = OWNHeatingEvent(f"*#4*3*7*{raw}*{raw_state}##") + temperature = "*0200" if state == ZONE_STATE_SETPOINT else "" + event = OWNHeatingEvent(f"*#4*3*7*{raw}*{raw_state}{temperature}##") assert event.message_type == MESSAGE_TYPE_ZONE_STATE assert (event.zone_context, event.zone_state) == (context, state) - assert event.set_temperature is None + assert event.set_temperature == (20.0 if state == ZONE_STATE_SETPOINT else None) @pytest.mark.parametrize("frame", ["*#4*3*7*9*1*0200##", "*#4*3*7*1*9##", "*#4*3*7*1##"]) @@ -206,6 +209,8 @@ def test_dimension_7_unknown_values_have_no_message_type(frame: str) -> None: event = OWNHeatingEvent(frame) assert event.message_type is None + assert event.zone_context is None + assert event.zone_state is None assert event.set_temperature is None assert "unknown zone state" in event.human_readable_log