Skip to content
Merged
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ All notable changes to `OWNd` are documented in this file.
- Canonical `libqtdevices` (`TS10_1_0_23`) coarse `WHAT 2..10` $\leftrightarrow$ fine `1..100%` conversion helpers (`COARSE_PRESET_TO_PERCENT`, `brightness_preset_to_percent`, `percent_to_brightness_preset`), `OWNLightingEvent.is_coarse_dimmer`, `OWNLightingEvent.brightness_preset_percent`, and `OWNLightingCommand.set_brightness(..., coarse=..., gateway=...)` (`#134`).
- `WHAT 39` end of movement (`*1*39*WHERE##`) from WHO 1 motion detectors: `message_type` `motion_detected` with `motion` `False`, new `OWNLightingEvent.motion_end`, `is_sensor` `True`, `is_on` `None`. It was `unknown_state` `39` before (`MyHOME#777`).

### Changed
- `OWNTransport.send()` takes the same `raise_on_error` keyword as `OWNCommandSession.send()` on `AsyncTcpTransport` and `AsyncSerialTransport`, and `OWNTransport.last_error` holds the error recorded by the last `send()`: a local refusal on both transports, and on the TCP transport everything its command session records (NACK, timeout, connection errors). Both transports now share one contract: `RuntimeError` when not connected, then a frame refused locally is logged at ERROR, kept in `last_error` and answered with `None`, or raised as `OWNProtocolError` with `raise_on_error=True`.

### 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.

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

### Added
Expand Down
76 changes: 64 additions & 12 deletions OWNd/connection/command_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,24 @@
OWNProtocolError,
OWNTimeoutError,
)
from ..message import OWNMessage, OWNSignaling
from ..message import OWNMessage, OWNSignaling, is_who4_dimension_22

# Why every send path refuses WHO 4 dimension 22 (see
# ``OWNd.message.is_who4_dimension_22``): the gateway never gets the frame.
DESTRUCTIVE_FRAME_REASON = (
"WHO 4 dimension 22 crashes the thermoregulation daemon (bt_termo) "
"on Linux IP gateways"
)


def destructive_frame_error(frame: str) -> OWNProtocolError:
"""The error every send path raises or records for a WHO 4 dimension 22 frame."""
return OWNProtocolError(
f"Refusing to send `{frame}`: {DESTRUCTIVE_FRAME_REASON}",
frame=frame,
reason="destructive_frame",
)


# Cooldown window (seconds) after an in-send() reconnect attempt fails, so
# concurrent callers queued behind _send_lock fail fast instead of each
Expand Down Expand Up @@ -69,6 +86,15 @@ async def send_to_gateway(
raise_on_error: bool = False,
) -> list[OWNMessage | str] | bool | None:
connection = cls(gateway)
# Refused before connect(): no socket, no handshake, no command slot
# on a single-session gateway (MH200N, F453AV...) and no 5-attempt
# backoff on an unreachable host for a frame that is never written.
frame = str(message)
if is_who4_dimension_22(frame):
connection._refuse_destructive_frame(
frame, message, raise_on_error=raise_on_error
)
return None
try:
if raise_on_error:
await connection.connect(raise_on_error=True)
Expand Down Expand Up @@ -219,6 +245,26 @@ async def send(
message, is_status_request, raise_on_error=raise_on_error
)

def _refuse_destructive_frame(
self, frame: str, message: str | OWNMessage, *, raise_on_error: bool
) -> None:
"""Record and log the refusal of a WHO 4 dimension 22 frame, never written.

``frame`` is the serialised string the error carries; ``message`` is
the caller's object, passed to the logger like every other send log
line (formatted lazily, and what downstream tests assert on).
"""
err = destructive_frame_error(frame)
self._last_error = err
self._logger.error(
"%s Refusing to send `%s`: %s.",
self._log_id,
message,
DESTRUCTIVE_FRAME_REASON,
)
if raise_on_error:
raise err

