Coverage for src/ramses_rf/schemas.py: 65%

1#!/usr/bin/env python3

2"""RAMSES RF - a RAMSES-II protocol decoder & analyser.

3

4:term:`Schema` processor for upper layer.

5"""

6

7 from __future__ import annotations

8

9 import logging

10 import re

11 from collections.abc import Callable

12 from typing import TYPE_CHECKING, Any, Final

13

14 import voluptuous as vol

15

16# TODO: deprecate re-exporting (via as) in favour of direct imports

17 from ramses_tx.const import (

18 SZ_ACTUATORS as SZ_ACTUATORS,

19 SZ_CONFIG as SZ_CONFIG,

20 SZ_DEVICES as SZ_DEVICES,

21 SZ_NAME,

22 SZ_SENSOR as SZ_SENSOR,

23 SZ_ZONE_TYPE,

24 SZ_ZONES,

25)

26

27# TODO: deprecate re-exporting (via as) in favour of direct imports

28 from ramses_tx.schemas import ( # noqa: F401

29 SCH_DEVICE_ID_ANY,

30 SCH_DEVICE_ID_APP,

31 SCH_DEVICE_ID_BDR,

32 SCH_DEVICE_ID_CTL,

33 SCH_DEVICE_ID_DHW,

34 SCH_DEVICE_ID_HGI,

35 SCH_DEVICE_ID_SEN,

36 SCH_DEVICE_ID_UFC,

37 SCH_ENGINE_DICT,

38 SCH_GLOBAL_TRAITS_DICT,

39 SCH_TRAITS as SCH_TRAITS,

40 SZ_ALIAS as SZ_ALIAS,

41 SZ_BLOCK_LIST,

42 SZ_BOUND_TO as SZ_BOUND_TO,

43 SZ_CLASS as SZ_CLASS,

44 SZ_DISABLE_SENDING,

45 SZ_ENFORCE_KNOWN_LIST,

46 SZ_FAKED as SZ_FAKED,

47 SZ_KNOWN_LIST as SZ_KNOWN_LIST,

48 SZ_PACKET_LOG,

49 SZ_SCHEME as SZ_SCHEME,

50 DeviceIdT,

51 sch_packet_log_dict_factory,

52 select_device_filter_mode,

53)

54

55 from . import exceptions as exc

56

57# TODO: deprecate re-exporting (via as) in favour of direct imports

58 from .const import (

59 DEFAULT_MAX_ZONES as DEFAULT_MAX_ZONES,

60 DEV_ROLE_MAP,

61 DEV_TYPE_MAP,

62 DEVICE_ID_REGEX,

63 DONT_CREATE_MESSAGES,

64 SZ_ZONE_IDX,

65 ZON_ROLE_MAP,

66 DevRole,

67 DevType,

68 SystemType,

69)

70

71 if TYPE_CHECKING:

72 from .device import Device

73 from .gateway import Gateway

74 from .system import Evohome

75

76

77 _LOGGER = logging.getLogger(__name__)

78

79

80#

81# 0/5: Schema strings

82 SZ_SCHEMA: Final = "schema"

83 SZ_MAIN_TCS: Final = "main_tcs"

84

85 SZ_CONTROLLER = DEV_TYPE_MAP[DevType.CTL]

86 SZ_SYSTEM: Final = "system"

87 SZ_APPLIANCE_CONTROL = DEV_ROLE_MAP[DevRole.APP]

88 SZ_ORPHANS: Final = "orphans"

89 SZ_ORPHANS_HEAT: Final = "orphans_heat"

90 SZ_ORPHANS_HVAC: Final = "orphans_hvac"

91

92 SZ_DHW_SYSTEM: Final = "stored_hotwater"

93 SZ_DHW_SENSOR = DEV_ROLE_MAP[DevRole.DHW]

94 SZ_DHW_VALVE = DEV_ROLE_MAP[DevRole.HTG]

95 SZ_HTG_VALVE = DEV_ROLE_MAP[DevRole.HT1]

96

97 SZ_SENSOR_FAKED: Final = "sensor_faked"

