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