async def _locked_send(
self,
message: str | OWNMessage,
Expand All @@ -227,10 +273,16 @@ async def _locked_send(
raise_on_error: bool = False,
) -> list[OWNMessage | str] | bool | None:
self._last_error = None
# Serialised once: the frame checked is the frame parsed, raised and
# written. Log lines keep the caller's object (formatted lazily).
frame = str(message)
if is_who4_dimension_22(frame):
self._refuse_destructive_frame(frame, message, raise_on_error=raise_on_error)
return None
msg_obj = (
message
if isinstance(message, OWNMessage)
else OWNMessage.parse(str(message), gateway=self._gateway)
else OWNMessage.parse(frame, gateway=self._gateway)
)
is_valid_frame = (
msg_obj is not None
Expand All @@ -239,8 +291,8 @@ async def _locked_send(
)
if not is_valid_frame:
err: OWNError = OWNProtocolError(
f"Invalid OpenWebNet frame `{message}`",
frame=str(message),
f"Invalid OpenWebNet frame `{frame}`",
frame=frame,
reason="invalid_frame",
)
self._last_error = err
Expand Down Expand Up @@ -292,7 +344,7 @@ async def _locked_send(
< SEND_RECONNECT_COOLDOWN
):
err = self._last_error or OWNConnectionError(
f"Command session unavailable; message `{message}` not sent",
f"Command session unavailable; message `{frame}` not sent",
reason="connection_unavailable",
)
self._last_error = err
Expand All @@ -319,7 +371,7 @@ async def _locked_send(
):
self._last_reconnect_failure = time.monotonic()
err = self._last_error or OWNConnectionError(
f"Command session unavailable; message `{message}` not sent",
f"Command session unavailable; message `{frame}` not sent",
reason="connection_unavailable",
)
await self.close()
Expand All @@ -336,7 +388,7 @@ async def _locked_send(

written = False
try:
self._stream_writer.write(str(message).encode())
self._stream_writer.write(frame.encode())
written = True
await self._stream_writer.drain()

Expand Down Expand Up @@ -368,8 +420,8 @@ async def _locked_send(
message,
)
err = OWNNackError(
f"Gateway rejected message `{message}` with NACK",
frame=str(message),
f"Gateway rejected message `{frame}` with NACK",
frame=frame,
responses=list(collected),
)
self._last_error = err
Expand All @@ -391,7 +443,7 @@ async def _locked_send(
message,
)
err = OWNProtocolError(
f"Unexpected response `{resulting_message}` to message `{message}`",
f"Unexpected response `{resulting_message}` to message `{frame}`",
frame=str(resulting_message),
reason="unexpected_response",
)
Expand All @@ -408,7 +460,7 @@ async def _locked_send(
except TimeoutError as error:
await self.close()
err = OWNTimeoutError(
f"Timed out awaiting the complete response for `{message}`",
f"Timed out awaiting the complete response for `{frame}`",
reason="command_timeout",
)
self._last_error = err
Expand Down Expand Up @@ -441,7 +493,7 @@ async def _locked_send(
# No retry: this is a definitive loss, and the consumer hears it.
self._set_connected(False)
err = OWNConnectionError(
f"Connection lost before acknowledgement of `{message}` ({error})",
f"Connection lost before acknowledgement of `{frame}` ({error})",
reason="connection_lost",
)
self._last_error = err
Expand Down
2 changes: 2 additions & 0 deletions OWNd/message/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@
_ZONE_STATES,
_zone_state,
_zone_state_text,
is_who4_dimension_22,
who4_temperature,
OWNHeatingCommand,
OWNHeatingEvent,
Expand Down Expand Up @@ -314,6 +315,7 @@
"who4_temperature",
"_zone_state",
"_zone_state_text",
"is_who4_dimension_22",
"OWNHeatingEvent",
"OWNHeatingCommand",
# Burglar Alarm (WHO 5)
Expand Down
34 changes: 34 additions & 0 deletions OWNd/message/heating.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,40 @@

_WHO4_TEMP_REGEX = re.compile(r"^[01][0-9]{3}$")

# WHO 4 DIMENSION 22 is the published split-unit (air conditioning) IR
# control: ``*#4*3#Z#N*22##`` requests the current setting and
# ``*#4*3#Z#N*#22*MOD*SP*VEL*SWING##`` writes it (WHO 4 specification,
# pp. 66-68). On Linux IP gateways the frame kills the thermoregulation
# daemon (bt_termo) and the gateway reboots about 3 minutes later (firmware
# oracle crash_frames; live F461 2.0.11). Every send path refuses it, in any
# shape: request, write (``*#22*``) or reply, with or without a dimension
# parameter, and in any ``*#4*ZONE*22##`` spelling.
#
# Anchored to the first ``*`` of each ``##``-delimited frame: ``*#4*`` means
# WHO 4 only there. Mid-frame, ``*#4*`` is WHERE ``#4`` (group 4 in WHO 1, 2,
# 16 and 22, central-unit zone 4 in WHO 4) or DIMENSION ``#4`` (a dimension 4
# write), so ``*#2*#4*#11#001*22##`` or ``*#1*14*#4*1*22##`` must not match.
# The WHO and DIMENSION tokens accept whatever the gateway's atoi() reads as
# 4 / 22: leading zeros, surrounding whitespace and a ``+`` sign
# (``*#04*2* 22##``, ``*#4*2*+22##``, ``*#4*2*22 ``).
_WHO4_DIMENSION_22_REGEX = re.compile(
r"^[^*]*\*#\s*\+?0*4\s*\*[^*]*\*\s*#?\s*\+?0*22(?:\s*$|[#*\s])"
)


def is_who4_dimension_22(frame: object) -> bool:
"""True if ``frame`` (or any ``##``-delimited frame in it) is WHO 4 DIMENSION 22.

``bytes``, ``bytearray`` and ``memoryview`` are decoded as latin-1 (the
wire encoding); anything else is checked through ``str()``.
"""
raw = (
bytes(frame).decode("latin-1")
if isinstance(frame, (bytes, bytearray, memoryview))
else str(frame)
)
return any(_WHO4_DIMENSION_22_REGEX.search(chunk) for chunk in raw.split("##"))


def who4_temperature(raw: str) -> float | None:
"""Decode WHO 4 temperature ``SXXX`` (tenths of a degree)."""
Expand Down
29 changes: 27 additions & 2 deletions OWNd/transport/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from collections.abc import Callable
from typing import Any

from ..exceptions import OWNError
from ..message import OWNMessage

Listener = Callable[[OWNMessage | str], Any]
Expand All @@ -23,6 +24,18 @@ def __init__(
self.log_id = log_id
self._logger = logger or logging.getLogger(__name__)
self._listeners: set[Listener] = set()
self._last_error: OWNError | None = None

@property
def last_error(self) -> OWNError | None:
"""The error recorded by the last ``send()``, if any.

Both transports record a local refusal (``OWNProtocolError``); the
TCP transport exposes its command session's ``last_error``, which
also covers NACK, timeout and connection errors. A serial NACK or
timeout returns ``None`` without recording an error.
"""
return self._last_error

@property
@abstractmethod
Expand All @@ -44,9 +57,21 @@ async def disconnect(self) -> None:

@abstractmethod
async def send(
self, message: str | OWNMessage, is_status_request: bool = False
self,
message: str | OWNMessage,
is_status_request: bool = False,
*,
raise_on_error: bool = False,
) -> list[OWNMessage | str] | bool | None:
"""Send one command and return its response."""
"""Send one command and return its response.

Raises ``RuntimeError`` when the transport is not connected. A frame
that is refused locally (invalid, or WHO 4 dimension 22 which crashes
the gateway's thermoregulation daemon) is logged at ERROR, kept in
``last_error`` and answered with ``None``; with ``raise_on_error`` it
raises ``OWNProtocolError`` (``reason`` ``invalid_frame`` or
``destructive_frame``) instead, like ``OWNCommandSession.send``.
"""

def register_listener(self, callback: Listener) -> Callable[[], None]:
"""Register an inbound-message callback and return its unsubscribe."""
Expand Down
68 changes: 58 additions & 10 deletions OWNd/transport/serial.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
import logging

from ..connection import COMMAND_RESPONSE_MAX_FRAMES, COMMAND_TIMEOUT
from ..message import OWNMessage, OWNSignaling
from ..connection.command_session import (
DESTRUCTIVE_FRAME_REASON,
destructive_frame_error,
)
from ..exceptions import OWNError, OWNProtocolError
from ..message import OWNMessage, OWNSignaling, is_who4_dimension_22
from .base import OWNTransport

SEPARATOR = b"##"
Expand Down Expand Up @@ -142,24 +147,67 @@ def _process_frame(self, frame: str) -> None:
self._pending_frames.append(message)

async def send(
self, message: str | OWNMessage, is_status_request: bool = False
self,
message: str | OWNMessage,
is_status_request: bool = False,
*,
raise_on_error: bool = False,
) -> list[OWNMessage | str] | bool | None:
del is_status_request
if not self._connected or self._writer is None:
raise RuntimeError("Serial transport is not connected")
self._last_error = None

# Serialised once, terminator included: the frame checked is the
# frame parsed, logged and written.
text = str(message)
if not text.endswith("##"):
text += "##"
if is_who4_dimension_22(text):
# Never written, whatever the caller: it reboots the gateway.
err: OWNError = destructive_frame_error(text)
self._last_error = err
self._logger.error(
"%s Refusing to send `%s`: %s.",
self.log_id,
text,
DESTRUCTIVE_FRAME_REASON,
)
if raise_on_error:
raise err
return None
msg_obj = (
message
if isinstance(message, OWNMessage)
else OWNMessage.parse(text, gateway=self.port)
)
if (
msg_obj is None
or not msg_obj.is_valid
or isinstance(msg_obj, OWNSignaling)
):
err = OWNProtocolError(
f"Invalid OpenWebNet frame `{text}`",
frame=text,
reason="invalid_frame",
)
self._last_error = err
self._logger.error(
"%s Refusing to send invalid OpenWebNet frame `%s`.",
self.log_id,
text,
)
if raise_on_error:
raise err
return None

async with self._command_lock:
loop = asyncio.get_running_loop()
self._pending_future = loop.create_future()
self._pending_request = OWNMessage.parse(
str(message), gateway=self.port
)
self._pending_request = msg_obj
self._pending_frames = []
try:
frame = str(message).encode()
if not frame.endswith(SEPARATOR):
frame += SEPARATOR
self._writer.write(frame)
self._writer.write(text.encode())
await self._writer.drain()
return await asyncio.wait_for(
self._pending_future, timeout=COMMAND_TIMEOUT
Expand All @@ -168,7 +216,7 @@ async def send(
self._logger.warning(
"%s Timed out waiting for serial response to `%s`.",
self.log_id,
message,
text,
)
return None
finally:
Expand Down
Loading
Loading