98

99 SZ_UFH_SYSTEM: Final = "underfloor_heating"

100 SZ_UFH_CTL = DEV_TYPE_MAP[DevType.UFC] # ufh_controller

101 SZ_CIRCUITS: Final = "circuits"

102

103 HEAT_ZONES_STRS = tuple(ZON_ROLE_MAP[t] for t in ZON_ROLE_MAP.HEAT_ZONES)

104

105 SCH_DOM_ID = vol.Match(r"^[0-9A-F]{2}$")

106 SCH_UFH_IDX = vol.Match(r"^0[0-8]$")

107 SCH_ZON_IDX = vol.Match(r"^0[0-9AB]$") # TODO: what if > 12 zones? (e.g. hometronics)

108

109

110 def ErrorRenamedKey(new_key: str) -> Callable[[Any], None]:

111 def renamed_key(node_value: Any) -> None:

112 raise vol.Invalid(f"the key name has changed: rename it to '{new_key}'")

113

114 return renamed_key

115

116

117#

118# 1/7: Schemas for CH/DHW systems, aka Heat/TCS (temp control systems)

119 SCH_TCS_SYS_CLASS = (SystemType.EVOHOME, SystemType.HOMETRONICS, SystemType.SUNDIAL)

120 SCH_TCS_SYS = vol.Schema(

121 {

122 vol.Required(SZ_APPLIANCE_CONTROL, default=None): vol.Any(

123 None, SCH_DEVICE_ID_APP

124 ),

125 vol.Optional("heating_control"): ErrorRenamedKey(SZ_APPLIANCE_CONTROL),

126 },

127 extra=vol.PREVENT_EXTRA,

128)

129

130 SCH_TCS_DHW = vol.Schema(

131 {

132 vol.Optional(SZ_SENSOR, default=None): vol.Any(None, SCH_DEVICE_ID_DHW),

133 vol.Optional(SZ_DHW_VALVE, default=None): vol.Any(None, SCH_DEVICE_ID_BDR),

134 vol.Optional(SZ_HTG_VALVE, default=None): vol.Any(None, SCH_DEVICE_ID_BDR),

135 vol.Optional(SZ_DHW_SENSOR): ErrorRenamedKey(SZ_SENSOR),

136 },

137 extra=vol.PREVENT_EXTRA,

138)

139

140 _CH_TCS_UFH_CIRCUIT = vol.Schema(

141 {

142 vol.Required(SCH_UFH_IDX): vol.Schema(

143 {vol.Optional(SZ_ZONE_IDX): SCH_ZON_IDX},

144 ),

145 },

146 extra=vol.PREVENT_EXTRA,

147)

148 SCH_TCS_UFH = vol.All(

149 vol.Schema(

150 {

151 vol.Required(SCH_DEVICE_ID_UFC): vol.Any(

152 None, {vol.Optional(SZ_CIRCUITS): vol.Any(None, dict)}

153 )

154 }

155 ),

156 vol.Length(min=1, max=3),

157 extra=vol.PREVENT_EXTRA,

158)

159

160 SCH_TCS_ZONES_ZON = vol.Schema(

161 {

162 vol.Optional(SZ_CLASS, default=None): vol.Any(None, *HEAT_ZONES_STRS),

163 vol.Optional(SZ_SENSOR, default=None): vol.Any(None, SCH_DEVICE_ID_SEN),

164 vol.Optional(SZ_DEVICES): ErrorRenamedKey(SZ_ACTUATORS),

165 vol.Optional(SZ_ACTUATORS, default=[]): vol.All(

166 [SCH_DEVICE_ID_ANY], vol.Length(min=0)

167 ),

168 vol.Optional(SZ_ZONE_TYPE): ErrorRenamedKey(SZ_CLASS),

169 vol.Optional("zone_sensor"): ErrorRenamedKey(SZ_SENSOR),

170 # vol.Optional(SZ_SENSOR_FAKED): bool,

171 vol.Optional(f"_{SZ_NAME}"): vol.Any(None, str),

172 },

173 extra=vol.PREVENT_EXTRA,

174)

