Coverage for src/ramses_rf/dispatcher.py: 20%

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

7 from __future__ import annotations

8

9 import contextlib

10 import logging

11 from datetime import timedelta as td

12 from typing import TYPE_CHECKING, Final

13

14 from ramses_tx import ALL_DEV_ADDR, CODES_BY_DEV_SLUG, Message

15 from ramses_tx.ramses import (

16 CODES_OF_HEAT_DOMAIN,

17 CODES_OF_HEAT_DOMAIN_ONLY,

18 CODES_OF_HVAC_DOMAIN_ONLY,

19)

20

21 from . import exceptions as exc

22 from .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)

31 from .device import Device, Fakeable

32

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

34 I_,

35 RP,

36 RQ,

37 W_,

38 Code,

39)

40

41 if 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

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

58

59 _TD_SECONDS_003 = td(seconds=3)

60

61

62 def _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

99 def _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

129 def _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

154 def _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

182 def 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)

283 def 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 )