Home IO Control
ESPHome add-on for IO-Homecontrol devices
Toggle main menu visibility
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
14
import
logging
15
16
import
esphome.codegen
as
cg
17
import
esphome.config_validation
as
cv
18
from
esphome.const
import
CONF_DEVICE_ID
19
20
from
.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
34
PA_PIN_OPTIONS = {
35
"BOOST"
: 0x80,
36
"RFO"
: 0x00,
37
}
38
39
RADIO_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.
50
TCXO_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.
70
FemProfile = home_io_control_ns.enum(
"FemProfile"
, is_class=
True
)
71
FEM_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.
86
FEM_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.
99
FEM_TX_POWER_MAX_QUIET = {
100
"gc1109"
: 3,
101
"kct8103l"
: 1,
102
"xy16p35"
: 0,
103
}
104
105
106
def
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
152
DEVICE_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.
182
MANUFACTURER_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).
200
ONEWAY_WIRE_PROFILE_MANUFACTURERS = {
201
MANUFACTURER_OPTIONS[
"somfy"
],
202
MANUFACTURER_OPTIONS[
"velux"
],
203
}
204
205
206
def
_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
222
def
_resolve_device_type_token
(token):
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
235
def
validate_device_type
(value):
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
254
def
validate_manufacturer
(value):
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
276
def
device_type_expression
(value):
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
283
def
validate_node_id
(value):
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
295
def
validate_system_key
(value):
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
307
def
validate_device_id
(value):
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
319
def
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
347
def
validate_linked_remote_entry
(value):
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
372
def
validate_status_poll_interval
(value):
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
home_io_control.hub_validators.validate_node_id
validate_node_id(value)
Definition
hub_validators.py:283
home_io_control.hub_validators.validate_linked_remote_entry
validate_linked_remote_entry(value)
Definition
hub_validators.py:347
home_io_control.hub_validators.validate_system_key
validate_system_key(value)
Definition
hub_validators.py:295
home_io_control.hub_validators.validate_status_poll_interval
validate_status_poll_interval(value)
Definition
hub_validators.py:372
home_io_control.hub_validators.validate_device_type
validate_device_type(value)
Definition
hub_validators.py:235
home_io_control.hub_validators._resolve_named_or_raw_token
_resolve_named_or_raw_token(token, options, max_value=0xFF)
Definition
hub_validators.py:206
home_io_control.hub_validators.validate_manufacturer
validate_manufacturer(value)
Definition
hub_validators.py:254
home_io_control.hub_validators._resolve_device_type_token
_resolve_device_type_token(token)
Definition
hub_validators.py:222
home_io_control.hub_validators.validate_device_id
validate_device_id(value)
Definition
hub_validators.py:307
home_io_control.hub_validators.device_type_expression
device_type_expression(value)
Definition
hub_validators.py:276
home_io_control.hub_validators.validate_fem
validate_fem(config)
Definition
hub_validators.py:106
home_io_control.hub_validators.inherit_esphome_device
inherit_esphome_device(companion_config, parent_config)
Definition
hub_validators.py:319
components
home_io_control
hub_validators.py
Generated by
1.18.0