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
5 changes: 3 additions & 2 deletions OWNd/message/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ class OWNMessage:
r"^\*#(?P<who>\d+)\*(?P<where>#?\d+)?(?P<where_param>(?:#\d+)*)?\*#(?P<dimension>\d+)(?P<dimension_param>(?:#\d+)*)?(?P<dimension_value>(?:\*\d*)+)##$" # pylint: disable=line-too-long
) # *#WHO*WHERE*#DIMENSION*VAL1*VALn##
_DIMENSION_REQUEST = re.compile(
r"^\*#(?P<who>\d+)\*(?P<where>#?\d+)?(?P<where_param>(?:#\d+)*)?\*(?P<dimension>\d+)##$"
r"^\*#(?P<who>\d+)\*(?P<where>#?\d+)?(?P<where_param>(?:#\d+)*)?\*(?P<dimension>\d+)(?P<dimension_param>(?:#\d+)*)?##$"
) # *#WHO*WHERE*DIMENSION##
_DIMENSION_REQUEST_REPLY = re.compile(
r"^\*#(?P<who>\d+)\*(?P<where>#?\d+)?(?P<where_param>(?:#\d+)*)?\*(?P<dimension>\d+)(?P<dimension_param>(?:#\d+)*)?(?P<dimension_value>(?:\*\d*)+)##$" # pylint: disable=line-too-long
Expand Down Expand Up @@ -146,6 +146,7 @@ def __init__(self, data: str) -> None:
self._where = match.group("where") or ""
self._where_param = (match.group("where_param") or "").split("#")[1:]
self._dimension = int(match.group("dimension"))
self._dimension_param = (match.group("dimension_param") or "").split("#")[1:]

elif match := self._DIMENSION_REQUEST_REPLY.match(self._raw):
self._is_valid_message = True
Expand Down Expand Up @@ -231,7 +232,7 @@ def interface(self) -> str | None:
"""The 'where' parameter corresponding to the bus interface of the subject of this message"""
return (
self._where_param[1]
if self._who in [1, 2, 15]
if self._who in [0, 1, 2, 14, 15]
and len(self._where_param) > 1
and self._where_param[0] == "4"
else None
Expand Down
2 changes: 2 additions & 0 deletions OWNd/message/cen.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,8 @@ def _parse_who25_event(data: str) -> OWNEvent:
what_code = None
if what_code is not None and 21 <= what_code <= 28:
return OWNCENPlusEvent(data)
if what_code is not None and what_code not in (31, 32):
return OWNEvent(data)
return OWNDryContactEvent(data)


Expand Down
79 changes: 54 additions & 25 deletions OWNd/message/heating.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,11 @@ def __init__(self, data: str) -> None:
else:
self._human_readable_log = f"Zone {self._zone}'s temperature probe is reporting a temperature of {self._secondary_temperature}°C."

elif self._dimension == 19: # Valves status
elif (
self._dimension == 19
and len(self._dimension_value) >= 2
and all(self._dimension_value[:2])
): # Valves status
self._type = MESSAGE_TYPE_ACTION
self._is_cooling = self._dimension_value[0] in _valve_active_states
self._is_heating = self._dimension_value[1] in _valve_active_states
Expand Down Expand Up @@ -399,11 +403,15 @@ def __init__(self, data: str) -> None:
self._is_active = False
self._human_readable_log += "; heating fan is off."

elif self._dimension == 20: # Actuator status
elif (
self._dimension == 20
and self._dimension_value
and self._dimension_value[0]
): # Actuator status
self._type = MESSAGE_TYPE_ACTION
self._is_active = self._dimension_value[0] in _actuator_active_states
self._actuator = (
self._where_param[0] if self._where_param[0] is not None else 1
self._where_param[0] if self._where_param else "1"
)
_value = int(self._dimension_value[0])
if _value == 0:
Expand Down Expand Up @@ -613,6 +621,10 @@ def set_mode(
if mode == CLIMATE_MODE_OFF:
mode_code = 303
elif mode == CLIMATE_MODE_AUTO:
# The firmware forwards 311 only as *4*311*#Z## (OWNd#77, A3), the
# form libqtdevices sends too, so ``standalone`` does not apply.
if not zone.startswith("#"):
zone = f"#{zone}"
mode_code = 311
else:
return None
Expand Down Expand Up @@ -672,39 +684,56 @@ def set_temperature(
def set_fan_speed(
cls, where: str | int, speed: int, standalone: bool = False
) -> OWNHeatingCommand:
central_local = re.compile(r"^#0#\d+$")
if central_local.match(str(where)):
zone = str(where)
zone_name = f"zone {int(str(where).split('#')[-1])}"
else:
"""Build a fan speed command; ``standalone`` is ignored (kept for compatibility)."""
where_str = str(where)
if where_str in ("#0", "0") or where_str.startswith("#0#"):
raise ValueError(
f"Fan speed cannot be set on central unit or general zone: {where}"
)
try:
zone_number = (
int(str(where).split("#")[-1]) if str(where).startswith("#") else int(where)
int(where_str.split("#")[-1])
if where_str.startswith("#")
else int(where)
)
zone_name = f"zone {zone_number}" if zone_number > 0 else "general"
if standalone:
zone = f"#{zone_number}" if zone_number == 0 else str(zone_number)
else:
zone = f"#{zone_number}"

speed_code = int(speed)
message = cls(f"*#4*{zone}*#11*{speed_code}##")
except (ValueError, TypeError):
raise ValueError(f"Invalid zone address: {where}")
if not (1 <= zone_number <= 99):
raise ValueError(f"Invalid zone number: {zone_number}. Zone must be 1..99")
try:
speed_code = int(speed)
except (ValueError, TypeError):
raise ValueError(f"Invalid fan speed: {speed}")
if not (0 <= speed_code <= 3):
raise ValueError(f"Invalid fan speed: {speed_code}. Speed must be 0..3")

message = cls(f"*#4*{zone_number}*#11*{speed_code}##")
message._human_readable_log = (
f"Setting {zone_name} fan speed to {speed_code}."
f"Setting zone {zone_number} fan speed to {speed_code}."
)
return message

@classmethod
def set_central_mode(
cls, where: str = "#0", mode: str = CLIMATE_MODE_HEAT
) -> OWNHeatingCommand:
"""Set operation mode for Central Unit (3550 99-zone or 4695 4-zone)."""
"""Set operation mode for Central Unit (3550 99-zone or 4695 4-zone).

The codes are the ones BTicino's touch-screen client sends to a central
unit (libqtdevices ``thermal_device.cpp``): 303 generic off, 1 winter,
0 summer, 302 generic protection. 102 is the winter (antifreeze)
protection; 202, the summer protection, is not offered because it
would also switch the plant to summer. 311 is the generic automatic
command libqtdevices sends to zones; on ``#0`` it puts every zone back
on the central unit's program.
"""
mode_map = {
CLIMATE_MODE_OFF: 100,
CLIMATE_MODE_HEAT: 101,
CLIMATE_MODE_COOL: 102,
CLIMATE_MODE_AUTO: 103,
"antifreeze": 110,
"protection": 111,
CLIMATE_MODE_OFF: 303,
CLIMATE_MODE_HEAT: 1,
CLIMATE_MODE_COOL: 0,
CLIMATE_MODE_AUTO: 311,
"antifreeze": 102,
"protection": 302,
}
mode_code = mode_map.get(mode)
if mode_code is None:
Expand Down
8 changes: 6 additions & 2 deletions OWNd/message/lighting.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,8 +412,12 @@ def switch_off(cls, where: str | int, _transition: int | None = None) -> OWNLigh
def set_brightness(
cls, where: str | int, _level: int = 30, _transition: int = 0
) -> OWNLightingCommand:
command_level = int(_level) + 100
transition_speed = _transition if _transition >= 0 and _transition <= 255 else 0
transition_speed = _transition if 0 <= _transition <= 255 else 0
if int(_level) <= 0:
return cls.switch_off(
where, transition_speed if transition_speed > 0 else None
)
command_level = min(int(_level), 100) + 100
message = cls(f"*#1*{where}*#1*{command_level}*{transition_speed}##")
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
Expand Down
2 changes: 1 addition & 1 deletion OWNd/message/sound.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""WHO 16 & 22: Sound diffusion and audio/video door entry events and commands."""
"""WHO 16: Sound diffusion and audio/video door entry events and commands."""

from __future__ import annotations

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ OWNd maintains an automated test suite with strict **100.0% line coverage** (3,0
| [`OWNd/message/heating.py`](OWNd/message/heating.py) | **100%** | WHO 4: Thermoregulation, heating/cooling zone control, and temperature decoders |
| [`OWNd/message/lighting.py`](OWNd/message/lighting.py) | **100%** | WHO 1: Lighting controls, dimming, RGB/HSV color, and motion/PIR sensors |
| [`OWNd/message/scenario.py`](OWNd/message/scenario.py) | **100%** | WHO 0, WHO 9, WHO 17: Scenario activation, auxiliary commands, and MH200/MH202 scenes |
| [`OWNd/message/sound.py`](OWNd/message/sound.py) | **100%** | WHO 16, WHO 22: Sound system and audio/video matrix routing |
| [`OWNd/message/sound.py`](OWNd/message/sound.py) | **100%** | WHO 16: Sound system and audio/video matrix routing (WHO 22 is not implemented) |
| [`OWNd/profiles.py`](OWNd/profiles.py) | **100%** | Declarative hardware gateway models (F454, MH200, MH200N, MH201, MH202, MyHomeServer1) |
| [`OWNd/transport/__init__.py`](OWNd/transport/__init__.py) | **100%** | Transport subpackage exports |
| [`OWNd/transport/base.py`](OWNd/transport/base.py) | **100%** | Abstract transport layer and event listener notification contracts |
Expand Down
14 changes: 8 additions & 6 deletions docs/protocol_conformance_matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,22 +26,24 @@ To prevent divergence and eliminate guesswork, every protocol frame and capabili
|:---:|---|---|---|:---:|:---:|:---:|
| **-** | **Signaling** | Gateway ACK / NACK | `*#*1##`, `*#*0##` | Valid | Matched | **VERIFIED (100%)** |
| **0** | **Scenarios** | Execute, Stop, Start | `*0*1*1##`, `*0*2*1##` | Valid | Matched | **VERIFIED (100%)** |
| **1** | **Lighting** | ON/OFF, Status, Speed Dimming, Private Bus (`#4#01`), Dimension writes | `*1*1*0311#4#01##`, `*1*1#5*12##`, `*#1*1*#1*20##` | Valid | Matched | **VERIFIED (100%)** |
| **2** | **Automation** | Shutter UP/DOWN/STOP, Bus Routing (`#4#1`), Slat Tilt, Position % | `*2*1*21#4#1##`, `*#2*1*#1*50##`, `*2*2*12##` | Valid | Matched | **VERIFIED (100%)** |
| **1** | **Lighting** | ON/OFF, Status, Speed Dimming, Private Bus (`#4#01`), Dimension writes | `*1*1*0311#4#01##`, `*1*1#5*12##`, `*#1*31*#1*150*5##` | Valid | Matched | **VERIFIED (100%)** |
| **2** | **Automation** | Shutter UP/DOWN/STOP, Bus Routing (`#4#01`), Slat Tilt, Position % | `*2*1*31#4#01##`, `*#2*31*#11#001*100##`, `*2*2*12##` | Valid | Matched | **VERIFIED (100%)** |
| **4** | **Thermoregulation** | Probe queries (`21.5°C`), Setpoints, Antifreeze, Negative Temp (`-4.8°C`) | `*#4*1*0*0215##`, `*#4*6*12*1048*3##`, `*#4*1*#14*0200*1##` | Valid | Matched | **VERIFIED (100%)** |
| **5** | **Burglar Alarm** | Zone status, CU status, Silent alarms, System arm/disarm | `*5*1*1##`, `*#5*1##`, `*5*2*2##` | Valid | Matched | **VERIFIED (100%)** |
| **9** | **Auxiliary** | Relay ON/OFF commands across auxiliary channels | `*9*1*1##`, `*9*0*1##` | Valid | Matched | **VERIFIED (100%)** |
| **13** | **Gateway Mgmt** | Firmware version, Gateway internal datetime requests | `*#13*0*0##`, `*#13*0*22##` | Valid | Matched | **VERIFIED (100%)** |
| **15** | **CEN Scenarios** | Press, short release, long release, extended hold | `*15*01*0001##`, `*15*01#1*0001##`, `*15*01#2*0001##`, `*15*01#3*0001##` | Valid | Matched | **VERIFIED (100%)** |
| **18** | **Energy Mgmt** | Instantaneous active power (W), Cumulative energy (kWh) | `*#18*51*113##`, `*#18*51*51##`, `*#18*51*52##` | Valid | Matched | **VERIFIED (100%)** |
| **25** | **CEN+ / Dry Contacts**| Short/Long press, release, 5-digit module addresses | `*25*21*0001##`, `*25*23*0001##`, `*25*22*0001##` | Valid | Matched | **VERIFIED (100%)** |
| **15** | **CEN Scenarios** | Press, short release, long release, extended hold | `*15*01*73##`, `*15*01#1*73##`, `*15*01#2*0110##`, `*15*01#3*0110##` | Valid | Matched | **VERIFIED (100%)** |
| **18** | **Energy Mgmt** | Instantaneous active power (W), Cumulative energy (kWh) | `*#18*51*113##`, `*#18*51*51##`, `*#18*51*52#26#9##` | Valid | Matched | **VERIFIED (100%)** |
| **25** | **CEN+ / Dry Contacts**| Short press; start, hold and release of an extended press; WHERE `2` + object 0–2047 | `*25*21#1*21##`, `*25*22#1*21##`, `*25*23#1*21##`, `*25*24#1*21##` | Valid | Matched | **VERIFIED (100%)** |

Sources for the examples changed in [OWNd#77](https://github.com/OpenWebNet-HA/OWNd/issues/77). The MyHomeServer1 firmware, run on an emulated bus, produces a bus frame for each of them and refuses the examples they replace. The WHO 15 press and short release (`*15*01*73##`, `*15*01#1*73##`) were captured on an MH200N + MyHomeServer1 bus in [MyHOME#541](https://github.com/OpenWebNet-HA/MyHOME/issues/541). Long release and extended hold (`#2`, `#3`) rest on the firmware alone. The WHO 25 examples are an MH201 capture from [MyHOME#418](https://github.com/OpenWebNet-HA/MyHOME/issues/418). The energy request `*#18*51*52#26#9##` was accepted by a real MyHomeServer1.

---

## 3. Key Architectural Truths & Design Decisions

### 3.1 String Preservation for Hardware Addresses
- **Observation**: Addresses such as `0311` (Lighting PTP) and `0001` (CEN+ module) begin with leading zeros.
- **Observation**: Addresses such as `0311` (Lighting PTP) and `0110` (CEN) begin with leading zeros.
- **Verifiable Truth**: Addresses **must never be converted to integers** (`311` or `1`). In OpenWebNet, leading zeros denote specific address spaces and module wiring. OWNd strictly enforces string types for `where` across all subsystems.

### 3.2 Private Bus Routing (`#4#bus`)
Expand Down
2 changes: 1 addition & 1 deletion scripts/update_readme_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"OWNd/message/heating.py": "WHO 4: Thermoregulation, heating/cooling zone control, and temperature decoders",
"OWNd/message/lighting.py": "WHO 1: Lighting controls, dimming, RGB/HSV color, and motion/PIR sensors",
"OWNd/message/scenario.py": "WHO 0, WHO 9, WHO 17: Scenario activation, auxiliary commands, and MH200/MH202 scenes",
"OWNd/message/sound.py": "WHO 16, WHO 22: Sound system and audio/video matrix routing",
"OWNd/message/sound.py": "WHO 16: Sound system and audio/video matrix routing (WHO 22 is not implemented)",
"OWNd/profiles.py": "Declarative hardware gateway models (F454, MH200, MH200N, MH201, MH202, MyHomeServer1)",
"OWNd/transport/base.py": "Abstract transport layer and event listener notification contracts",
"OWNd/transport/serial.py": "Async Serial/USB transport for Legrand 3578 interface with in-band demux",
Expand Down
32 changes: 16 additions & 16 deletions tests/golden/corpus.json
Original file line number Diff line number Diff line change
Expand Up @@ -1204,14 +1204,14 @@
},
{
"id": "thermo.cmd.central.mode.heat.cu99",
"frame": "*4*101*#0##",
"frame": "*4*1*#0##",
"direction": "command",
"who": 4,
"what": 101,
"what": 1,
"where": "#0",
"source": "legrand-spec",
"source": "encyclopedia",
"mcp_valid": true,
"mcp_notes": "Set 99-zone Central Unit to Heating mode",
"mcp_notes": "Set 99-zone Central Unit to Heating mode, WHAT 1 = heating (WHO 4 table in Encyclopedia functional/who-4-temperature-control/what.md; MyHomeServer1 firmware emits a bus frame for it (OWNd#77); BTicino's touch-screen client sends this exact frame to the 99-zone central unit (libqtdevices TS10_1_0_23 thermal_device.cpp, test/test_thermal_device.cpp). The judge warns that the WHAT is not standard: its WHO 4 catalog still has the 100..103 reading this replaced)",
"roundtrip": true,
"builder": {
"class": "OWNHeatingCommand",
Expand All @@ -1225,14 +1225,14 @@
},
{
"id": "thermo.cmd.central.mode.cool.cu99",
"frame": "*4*102*#0##",
"frame": "*4*0*#0##",
"direction": "command",
"who": 4,
"what": 102,
"what": 0,
"where": "#0",
"source": "legrand-spec",
"source": "encyclopedia",
"mcp_valid": true,
"mcp_notes": "Set 99-zone Central Unit to Cooling mode",
"mcp_notes": "Set 99-zone Central Unit to Cooling mode, WHAT 0 = conditioning (WHO 4 table in Encyclopedia functional/who-4-temperature-control/what.md; MyHomeServer1 firmware emits a bus frame for it (OWNd#77); BTicino's touch-screen client sends this exact frame to the 99-zone central unit (libqtdevices TS10_1_0_23 thermal_device.cpp, test/test_thermal_device.cpp). The judge warns that the WHAT is not standard: its WHO 4 catalog still has the 100..103 reading this replaced)",
"roundtrip": true,
"builder": {
"class": "OWNHeatingCommand",
Expand All @@ -1246,14 +1246,14 @@
},
{
"id": "thermo.cmd.central.mode.off.cu99",
"frame": "*4*100*#0##",
"frame": "*4*303*#0##",
"direction": "command",
"who": 4,
"what": 100,
"what": 303,
"where": "#0",
"source": "legrand-spec",
"source": "encyclopedia",
"mcp_valid": true,
"mcp_notes": "Turn conditioning OFF on 99-zone Central Unit",
"mcp_notes": "Turn 99-zone Central Unit OFF, WHAT 303 = OFF generic (WHO 4 table in Encyclopedia functional/who-4-temperature-control/what.md; MyHomeServer1 firmware emits a bus frame for it (OWNd#77); BTicino's touch-screen client sends this exact frame to the 99-zone central unit (libqtdevices TS10_1_0_23 thermal_device.cpp, test/test_thermal_device.cpp). The judge warns that the WHAT is not standard: its WHO 4 catalog still has the 100..103 reading this replaced)",
"roundtrip": true,
"builder": {
"class": "OWNHeatingCommand",
Expand Down Expand Up @@ -1316,14 +1316,14 @@
},
{
"id": "thermo.cmd.central.mode.heat.cu4",
"frame": "*4*101*#0#1##",
"frame": "*4*1*#0#1##",
"direction": "command",
"who": 4,
"what": 101,
"what": 1,
"where": "#0",
"source": "legrand-spec",
"source": "encyclopedia",
"mcp_valid": true,
"mcp_notes": "Set 4-zone Central Unit to Heating mode",
"mcp_notes": "Set 4-zone Central Unit to Heating mode, WHAT 1 = heating (WHO 4 table in Encyclopedia functional/who-4-temperature-control/what.md; MyHomeServer1 firmware emits a bus frame for it (OWNd#77); libqtdevices TS10_1_0_23 sends WHAT 1 to a 4-zone central too, but its test addresses that central as #1#2, not #0#1. The judge warns that the WHAT is not standard: its WHO 4 catalog still has the 100..103 reading this replaced)",
"roundtrip": true,
"builder": {
"class": "OWNHeatingCommand",
Expand Down
Loading
Loading