Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions OWNd/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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}##")
Expand Down
4 changes: 2 additions & 2 deletions tests/golden/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions tests/golden/SOURCE.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading