diff --git a/OWNd/message.py b/OWNd/message.py index 69a4fe4..364807d 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,27 @@ 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##. + # 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}." + elif self._dimension == 11: # Fan speed self._type = MESSAGE_TYPE_FAN_SPEED _fan_mode = int(self._dimension_value[0]) @@ -1138,6 +1219,22 @@ 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. + 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 + def is_active(self) -> bool | None: return self._is_active @@ -2234,6 +2331,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/golden/README.md b/tests/golden/README.md index f7e6a7a..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 (58 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`). +- **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 7c2f750..7be14bd 100644 --- a/tests/golden/SOURCE.yaml +++ b/tests/golden/SOURCE.yaml @@ -98,6 +98,24 @@ 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" + - 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 e322dea..9182ea7 100644 --- a/tests/golden/corpus.json +++ b/tests/golden/corpus.json @@ -1291,6 +1291,214 @@ }, "_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": "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 8f66b88..02bfa0a 100644 --- a/tests/golden/frames/who04_thermo.yaml +++ b/tests/golden/frames/who04_thermo.yaml @@ -168,3 +168,162 @@ 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 + +- 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 8f3af37..3e80e81 100644 --- a/tests/test_heating.py +++ b/tests/test_heating.py @@ -1,5 +1,10 @@ """Regression tests for WHO 4 heating messages.""" +import json +from pathlib import Path + +import pytest + from OWNd.message import ( LOCAL_CONTROL_NORMAL, LOCAL_CONTROL_OFF, @@ -11,8 +16,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 +149,128 @@ 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*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( + 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"), + [("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. + 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 == (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##"]) +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 + + +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." + + +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)