175 SCH_TCS_ZONES = vol.All(

176 vol.Schema({vol.Required(SCH_ZON_IDX): SCH_TCS_ZONES_ZON}),

177 vol.Length(min=1, max=12),

178 extra=vol.PREVENT_EXTRA,

179)

180

181 SCH_TCS = vol.Schema(

182 {

183 vol.Optional(SZ_SYSTEM, default={}): vol.Any({}, SCH_TCS_SYS),

184 vol.Optional(SZ_DHW_SYSTEM, default={}): vol.Any({}, SCH_TCS_DHW),

185 vol.Optional(SZ_UFH_SYSTEM, default={}): vol.Any({}, SCH_TCS_UFH),

186 vol.Optional(SZ_ORPHANS, default=[]): vol.All(

187 [SCH_DEVICE_ID_ANY], vol.Unique()

188 ),

189 vol.Optional(SZ_ZONES, default={}): vol.Any({}, SCH_TCS_ZONES),

190 vol.Optional(vol.Remove("is_tcs")): vol.Coerce(bool),

191 },

192 extra=vol.PREVENT_EXTRA,

193)

194

195

196#

197# 2/7: Schemas for Ventilation control systems, aka HVAC/VCS

198 SZ_REMOTES: Final = "remotes"

199 SZ_SENSORS: Final = "sensors"

200

201 SCH_VCS_DATA = vol.Schema(

202 {

203 vol.Optional(SZ_REMOTES, default=[]): vol.All(

204 [SCH_DEVICE_ID_ANY],

205 vol.Unique(), # vol.Length(min=1)

206 ),

207 vol.Optional(SZ_SENSORS, default=[]): vol.All(

208 [SCH_DEVICE_ID_ANY],

209 vol.Unique(), # vol.Length(min=1)

210 ),

211 vol.Optional(vol.Remove("is_vcs")): vol.Coerce(bool),

212 },

213 extra=vol.PREVENT_EXTRA,

214)

215 SCH_VCS_KEYS = vol.Schema(

216 {

217 vol.Required(

218 vol.Any(SZ_REMOTES, SZ_SENSORS),

219 msg=(

220 "The ventilation control system schema must include at least "

221 f"one of [{SZ_REMOTES}, {SZ_SENSORS}]"

222 ),

223 ): object

224 },

225 extra=vol.ALLOW_EXTRA, # must be ALLOW_EXTRA, as is a subset of SCH_VCS_DATA

226)

227 SCH_VCS = vol.All(SCH_VCS_KEYS, SCH_VCS_DATA)

228

229

230#

231# 3/7: Global Schema for Heat/HVAC systems

232 SCH_GLOBAL_SCHEMAS_DICT = { # System schemas - can be 0-many Heat/HVAC schemas

233 # orphans are devices to create that won't be in a (cached) schema...

234 vol.Optional(SZ_MAIN_TCS): vol.Any(None, SCH_DEVICE_ID_CTL),

235 vol.Optional(vol.Remove("main_controller")): vol.Any(None, SCH_DEVICE_ID_CTL),

236 vol.Optional(SCH_DEVICE_ID_CTL): vol.Any(SCH_TCS, SCH_VCS),

237 vol.Optional(SCH_DEVICE_ID_ANY): SCH_VCS, # must be after SCH_DEVICE_ID_CTL

238 vol.Optional(SZ_ORPHANS_HEAT): vol.All([SCH_DEVICE_ID_ANY], vol.Unique()),

239 vol.Optional(SZ_ORPHANS_HVAC): vol.All([SCH_DEVICE_ID_ANY], vol.Unique()),

240 vol.Optional("transport_constructor"): vol.Any(callable, None),

241}

242 SCH_GLOBAL_SCHEMAS = vol.Schema(SCH_GLOBAL_SCHEMAS_DICT, extra=vol.PREVENT_EXTRA)

243

244#

245# 4/7: Gateway (parser/state) configuration

246 SZ_DISABLE_DISCOVERY: Final = "disable_discovery"

