Skip to content
3 changes: 3 additions & 0 deletions OWNd/message/cen.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,9 @@ def _parse_who25_command(data: str) -> OWNCommand:
what_code = None
if what_code is not None and 21 <= what_code <= 28:
return OWNCenPlusCommand(data)
# Same rule as _parse_who25_event: only WHAT 31/32 are dry contacts.
if what_code is not None and what_code not in (31, 32):
return OWNCommand(data)
return OWNDryContactCommand(data)


Expand Down
466 changes: 421 additions & 45 deletions OWNd/message/heating.py

Large diffs are not rendered by default.

8 changes: 5 additions & 3 deletions OWNd/message/lighting.py
Original file line number Diff line number Diff line change
Expand Up @@ -417,12 +417,14 @@ def set_brightness(
return cls.switch_off(
where, transition_speed if transition_speed > 0 else None
)
command_level = min(int(_level), 100) + 100
level = min(int(_level), 100)
command_level = level + 100
message = cls(f"*#1*{where}*#1*{command_level}*{transition_speed}##")
capped = f" (requested {_level}%)" if int(_level) > 100 else ""
message._human_readable_log = (
f"Setting light {message._where}{message._interface_log_text} brightness to {_level}% with transition speed {transition_speed}." # pylint: disable=line-too-long
f"Setting light {message._where}{message._interface_log_text} brightness to {level}%{capped} with transition speed {transition_speed}." # pylint: disable=line-too-long
if transition_speed > 0
else f"Setting light {message._where}{message._interface_log_text} brightness to {_level}%."
else f"Setting light {message._where}{message._interface_log_text} brightness to {level}%{capped}."
)
return message

Expand Down
8 changes: 4 additions & 4 deletions tests/golden/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,19 @@ 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 (105 Fixtures)
## Supported Subsystems Catalog (118 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=2 Automation (`who02_automation.yaml`)**: Shutter UP/DOWN/STOP, local bus routing (`21#4#01`; the firmware refuses a one-digit interface), 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: `*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.
- **WHO=15 CEN Pushbuttons (`who15_cen.yaml`)**: Pressure, short release, long release and extended pressure (`*15*BUTTON[#1|#2|#3]*WHERE##`).
- **WHO=15 CEN Pushbuttons (`who15_cen.yaml`)**: Pressure, short release, long release and extended pressure (`*15*BUTTON[#1|#2|#3]*WHERE##`), with a four-digit source address captured on an MH200N + MyHomeServer1 bus (`0512`, MyHOME#624).
- **WHO=18 Energy Management (`who18_energy.yaml`)**: Instantaneous active power and cumulative energy totalizers.
- **WHO=25 CEN+ / Dry Contacts (`who25_cen_plus.yaml`)**: CEN+ short press, hold start, hold repeat and release (`*25*21..24#PUSHBUTTON*2OBJECT##`, captured on an MH201) and a dry-contact event (`*25*31#1*WHERE##`).
- **WHO=25 CEN+ / Dry Contacts (`who25_cen_plus.yaml`)**: CEN+ short press, hold start, hold repeat and release (`*25*21..24#PUSHBUTTON*2OBJECT##`, captured on an MH201) and a dry-contact event captured on an H4890 (`*25*31#1*339##`; on the bus, dry-contact WHEREs start with `3`).

## Directory Structure

Expand Down
18 changes: 18 additions & 0 deletions tests/golden/SOURCE.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,24 @@ authorities:
date: "2026-10-01"
gateway: "Legrand F455 Basic Gateway, firmware 1.0.86"
devices: "Seven lights/dimmers (addresses 11-17), physical pushbuttons with command translations (1000#0/1) and dimmer telemetry reporting OFF in answer to dimension 1 query (*#1*13*4*100*2##)"
- name: "Physical Bus Capture (BTicino H4890, WHO 25 dry contacts)"
reference: "https://github.com/OpenWebNet-HA/MyHOME/issues/466#issuecomment-5846053726"
author: "@nicolacavallo84"
date: "2026-09-26"
gateway: "BTicino H4890 touch screen, firmware 4.0.15"
devices: "Dry contacts reported as *25*31#1*33N## / *25*32#1*33N## (WHERE 33, 331-339)"
- name: "Physical Bus Capture (BTicino F454, WHO 4 99-zone central unit)"
reference: "https://github.com/OpenWebNet-HA/MyHOME/issues/629#issuecomment-5993412070"
author: "@ricdijk"
date: "2026-10-05"
gateway: "BTicino F454, firmware 1.0.34"
devices: "99-zone central unit #0 with zones 1-7 and 11-15: *#4*#0## answered within 0.13 s (*4*202*#0##, *4*21*#0##, *4*22*#0##, *4*24*#0##); *#4*#0*14## not answered"
- name: "Physical Bus Capture (MH200N + MyHomeServer1 on one bus, WHO 15 four-digit CEN address)"
reference: "https://github.com/OpenWebNet-HA/MyHOME/issues/624#issuecomment-6002817949"
author: "@wave68runner"
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"
role: "Provides authentic on-wire bus frames and status replies captured from live physical hardware."

community_capture_cen_plus:
Expand Down
137 changes: 104 additions & 33 deletions tests/golden/corpus.json
Original file line number Diff line number Diff line change
Expand Up @@ -634,42 +634,42 @@
},
{
"id": "cover.cmd.up.bus.21",
"frame": "*2*1*21#4#1##",
"frame": "*2*1*21#4#01##",
"direction": "command",
"who": 2,
"what": 1,
"where": "21",
"interface": "1",
"source": "legrand-spec",
"interface": "01",
"source": "encyclopedia",
"mcp_valid": true,
"mcp_notes": "Automation: UP at address 21 on private SCS bus 1",
"mcp_notes": "Automation: UP at address 21 on local bus interface 01 (Encyclopedia functional/who-2-automation/addressing.md: APL#4#INTERFACE, INTERFACE = [0-1][1-9]). Corrected from *2*1*21#4#1##: the MyHomeServer1 firmware refuses a one-digit interface (*2*1*31#4#1## NACK, *2*1*31#4#01## ACK, au2.own, OWNd#77) and so does the MH200N emulation; every interface capture in this file uses two digits",
"roundtrip": true,
"builder": {
"class": "OWNAutomationCommand",
"method": "raise_shutter",
"args": [
"21#4#1"
"21#4#01"
]
},
"_file": "who02_automation.yaml"
},
{
"id": "cover.cmd.stop.bus.21",
"frame": "*2*0*21#4#1##",
"frame": "*2*0*21#4#01##",
"direction": "command",
"who": 2,
"what": 0,
"where": "21",
"interface": "1",
"source": "legrand-spec",
"interface": "01",
"source": "encyclopedia",
"mcp_valid": true,
"mcp_notes": "Automation: STOP at address 21 on private SCS bus 1",
"mcp_notes": "Automation: STOP at address 21 on local bus interface 01 (Encyclopedia who-2-automation/addressing.md, INTERFACE = [0-1][1-9]). Corrected from *2*0*21#4#1##, a one-digit interface the firmware refuses (au2.own, OWNd#77)",
"roundtrip": true,
"builder": {
"class": "OWNAutomationCommand",
"method": "stop_shutter",
"args": [
"21#4#1"
"21#4#01"
]
},
"_file": "who02_automation.yaml"
Expand Down Expand Up @@ -1160,28 +1160,65 @@
},
{
"id": "thermo.cmd.mode.antifreeze.zone1",
"frame": "*4*110*1##",
"frame": "*4*102*#1##",
"direction": "command",
"who": 4,
"what": 110,
"where": "1",
"source": "legrand-spec",
"what": 102,
"where": "#1",
"source": "encyclopedia",
"mcp_valid": true,
"mcp_notes": "Set Zone 1 to Antifreeze protection mode",
"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*111*1##",
"frame": "*4*302*#1##",
"direction": "command",
"who": 4,
"what": 111,
"where": "1",
"source": "legrand-spec",
"what": 302,
"where": "#1",
"source": "encyclopedia",
"mcp_valid": true,
"mcp_notes": "Set Zone 1 to Thermal Protection mode",
"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"
},
{
Expand Down Expand Up @@ -1294,26 +1331,39 @@
},
{
"id": "thermo.cmd.central.status.cu99",
"frame": "*#4*#0*14##",
"direction": "dimension_request",
"frame": "*#4*#0##",
"direction": "status_request",
"who": 4,
"what": null,
"where": "#0",
"dimension": 14,
"dimension_values": [],
"source": "legrand-spec",
"source": "community-plant-capture",
"mcp_valid": true,
"mcp_notes": "Request 99-zone Central Unit status",
"mcp_notes": "Request 99-zone Central Unit plain status: an F454 (FW 1.0.34) answered within 0.13 s with *4*202*#0##, *4*21*#0##, *4*22*#0##, *4*24*#0## (MyHOME tests/fixtures/traces/issue_629/myhome_trace_F454_all_2026-10-05T11-14-00.json, MyHOME#629). Mirrors the entry in MyHOME's golden copy",
"roundtrip": true,
"builder": {
"class": "OWNHeatingCommand",
"method": "central_status",
"method": "status",
"args": [
"#0"
]
},
"_file": "who04_thermo.yaml"
},
{
"id": "thermo.cmd.central.status.cu99.dim14_legacy",
"frame": "*#4*#0*14##",
"direction": "dimension_request",
"who": 4,
"what": null,
"where": "#0",
"dimension": 14,
"dimension_values": [],
"source": "legrand-spec",
"mcp_valid": true,
"mcp_notes": "Request 99-zone Central Unit status on dimension 14, what central_status() builds. Gateways refuse it: an F454 (MyHOME#629) and a real MyHomeServer1 3.1.8 (gdluck's gateway probe, OWNd#77) answer *#*0##. central_status() is deprecated for that reason (poll with status('#0'), see thermo.cmd.central.status.cu99), so this entry has no builder block. Kept only to record the refused frame; roundtrip means OWNd parses and re-serializes it exactly, not that a gateway accepts it",
"roundtrip": true,
"_file": "who04_thermo.yaml"
},
{
"id": "thermo.cmd.central.mode.heat.cu4",
"frame": "*4*1*#0#1##",
Expand Down Expand Up @@ -1874,10 +1924,31 @@
"where": "0001",
"source": "openwebnet4j",
"mcp_valid": true,
"mcp_notes": "CEN: Button 1 extended pressure on module 0001",
"mcp_notes": "CEN: Button 1 extended pressure on module 0001. Disputed: the Encyclopedia CEN table allows [00][01-15] (functional/who-15-cen/README.md), but the MyHomeServer1 firmware refuses WHERE 0001 (*15*01*0001## produces no bus frame, au1.tsv, OWNd#77; four digits only when A or PL is 10 or more). No capture either way; see cen.event.extended_press.btn3.0512 for a captured four-digit address",
"roundtrip": true,
"_file": "who15_cen.yaml"
},
{
"id": "cen.event.extended_press.btn3.0512",
"frame": "*15*03#3*0512##",
"direction": "event",
"who": 15,
"what": "03#3",
"where": "0512",
"source": "community-plant-capture",
"mcp_valid": true,
"mcp_notes": "CEN: extended pressure on button 3 of source 0512 (A 5, PL 12: four digits because PL is 10 or more). Captured on an MH200N and a MyHomeServer1 on the same bus, which emit the same WHERE with its leading zero (MyHOME tests/fixtures/traces/issue_624/, MyHOME#624). The full press: *15*03*0512##, *15*03#3*0512## (repeated while held), *15*03#2*0512##",
"roundtrip": true,
"builder": {
"class": "OWNCenCommand",
"method": "start_long_press",
"args": [
"0512",
3
]
},
"_file": "who15_cen.yaml"
},
{
"id": "energy.req.active_power.meter1",
"frame": "*#18*1*1##",
Expand Down Expand Up @@ -2039,15 +2110,15 @@
"_file": "who25_cen_plus.yaml"
},
{
"id": "dry_contact.event.on.15",
"frame": "*25*31#1*15##",
"id": "dry_contact.event.on.339",
"frame": "*25*31#1*339##",
"direction": "event",
"who": 25,
"what": "31#1",
"where": "15",
"source": "encyclopedia",
"where": "339",
"source": "community-plant-capture",
"mcp_valid": true,
"mcp_notes": "Dry contact 15 ON / IR detection; #1 marks an event, #0 a state reply (Encyclopedia functional/who-25-transversal/dry-contact-ir.md @c57dcec)",
"mcp_notes": "Dry contact ON / IR detection at WHERE 339; #1 marks an event, #0 a state reply (Encyclopedia functional/who-25-transversal/dry-contact-ir.md). Captured on an H4890 (MyHOME tests/fixtures/traces/issue_466/myhome_trace_H4890_all_2026-09-26T11-51-26.json, MyHOME#466). Replaces the Encyclopedia-derived *25*31#1*15##: on the bus every captured dry contact has a WHERE that starts with 3 (33, 331..339 on MH200N, MH202, F454 and H4890), and the firmware's bt_luci uses the same rule (gdluck's audit, OWNd#77). The judge warns that 339 is outside the documented 1..201; the captures show the bus form",
"roundtrip": true,
"_file": "who25_cen_plus.yaml"
},
Expand Down
20 changes: 10 additions & 10 deletions tests/golden/frames/who02_automation.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -59,36 +59,36 @@
args: ["93"]

- id: cover.cmd.up.bus.21
frame: "*2*1*21#4#1##"
frame: "*2*1*21#4#01##"
direction: command
who: 2
what: 1
where: "21"
interface: "1"
source: legrand-spec
interface: "01"
source: encyclopedia
mcp_valid: true
mcp_notes: "Automation: UP at address 21 on private SCS bus 1"
mcp_notes: "Automation: UP at address 21 on local bus interface 01 (Encyclopedia functional/who-2-automation/addressing.md: APL#4#INTERFACE, INTERFACE = [0-1][1-9]). Corrected from *2*1*21#4#1##: the MyHomeServer1 firmware refuses a one-digit interface (*2*1*31#4#1## NACK, *2*1*31#4#01## ACK, au2.own, OWNd#77) and so does the MH200N emulation; every interface capture in this file uses two digits"
roundtrip: true
builder:
class: OWNAutomationCommand
method: raise_shutter
args: ["21#4#1"]
args: ["21#4#01"]

- id: cover.cmd.stop.bus.21
frame: "*2*0*21#4#1##"
frame: "*2*0*21#4#01##"
direction: command
who: 2
what: 0
where: "21"
interface: "1"
source: legrand-spec
interface: "01"
source: encyclopedia
mcp_valid: true
mcp_notes: "Automation: STOP at address 21 on private SCS bus 1"
mcp_notes: "Automation: STOP at address 21 on local bus interface 01 (Encyclopedia who-2-automation/addressing.md, INTERFACE = [0-1][1-9]). Corrected from *2*0*21#4#1##, a one-digit interface the firmware refuses (au2.own, OWNd#77)"
roundtrip: true
builder:
class: OWNAutomationCommand
method: stop_shutter
args: ["21#4#1"]
args: ["21#4#01"]

- id: cover.cmd.up.gen.0
frame: "*2*1*0##"
Expand Down
Loading
Loading