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, DEVICE_MODEL_OPTIONS, DEVICE_FEATURES (with the FEATURE_* bits, KIND_EXPERIMENTAL and
12## NAMED_POSITION_FEATURES), MANUFACTURER_OPTIONS, PA_PIN_OPTIONS, TCXO_VOLTAGE_OPTIONS and
13## FEM_TX_POWER_MAX_QUIET mirror C++ tables; the ``tests/sync/`` host tests and
14## ``make yaml-emitter-sync`` read them from this file by name.
15
16import logging
17
18import esphome.codegen as cg
19import esphome.config_validation as cv
20from esphome.const import (
21 CONF_CS_PIN,
22 CONF_DEVICE_ID,
23 DEVICE_CLASS_AWNING,
24 DEVICE_CLASS_BLIND,
25 DEVICE_CLASS_CURTAIN,
26 DEVICE_CLASS_DOOR,
27 DEVICE_CLASS_GARAGE,
28 DEVICE_CLASS_GATE,
29 DEVICE_CLASS_SHADE,
30 DEVICE_CLASS_SHUTTER,
31 DEVICE_CLASS_WINDOW,
32)
33
34from .hub_names import (
35 CONF_FEM,
36 CONF_FEM_EN_PIN,
37 CONF_FEM_PA_PIN,
38 CONF_RADIO_TYPE,
39 CONF_TX_POWER,
40 CONF_VFEM_PIN,
41 MIN_BATTERY_LEVEL_POLL_INTERVAL_MS,
42 MIN_RAIN_SENSOR_POLL_INTERVAL_MS,
43 MIN_SUN_LEVEL_POLL_INTERVAL_MS,
44 MIN_STATUS_POLL_INTERVAL_MS,
45 MIN_STATUS_REFRESH_INTERVAL_MS,
46 home_io_control_ns,
47)
48
49_LOGGER = logging.getLogger(__name__)
50
51
52PA_PIN_OPTIONS = {
53 "BOOST": 0x80,
54 "RFO": 0x00,
55}
56
57RADIO_TYPE_OPTIONS = {
58 "sx1276": "sx1276",
59 "sx1262": "sx1262",
60 "lr1121": "lr1121",
61}
62
63# Radio types whose chip sits on the SPI bus, so the hub registers as an SPI device for them and
64# needs its chip-select pin. Every radio type is one today; a radio reached some other way is
65# left out of this set.
66SPI_RADIO_TYPES = frozenset({"sx1276", "sx1262", "lr1121"})
67
68# 0-based voltage enum shared verbatim by the SX1262 SetDIO3AsTCXOCtrl and LR1121 SetTcxoMode
69# commands (0x00 = 1.6 V .. 0x07 = 3.3 V; Semtech SX1261/2 datasheet Table 13-35). Both drivers
70# pass the generated integer straight through to the chip. The YAML strings ("1_8V" etc.) are
71# unchanged, so this is not a user-visible config change. "NONE" (0xFF) is a sentinel for boards
72# with a bare crystal instead of a TCXO: the driver skips the DIO3/TCXO programming entirely.
73TCXO_VOLTAGE_OPTIONS = {
74 "1_6V": 0x00,
75 "1_7V": 0x01,
76 "1_8V": 0x02,
77 "2_2V": 0x03,
78 "2_4V": 0x04,
79 "2_7V": 0x05,
80 "3_0V": 0x06,
81 "3_3V": 0x07,
82 "NONE": 0xFF,
83}
84
85# Which RF front-end part is fitted (ADR 0035). Part names, not board names -- board names
86# belong to config/boards/heltec-v4-*.yaml. "Part", not "chip": gc1109/kct8103l are each a single
87# bare FEM IC, but xy16p35 names an RF module with no single chip inside it independently
88# identifiable as "the FEM chip" from anything published -- see FEM_REQUIRED_PINS' own comment and
89# radio_interface.h's FemProfile doc block for the fuller explanation. Validated with cv.one_of
90# (not cv.enum) so config[CONF_FEM] stays a plain string usable in ordinary Python comparisons in
91# validate_fem() below; the mapping to the generated C++ enum happens explicitly in to_code(),
92# same pattern as ONEWAY_COMMANDS.
93FemProfile = home_io_control_ns.enum("FemProfile", is_class=True)
94FEM_PROFILES = {
95 "none": FemProfile.NONE,
96 "gc1109": FemProfile.GC1109, # Heltec WiFi LoRa 32 V4.2
97 "kct8103l": FemProfile.KCT8103L, # Heltec WiFi LoRa 32 V4.3 / V4 R8
98 "xy16p35": FemProfile.XY16P35, # LilyGO T-Beam 1W SX1262
99}
100
101# Config keys a `fem:` profile actually drives. Every profile requires vfem_pin (the FEM/module
102# power enable) and fem_pa_pin (the mode pin whose active-during-TX level is profile-dependent --
103# see FemProfile's own doc comment in radio_interface.h). fem_en_pin (CSD, a secondary chip-enable
104# some front-end parts expose) is required only where the part actually has one: GC1109 and
105# KCT8103L do, XY16P35 does not (LilyGO's own datasheet names only "LDO EN" and "LNA Ctrl" --
106# no third pin).
107# `fem:` never supplies the GPIO numbers themselves; those always come from the board's own
108# config/boards/*.yaml, exactly like every other radio pin in this schema.
109FEM_REQUIRED_PINS = {
110 "gc1109": (CONF_VFEM_PIN, CONF_FEM_EN_PIN, CONF_FEM_PA_PIN),
111 "kct8103l": (CONF_VFEM_PIN, CONF_FEM_EN_PIN, CONF_FEM_PA_PIN),
112 "xy16p35": (CONF_VFEM_PIN, CONF_FEM_PA_PIN),
113}
114
115# Highest tx_power setting whose estimated antenna-port power still stays at or under the
116# 868 MHz SRD ERP limit (+14 dBm); validate_fem() warns on anything above it. Derived from the
117# same low-drive net-gain figures as SX1262_FEM_GAIN_*_DB in radio_sx1262.cpp (GC1109 ~+11 dB ->
118# 14 dBm at tx_power 3, 15 dBm at 4; KCT8103L ~+13 dB -> 14 dBm at tx_power 1, 15 dBm at 2;
119# XY16P35 ~+14 dB (measured; the PA's own nominal spec is +12dB) -> 14 dBm at tx_power 0, 15 dBm
120# at 1). Kept here by hand since Python and C++ share no header; the driver's own boot-time
121# ESP_LOGW carries the authoritative per-tx_power estimate and its uncertainty.
122FEM_TX_POWER_MAX_QUIET = {
123 "gc1109": 3,
124 "kct8103l": 1,
125 "xy16p35": 0,
126}
127
128
130 """Require the SPI device keys for a radio on the SPI bus. The schema takes them as optional
131 (``spi.spi_device_schema(False, ...)``) so that the requirement follows ``radio_type`` here,
132 in one place, rather than being hard-wired into the schema for every radio."""
133 radio_type = config[CONF_RADIO_TYPE]
134 if radio_type in SPI_RADIO_TYPES and CONF_CS_PIN not in config:
135 raise cv.Invalid(
136 f"radio_type: {radio_type} is an SPI radio and needs cs_pin: (its chip-select GPIO); "
137 "a board package in config/boards/ sets it for you",
138 path=[CONF_CS_PIN],
139 )
140 return config
141
142
143def validate_fem(config):
144 """Cross-key validation for `fem:` (ADR 0035): which front-end part's control behaviour the
145 SX1262 driver applies to fem_pa_pin (and, for the two parts that have one, fem_en_pin), never
146 which GPIO numbers to use for them -- those are always the board package's job, the same as
147 every other radio pin. Requires radio_type: sx1262 (the raw FEM pins are silently ignored on
148 every other chip -- select_and_construct_radio_() only ever hands them to RadioSX1262 -- so a
149 mismatched radio_type would configure hardware the driver never drives), and that every pin
150 this profile's behaviour needs is actually present, naming exactly which is missing rather
151 than leaving the driver to silently no-op on a null pin. Also warns when tx_power looks likely
152 to push this FEM's antenna-port power over a typical 868 MHz SRD limit -- radio_sx1262.cpp's
153 own init() logs the actual per-part estimate this warning can only gesture at without knowing
154 the front-end part's exact gain table here too.
155 """
156 profile = config[CONF_FEM]
157 if profile == "none":
158 return config
159
160 if config[CONF_RADIO_TYPE] != "sx1262":
161 raise cv.Invalid(
162 f"fem: {profile} requires radio_type: sx1262 -- the front-end module pins are only "
163 "wired to the SX1262 driver"
164 )
165
166 missing = [key for key in FEM_REQUIRED_PINS[profile] if key not in config]
167 if missing:
168 raise cv.Invalid(
169 f"fem: {profile} requires {', '.join(missing)} to be set -- see "
170 "docs/hardware.md#front-end-module-fem-support for what your board needs"
171 )
172
173 if config[CONF_TX_POWER] > FEM_TX_POWER_MAX_QUIET[profile]:
174 _LOGGER.warning(
175 "fem: %s together with tx_power: %d -- a front-end module turns tx_power into a "
176 "much larger antenna-port power than the bare SX1262 would radiate, and at this "
177 "setting the estimate is likely over a typical 868 MHz SRD ERP limit (+14 dBm). The "
178 "driver logs its own per-profile estimate (and its uncertainty) at boot -- check it, "
179 "and prefer tx_power: %d or lower (what this profile's own board package ships) "
180 "until you have measured the actual radiated power for your board",
181 profile,
182 config[CONF_TX_POWER],
183 FEM_TX_POWER_MAX_QUIET[profile],
184 )
185
186 return config
187
188
189# The wire carries the node type in ten bits (0..1023). The names below are the types a YAML entity
190# can declare; the table that names every node type and subtype is proto_device_types.cpp, and
191# tests/sync/device_type_sync_test.cpp keeps the two in step.
192DEVICE_TYPE_MAX = 0x3FF
193
194DEVICE_TYPE_OPTIONS = {
195 "unknown": 0x00,
196 "interior_venetian_blind": 0x01,
197 "roller_shutter": 0x02,
198 "vertical_exterior_awning": 0x03,
199 "window_opener": 0x04,
200 "garage_opener": 0x05,
201 "light": 0x06,
202 "gate_opener": 0x07,
203 "rolling_door_opener": 0x08,
204 "lock": 0x09,
205 "vertical_interior_blind": 0x0A,
206 "dual_roller_shutter": 0x0D,
207 "heating_temperature_interface": 0x0E,
208 "on_off_switch": 0x0F,
209 "horizontal_awning": 0x10,
210 "exterior_venetian_blind": 0x11,
211 "louvre_blind": 0x12,
212 "curtain_track": 0x13,
213 "exterior_heating": 0x15,
214 "heat_pump": 0x16,
215 "intrusion_alarm": 0x17,
216 "swinging_shutter": 0x18,
217 "sliding_window": 0x1B,
218 "bioclimatic_pergola": 0x1D,
219 "domestic_hot_water": 0x33,
220 "electrical_heater": 0x34,
221}
222
223# The Home Assistant device class a cover of each declared kind gets when its YAML sets no
224# `device_class:` (cover.py injects it; an explicit value, including "", wins). Python-only: no C++
225# code reads a device class. A kind whose class would be a guess has no entry.
226DEFAULT_COVER_DEVICE_CLASS = {
227 "garage_opener": DEVICE_CLASS_GARAGE,
228 "gate_opener": DEVICE_CLASS_GATE,
229 "rolling_door_opener": DEVICE_CLASS_DOOR,
230 "window_opener": DEVICE_CLASS_WINDOW,
231 "sliding_window": DEVICE_CLASS_WINDOW,
232 "roller_shutter": DEVICE_CLASS_SHUTTER,
233 "dual_roller_shutter": DEVICE_CLASS_SHUTTER,
234 "swinging_shutter": DEVICE_CLASS_SHUTTER,
235 "interior_venetian_blind": DEVICE_CLASS_BLIND,
236 "exterior_venetian_blind": DEVICE_CLASS_BLIND,
237 "louvre_blind": DEVICE_CLASS_BLIND,
238 "vertical_interior_blind": DEVICE_CLASS_SHADE,
239 "curtain_track": DEVICE_CLASS_CURTAIN,
240 "horizontal_awning": DEVICE_CLASS_AWNING,
241 "vertical_exterior_awning": DEVICE_CLASS_AWNING,
242}
243
244# === Device capability bits ===
245#
246# Python's mirror of the capability word each node-type table row carries (proto_device_features.h,
247# proto_device_types.cpp), so the codegen can decide which companions and keys a declared kind has.
248# The C++ table is authoritative; tests/sync/device_features_sync_test.cpp checks every bit value and
249# the whole word of every (type, subtype). Literals only: the sync test parses this file and reads no
250# expressions.
251FEATURE_MAIN_PARAMETER_ONLY = 0x0001
252FEATURE_LEVELS = 0x0002
253FEATURE_TILT = 0x0004
254FEATURE_FAVORITE = 0x0008
255FEATURE_VENT = 0x0010
256FEATURE_PARTIAL = 0x0020
257FEATURE_PEDESTRIAN = 0x0040
258FEATURE_EXTENDED_POSITION_FRAME = 0x0080
259FEATURE_WINDOW_COMPANIONS = 0x0100
260FEATURE_INVERTED = 0x0200
261FEATURE_WATER_HEATER = 0x0400
262FEATURE_NIGHT_LIGHT = 0x0800
263# Not a capability: Python's copy of the row's EXPERIMENTAL identity flag (device_type_flags in
264# proto_device_types.h), carried in bit 15 of the same word so there is one table and one sync test.
265# Set on a kind no capture or report has confirmed on a real device.
266KIND_EXPERIMENTAL = 0x8000
267
268# The models a (type, subtype) can have below it (proto_device_model.h DeviceModel, the model rows of
269# proto_device_types.cpp), by their YAML io_model: name. Values mirror the C++ enum;
270# tests/sync/device_features_sync_test.cpp checks them. Which (type, subtype) offers a model is the
271# "<name>/<subtype>/<model>" key of DEVICE_FEATURES.
272DEVICE_MODEL_OPTIONS = {
273 "towel_dryer": 1,
274 "heater_with_light": 2,
275}
276
277# The capability word of each row whose type has a YAML name, keyed "<name>" for subtype 0,
278# "<name>/<subtype>" for a subtype row and "<name>/<subtype>/<model>" for a model row. A row with no
279# capability is left out, except a subtype row under a type that has capabilities (without its explicit
280# zero the lookup would give it the type's word), a model row (it is how the codegen knows the model is
281# offered) and an experimental row (its word is KIND_EXPERIMENTAL at least).
282DEVICE_FEATURES = {
283 "interior_venetian_blind": 0x000E, # LEVELS TILT FAVORITE
284 "roller_shutter": 0x008A, # LEVELS FAVORITE EXTENDED_POSITION_FRAME
285 "roller_shutter/1": 0x008A, # LEVELS FAVORITE EXTENDED_POSITION_FRAME
286 "roller_shutter/2": 0x008A, # LEVELS FAVORITE EXTENDED_POSITION_FRAME
287 "vertical_exterior_awning": 0x008A, # LEVELS FAVORITE EXTENDED_POSITION_FRAME
288 "window_opener": 0x009A, # LEVELS FAVORITE VENT EXTENDED_POSITION_FRAME
289 "window_opener/1": 0x009A, # LEVELS FAVORITE VENT EXTENDED_POSITION_FRAME
290 "garage_opener": 0x0023, # MAIN_PARAMETER_ONLY LEVELS PARTIAL
291 "garage_opener/58": 0x0021, # MAIN_PARAMETER_ONLY PARTIAL
292 "light": 0x0080, # EXTENDED_POSITION_FRAME
293 "light/1": 0x8082, # KIND_EXPERIMENTAL LEVELS EXTENDED_POSITION_FRAME # LEVELS EXTENDED_POSITION_FRAME
294 "light/2": 0x8082, # KIND_EXPERIMENTAL LEVELS EXTENDED_POSITION_FRAME # LEVELS EXTENDED_POSITION_FRAME
295 "light/58": 0x0001, # MAIN_PARAMETER_ONLY
296 "gate_opener": 0x00CA, # LEVELS FAVORITE PEDESTRIAN EXTENDED_POSITION_FRAME
297 "gate_opener/58": 0x8049, # KIND_EXPERIMENTAL MAIN_PARAMETER_ONLY FAVORITE PEDESTRIAN
298 "rolling_door_opener": 0x008A, # LEVELS FAVORITE EXTENDED_POSITION_FRAME
299 "lock": 0x0080, # EXTENDED_POSITION_FRAME
300 "lock/1": 0x8080, # KIND_EXPERIMENTAL EXTENDED_POSITION_FRAME
301 "lock/2": 0x8000, # KIND_EXPERIMENTAL; car controller: no capability of the lock family
302 "vertical_interior_blind": 0x008A, # LEVELS FAVORITE EXTENDED_POSITION_FRAME
303 "dual_roller_shutter": 0x000A, # LEVELS FAVORITE
304 "on_off_switch": 0x0080, # EXTENDED_POSITION_FRAME
305 "on_off_switch/2": 0x8080, # KIND_EXPERIMENTAL EXTENDED_POSITION_FRAME
306 "horizontal_awning": 0x028A, # LEVELS FAVORITE EXTENDED_POSITION_FRAME INVERTED
307 "horizontal_awning/1": 0x028A, # LEVELS FAVORITE EXTENDED_POSITION_FRAME INVERTED
308 "exterior_venetian_blind": 0x008E, # LEVELS TILT FAVORITE EXTENDED_POSITION_FRAME
309 "louvre_blind": 0x008E, # LEVELS TILT FAVORITE EXTENDED_POSITION_FRAME
310 "curtain_track": 0x008A, # LEVELS FAVORITE EXTENDED_POSITION_FRAME
311 "exterior_heating": 0x0003, # MAIN_PARAMETER_ONLY LEVELS
312 "exterior_heating/58": 0x0001, # MAIN_PARAMETER_ONLY
313 "heat_pump/1": 0x8000, # KIND_EXPERIMENTAL
314 "heat_pump/2": 0x8000, # KIND_EXPERIMENTAL
315 "swinging_shutter": 0x008A, # LEVELS FAVORITE EXTENDED_POSITION_FRAME
316 "swinging_shutter/1": 0x008A, # LEVELS FAVORITE EXTENDED_POSITION_FRAME
317 "swinging_shutter/58": 0x8009, # KIND_EXPERIMENTAL MAIN_PARAMETER_ONLY FAVORITE
318 "sliding_window": 0x008A, # LEVELS FAVORITE EXTENDED_POSITION_FRAME
319 "sliding_window/1": 0x808A, # KIND_EXPERIMENTAL LEVELS FAVORITE EXTENDED_POSITION_FRAME
320 "bioclimatic_pergola": 0x008A, # LEVELS FAVORITE EXTENDED_POSITION_FRAME
321 "domestic_hot_water": 0x8400, # KIND_EXPERIMENTAL WATER_HEATER
322 "electrical_heater/1": 0x0100, # WINDOW_COMPANIONS
323 "electrical_heater/1/towel_dryer": 0x0000, # no window function
324 "electrical_heater/1/heater_with_light": 0x0900, # WINDOW_COMPANIONS NIGHT_LIGHT
325}
326
327# The named positions (proto_device_types.h NAMED_POSITIONS), keyed by the CoverCommand name
328# cover_command_name() returns. Each companion button of cover.py is created from the bit here.
329NAMED_POSITION_FEATURES = {
330 "FAVORITE": 0x0008,
331 "VENT": 0x0010,
332 "PEDESTRIAN": 0x0040,
333 "PARTIAL": 0x0020,
334}
335
336# Subtype that marks a device taking the main parameter only, on any node type.
337SUBTYPE_MAIN_PARAMETER_ONLY = 58
338
339_DEVICE_TYPE_NAMES = {value: name for name, value in DEVICE_TYPE_OPTIONS.items()}
340
341
342def device_type_name(device_type):
343 """The io_device_type name of a node type value, or None for one YAML has no name for."""
344 return _DEVICE_TYPE_NAMES.get(device_type)
345
346
347_DEVICE_MODEL_NAMES = {value: name for name, value in DEVICE_MODEL_OPTIONS.items()}
348
349
350def device_models(device_type, subtype=0):
351 """The io_model names (io_device_type value, io_subtype) offers, sorted; empty for most kinds."""
352 name = device_type_name(device_type)
353 if name is None:
354 return []
355 prefix = f"{name}/{subtype}/"
356 return sorted(key[len(prefix):] for key in DEVICE_FEATURES if key.startswith(prefix))
357
358
359def device_features(device_type, subtype=0, model=0):
360 """The capability word of (io_device_type value, io_subtype, io_model value), as the C++
361 device_features() answers.
362
363 The model row; else the exact row; else, for subtype 58, the main-parameter-only variant of the
364 type's row (of no capabilities when the type has none); else the type's own row. A subtype without
365 a row of its own takes the type row's capabilities but never its KIND_EXPERIMENTAL marker: C++
366 device_is_experimental() reads only the exact row, so the marker names a row, not a family. A node type YAML
367 has no name for (a raw value) has no row here, so it has no capabilities beyond the subtype-58
368 rule. ``model`` is a DEVICE_MODEL_OPTIONS value, 0 for none; codegen passes the config's model
369 through platform_common.config_model().
370 """
371 name = device_type_name(device_type)
372 model_name = _DEVICE_MODEL_NAMES.get(model)
373 if name is not None and model_name is not None:
374 model_row = DEVICE_FEATURES.get(f"{name}/{subtype}/{model_name}")
375 if model_row is not None:
376 return model_row
377 if name is not None:
378 exact = DEVICE_FEATURES.get(name if subtype == 0 else f"{name}/{subtype}")
379 if exact is not None:
380 return exact
381 base = (DEVICE_FEATURES.get(name, 0) if name is not None else 0) & ~KIND_EXPERIMENTAL
382 if subtype == SUBTYPE_MAIN_PARAMETER_ONLY:
383 cleared = FEATURE_LEVELS | FEATURE_TILT | FEATURE_EXTENDED_POSITION_FRAME
384 return (base & ~cleared) | FEATURE_MAIN_PARAMETER_ONLY
385 return base
386
387
388def kind_is_experimental(device_type, subtype=0, model=0):
389 """True when no capture or report has confirmed (io_device_type value, io_subtype) on a real device.
390
391 Reads KIND_EXPERIMENTAL from the same word as the capabilities, so it follows the same fallback as
392 device_features(): only an exact row (or model row) carries the marker, and a subtype with no row of
393 its own never inherits it from its type row.
394 """
395 return bool(device_features(device_type, subtype, model) & KIND_EXPERIMENTAL)
396
397
398# Device-type names that were renamed. Each maps to the message the validator raises, so a config
399# that still uses one fails with the name to switch to rather than a generic "unknown name".
400RENAMED_DEVICE_TYPES = {
401 "screen": (
402 "device type 'screen' no longer exists: type 11 is a secure configuration device, not an "
403 "actuator. Use the type Scan Paired Devices reports for your device, usually "
404 "'vertical_exterior_awning' or 'vertical_interior_blind'"
405 ),
406 "blind": "device type 'blind' was renamed: use 'vertical_interior_blind' instead",
407 "venetian_blind": "device type 'venetian_blind' was renamed: use 'interior_venetian_blind' instead",
408 "awning": "device type 'awning' was renamed: use 'vertical_exterior_awning' instead",
409 "dual_shutter": "device type 'dual_shutter' was renamed: use 'dual_roller_shutter' instead",
410 "external_venetian_blind": (
411 "device type 'external_venetian_blind' was renamed: use 'exterior_venetian_blind' instead"
412 ),
413}
414
415
416# Mirrors proto_constants.h's MANUFACTURER_* constants (IO-Homecontrol alliance-assigned IDs) --
417# lowercased versions of those constant names, so a name typo'd here is easy to spot against the
418# C++ source. Not every possible byte has a name (MANUFACTURER_ID_MAX=12); an identity whose real
419# manufacturer isn't in this table still works via the raw-integer escape hatch every caller of
420# _resolve_named_or_raw_token() below shares.
421MANUFACTURER_OPTIONS = {
422 "velux": 0x01,
423 "somfy": 0x02,
424 "honeywell": 0x03,
425 "hormann": 0x04,
426 "assa_abloy": 0x05,
427 "niko": 0x06,
428 "window_master": 0x07,
429 "renson": 0x08,
430 "ciat": 0x09,
431 "secuyou": 0x0A,
432 "overkiz": 0x0B,
433 "atlantic_group": 0x0C,
434}
435
436# Manufacturer bytes for which oneway_controller.h's resolve_oneway_wire_profile() has a real 1W
437# wire profile. Keep this set in sync with that C++ switch — two values, and there is no automated
438# check (scripts/check-yaml-emitters.py compares key names, not table contents).
439ONEWAY_WIRE_PROFILE_MANUFACTURERS = {
440 MANUFACTURER_OPTIONS["somfy"],
441 MANUFACTURER_OPTIONS["velux"],
442}
443
444
445def _resolve_named_or_raw_token(token, options, max_value=0xFF):
446 """Resolve a lowercase, stripped token (a name from `options`, or a raw int/hex string) to
447 an integer 0-`max_value`.
448
449 Shared "named value, else raw integer" acceptance rule: validate_device_type()/
450 validate_linked_remote_entry() (DEVICE_TYPE_OPTIONS) and validate_manufacturer()
451 (MANUFACTURER_OPTIONS) are the same shape of small, protocol-defined enum with an escape
452 hatch for values this project hasn't named yet, so the lookup lives here once.
453 @raises ValueError if token is neither a known name nor a parseable integer.
454 @raises cv.Invalid if token parses as an integer but is out of range.
455 """
456 if token in options:
457 return options[token]
458 return cv.int_range(min=0, max=max_value)(int(token, 0))
459
460
462 """Resolve a lowercase, stripped device-type token (name or raw int/hex string) to 0-1023.
463
464 Single source of truth for the "named value from DEVICE_TYPE_OPTIONS, else raw integer"
465 acceptance rule shared by validate_device_type() (io_device_type) and
466 validate_linked_remote_entry() (the class:<device_type> linked-remotes form) — both accept
467 the exact same set of device-type spellings, so the lookup lives here once.
468 @raises ValueError if token is neither a known name nor a parseable integer.
469 @raises cv.Invalid if token is a renamed device-type name (naming the new one), or parses as an
470 integer but is out of range 0-1023.
471 """
472 if token in RENAMED_DEVICE_TYPES:
473 raise cv.Invalid(RENAMED_DEVICE_TYPES[token])
474 return _resolve_named_or_raw_token(token, DEVICE_TYPE_OPTIONS, DEVICE_TYPE_MAX)
475
476
478 """Validate io_device_type as a named string or integer 0-1023."""
479 if isinstance(value, int):
480 return cv.int_range(min=0, max=DEVICE_TYPE_MAX)(value)
481
482 if isinstance(value, str):
483 normalized = cv.string_strict(value).strip().lower()
484 try:
485 return _resolve_device_type_token(normalized)
486 except ValueError as err:
487 raise cv.Invalid(
488 "Device type must be a known name or an integer in the range 0..1023 (for example 0x11)"
489 ) from err
490
491 raise cv.Invalid(
492 "Device type must be a known name or an integer in the range 0..1023"
493 )
494
495
497 """Validate manufacturer as a named string (MANUFACTURER_OPTIONS) or integer 0-255.
498
499 Mirrors validate_device_type() exactly (same "name, else raw integer" shape via
500 _resolve_named_or_raw_token()) — a manufacturer ID is the same kind of small,
501 protocol-defined enum, it just has no linked_remotes-style second caller.
502 """
503 if isinstance(value, int):
504 return cv.int_range(min=0, max=0xFF)(value)
505
506 if isinstance(value, str):
507 normalized = cv.string_strict(value).strip().lower()
508 try:
509 return _resolve_named_or_raw_token(normalized, MANUFACTURER_OPTIONS)
510 except ValueError as err:
511 raise cv.Invalid(
512 "manufacturer must be a known name or an integer in the range 0..255 (for example 0x02)"
513 ) from err
514
515 raise cv.Invalid("manufacturer must be a known name or an integer in the range 0..255")
516
517
519 """Generate a C++ static_cast expression for a validated device type."""
520 return cg.RawExpression(
521 f"static_cast<esphome::home_io_control::DeviceType>(0x{value:02X})"
522 )
523
524
526 """Generate a C++ static_cast expression for a validated DEVICE_MODEL_OPTIONS value."""
527 return cg.RawExpression(f"static_cast<esphome::home_io_control::DeviceModel>({value})")
528
529
531 """Validate io_model as one of the DEVICE_MODEL_OPTIONS names (lowercase).
532
533 Whether the declared io_device_type / io_subtype offers the model is
534 platform_common.validate_device_model()'s job: it needs the whole entry.
535 """
536 normalized = cv.string_strict(value).strip().lower()
537 if normalized not in DEVICE_MODEL_OPTIONS:
538 raise cv.Invalid(
539 f"Unknown io_model '{value}'; known models: {', '.join(sorted(DEVICE_MODEL_OPTIONS))}"
540 )
541 return normalized
542
543
545 """Validate node_id as exactly 6 hex characters (3 bytes)."""
546 value = cv.string_strict(value).upper()
547 if len(value) != 6:
548 raise cv.Invalid("Node ID must be exactly 6 hex characters (3 bytes)")
549 try:
550 int(value, 16)
551 except ValueError:
552 raise cv.Invalid("Node ID must be valid hexadecimal")
553 return value
554
555
557 """Validate system_key as exactly 32 hex characters (16 bytes)."""
558 value = cv.string_strict(value).upper()
559 if len(value) != 32:
560 raise cv.Invalid("System key must be exactly 32 hex characters (16 bytes)")
561 try:
562 int(value, 16)
563 except ValueError:
564 raise cv.Invalid("System key must be valid hexadecimal")
565 return value
566
567
569 """Validate io_device_id as exactly 6 hex characters (3 bytes)."""
570 value = cv.string_strict(value).upper()
571 if len(value) != 6:
572 raise cv.Invalid("Device ID must be exactly 6 hex characters (3 bytes)")
573 try:
574 int(value, 16)
575 except ValueError:
576 raise cv.Invalid("Device ID must be valid hexadecimal")
577 return value
578
579
580def inherit_esphome_device(companion_config, parent_config):
581 """Propagate the parent entity's ESPHome sub-device (YAML `device_id:`) onto a hand-built
582 companion config dict, so the companion entity groups under the same HA device as its parent.
583
584 Lives here, a leaf module, so any platform module can import it without the whole
585 platform-schema module; platform_common.py re-exports it for cover.py.
586
587 Companion entities (diagnostic sensors, cover favorite/vent buttons, ...) are built from
588 dicts fed straight to e.g. new_text_sensor()/new_button() rather than through the platform's
589 own cv.Schema(), so they never go through ENTITY_BASE_SCHEMA and never pick up `device_id:`
590 on their own. esphome.core.entity_helpers.setup_entity() reads it with
591 `config.get(CONF_DEVICE_ID)`, a truthiness check, so an explicit `None` and an absent key
592 behave identically; omitted here rather than set to None just to keep the dict shape
593 identical to a companion with no sub-device at all.
594
595 Deliberately not called anywhere for the hub-level dynamic entities (1W identity buttons/
596 sensors, the arming switches, LR1121 firmware controls, tuning numbers/selects): none of their
597 parent configs carry a `device_id:` schema slot, since those entities aren't attached to a
598 single cover/light/switch/lock to inherit one from. `device_id:` grouping is scoped to the
599 four device-bound platforms; hub-level entities always live on ESPHome's main device.
600 """
601 if (esphome_device_id := parent_config.get(CONF_DEVICE_ID)) is not None:
602 companion_config[CONF_DEVICE_ID] = esphome_device_id
603 return companion_config
604
605
607 """Validate a linked_remotes entry: either a device ID or 'class:<device_type>'.
608
609 The class form matches how 1W remotes address a typed broadcast (e.g. "all awnings")
610 rather than a single node, so one entry can cover many same-type devices without
611 enumerating each one. Shares _resolve_device_type_token() with validate_device_type()
612 so a type without a named YAML alias yet (e.g. discovered via pairing) can still be
613 class-linked. Normalized to 'class:0x<HH>' (uppercase hex) so wire_device_binding() can
614 parse the type directly without a second DEVICE_TYPE_OPTIONS lookup; bare device IDs are
615 validated exactly as before and behave identically.
616 """
617 if isinstance(value, str) and value.lower().startswith("class:"):
618 type_token = value.split(":", 1)[1].strip().lower()
619 try:
620 type_value = _resolve_device_type_token(type_token)
621 except ValueError as err:
622 raise cv.Invalid(
623 f"Unknown device class '{type_token}' in linked_remotes; expected one of: "
624 + ", ".join(sorted(DEVICE_TYPE_OPTIONS))
625 + ", or a raw integer such as 0x14"
626 ) from err
627 return f"class:0x{type_value:02X}"
628 return validate_device_id(value)
629
630
631def _validate_min_interval(value, minimum_ms, key):
632 """Parse a time period and reject anything shorter than minimum_ms, naming the key."""
633 value = cv.positive_time_period_milliseconds(value)
634 if value.total_milliseconds < minimum_ms:
635 raise cv.Invalid(f"{key} must be at least {minimum_ms}ms")
636 return value
637
638
640 """Validate status_poll_interval is at least MIN_STATUS_POLL_INTERVAL_MS."""
642 value, MIN_STATUS_POLL_INTERVAL_MS, "status_poll_interval"
643 )
644
645
647 """Validate rain_sensor_poll_interval is at least MIN_RAIN_SENSOR_POLL_INTERVAL_MS."""
649 value, MIN_RAIN_SENSOR_POLL_INTERVAL_MS, "rain_sensor_poll_interval"
650 )
651
652
654 """Validate sun_level_poll_interval is at least MIN_SUN_LEVEL_POLL_INTERVAL_MS."""
656 value, MIN_SUN_LEVEL_POLL_INTERVAL_MS, "sun_level_poll_interval"
657 )
658
659
661 """Validate battery_level_poll_interval is at least MIN_BATTERY_LEVEL_POLL_INTERVAL_MS."""
663 value, MIN_BATTERY_LEVEL_POLL_INTERVAL_MS, "battery_level_poll_interval"
664 )
665
666
668 """Validate status_refresh_interval is at least MIN_STATUS_REFRESH_INTERVAL_MS."""
670 value, MIN_STATUS_REFRESH_INTERVAL_MS, "status_refresh_interval"
671 )
_validate_min_interval(value, minimum_ms, key)
device_features(device_type, subtype=0, model=0)
device_models(device_type, subtype=0)
kind_is_experimental(device_type, subtype=0, model=0)
_resolve_named_or_raw_token(token, options, max_value=0xFF)
inherit_esphome_device(companion_config, parent_config)