247 SZ_ENABLE_EAVESDROP: Final = "enable_eavesdrop"

248 SZ_MAX_ZONES: Final = "max_zones" # TODO: move to TCS-attr from GWY-layer

249 SZ_REDUCE_PROCESSING: Final = "reduce_processing"

250 SZ_USE_ALIASES: Final = "use_aliases" # use friendly device names from known_list

251 SZ_USE_NATIVE_OT: Final = "use_native_ot" # favour OT (3220s) over RAMSES

252

253 SCH_GATEWAY_DICT = {

254 vol.Optional(SZ_DISABLE_DISCOVERY, default=False): bool,

255 vol.Optional(SZ_ENABLE_EAVESDROP, default=False): bool,

256 vol.Optional(SZ_MAX_ZONES, default=DEFAULT_MAX_ZONES): vol.All(

257 int, vol.Range(min=1, max=16)

258 ), # NOTE: no default

259 vol.Optional(SZ_REDUCE_PROCESSING, default=0): vol.All(

260 int, vol.Range(min=0, max=DONT_CREATE_MESSAGES)

261 ),

262 vol.Optional(SZ_USE_ALIASES, default=False): bool,

263 vol.Optional(SZ_USE_NATIVE_OT, default="prefer"): vol.Any(

264 "always", "prefer", "avoid", "never"

265 ),

266}

267 SCH_GATEWAY_CONFIG = vol.Schema(SCH_GATEWAY_DICT, extra=vol.REMOVE_EXTRA)

268

269

270#

271# 5/7: the Global (gateway) Schema

272 SCH_GLOBAL_CONFIG = (

273 vol.Schema(

274 {

275 # Gateway/engine Configuration, incl. packet_log, serial_port params...

276 vol.Optional(SZ_CONFIG, default={}): SCH_GATEWAY_DICT | SCH_ENGINE_DICT

277 },

278 extra=vol.PREVENT_EXTRA,

279 )

280 .extend(SCH_GLOBAL_SCHEMAS_DICT)

281 .extend(SCH_GLOBAL_TRAITS_DICT)

282 .extend(sch_packet_log_dict_factory(default_backups=0))

283)

284

285

286#

287# 6/7: External Schemas, to be used by clients of this library

288 def NormaliseRestoreCache() -> Callable[[bool | dict[str, bool]], dict[str, bool]]:

289 """Convert a shorthand restore_cache bool to a dict.

290

291 restore_cache: bool -> restore_cache:

292 restore_schema: bool

293 restore_state: bool

294 """

295

296 def normalise_restore_cache(node_value: bool | dict[str, bool]) -> dict[str, bool]:

297 if isinstance(node_value, dict):

298 return node_value

299 return {SZ_RESTORE_SCHEMA: node_value, SZ_RESTORE_STATE: node_value}

300

301 return normalise_restore_cache

302

303

304 SZ_RESTORE_CACHE: Final = "restore_cache"

305 SZ_RESTORE_SCHEMA: Final = "restore_schema"

306 SZ_RESTORE_STATE: Final = "restore_state"

307

308 SCH_RESTORE_CACHE_DICT = {

309 vol.Optional(SZ_RESTORE_CACHE, default=True): vol.Any(

310 vol.All(bool, NormaliseRestoreCache()),

311 vol.Schema(

312 {

313 vol.Optional(SZ_RESTORE_SCHEMA, default=True): bool,

314 vol.Optional(SZ_RESTORE_STATE, default=True): bool,

315 }

316 ),

317 )

318}

319

320

321#

322# 7/7: Other stuff

323 def _get_device(gwy: Gateway, dev_id: DeviceIdT, **kwargs: Any) -> Device: # , **traits

324 """Get a device from the gateway.

325

326 Raise a LookupError if a device_id is filtered out by the known or block list.

327

328 The underlying method is wrapped only to provide a better error message.

329 """

330

331 def check_filter_lists(dev_id: DeviceIdT) -> None:

332 """Raise a LookupError if a device_id is filtered out by a list."""

333

