Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
hub_validators.py
Go to the documentation of this file.
1## @file
2## @brief Field validators and option tables for the ``home_io_control:`` hub block.
3## @ingroup hioc_codegen
4##
5## Radio type, PA pin, TCXO voltage and FEM profile tables (``validate_radio_transport`` checks
6## that an SPI radio has its chip-select pin, ``validate_fem`` a profile's required pins and
7## TX-power ceiling), the device-type and manufacturer
8## tables, and the validators for node IDs, system keys, device IDs, linked remotes and
9## status-poll intervals that the hub schema and the platform modules share.
10##
11## DEVICE_TYPE_OPTIONS, MANUFACTURER_OPTIONS, PA_PIN_OPTIONS, TCXO_VOLTAGE_OPTIONS and
12## FEM_TX_POWER_MAX_QUIET mirror C++ tables; the ``tests/sync/`` host tests and
13## ``make yaml-emitter-sync`` read them from this file by name.
14
15import logging
16
17import esphome.codegen as cg
18import esphome.config_validation as cv
19from esphome.const import CONF_CS_PIN, CONF_DEVICE_ID
20
21from .hub_names import (
22 CONF_FEM,
23 CONF_FEM_EN_PIN,
24 CONF_FEM_PA_PIN,
25 CONF_RADIO_TYPE,
26 CONF_TX_POWER,
27 CONF_VFEM_PIN,
28 MIN_RAIN_SENSOR_POLL_INTERVAL_MS,
29 MIN_STATUS_POLL_INTERVAL_MS,
30 home_io_control_ns,
31)
32
33_LOGGER = logging.getLogger(__name__)
34
35
36PA_PIN_OPTIONS = {
37 "BOOST": 0x80,
38 "RFO": 0x00,
39}
40
41RADIO_TYPE_OPTIONS = {
42 "sx1276": "sx1276",
43 "sx1262": "sx1262",
44 "lr1121": "lr1121",
45}
46
47# Radio types whose chip sits on the SPI bus, so the hub registers as an SPI device for them and
48# needs its chip-select pin. Every radio type is one today; a radio reached some other way is
49# left out of this set.
50SPI_RADIO_TYPES = frozenset({"sx1276", "sx1262", "lr1121"})
51
52# 0-based voltage enum shared verbatim by the SX1262 SetDIO3AsTCXOCtrl and LR1121 SetTcxoMode
53# commands (0x00 = 1.6 V .. 0x07 = 3.3 V; Semtech SX1261/2 datasheet Table 13-35). Both drivers
54# pass the generated integer straight through to the chip. The YAML strings ("1_8V" etc.) are
55# unchanged, so this is not a user-visible config change. "NONE" (0xFF) is a sentinel for boards
56# with a bare crystal instead of a TCXO: the driver skips the DIO3/TCXO programming entirely.
57TCXO_VOLTAGE_OPTIONS = {
58 "1_6V": 0x00,
59 "1_7V": 0x01,
60 "1_8V": 0x02,
61 "2_2V": 0x03,
62 "2_4V": 0x04,
63 "2_7V": 0x05,
64 "3_0V": 0x06,
65 "3_3V": 0x07,
66 "NONE": 0xFF,
67}
68
69# Which RF front-end part is fitted (ADR 0035). Part names, not board names -- board names
70# belong to config/boards/heltec-v4-*.yaml. "Part", not "chip": gc1109/kct8103l are each a single
71# bare FEM IC, but xy16p35 names an RF module with no single chip inside it independently
72# identifiable as "the FEM chip" from anything published -- see FEM_REQUIRED_PINS' own comment and
73# radio_interface.h's FemProfile doc block for the fuller explanation. Validated with cv.one_of
74# (not cv.enum) so config[CONF_FEM] stays a plain string usable in ordinary Python comparisons in
75# validate_fem() below; the mapping to the generated C++ enum happens explicitly in to_code(),
76# same pattern as ONEWAY_COMMANDS.
77FemProfile = home_io_control_ns.enum("FemProfile", is_class=True)
78FEM_PROFILES = {
79 "none": FemProfile.NONE,
80 "gc1109": FemProfile.GC1109, # Heltec WiFi LoRa 32 V4.2
81 "kct8103l": FemProfile.KCT8103L, # Heltec WiFi LoRa 32 V4.3 / V4 R8
82 "xy16p35": FemProfile.XY16P35, # LilyGO T-Beam 1W SX1262
83}
84
85# Config keys a `fem:` profile actually drives. Every profile requires vfem_pin (the FEM/module
86# power enable) and fem_pa_pin (the mode pin whose active-during-TX level is profile-dependent --
87# see FemProfile's own doc comment in radio_interface.h). fem_en_pin (CSD, a secondary chip-enable
88# some front-end parts expose) is required only where the part actually has one: GC1109 and
89# KCT8103L do, XY16P35 does not (LilyGO's own datasheet names only "LDO EN" and "LNA Ctrl" --
90# no third pin).
91# `fem:` never supplies the GPIO numbers themselves; those always come from the board's own
92# config/boards/*.yaml, exactly like every other radio pin in this schema.
93FEM_REQUIRED_PINS = {
94 "gc1109": (CONF_VFEM_PIN, CONF_FEM_EN_PIN, CONF_FEM_PA_PIN),
95 "kct8103l": (CONF_VFEM_PIN, CONF_FEM_EN_PIN, CONF_FEM_PA_PIN),
96 "xy16p35": (CONF_VFEM_PIN, CONF_FEM_PA_PIN),
97}
98
99# Highest tx_power setting whose estimated antenna-port power still stays at or under the
100# 868 MHz SRD ERP limit (+14 dBm); validate_fem() warns on anything above it. Derived from the
101# same low-drive net-gain figures as SX1262_FEM_GAIN_*_DB in radio_sx1262.cpp (GC1109 ~+11 dB ->
102# 14 dBm at tx_power 3, 15 dBm at 4; KCT8103L ~+13 dB -> 14 dBm at tx_power 1, 15 dBm at 2;
103# XY16P35 ~+14 dB (measured; the PA's own nominal spec is +12dB) -> 14 dBm at tx_power 0, 15 dBm
104# at 1). Kept here by hand since Python and C++ share no header; the driver's own boot-time
105# ESP_LOGW carries the authoritative per-tx_power estimate and its uncertainty.
106FEM_TX_POWER_MAX_QUIET = {
107 "gc1109": 3,
108 "kct8103l": 1,
109 "xy16p35": 0,
110}
111
112
114 """Require the SPI device keys for a radio on the SPI bus. The schema takes them as optional
115 (``spi.spi_device_schema(False, ...)``) so that the requirement follows ``radio_type`` here,
116 in one place, rather than being hard-wired into the schema for every radio."""
117 radio_type = config[CONF_RADIO_TYPE]
118 if radio_type in SPI_RADIO_TYPES and CONF_CS_PIN not in config:
119 raise cv.Invalid(
120 f"radio_type: {radio_type} is an SPI radio and needs cs_pin: (its chip-select GPIO); "
121 "a board package in config/boards/ sets it for you",
122 path=[CONF_CS_PIN],
123 )
124 return config
125
126
127def validate_fem(config):
128 """Cross-key validation for `fem:` (ADR 0035): which front-end part's control behaviour the
129 SX1262 driver applies to fem_pa_pin (and, for the two parts that have one, fem_en_pin), never
130 which GPIO numbers to use for them -- those are always the board package's job, the same as
131 every other radio pin. Requires radio_type: sx1262 (the raw FEM pins are silently ignored on
132 every other chip -- select_and_construct_radio_() only ever hands them to RadioSX1262 -- so a
133 mismatched radio_type would configure hardware the driver never drives), and that every pin
134 this profile's behaviour needs is actually present, naming exactly which is missing rather
135 than leaving the driver to silently no-op on a null pin. Also warns when tx_power looks likely
136 to push this FEM's antenna-port power over a typical 868 MHz SRD limit -- radio_sx1262.cpp's
137 own init() logs the actual per-part estimate this warning can only gesture at without knowing
138 the front-end part's exact gain table here too.
139 """
140 profile = config[CONF_FEM]
141 if profile == "none":
142 return config
143
144 if config[CONF_RADIO_TYPE] != "sx1262":
145 raise cv.Invalid(
146 f"fem: {profile} requires radio_type: sx1262 -- the front-end module pins are only "
147 "wired to the SX1262 driver"
148 )
149
150 missing = [key for key in FEM_REQUIRED_PINS[profile] if key not in config]
151 if missing:
152 raise cv.Invalid(
153 f"fem: {profile} requires {', '.join(missing)} to be set -- see "
154 "docs/hardware.md#front-end-module-fem-support for what your board needs"
155 )
156
157 if config[CONF_TX_POWER] > FEM_TX_POWER_MAX_QUIET[profile]:
158 _LOGGER.warning(
159 "fem: %s together with tx_power: %d -- a front-end module turns tx_power into a "
160 "much larger antenna-port power than the bare SX1262 would radiate, and at this "
161 "setting the estimate is likely over a typical 868 MHz SRD ERP limit (+14 dBm). The "
162 "driver logs its own per-profile estimate (and its uncertainty) at boot -- check it, "
163 "and prefer tx_power: %d or lower (what this profile's own board package ships) "
164 "until you have measured the actual radiated power for your board",
165 profile,
166 config[CONF_TX_POWER],
167 FEM_TX_POWER_MAX_QUIET[profile],
168 )
169
170 return config
171
172
173# The wire carries the node type in ten bits (0..1023). The names below are the types a YAML entity
174# can declare; the table that names every node type and subtype is proto_device_types.cpp, and
175# tests/sync/device_type_sync_test.cpp keeps the two in step.
176DEVICE_TYPE_MAX = 0x3FF
177
178DEVICE_TYPE_OPTIONS = {
179 "unknown": 0x00,
180 "interior_venetian_blind": 0x01,
181 "roller_shutter": 0x02,
182 "vertical_exterior_awning": 0x03,
183 "window_opener": 0x04,
184 "garage_opener": 0x05,
185 "light": 0x06,
186 "gate_opener": 0x07,
187 "rolling_door_opener": 0x08,
188 "lock": 0x09,
189 "vertical_interior_blind": 0x0A,
190 "dual_roller_shutter": 0x0D,
191 "heating_temperature_interface": 0x0E,
192 "on_off_switch": 0x0F,
193 "horizontal_awning": 0x10,
194 "exterior_venetian_blind": 0x11,
195 "louvre_blind": 0x12,
196 "curtain_track": 0x13,
197 "exterior_heating": 0x15,
198 "heat_pump": 0x16,
199 "intrusion_alarm": 0x17,
200 "swinging_shutter": 0x18,
201 "sliding_window": 0x1B,
202 "bioclimatic_pergola": 0x1D,
203 "electrical_heater": 0x34,
204}
205
206# Heating profiles (proto_heating_profiles.cpp) that have a window sensor and an open-window
207# detection switch, as {io_device_type name: io_subtype}. climate.py generates those two companions
208# for exactly these devices; tests/sync/heating_profile_sync_test.cpp keeps the table in step with
209# the C++ profile features.
210HEATING_WINDOW_PROFILES = {
211 "electrical_heater": 1,
212}
213
214
215def heating_profile_has_window(device_type, subtype):
216 """True when the heating profile of (io_device_type value, io_subtype) has the window companions."""
217 return any(
218 DEVICE_TYPE_OPTIONS[name] == device_type and profile_subtype == subtype
219 for name, profile_subtype in HEATING_WINDOW_PROFILES.items()
220 )
221
222
223# Device-type names that were renamed. Each maps to the message the validator raises, so a config
224# that still uses one fails with the name to switch to rather than a generic "unknown name".
225RENAMED_DEVICE_TYPES = {
226 "screen": (
227 "device type 'screen' no longer exists: type 11 is a secure configuration device, not an "
228 "actuator. Use the type Scan Paired Devices reports for your device, usually "
229 "'vertical_exterior_awning' or 'vertical_interior_blind'"
230 ),
231 "blind": "device type 'blind' was renamed: use 'vertical_interior_blind' instead",
232 "venetian_blind": "device type 'venetian_blind' was renamed: use 'interior_venetian_blind' instead",
233 "awning": "device type 'awning' was renamed: use 'vertical_exterior_awning' instead",
234 "dual_shutter": "device type 'dual_shutter' was renamed: use 'dual_roller_shutter' instead",
235 "external_venetian_blind": (
236 "device type 'external_venetian_blind' was renamed: use 'exterior_venetian_blind' instead"
237 ),
238}
239
240
241# Mirrors proto_constants.h's MANUFACTURER_* constants (IO-Homecontrol alliance-assigned IDs) --
242# lowercased versions of those constant names, so a name typo'd here is easy to spot against the
243# C++ source. Not every possible byte has a name (MANUFACTURER_ID_MAX=12); an identity whose real
244# manufacturer isn't in this table still works via the raw-integer escape hatch every caller of
245# _resolve_named_or_raw_token() below shares.
246MANUFACTURER_OPTIONS = {
247 "velux": 0x01,
248 "somfy": 0x02,
249 "honeywell": 0x03,
250 "hormann": 0x04,
251 "assa_abloy": 0x05,
252 "niko": 0x06,
253 "window_master": 0x07,
254 "renson": 0x08,
255 "ciat": 0x09,
256 "secuyou": 0x0A,
257 "overkiz": 0x0B,
258 "atlantic_group": 0x0C,
259}
260
261# Manufacturer bytes for which oneway_controller.h's resolve_oneway_wire_profile() has a real 1W
262# wire profile. Keep this set in sync with that C++ switch — two values, and there is no automated
263# check (scripts/check-yaml-emitters.py compares key names, not table contents).
264ONEWAY_WIRE_PROFILE_MANUFACTURERS = {
265 MANUFACTURER_OPTIONS["somfy"],
266 MANUFACTURER_OPTIONS["velux"],
267}
268
269
270def _resolve_named_or_raw_token(token, options, max_value=0xFF):
271 """Resolve a lowercase, stripped token (a name from `options`, or a raw int/hex string) to
272 an integer 0-`max_value`.
273
274 Shared "named value, else raw integer" acceptance rule: validate_device_type()/
275 validate_linked_remote_entry() (DEVICE_TYPE_OPTIONS) and validate_manufacturer()
276 (MANUFACTURER_OPTIONS) are the same shape of small, protocol-defined enum with an escape
277 hatch for values this project hasn't named yet, so the lookup lives here once.
278 @raises ValueError if token is neither a known name nor a parseable integer.
279 @raises cv.Invalid if token parses as an integer but is out of range.
280 """
281 if token in options:
282 return options[token]
283 return cv.int_range(min=0, max=max_value)(int(token, 0))
284
285
287 """Resolve a lowercase, stripped device-type token (name or raw int/hex string) to 0-1023.
288
289 Single source of truth for the "named value from DEVICE_TYPE_OPTIONS, else raw integer"
290 acceptance rule shared by validate_device_type() (io_device_type) and
291 validate_linked_remote_entry() (the class:<device_type> linked-remotes form) — both accept
292 the exact same set of device-type spellings, so the lookup lives here once.
293 @raises ValueError if token is neither a known name nor a parseable integer.
294 @raises cv.Invalid if token is a renamed device-type name (naming the new one), or parses as an
295 integer but is out of range 0-1023.
296 """
297 if token in RENAMED_DEVICE_TYPES:
298 raise cv.Invalid(RENAMED_DEVICE_TYPES[token])
299 return _resolve_named_or_raw_token(token, DEVICE_TYPE_OPTIONS, DEVICE_TYPE_MAX)
300
301
303 """Validate io_device_type as a named string or integer 0-1023."""
304 if isinstance(value, int):
305 return cv.int_range(min=0, max=DEVICE_TYPE_MAX)(value)
306
307 if isinstance(value, str):
308 normalized = cv.string_strict(value).strip().lower()
309 try:
310 return _resolve_device_type_token(normalized)
311 except ValueError as err:
312 raise cv.Invalid(
313 "Device type must be a known name or an integer in the range 0..1023 (for example 0x11)"
314 ) from err
315
316 raise cv.Invalid(
317 "Device type must be a known name or an integer in the range 0..1023"
318 )
319
320
322 """Validate manufacturer as a named string (MANUFACTURER_OPTIONS) or integer 0-255.
323
324 Mirrors validate_device_type() exactly (same "name, else raw integer" shape via
325 _resolve_named_or_raw_token()) — a manufacturer ID is the same kind of small,
326 protocol-defined enum, it just has no linked_remotes-style second caller.
327 """
328 if isinstance(value, int):
329 return cv.int_range(min=0, max=0xFF)(value)
330
331 if isinstance(value, str):
332 normalized = cv.string_strict(value).strip().lower()
333 try:
334 return _resolve_named_or_raw_token(normalized, MANUFACTURER_OPTIONS)
335 except ValueError as err:
336 raise cv.Invalid(
337 "manufacturer must be a known name or an integer in the range 0..255 (for example 0x02)"
338 ) from err
339
340 raise cv.Invalid("manufacturer must be a known name or an integer in the range 0..255")
341
342
344 """Generate a C++ static_cast expression for a validated device type."""
345 return cg.RawExpression(
346 f"static_cast<esphome::home_io_control::DeviceType>(0x{value:02X})"
347 )
348
349
351 """Validate node_id as exactly 6 hex characters (3 bytes)."""
352 value = cv.string_strict(value).upper()
353 if len(value) != 6:
354 raise cv.Invalid("Node ID must be exactly 6 hex characters (3 bytes)")
355 try:
356 int(value, 16)
357 except ValueError:
358 raise cv.Invalid("Node ID must be valid hexadecimal")
359 return value
360
361
363 """Validate system_key as exactly 32 hex characters (16 bytes)."""
364 value = cv.string_strict(value).upper()
365 if len(value) != 32:
366 raise cv.Invalid("System key must be exactly 32 hex characters (16 bytes)")
367 try:
368 int(value, 16)
369 except ValueError:
370 raise cv.Invalid("System key must be valid hexadecimal")
371 return value
372
373
375 """Validate io_device_id as exactly 6 hex characters (3 bytes)."""
376 value = cv.string_strict(value).upper()
377 if len(value) != 6:
378 raise cv.Invalid("Device ID must be exactly 6 hex characters (3 bytes)")
379 try:
380 int(value, 16)
381 except ValueError:
382 raise cv.Invalid("Device ID must be valid hexadecimal")
383 return value
384
385
386def inherit_esphome_device(companion_config, parent_config):
387 """Propagate the parent entity's ESPHome sub-device (YAML `device_id:`) onto a hand-built
388 companion config dict, so the companion entity groups under the same HA device as its parent.
389
390 Lives here rather than in platform_common.py: it is needed by button.py's pairing-result
391 sensor, which is not a device-bound platform and would otherwise have to import the whole
392 platform-schema module for a four-line helper. platform_common.py re-exports it so cover.py's
393 existing import keeps working.
394
395 Companion entities (diagnostic sensors, cover favorite/vent buttons, ...) are built from
396 dicts fed straight to e.g. new_text_sensor()/new_button() rather than through the platform's
397 own cv.Schema(), so they never go through ENTITY_BASE_SCHEMA and never pick up `device_id:`
398 on their own. esphome.core.entity_helpers.setup_entity() reads it with
399 `config.get(CONF_DEVICE_ID)`, a truthiness check, so an explicit `None` and an absent key
400 behave identically; omitted here rather than set to None just to keep the dict shape
401 identical to a companion with no sub-device at all.
402
403 Deliberately not called anywhere for the hub-level dynamic entities (1W identity buttons/
404 sensors, the arming switches, LR1121 firmware controls, tuning numbers/selects): none of their
405 parent configs carry a `device_id:` schema slot, since those entities aren't attached to a
406 single cover/light/switch/lock to inherit one from. `device_id:` grouping is scoped to the
407 four device-bound platforms; hub-level entities always live on ESPHome's main device.
408 """
409 if (esphome_device_id := parent_config.get(CONF_DEVICE_ID)) is not None:
410 companion_config[CONF_DEVICE_ID] = esphome_device_id
411 return companion_config
412
413
415 """Validate a linked_remotes entry: either a device ID or 'class:<device_type>'.
416
417 The class form matches how 1W remotes address a typed broadcast (e.g. "all awnings")
418 rather than a single node, so one entry can cover many same-type devices without
419 enumerating each one. Shares _resolve_device_type_token() with validate_device_type()
420 so a type without a named YAML alias yet (e.g. discovered via pairing) can still be
421 class-linked. Normalized to 'class:0x<HH>' (uppercase hex) so wire_device_binding() can
422 parse the type directly without a second DEVICE_TYPE_OPTIONS lookup; bare device IDs are
423 validated exactly as before and behave identically.
424 """
425 if isinstance(value, str) and value.lower().startswith("class:"):
426 type_token = value.split(":", 1)[1].strip().lower()
427 try:
428 type_value = _resolve_device_type_token(type_token)
429 except ValueError as err:
430 raise cv.Invalid(
431 f"Unknown device class '{type_token}' in linked_remotes; expected one of: "
432 + ", ".join(sorted(DEVICE_TYPE_OPTIONS))
433 + ", or a raw integer such as 0x14"
434 ) from err
435 return f"class:0x{type_value:02X}"
436 return validate_device_id(value)
437
438
439def _validate_min_interval(value, minimum_ms, key):
440 """Parse a time period and reject anything shorter than minimum_ms, naming the key."""
441 value = cv.positive_time_period_milliseconds(value)
442 if value.total_milliseconds < minimum_ms:
443 raise cv.Invalid(f"{key} must be at least {minimum_ms}ms")
444 return value
445
446
448 """Validate status_poll_interval is at least MIN_STATUS_POLL_INTERVAL_MS."""
450 value, MIN_STATUS_POLL_INTERVAL_MS, "status_poll_interval"
451 )
452
453
455 """Validate rain_sensor_poll_interval is at least MIN_RAIN_SENSOR_POLL_INTERVAL_MS."""
457 value, MIN_RAIN_SENSOR_POLL_INTERVAL_MS, "rain_sensor_poll_interval"
458 )
_validate_min_interval(value, minimum_ms, key)
heating_profile_has_window(device_type, subtype)
_resolve_named_or_raw_token(token, options, max_value=0xFF)
inherit_esphome_device(companion_config, parent_config)