Coverage for src/ramses_rf/dispatcher.py: 20%
114 statements
« prev ^ index » next coverage.py v7.11.3, created at 2026-01-05 21:46 +0100
« prev ^ index » next coverage.py v7.11.3, created at 2026-01-05 21:46 +0100
1#!/usr/bin/env python3
2"""RAMSES RF - Decode/process a message (payload into JSON)."""
4# TODO:
5# - fix dispatching - what devices (some are Addr) are sent packets, esp. 1FC9s
7from __future__ import annotations
9import contextlib
10import logging
11from datetime import timedelta as td
12from typing import TYPE_CHECKING, Final
14from ramses_tx import ALL_DEV_ADDR, CODES_BY_DEV_SLUG, Message
15from ramses_tx.ramses import (
16 CODES_OF_HEAT_DOMAIN,
17 CODES_OF_HEAT_DOMAIN_ONLY,
18 CODES_OF_HVAC_DOMAIN_ONLY,
19)
21from . import exceptions as exc
22from .const import (
23 DEV_TYPE_MAP,
24 DONT_CREATE_ENTITIES,
25 DONT_UPDATE_ENTITIES,
26 SZ_DEVICES,
27 SZ_OFFER,
28 SZ_PHASE,
29 DevType,
30)
31from .device import Device, Fakeable
33from .const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
34 I_,
35 RP,
36 RQ,
37 W_,
38 Code,
39)
41if TYPE_CHECKING:
42 from . import Gateway
44#
45# NOTE: All debug flags should be False for deployment to end-users
46_DBG_FORCE_LOG_MESSAGES: Final[bool] = False # useful for dev/test
47_DBG_INCREASE_LOG_LEVELS: Final[bool] = (
48 False # set True for developer-friendly log spam
49)
51_LOGGER = logging.getLogger(__name__)
54__all__ = ["detect_array_fragment", "process_msg"]
57MSG_FORMAT_18 = "|| {:18s} | {:18s} | {:2s} | {:16s} | {:^4s} || {}"
59_TD_SECONDS_003 = td(seconds=3)
62def _create_devices_from_addrs(gwy: Gateway, this: Message) -> None:
63 """Discover and create any new devices using the packet addresses (not payload)."""
65 # FIXME: changing Address to Devices is messy: ? Protocol for same method signatures
66 # prefer Devices but can continue with Addresses if required...
67 this.src = gwy.device_by_id.get(this.src.id, this.src) # type: ignore[assignment]
68 this.dst = gwy.device_by_id.get(this.dst.id, this.dst) # type: ignore[assignment]
70 # Devices need to know their controller, ?and their location ('parent' domain)
71 # NB: only addrs processed here, packet metadata is processed elsewhere
73 # Determining bindings to a controller:
74 # - configury; As per any schema # codespell:ignore configury
75 # - discovery: If in 000C pkt, or pkt *to* device where src is a controller
76 # - eavesdrop: If pkt *from* device where dst is a controller
78 # Determining location in a schema (domain/DHW/zone):
79 # - configury; As per any schema # codespell:ignore configury
80 # - discovery: If in 000C pkt - unable for 10: & 00: (TRVs)
81 # - discovery: from packet fingerprint, excl. payloads (only for 10:)
82 # - eavesdrop: from packet fingerprint, incl. payloads
84 if not isinstance(this.src, Device): # type: ignore[unreachable]
85 # may: LookupError, but don't suppress
86 this.src = gwy.get_device(this.src.id) # type: ignore[assignment]
87 if this.dst.id == this.src.id:
88 this.dst = this.src
89 return
91 if not gwy.config.enable_eavesdrop:
92 return
94 if not isinstance(this.dst, Device) and this.src != gwy.hgi: # type: ignore[unreachable]
95 with contextlib.suppress(LookupError):
96 this.dst = gwy.get_device(this.dst.id) # type: ignore[assignment]
99def _check_msg_addrs(msg: Message) -> None: # TODO
100 """Validate the packet's address set.
102 Raise InvalidAddrSetError if the metadata is invalid, otherwise simply return.
103 """
105 # TODO: needs work: doesn't take into account device's (non-HVAC) class
107 if (
108 msg.src.id != msg.dst.id
109 and msg.src.type == msg.dst.type
110 and msg.src.type in DEV_TYPE_MAP.HEAT_DEVICES # could still be HVAC domain
111 ):
112 # .I --- 18:013393 18:000730 --:------ 0001 005 00FFFF0200 # invalid
113 # .I --- 01:078710 --:------ 01:144246 1F09 003 FF04B5 # invalid
114 # .I --- 29:151550 29:237552 --:------ 22F3 007 00023C03040000 # valid? HVAC
115 if msg.code in CODES_OF_HEAT_DOMAIN_ONLY:
116 raise exc.PacketAddrSetInvalid(
117 f"Invalid addr pair: {msg.src!r}/{msg.dst!r}"
118 )
119 elif msg.code in CODES_OF_HEAT_DOMAIN:
120 _LOGGER.warning(
121 f"{msg!r} < Invalid addr pair: {msg.src!r}/{msg.dst!r}, is it HVAC?"
122 )
123 elif msg.code not in CODES_OF_HVAC_DOMAIN_ONLY:
124 _LOGGER.info(
125 f"{msg!r} < Invalid addr pair: {msg.src!r}/{msg.dst!r}, is it HVAC?"
126 )
129def _check_src_slug(msg: Message, *, slug: str | None = None) -> None:
130 """Validate the packet's source device class against its verb/code pair."""
132 if slug is None: # slug = best_dev_role(msg.src, msg=msg)._SLUG
133 slug = getattr(msg.src, "_SLUG", None)
134 if slug in (None, DevType.HGI, DevType.DEV, DevType.HEA, DevType.HVC):
135 return # TODO: use DEV_TYPE_MAP.PROMOTABLE_SLUGS
137 if slug not in CODES_BY_DEV_SLUG:
138 raise exc.PacketInvalid(f"{msg!r} < Unknown src slug ({slug}), is it HVAC?")
140 #
142 if msg.code not in CODES_BY_DEV_SLUG[slug]:
143 raise exc.PacketInvalid(f"{msg!r} < Unexpected code for src ({slug}) to Tx")
145 #
146 #
148 if msg.verb not in CODES_BY_DEV_SLUG[slug][msg.code]:
149 raise exc.PacketInvalid(
150 f"{msg!r} < Unexpected verb/code for src ({slug}) to Tx"
151 )
154def _check_dst_slug(msg: Message, *, slug: str | None = None) -> None:
155 """Validate the packet's destination device class against its verb/code pair."""
157 if slug is None:
158 slug = getattr(msg.dst, "_SLUG", None)
159 if slug in (None, DevType.HGI, DevType.DEV, DevType.HEA, DevType.HVC):
160 return # TODO: use DEV_TYPE_MAP.PROMOTABLE_SLUGS
162 if slug not in CODES_BY_DEV_SLUG:
163 raise exc.PacketInvalid(f"{msg!r} < Unknown dst slug ({slug}), is it HVAC?")
165 if f"{slug}/{msg.verb}/{msg.code}" in (f"CTL/{RQ}/{Code._3EF1}",):
166 return # HACK: an exception-to-the-rule that need sorting
168 if msg.code not in CODES_BY_DEV_SLUG[slug]:
169 raise exc.PacketInvalid(f"{msg!r} < Unexpected code for dst ({slug}) to Rx")
171 if f"{msg.verb}/{msg.code}" in (f"{W_}/{Code._0001}",):
172 return # HACK: an exception-to-the-rule that need sorting
173 if f"{slug}/{msg.verb}/{msg.code}" in (f"{DevType.BDR}/{RQ}/{Code._3EF0}",):
174 return # HACK: an exception-to-the-rule that need sorting
176 if {RQ: RP, RP: RQ, W_: I_}[msg.verb] not in CODES_BY_DEV_SLUG[slug][msg.code]:
177 raise exc.PacketInvalid(
178 f"{msg!r} < Unexpected verb/code for dst ({slug}) to Rx"
179 )
182def process_msg(gwy: Gateway, msg: Message) -> None:
183 """Decoding the packet payload and route it appropriately."""
185 # All methods require msg with a valid payload, except _create_devices_from_addrs(),
186 # which requires a valid payload only for 000C.
188 def logger_xxxx(msg: Message) -> None:
189 """
190 Log msg according to src, code, log.debug setting.
192 :param msg: the Message being processed
193 :return: None
194 """
195 if _DBG_FORCE_LOG_MESSAGES:
196 _LOGGER.warning(msg)
197 elif msg.src != gwy.hgi or (msg.code != Code._PUZZ and msg.verb != RQ):
198 _LOGGER.info(msg)
199 elif msg.src != gwy.hgi or msg.verb != RQ:
200 _LOGGER.info(msg)
201 elif _LOGGER.getEffectiveLevel() == logging.DEBUG:
202 _LOGGER.info(msg)
204 try: # validate / dispatch the packet
205 _check_msg_addrs(msg) # ?InvalidAddrSetError TODO: ?useful at all
207 # TODO: any use in creating a device only if the payload is valid?
208 if gwy.config.reduce_processing >= DONT_CREATE_ENTITIES:
209 logger_xxxx(msg) # return ensures try's else: clause won't be invoked
210 return
212 try:
213 _create_devices_from_addrs(gwy, msg)
214 except LookupError as err:
215 (_LOGGER.error if _DBG_INCREASE_LOG_LEVELS else _LOGGER.warning)(
216 "%s < %s(%s)", msg._pkt, err.__class__.__name__, err
217 )
218 return
220 _check_src_slug(msg) # ? raise exc.PacketInvalid
221 if (
222 msg.src._SLUG != DevType.HGI # avoid: msg.src.id != gwy.hgi.id
223 and msg.verb != I_
224 and msg.dst != msg.src
225 ):
226 # HGI80 can do what it likes
227 # receiving an I_ isn't currently in the schema & so can't yet be tested
228 _check_dst_slug(msg) # ? raise exc.PacketInvalid
230 if gwy.config.reduce_processing >= DONT_UPDATE_ENTITIES:
231 logger_xxxx(msg) # return ensures try's else: clause won't be invoked
232 return
234 # NOTE: here, msgs are routed only to devices: routing to other entities (i.e.
235 # systems, zones, circuits) is done by those devices (e.g. UFC to UfhCircuit)
237 if isinstance(msg.src, Device): # type: ignore[unreachable]
238 gwy._loop.call_soon(msg.src._handle_msg, msg) # type: ignore[unreachable]
240 # TODO: only be for fully-faked (not Fakable) dst (it picks up via RF if not)
242 if msg.code == Code._1FC9 and msg.payload[SZ_PHASE] == SZ_OFFER:
243 devices = [d for d in gwy.devices if d != msg.src and d._is_binding]
245 elif msg.dst == ALL_DEV_ADDR: # some offers use dst=63:, so after 1FC9 offer
246 devices = [d for d in gwy.devices if d != msg.src and d.is_faked]
248 elif msg.dst is not msg.src and isinstance(msg.dst, Fakeable): # type: ignore[unreachable]
249 # to eavesdrop pkts from other devices, but relevant to this device
250 # dont: msg.dst._handle_msg(msg)
251 devices = [msg.dst] # type: ignore[unreachable]
253 # TODO: this may not be required...
254 elif hasattr(msg.src, SZ_DEVICES): # FIXME: use isinstance()
255 # elif isinstance(msg.src, Controller):
256 # .I --- 22:060293 --:------ 22:060293 0008 002 000C
257 # .I --- 01:054173 --:------ 01:054173 0008 002 03AA
258 # needed for (e.g.) faked relays: each device decides if the pkt is useful
259 devices = msg.src.devices
261 else:
262 devices = []
264 for d in devices: # FIXME: some may be Addresses?
265 gwy._loop.call_soon(d._handle_msg, msg)
267 except (AssertionError, exc.RamsesException, NotImplementedError) as err:
268 (_LOGGER.error if _DBG_INCREASE_LOG_LEVELS else _LOGGER.warning)(
269 "%s < %s(%s)", msg._pkt, err.__class__.__name__, err
270 )
272 except (AttributeError, LookupError, TypeError, ValueError) as err:
273 _LOGGER.exception("%s < %s(%s)", msg._pkt, err.__class__.__name__, err)
275 else:
276 logger_xxxx(msg)
277 if gwy.msg_db:
278 gwy.msg_db.add(msg)
279 # why add it? enable for evohome
282# TODO: this needs cleaning up (e.g. handle intervening packet)
283def detect_array_fragment(this: Message, prev: Message) -> bool: # _PayloadT
284 """Return a merged array if this pkt is the latter half of an array."""
285 # This will work, even if the 2nd pkt._is_array == False as 1st == True
286 # .I --- 01:158182 --:------ 01:158182 000A 048 001201F409C4011101F409C40...
287 # .I --- 01:158182 --:------ 01:158182 000A 006 081001F409C4
289 return bool(
290 prev._has_array
291 and this.code in (Code._000A, Code._22C9) # TODO: not a complete list
292 and this.code == prev.code
293 and this.verb == prev.verb == I_
294 and this.src == prev.src
295 and this.dtm < prev.dtm + _TD_SECONDS_003
296 )