334 err_msg = None

335 if gwy._enforce_known_list and dev_id not in gwy._include:

336 err_msg = f"it is in the {SZ_SCHEMA}, but not in the {SZ_KNOWN_LIST}"

337 # issue ramses_cc #296: if enforce_known_list is turned on, error on any "unknown" dev_id

338 # fix: delete from schema?

339 if dev_id in gwy._exclude:

340 err_msg = f"it is in the {SZ_SCHEMA}, but also in the {SZ_BLOCK_LIST}"

341

342 if err_msg:

343 raise LookupError(

344 f"Can't create {dev_id}: {err_msg} (check the lists and the {SZ_SCHEMA})"

345 )

346

347 check_filter_lists(dev_id)

348

349 return gwy.get_device(dev_id, **kwargs)

350

351

352 def load_schema(

353 gwy: Gateway, known_list: dict[DeviceIdT, Any] | None = None, **schema: Any

354) -> None:

355 """Instantiate all entities in the schema, and faked devices in the known_list."""

356

357 from .device import Fakeable # circular import

358

359 known_list = known_list or {}

360

361 # schema: dict = SCH_GLOBAL_SCHEMAS_DICT(schema)

362

363 [

364 load_tcs(gwy, ctl_id, schema) # type: ignore[arg-type]

365 for ctl_id, schema in schema.items()

366 if re.match(DEVICE_ID_REGEX.ANY, ctl_id) and SZ_REMOTES not in schema

367 ]

368 if schema.get(SZ_MAIN_TCS):

369 gwy._tcs = gwy.system_by_id.get(schema[SZ_MAIN_TCS])

370 [

371 load_fan(gwy, fan_id, schema) # type: ignore[arg-type]

372 for fan_id, schema in schema.items()

373 if re.match(DEVICE_ID_REGEX.ANY, fan_id) and SZ_REMOTES in schema

374 ]

375 [ # NOTE: class favoured, domain ignored

376 _get_device(gwy, device_id) # domain=key[-4:])

377 for key in (SZ_ORPHANS_HEAT, SZ_ORPHANS_HVAC)

378 for device_id in schema.get(key, [])

379 ] # TODO: pass domain (Heat/HVAC), or generalise to SZ_ORPHANS

380

381 # create any devices in the known list that are faked, or fake those already created

382 for device_id, traits in known_list.items():

383 if traits.get(SZ_FAKED):

384 dev = _get_device(gwy, device_id) # , **traits)

385 if not isinstance(dev, Fakeable):

386 raise exc.SystemSchemaInconsistent(f"Device is not fakeable: {dev}")

387 if not dev.is_faked:

388 dev._make_fake()

389

390

391 def load_fan(gwy: Gateway, fan_id: DeviceIdT, schema: dict[str, Any]) -> Device:

392 """Create a FAN using its schema (i.e. with remotes, sensors)."""

393

394 fan = _get_device(gwy, fan_id)

395 # fan._update_schema(**schema) # TODO

396

397 return fan

398

399

400 def load_tcs(gwy: Gateway, ctl_id: DeviceIdT, schema: dict[str, Any]) -> Evohome:

401 """Create a TCS using its schema."""

402 # print(schema)

403 # schema = SCH_TCS_ZONES_ZON(schema)

404

405 ctl = _get_device(gwy, ctl_id)

406 ctl.tcs._update_schema(**schema)

407

408 for dev_id in schema.get(SZ_UFH_SYSTEM, {}): # UFH controllers

409 _get_device(gwy, dev_id, parent=ctl.tcs) # , **_schema)

410

411 for dev_id in schema.get(SZ_ORPHANS, []):

412 _get_device(gwy, dev_id, parent=ctl)

413

414 # if DEV_MODE:

415 # import json

416

417 # src = json.dumps(shrink(schema), sort_keys=True)

418 # dst = json.dumps(shrink(gwy.system_by_id[ctl.id].schema), sort_keys=True)

419 # # assert dst == src, "They don't match!"

420 # print(src)

421 # print(dst)

422

423 return ctl.tcs