Skip to content
Closed
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ All notable changes to `OWNd` are documented in this file.
### Fixed
- **WHO 4 Dimension 22 is never sent**: Dimension 22 is the published split-unit IR control (`*#4*3#Z#N*22##` / `*#4*3#Z#N*#22*MOD*SP*VEL*SWING##`, WHO 4 specification pp. 66-68); in any spelling (`*#4*ZONE*22##`, `*#4*#0*22##`, leading zeros, whitespace or `+` tokens) it crashes the thermoregulation daemon (`bt_termo`) on Linux IP gateways and reboots an F461 2.0.11 about three minutes later (own-firmware-oracle `tools/expected_divergences.yaml` crash frames, live F461 2.0.11). `OWNCommandSession.send` (and so `AsyncTcpTransport.send` and `keepalive`), `OWNCommandSession.send_to_gateway` (before `connect()`: no socket, no handshake) and `AsyncSerialTransport.send` refuse it before anything is written, with the contract above and `reason="destructive_frame"`. The detector `is_who4_dimension_22()` is public, accepts `bytes`, and is anchored to the first `*` of each frame, so a mid-frame WHERE `#4` / DIMENSION `#4` such as `*#2*#4*#11#001*22##` or `*#1*14*#4*1*22##` stays sendable. Parsing is unchanged: the frame can still be read from the bus.
- **`AsyncSerialTransport.send` validates outbound frames** like `OWNCommandSession.send`: an invalid or signaling frame (for example the single-`#` concatenation `*1*1*11#*#4*2*22##`) is refused with `reason="invalid_frame"` instead of being written to the port. The frame is serialised once, terminator included: the string checked is the string written.
- **WHO 4 (`OWNHeatingCommand.central_status`)**: the deprecation text said gateways refuse `*#4*WHERE*14##`; six of the twelve emulated gateways forward it to the bus (own-firmware-oracle `trace-corpus`) and it is simply unanswered without a central unit. The warning now points to `status('#0')`. The frame is unchanged.

## [2.0.0b11] - 2026-10-20

Expand Down
29 changes: 20 additions & 9 deletions OWNd/message/heating.py
Original file line number Diff line number Diff line change
Expand Up @@ -1328,17 +1328,28 @@ def set_timed_manual_end_time(

@classmethod
def central_status(cls, where: str = "#0") -> OWNHeatingCommand:
"""Deprecated: builds ``*#4*WHERE*14##``, which gateways refuse.

A real MyHomeServer1 3.1.8 (OWNd#77 gateway probe) answers ``*#*0##``
(NACK); on an F454 (MyHOME#629) dimension 14 silently timed out while
``status("#0")`` (``*#4*#0##``) answered within 0.13 s. Use
``status(where)`` to poll a central unit. The frame is kept unchanged
for existing callers.
"""Deprecated: builds ``*#4*WHERE*14##``, unanswered without a central unit.

In the firmware oracle (``trace-corpus``) F453, F453AV, F460, F461,
MH200N and MyHomeServer1 forward ``*#4*#0*14##`` to the bus
(``nack``/``out``), while F450, F454, F459, H4684, MH200 and MH202
refuse it (``nack``/``silent``). Forwarded or not, only a central unit
answers it: a live F461 2.0.11 without one NACKs it after the bus
timeout, a live MyHomeServer1 3.87.13 without one NACKs it after
~4.2 s, a real MyHomeServer1 3.1.8 (OWNd#77 gateway probe) NACKs it,
and on an F454 (MyHOME#629) it timed out while ``status("#0")``
(``*#4*#0##``) answered within 0.13 s. The Legrand WHO 4 document
defines the dimension 14 read on zones 1..99 only (p. 18) and, on
``#0``, reads of dimensions 30 and 31 (pp. 57-58): ``*#4*#0*14##`` is
undefined there, and the central unit's set point comes back inside
the ``*4*110#T*#0##`` reply to ``*#4*#0##`` (p. 56). Use
``status('#0')`` (or ``status(where)``) to poll a central unit. The
frame is kept unchanged for existing callers.
"""
warnings.warn(
"OWNHeatingCommand.central_status() builds *#4*WHERE*14##, which gateways "
"refuse; use OWNHeatingCommand.status(where) instead",
"OWNHeatingCommand.central_status() builds *#4*WHERE*14##, which goes "
"unanswered without a central unit; use OWNHeatingCommand.status('#0') "
"(OWNHeatingCommand.status(where)) instead",
DeprecationWarning,
stacklevel=2,
)
Expand Down
39 changes: 39 additions & 0 deletions tests/test_who4_central_status_text.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""``central_status`` deprecation text names the real reason.

``*#4*#0*14##`` is forwarded to the bus by F453, F453AV, F460, F461, MH200N
and MyHomeServer1 in the firmware oracle (``trace-corpus``, ``nack``/``out``)
and refused by the other six; either way it goes unanswered without a
central unit. The frame is unchanged.
"""

from __future__ import annotations

import warnings

from OWNd.message import OWNHeatingCommand


def test_central_status_deprecation_names_the_real_reason() -> None:
doc = OWNHeatingCommand.central_status.__doc__ or ""
assert "which gateways refuse" not in doc
assert "unanswered without a central unit" in doc
assert "trace-corpus" in doc
assert "F461" in doc

with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
command = OWNHeatingCommand.central_status("#0")
assert str(command) == "*#4*#0*14##"
(warning,) = caught
assert issubclass(warning.category, DeprecationWarning)
text = str(warning.message)
assert "refuse" not in text
assert "unanswered without a central unit" in text
assert "status('#0')" in text


def test_central_status_frame_and_default_are_unchanged() -> None:
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
assert str(OWNHeatingCommand.central_status()) == "*#4*#0*14##"
assert str(OWNHeatingCommand.central_status("#0#1")) == "*#4*#0#1*14##"
Loading