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

1#!/usr/bin/env python3 

2"""RAMSES RF - Decode/process a message (payload into JSON).""" 

3 

4# TODO: 

5# - fix dispatching - what devices (some are Addr) are sent packets, esp. 1FC9s 

6 

7from __future__ import annotations 

8 

9import contextlib 

10import logging 

11from datetime import timedelta as td 

12from typing import TYPE_CHECKING, Final 

13 

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) 

20 

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 

32 

33from .const import ( # noqa: F401, isort: skip, pylint: disable=unused-import 

34 I_, 

35 RP, 

36 RQ, 

37 W_, 

38 Code, 

39) 

40 

41if TYPE_CHECKING: 

42 from . import Gateway 

43 

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) 

50 

51_LOGGER = logging.getLogger(__name__) 

52 

53 

54__all__ = ["detect_array_fragment", "process_msg"] 

55 

56 

57MSG_FORMAT_18 = "|| {:18s} | {:18s} | {:2s} | {:16s} | {:^4s} || {}" 

58 

59_TD_SECONDS_003 = td(seconds=3) 

60 

61 

62def _create_devices_from_addrs(gwy: Gateway, this: Message) -> None: 

63 """Discover and create any new devices using the packet addresses (not payload).""" 

64 

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] 

69 

70 # Devices need to know their controller, ?and their location ('parent' domain) 

71 # NB: only addrs processed here, packet metadata is processed elsewhere 

72 

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 

77 

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 

83 

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 

90 

91 if not gwy.config.enable_eavesdrop: 

92 return 

93 

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] 

97 

98 

99def _check_msg_addrs(msg: Message) -> None: # TODO 

100 """Validate the packet's address set. 

101 

102 Raise InvalidAddrSetError if the metadata is invalid, otherwise simply return. 

103 """ 

104 

105 # TODO: needs work: doesn't take into account device's (non-HVAC) class 

106 

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 ) 

127 

128 

129def _check_src_slug(msg: Message, *, slug: str | None = None) -> None: 

130 """Validate the packet's source device class against its verb/code pair.""" 

131 

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 

136 

137 if slug not in CODES_BY_DEV_SLUG: 

138 raise exc.PacketInvalid(f"{msg!r} < Unknown src slug ({slug}), is it HVAC?") 

139 

140 # 

141 

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") 

144 

145 # 

146 # 

147 

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 ) 

152 

153 

154def _check_dst_slug(msg: Message, *, slug: str | None = None) -> None: 

155 """Validate the packet's destination device class against its verb/code pair.""" 

156 

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 

161 

162 if slug not in CODES_BY_DEV_SLUG: 

163 raise exc.PacketInvalid(f"{msg!r} < Unknown dst slug ({slug}), is it HVAC?") 

164 

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 

167 

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") 

170 

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 

175 

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 ) 

180 

181 

182def process_msg(gwy: Gateway, msg: Message) -> None: 

183 """Decoding the packet payload and route it appropriately.""" 

184 

185 # All methods require msg with a valid payload, except _create_devices_from_addrs(), 

186 # which requires a valid payload only for 000C. 

187 

188 def logger_xxxx(msg: Message) -> None: 

189 """ 

190 Log msg according to src, code, log.debug setting. 

191 

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) 

203 

204 try: # validate / dispatch the packet 

205 _check_msg_addrs(msg) # ?InvalidAddrSetError TODO: ?useful at all 

206 

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 

211 

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 

219 

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 

229 

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 

233 

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) 

236 

237 if isinstance(msg.src, Device): # type: ignore[unreachable] 

238 gwy._loop.call_soon(msg.src._handle_msg, msg) # type: ignore[unreachable] 

239 

240 # TODO: only be for fully-faked (not Fakable) dst (it picks up via RF if not) 

241 

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] 

244 

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] 

247 

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] 

252 

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 

260 

261 else: 

262 devices = [] 

263 

264 for d in devices: # FIXME: some may be Addresses? 

265 gwy._loop.call_soon(d._handle_msg, msg) 

266 

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 ) 

271 

272 except (AttributeError, LookupError, TypeError, ValueError) as err: 

273 _LOGGER.exception("%s < %s(%s)", msg._pkt, err.__class__.__name__, err) 

274 

275 else: 

276 logger_xxxx(msg) 

277 if gwy.msg_db: 

278 gwy.msg_db.add(msg) 

279 # why add it? enable for evohome 

280 

281 

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 

288 

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 )