Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
tuning.py
Go to the documentation of this file.
1## @file
2## @brief ESPHome tuning schema and code generation for Home IO Control.
3## @ingroup hioc_codegen
4##
5## Provides the YAML ``tuning:`` block under ``home_io_control:`` and optionally
6## generates Home Assistant ``number`` and ``select`` entities so users can adjust
7## pairing and radio parameters at runtime. Tuned values are volatile and reset on
8## every boot.
9
10import esphome.codegen as cg
11import esphome.config_validation as cv
12from esphome.components import number, select
13from esphome.const import CONF_ID, CONF_NAME, ENTITY_CATEGORY_CONFIG
14from esphome.core import ID
15
16home_io_control_ns = cg.esphome_ns.namespace("home_io_control")
17
18CONF_TUNING = "tuning"
19CONF_UI_CONTROLS = "ui_controls"
20
21# Fixed ID prefix for the generated tuning companion entities (see _inject_tuning_companion_ids).
22_COMPANION_ID_BASE = "home_io_control"
23
24# --- Radio / physical layer ---
25CONF_SX1262_RX_BANDWIDTH = "sx1262_rx_bandwidth"
26CONF_SX1262_RESPONSE_PREAMBLE = "sx1262_response_preamble"
27CONF_SX1262_POST_TX_SETTLE_US = "sx1262_post_tx_settle_us"
28CONF_SX1276_RX_BANDWIDTH = "sx1276_rx_bandwidth"
29CONF_SX1276_RESPONSE_PREAMBLE = "sx1276_response_preamble"
30CONF_SX1276_DISCOVERY_HOP_SLICE_MS = "sx1276_discovery_hop_slice_ms"
31CONF_SX1262_DISCOVERY_HOP_SLICE_MS = "sx1262_discovery_hop_slice_ms"
32CONF_LR1121_RX_BANDWIDTH = "lr1121_rx_bandwidth"
33CONF_LR1121_RESPONSE_PREAMBLE = "lr1121_response_preamble"
34CONF_LR1121_POST_TX_SETTLE_US = "lr1121_post_tx_settle_us"
35CONF_LR1121_DISCOVERY_HOP_SLICE_MS = "lr1121_discovery_hop_slice_ms"
36CONF_COLD_BROADCAST_REPLY_PREAMBLE = "cold_broadcast_reply_preamble"
37CONF_NORMAL_START_PREAMBLE = "normal_start_preamble"
38CONF_LBT_MAX_RETRIES = "lbt_max_retries"
39CONF_LBT_RSSI_THRESHOLD_DBM = "lbt_rssi_threshold_dbm"
40CONF_LOW_POWER_WAKE_BELIEF = "low_power_wake_belief"
41CONF_EXCHANGE_START_RESPONSE_WAIT_MS = "exchange_start_response_wait_ms"
42CONF_EXCHANGE_RESPONSE_WAIT_MS = "exchange_response_wait_ms"
43CONF_EXCHANGE_TOTAL_BUDGET_MS = "exchange_total_budget_ms"
44
45# --- Pairing protocol ---
46CONF_PAIRING_DISCOVERY_COMMANDS = "pairing_discovery_commands"
47CONF_PAIRING_DISCOVERY_DESTINATION = "pairing_discovery_destination"
48CONF_PAIRING_DISCOVERY_PAYLOAD = "pairing_discovery_payload"
49CONF_PAIRING_DISCOVERY_LOW_POWER = "pairing_discovery_low_power"
50CONF_PAIRING_DISCOVERY_ACK_CAPABLE = "pairing_discovery_ack_capable"
51CONF_PAIRING_DISCOVERY_PREAMBLE = "pairing_discovery_preamble"
52CONF_PAIRING_DISCOVERY_WAIT_MS = "pairing_discovery_wait_ms"
53CONF_PAIRING_DISCOVERY_INITIAL_DWELL_MS = "pairing_discovery_initial_dwell_ms"
54CONF_PAIRING_KEY_EXCHANGE_RETRIES = "pairing_key_exchange_retries"
55CONF_SCAN_POWER_CLASSES = "scan_power_classes"
56CONF_PAIRING_DISCOVER_CONFIRM = "pairing_discover_confirm"
57CONF_PAIRING_DISCOVERY_LISTEN_CHANNELS = "pairing_discovery_listen_channels"
58CONF_PAIRING_KEY_INIT_DELAY_MS = "pairing_key_init_delay_ms"
59
60# C++ type references
61TuningConfig = home_io_control_ns.class_("TuningConfig")
62IOHomeTuningNumber = home_io_control_ns.class_(
63 "IOHomeTuningNumber", number.Number, cg.Component
64)
65IOHomeTuningSelect = home_io_control_ns.class_(
66 "IOHomeTuningSelect", select.Select, cg.Component
67)
68
69# C++ enums. These stringify to their fully-qualified C++ names (e.g.
70# esphome::home_io_control::SX1262RxBandwidth::BW_117_3_KHZ) so generated code compiles in
71# the global-namespace setup() function.
72SX1262RxBandwidth = home_io_control_ns.enum("SX1262RxBandwidth", is_class=True)
73SX1276RxBandwidth = home_io_control_ns.enum("SX1276RxBandwidth", is_class=True)
74LR1121RxBandwidth = home_io_control_ns.enum("LR1121RxBandwidth", is_class=True)
75DiscoveryCommand = home_io_control_ns.enum("DiscoveryCommand", is_class=True)
76ScanPowerClasses = home_io_control_ns.enum("ScanPowerClasses", is_class=True)
77DiscoverConfirmMode = home_io_control_ns.enum("DiscoverConfirmMode", is_class=True)
78DiscoveryListenChannels = home_io_control_ns.enum("DiscoveryListenChannels", is_class=True)
79
80# Map each YAML option string to its C++ enum value. Options are bare kHz numbers (the "kHz"
81# unit lives in the entity name) for uniformity with the numeric parameters.
82SX1262_BANDWIDTH_OPTIONS = {
83 "39.0": SX1262RxBandwidth.BW_39_0_KHZ,
84 "46.9": SX1262RxBandwidth.BW_46_9_KHZ,
85 "58.6": SX1262RxBandwidth.BW_58_6_KHZ,
86 "78.2": SX1262RxBandwidth.BW_78_2_KHZ,
87 "117.3": SX1262RxBandwidth.BW_117_3_KHZ,
88 "156.2": SX1262RxBandwidth.BW_156_2_KHZ,
89 "187.2": SX1262RxBandwidth.BW_187_2_KHZ,
90}
91
92SX1276_BANDWIDTH_OPTIONS = {
93 "20.8": SX1276RxBandwidth.BW_20_8_KHZ,
94 "41.7": SX1276RxBandwidth.BW_41_7_KHZ,
95 "62.5": SX1276RxBandwidth.BW_62_5_KHZ,
96 "83.3": SX1276RxBandwidth.BW_83_3_KHZ,
97 "125.0": SX1276RxBandwidth.BW_125_0_KHZ,
98}
99
100# LR1121 shares the Semtech GFSK bandwidth grid with SX1262, so LR1121RxBandwidth is
101# byte-for-byte identical to SX1262RxBandwidth today (tuning_config.h; the
102# Sx1262AndLr1121BandwidthTablesAgree test pins them together). They are kept as separate C++
103# enums, and separate option dicts here, only so each chip's option set can diverge if a real
104# chip difference ever demands it. The two narrowest options (39.0/46.9 kHz) are offered on
105# both chips for probing below the SX1262 default; both sit well below the 76.8 kHz (DSB)
106# sizing floor for this waveform, so neither suits everyday use.
107LR1121_BANDWIDTH_OPTIONS = {
108 "39.0": LR1121RxBandwidth.BW_39_0_KHZ,
109 "46.9": LR1121RxBandwidth.BW_46_9_KHZ,
110 "58.6": LR1121RxBandwidth.BW_58_6_KHZ,
111 "78.2": LR1121RxBandwidth.BW_78_2_KHZ,
112 "117.3": LR1121RxBandwidth.BW_117_3_KHZ,
113 "156.2": LR1121RxBandwidth.BW_156_2_KHZ,
114 "187.2": LR1121RxBandwidth.BW_187_2_KHZ,
115}
116
117DISCOVERY_COMMAND_OPTIONS = {
118 "0x28": DiscoveryCommand.DISCOVER,
119 "0x2E": DiscoveryCommand.DISCOVER_ALT,
120}
121# 0x2A (CMD_DISCOVER_SPE_REQ) is deliberately excluded: it is a roll-call answered only by devices
122# that already hold the controller's system key, so a device in learning mode never answers it.
123# Offering it here could only add replies from already-paired devices to a pairing attempt, never
124# help reach the unpaired one. The command byte itself stays valid — see
125# DiscoveryCommand::DISCOVER_SPE in tuning_config.h.
126
127# Home Assistant `select` entities are single-choice, but the discovery phase can send an
128# ordered list of commands. These comma-separated presets expose the useful combinations as
129# selectable options; the C++ dispatch (update_tuning_select) parses them back into the vector.
130DISCOVERY_COMMAND_PRESETS = [
131 "0x28", # default 2W discovery
132 "0x2E", # alternate discovery — kept for completeness/experimentation, but broadcast 0x2E has
133 # never drawn a response from any device this project has real evidence for; see the
134 # CMD_DISCOVER_ALT_REQ doc comment in proto_constants.h. Not a recommended fix.
135 "0x28,0x2E", # both broadcasts
136]
137
138# Destinations a discovery broadcast may be addressed to. The first three are the class-less
139# forms every controller in this project's corpus uses — a real Somfy TaHoma Switch's own 0x28
140# goes to 0x00003B (corpus: somfy_tahoma_pairing_key_extraction_success_sx1276).
141#
142# The 0x0001xx pair are *typed* broadcasts for the lighting class: an io broadcast address encodes
143# the device type as (type << 6) | subtype-mask, so LIGHT (0x06) gives 0x0001BF with the
144# all-subtypes mask 0x3F and 0x0001BB with discovery's 0x3B. They are offered because the only
145# system-opening sweep in the corpus is typed by class — a VELUX KLI 313 hitting 0x0000BF /
146# 0x0000FF / 0x00037F (roller_shutter / awning / dual_shutter) in
147# velux_ssl_discovery_tahoma_pairing — while every discovery this project sends is class-less.
148# No capture shows a *hub* sourcing a typed discovery, so these are an experiment, not a fix.
149DISCOVERY_DESTINATION_OPTIONS = [
150 "auto",
151 "0x00003B",
152 "0x00003F",
153 "0x0001BB",
154 "0x0001BF",
155]
156
157PAIRING_DISCOVERY_PAYLOAD_OPTIONS = ["none", "0x00"]
158
159# Which power classes scan_paired_devices() calls. Option strings mirror the roll-call report's
160# power_save= values so the two share one vocabulary; the C++ side of these exact strings is
161# scan_power_classes_to_string() / power_save_mode_name() (tuning_config.h / proto_constants.h),
162# pinned against each other by tuning_registry_test.cpp — there is no build-time gate comparing
163# the Python and C++ strings directly.
164SCAN_POWER_CLASSES_OPTIONS = {
165 "both": ScanPowerClasses.BOTH,
166 "always_alive": ScanPowerClasses.ALWAYS_ALIVE,
167 "low_power": ScanPowerClasses.LOW_POWER,
168}
169
170# Whether/how PairingEngine sends CMD_DISCOVER_CONFIRM (0x2C) during pairing. Option strings
171# mirror discover_confirm_mode_to_string() (tuning_config.h), pinned against each other by
172# tuning_registry_test.cpp the same way SCAN_POWER_CLASSES_OPTIONS is — there is no build-time gate
173# comparing the Python and C++ strings directly. "skip"/"send"/"send_with_ack", not "on"/"off":
174# YAML 1.1 turns bare on/off into booleans.
175DISCOVER_CONFIRM_MODE_OPTIONS = {
176 "skip": DiscoverConfirmMode.SKIP,
177 "send": DiscoverConfirmMode.SEND,
178 "send_with_ack": DiscoverConfirmMode.SEND_WITH_ACK,
179}
180
181# Which channels the discovery response wait covers. `skip_request` (default) listens on the two
182# channels that are not the one the request went out on; `all` includes it, at the cost of a third
183# of the dwell on the other two. See DiscoveryListenChannels in tuning_config.h for why the
184# default skips, and why that reasoning cannot settle the case of a device that never answers.
185DISCOVERY_LISTEN_CHANNELS_OPTIONS = {
186 "skip_request": DiscoveryListenChannels.SKIP_REQUEST,
187 "all": DiscoveryListenChannels.ALL,
188}
189
190
191def _id_key(param_key):
192 """Config-dict key under which a parameter's injected companion entity ID is stored."""
193 return f"_{param_key}_id"
194
195
196# UI entity names. Radio params are prefixed "Radio" and pairing params "Pairing" so that,
197# under Home Assistant's Configuration section (entity_category=config), the two logical groups
198# cluster together alphabetically.
199UI_NAMES = {
200 CONF_SX1262_RX_BANDWIDTH: "Radio SX1262 RX Bandwidth (kHz)",
201 CONF_SX1262_RESPONSE_PREAMBLE: "Radio SX1262 Response Preamble",
202 CONF_SX1262_POST_TX_SETTLE_US: "Radio SX1262 Post-TX Settle",
203 CONF_SX1276_RX_BANDWIDTH: "Radio SX1276 RX Bandwidth (kHz)",
204 CONF_SX1276_RESPONSE_PREAMBLE: "Radio SX1276 Response Preamble",
205 CONF_SX1276_DISCOVERY_HOP_SLICE_MS: "Radio SX1276 Discovery Hop Slice",
206 CONF_SX1262_DISCOVERY_HOP_SLICE_MS: "Radio SX1262 Discovery Hop Slice",
207 CONF_LR1121_RX_BANDWIDTH: "Radio LR1121 RX Bandwidth (kHz)",
208 CONF_LR1121_RESPONSE_PREAMBLE: "Radio LR1121 Response Preamble",
209 CONF_LR1121_POST_TX_SETTLE_US: "Radio LR1121 Post-TX Settle",
210 CONF_LR1121_DISCOVERY_HOP_SLICE_MS: "Radio LR1121 Discovery Hop Slice",
211 CONF_COLD_BROADCAST_REPLY_PREAMBLE: "Radio Cold Broadcast Reply Preamble",
212 CONF_NORMAL_START_PREAMBLE: "Radio Normal Start Preamble",
213 CONF_LBT_MAX_RETRIES: "Radio LBT Max Retries",
214 CONF_LBT_RSSI_THRESHOLD_DBM: "Radio LBT RSSI Threshold",
215 CONF_LOW_POWER_WAKE_BELIEF: "Radio Low Power Wake Belief",
216 CONF_EXCHANGE_START_RESPONSE_WAIT_MS: "Exchange Start Response Wait",
217 CONF_EXCHANGE_RESPONSE_WAIT_MS: "Exchange Response Wait",
218 CONF_EXCHANGE_TOTAL_BUDGET_MS: "Exchange Total Budget",
219 CONF_PAIRING_DISCOVERY_COMMANDS: "Pairing Discovery Commands",
220 CONF_PAIRING_DISCOVERY_DESTINATION: "Pairing Discovery Destination",
221 CONF_PAIRING_DISCOVERY_PAYLOAD: "Pairing Discovery Payload",
222 CONF_PAIRING_DISCOVERY_LOW_POWER: "Pairing Discovery Low Power",
223 CONF_PAIRING_DISCOVERY_ACK_CAPABLE: "Pairing Discovery ACK Capable",
224 CONF_PAIRING_DISCOVERY_PREAMBLE: "Pairing Discovery Preamble",
225 CONF_PAIRING_DISCOVERY_WAIT_MS: "Pairing Discovery Wait",
226 CONF_PAIRING_DISCOVERY_INITIAL_DWELL_MS: "Pairing Discovery Initial Dwell",
227 CONF_PAIRING_KEY_EXCHANGE_RETRIES: "Pairing Key Exchange Retries",
228 CONF_SCAN_POWER_CLASSES: "Pairing Scan Power Classes",
229 CONF_PAIRING_DISCOVER_CONFIRM: "Pairing Discover Confirm",
230 CONF_PAIRING_KEY_INIT_DELAY_MS: "Pairing Key Init Delay",
231 CONF_PAIRING_DISCOVERY_LISTEN_CHANNELS: "Pairing Discovery Listen Channels",
232}
233
234# Numeric parameters: key -> (min, max, step, unit). Single source of truth for both the
235# YAML validation range and the Home Assistant `number` entity bounds, so the two cannot drift.
236_NUMBER_PARAMS = {
237 # Floor is 8, not an arbitrary round number: it's SHORT_PREAMBLE, the protocol's own nominal
238 # preamble length and the lowest value that makes sense to transmit (below it there isn't
239 # enough preamble left for the peer's detector to lock on at all). Same floor as
240 # sx1276_response_preamble below.
241 CONF_SX1262_RESPONSE_PREAMBLE: (8, 256, 1, "B"),
242 CONF_SX1262_POST_TX_SETTLE_US: (0, 2000, 10, "µs"),
243 CONF_SX1276_RESPONSE_PREAMBLE: (8, 256, 1, "B"),
244 CONF_SX1276_DISCOVERY_HOP_SLICE_MS: (5, 200, 1, "ms"),
245 # Floor is 0, not a physically meaningful minimum for the chip: coverage degrades gradually as
246 # the dwell shortens and only truly collapses at the literal 0 ms edge case, where
247 # wait_for_packet(..., 0) returns before any guard can observe activity at all. Left open so
248 # that floor stays empirically checkable rather than assumed. See
249 # SX1262_DISCOVERY_HOP_SLICE_MS in tuning_config.h for why the default itself is short.
250 CONF_SX1262_DISCOVERY_HOP_SLICE_MS: (0, 500, 1, "ms"),
251 # LR1121 numeric ranges reuse the SX1262 bounds — same chip-family physical constraints, and
252 # a validated SX1262 value encodes protocol-side reality more than a chip quirk.
253 CONF_LR1121_RESPONSE_PREAMBLE: (8, 256, 1, "B"),
254 CONF_LR1121_POST_TX_SETTLE_US: (0, 2000, 10, "µs"),
255 CONF_LR1121_DISCOVERY_HOP_SLICE_MS: (0, 500, 1, "ms"),
256 # Same (8, 256) floor/ceiling as sx1262_response_preamble above, for the same reason: 8 is
257 # SHORT_PREAMBLE, the lowest value that leaves the peer's detector anything to lock on to.
258 # This field is chip-neutral (see proto_timing.h COLD_BROADCAST_REPLY_PREAMBLE, which lives
259 # there rather than in tuning_config.h precisely because it is chip-neutral -- see that header's
260 # own "chip-neutral defaults live in proto_timing.h" comment) so it has no per-chip range to
261 # inherit the way lr1121_response_preamble does.
262 CONF_COLD_BROADCAST_REPLY_PREAMBLE: (8, 256, 1, "B"),
263 # Same (8, 256) floor/ceiling as the response-preamble params: 8 is SHORT_PREAMBLE, the lowest
264 # value that leaves the peer's detector anything to lock on to. The default is 32
265 # (NORMAL_START_PREAMBLE in proto_timing.h) -- 256 bits, inside the preamble band the protocol
266 # reference documents -- not 8: brand-new devices have been seen failing at 1/4/8 B
267 # (docs/configuration/tuning.md), so 8 is a proven-safe floor for the knob, not a sensible default.
268 CONF_NORMAL_START_PREAMBLE: (8, 256, 1, "B"),
269 # Floor is 8 (SHORT_PREAMBLE), same reasoning as the response-preamble params above. Ceiling is
270 # the current default (LONG_PREAMBLE, 1024) rather than 256 like the other preamble knobs: this
271 # one exists specifically to let a stuck pairing attempt go *shorter* than the long wake-up
272 # burst (issue #27/#87's precedent), not to go longer than it — nothing calls for raising it
273 # further.
274 CONF_PAIRING_DISCOVERY_PREAMBLE: (8, 1024, 1, "B"),
275 CONF_LBT_MAX_RETRIES: (0, 10, 1, ""),
276 CONF_LBT_RSSI_THRESHOLD_DBM: (-95, -70, 1, "dBm"),
277 # Ceilings are generous because the right value is a property of the target device, not of
278 # the radio: measured RS100 solar reply latencies reach ~3 s (see RESPONSE_START_WAIT_MS).
279 # Every millisecond here is loop-blocking time on a *failed* exchange only (ADR 0013).
280 CONF_EXCHANGE_START_RESPONSE_WAIT_MS: (200, 4000, 50, "ms"),
281 CONF_EXCHANGE_RESPONSE_WAIT_MS: (200, 4000, 50, "ms"),
282 # Ceiling on a whole exchange. Every millisecond over ~2550 blocks the ESPHome loop past its
283 # own warning threshold, so raise this only when persistence matters more than responsiveness.
284 CONF_EXCHANGE_TOTAL_BUDGET_MS: (500, 12000, 100, "ms"),
285 CONF_PAIRING_DISCOVERY_WAIT_MS: (500, 5000, 50, "ms"),
286 CONF_PAIRING_DISCOVERY_INITIAL_DWELL_MS: (0, 500, 10, "ms"),
287 CONF_PAIRING_KEY_EXCHANGE_RETRIES: (1, 5, 1, ""),
288 # Ceiling covers KLR300's observed 0x2C -> 0x31 gap (~8.1 s) with headroom; step 100 matches
289 # the other pairing-phase millisecond knobs.
290 CONF_PAIRING_KEY_INIT_DELAY_MS: (0, 10000, 100, "ms"),
291}
292
293# Plain boolean parameters: the C++ TuningConfig field name equals the YAML key, and every one is
294# a simple On/Off select — driving the schema, the select options, and the to_code() assignment
295# from one list each, the same way _NUMBER_PARAMS drives the numeric ones below.
296#
297# pairing_discovery_ack_capable is opt-in: see the init_frame() doc in proto_frame.h for why
298# CTRL1_ACK must never become an unconditional default. This knob scopes it to the discovery
299# broadcast only, off by default.
300#
301# low_power_wake_belief is the opposite: on by default, and a diagnostic off-switch. Setting it to
302# false restores the fixed long wake-up preamble on every try to a low_power device.
303_BOOL_PARAMS = (CONF_PAIRING_DISCOVERY_LOW_POWER, CONF_PAIRING_DISCOVERY_ACK_CAPABLE, CONF_LOW_POWER_WAKE_BELIEF)
304_BOOL_SELECT_OPTIONS = ["Off", "On"]
305
306# Select parameters: key -> ordered option list. Single source of truth for both the
307# companion-ID injection and the entity creation.
308_SELECT_OPTIONS = {
309 CONF_PAIRING_DISCOVERY_COMMANDS: DISCOVERY_COMMAND_PRESETS,
310 CONF_PAIRING_DISCOVERY_DESTINATION: DISCOVERY_DESTINATION_OPTIONS,
311 CONF_PAIRING_DISCOVERY_PAYLOAD: PAIRING_DISCOVERY_PAYLOAD_OPTIONS,
312 # check-tuning-sync.py's AST scan only understands plain `CONF_*: value` entries in this dict
313 # (no `**` unpacking), so the boolean rows stay written out individually here even though
314 # _BOOL_PARAMS drives the schema and to_code() loops below.
315 CONF_PAIRING_DISCOVERY_LOW_POWER: _BOOL_SELECT_OPTIONS,
316 CONF_PAIRING_DISCOVERY_ACK_CAPABLE: _BOOL_SELECT_OPTIONS,
317 CONF_SX1262_RX_BANDWIDTH: list(SX1262_BANDWIDTH_OPTIONS),
318 CONF_SX1276_RX_BANDWIDTH: list(SX1276_BANDWIDTH_OPTIONS),
319 CONF_LR1121_RX_BANDWIDTH: list(LR1121_BANDWIDTH_OPTIONS),
320 CONF_LOW_POWER_WAKE_BELIEF: _BOOL_SELECT_OPTIONS,
321 CONF_SCAN_POWER_CLASSES: list(SCAN_POWER_CLASSES_OPTIONS),
322 CONF_PAIRING_DISCOVER_CONFIRM: list(DISCOVER_CONFIRM_MODE_OPTIONS),
323 CONF_PAIRING_DISCOVERY_LISTEN_CHANNELS: list(DISCOVERY_LISTEN_CHANNELS_OPTIONS),
324}
325
326
327def _one_of_string(param_name, options, coerce_number=False):
328 """Build a validator that requires a string value present in `options`.
329
330 Works for both list and dict `options` (dict membership tests the keys), so a single
331 factory replaces a hand-written validator per option set while keeping a per-parameter
332 error message. With `coerce_number`, a numeric YAML value (e.g. ``117.3``) is accepted and
333 normalized to its string form, so bare-number options do not force the user to quote them.
334 """
335
336 def validate(value):
337 if coerce_number and isinstance(value, (int, float)) and not isinstance(value, bool):
338 value = str(value)
339 value = cv.string_strict(value)
340 if value not in options:
341 raise cv.Invalid(f"{param_name} must be one of {list(options)}")
342 return value
343
344 return validate
345
346
347_validate_bandwidth = _one_of_string(
348 CONF_SX1262_RX_BANDWIDTH, SX1262_BANDWIDTH_OPTIONS, coerce_number=True
349)
350_validate_sx1276_bandwidth = _one_of_string(
351 CONF_SX1276_RX_BANDWIDTH, SX1276_BANDWIDTH_OPTIONS, coerce_number=True
352)
353_validate_lr1121_bandwidth = _one_of_string(
354 CONF_LR1121_RX_BANDWIDTH, LR1121_BANDWIDTH_OPTIONS, coerce_number=True
355)
356_validate_discovery_command = _one_of_string(
357 f"{CONF_PAIRING_DISCOVERY_COMMANDS} entries", DISCOVERY_COMMAND_OPTIONS
358)
359_validate_discovery_destination = _one_of_string(
360 CONF_PAIRING_DISCOVERY_DESTINATION, DISCOVERY_DESTINATION_OPTIONS
361)
362_validate_discovery_payload = _one_of_string(
363 CONF_PAIRING_DISCOVERY_PAYLOAD, PAIRING_DISCOVERY_PAYLOAD_OPTIONS
364)
365_validate_scan_power_classes = _one_of_string(
366 CONF_SCAN_POWER_CLASSES, SCAN_POWER_CLASSES_OPTIONS
367)
368_validate_discover_confirm_mode = _one_of_string(
369 CONF_PAIRING_DISCOVER_CONFIRM, DISCOVER_CONFIRM_MODE_OPTIONS
370)
371_validate_discovery_listen_channels = _one_of_string(
372 CONF_PAIRING_DISCOVERY_LISTEN_CHANNELS, DISCOVERY_LISTEN_CHANNELS_OPTIONS
373)
374
375
377 """Convert an explicit destination string like '0x00003B' to a list of 3 byte values."""
378 digits = value[2:] # strip the '0x' prefix
379 return [int(digits[i : i + 2], 16) for i in range(0, 6, 2)]
380
381
382def _parse_payload(value):
383 """Convert a payload option string like '0x00' to its numeric byte value."""
384 return int(value, 16)
385
386
387# Tuning sub-schema (imported and extended by the hub __init__.py).
388#
389# Only `ui_controls` carries a Python default (it is a feature toggle, not a tunable). Every
390# tunable is a plain cv.Optional with NO default: when a key is omitted, the C++ TuningConfig
391# field keeps its canonical default from proto_frame.h, which is the single source of truth.
392# to_code() only overrides fields that are actually present in the validated config.
393TUNING_SCHEMA = cv.Schema(
394 {
395 cv.Optional(CONF_UI_CONTROLS, default=False): cv.boolean,
396 cv.Optional(CONF_SX1262_RX_BANDWIDTH): _validate_bandwidth,
397 cv.Optional(CONF_SX1276_RX_BANDWIDTH): _validate_sx1276_bandwidth,
398 cv.Optional(CONF_LR1121_RX_BANDWIDTH): _validate_lr1121_bandwidth,
399 cv.Optional(CONF_PAIRING_DISCOVERY_COMMANDS): cv.All(
400 cv.ensure_list(_validate_discovery_command),
401 cv.Length(min=1),
402 ),
403 cv.Optional(CONF_PAIRING_DISCOVERY_DESTINATION): _validate_discovery_destination,
404 cv.Optional(CONF_PAIRING_DISCOVERY_PAYLOAD): _validate_discovery_payload,
405 cv.Optional(CONF_SCAN_POWER_CLASSES): _validate_scan_power_classes,
406 cv.Optional(CONF_PAIRING_DISCOVER_CONFIRM): _validate_discover_confirm_mode,
407 cv.Optional(
408 CONF_PAIRING_DISCOVERY_LISTEN_CHANNELS
409 ): _validate_discovery_listen_channels,
410 **{cv.Optional(key): cv.boolean for key in _BOOL_PARAMS},
411 # Numeric parameters share their range with the number-entity bounds via _NUMBER_PARAMS.
412 **{
413 cv.Optional(key): cv.int_range(min=lo, max=hi)
414 for key, (lo, hi, _step, _unit) in _NUMBER_PARAMS.items()
415 },
416 }
417)
418
419
421 """Declare companion entity IDs for tuning UI controls.
422
423 ESPHome 2026.x sizes the runtime component vector from the number of IDs
424 declared during validation. If UI entities were created only in to_code(),
425 they could be silently dropped. This post-validator injects declared IDs
426 before the vector is sized.
427 """
428 if not config[CONF_UI_CONTROLS]:
429 return config
430
431 # The tuning block is a sub-schema of home_io_control and does not carry the parent's
432 # CONF_ID, so the companion entity IDs use a fixed prefix. It only needs to be unique
433 # among the generated tuning entities.
434 base = _COMPANION_ID_BASE
435
436 for key in _SELECT_OPTIONS:
437 config[_id_key(key)] = ID(f"{base}_{key}", is_declaration=True, type=IOHomeTuningSelect)
438 for key in _NUMBER_PARAMS:
439 config[_id_key(key)] = ID(f"{base}_{key}", is_declaration=True, type=IOHomeTuningNumber)
440
441 return config
442
443
444TUNING_CONFIG_SCHEMA = cv.All(TUNING_SCHEMA, _inject_tuning_companion_ids)
445
446
447def _cpp_bool(value):
448 """Format a Python bool as a C++ boolean literal."""
449 return "true" if value else "false"
450
451
452def _assign(struct, field, value):
453 """Emit a `struct.field = value;` C++ assignment statement at codegen time.
454
455 ESPHome's MockObj does not support Python attribute assignment, so struct fields
456 are populated with raw assignment statements. `value` must already be valid C++
457 (an int, or a string such as an enum value or brace-init list).
458 """
459 cg.add(cg.RawExpression(f"{struct}.{field} = {value}"))
460
461
462def _apply_tuning_config(config, var):
463 """Generate C++ code that builds a TuningConfig from the validated YAML.
464
465 Only keys the user actually provided are emitted; every omitted key keeps the
466 default baked into the C++ TuningConfig struct (sourced from proto_frame.h), so
467 the defaults are never restated here.
468 """
469 tuning_id = cv.declare_id(TuningConfig)("tuning_config")
470 tuning = cg.new_variable(tuning_id, cg.RawExpression(f"{TuningConfig}()"))
471
472 # Presence of the block is what activates the override layer.
473 _assign(tuning, "active", "true")
474
475 # --- Parameters needing custom handling (enum/list/byte/bool) ---
476 if CONF_SX1262_RX_BANDWIDTH in config:
477 _assign(
478 tuning,
479 "sx1262_rx_bandwidth",
480 SX1262_BANDWIDTH_OPTIONS[config[CONF_SX1262_RX_BANDWIDTH]],
481 )
482
483 if CONF_SX1276_RX_BANDWIDTH in config:
484 _assign(
485 tuning,
486 "sx1276_rx_bandwidth",
487 SX1276_BANDWIDTH_OPTIONS[config[CONF_SX1276_RX_BANDWIDTH]],
488 )
489
490 if CONF_LR1121_RX_BANDWIDTH in config:
491 _assign(
492 tuning,
493 "lr1121_rx_bandwidth",
494 LR1121_BANDWIDTH_OPTIONS[config[CONF_LR1121_RX_BANDWIDTH]],
495 )
496
497 if CONF_SCAN_POWER_CLASSES in config:
498 _assign(
499 tuning,
500 "scan_power_classes",
501 SCAN_POWER_CLASSES_OPTIONS[config[CONF_SCAN_POWER_CLASSES]],
502 )
503
504 if CONF_PAIRING_DISCOVER_CONFIRM in config:
505 _assign(
506 tuning,
507 "pairing_discover_confirm",
508 DISCOVER_CONFIRM_MODE_OPTIONS[config[CONF_PAIRING_DISCOVER_CONFIRM]],
509 )
510
511 if CONF_PAIRING_DISCOVERY_LISTEN_CHANNELS in config:
512 _assign(
513 tuning,
514 "pairing_discovery_listen_channels",
515 DISCOVERY_LISTEN_CHANNELS_OPTIONS[
516 config[CONF_PAIRING_DISCOVERY_LISTEN_CHANNELS]
517 ],
518 )
519
520 # Ordered discovery commands. Clear the struct default before appending so a
521 # user-provided list replaces it rather than extending it.
522 if CONF_PAIRING_DISCOVERY_COMMANDS in config:
523 cg.add(tuning.pairing_discovery_commands.clear())
524 for cmd in config[CONF_PAIRING_DISCOVERY_COMMANDS]:
525 cg.add(
526 tuning.pairing_discovery_commands.push_back(
527 DISCOVERY_COMMAND_OPTIONS[cmd]
528 )
529 )
530
531 if CONF_PAIRING_DISCOVERY_DESTINATION in config:
532 dest = config[CONF_PAIRING_DISCOVERY_DESTINATION]
533 if dest == "auto":
534 _assign(tuning, "pairing_discovery_destination_auto", "true")
535 else:
536 _assign(tuning, "pairing_discovery_destination_auto", "false")
538 _assign(
539 tuning,
540 "pairing_discovery_destination",
541 f"{{0x{b[0]:02X}, 0x{b[1]:02X}, 0x{b[2]:02X}}}",
542 )
543
544 if CONF_PAIRING_DISCOVERY_PAYLOAD in config:
545 payload = config[CONF_PAIRING_DISCOVERY_PAYLOAD]
546 if payload == "none":
547 _assign(tuning, "pairing_discovery_payload_enabled", "false")
548 else:
549 _assign(tuning, "pairing_discovery_payload_enabled", "true")
550 _assign(tuning, "pairing_discovery_payload", f"0x{_parse_payload(payload):02X}")
551
552 # --- Plain boolean parameters: the C++ struct field name equals the YAML key. ---
553 for key in _BOOL_PARAMS:
554 if key in config:
555 _assign(tuning, key, _cpp_bool(config[key]))
556
557 # --- Plain integer parameters: the C++ struct field name equals the YAML key. ---
558 for key in _NUMBER_PARAMS:
559 if key in config:
560 _assign(tuning, key, config[key])
561
562 # Emitted under the same condition as the value above, and deliberately next to it: the flag
563 # tells setup() the user picked this value, so the radio driver's default must not replace it
564 # (ADR 0042). "The user chose 32" and "nobody chose anything" are indistinguishable from the
565 # value alone, which is why a second field exists at all.
566 if CONF_NORMAL_START_PREAMBLE in config:
567 _assign(tuning, "normal_start_preamble_from_yaml", "true")
568
569 cg.add(var.set_tuning_config(tuning))
570
571
572async def _create_tuning_entities(config, var):
573 """Generate number/select entities for the tuning parameters when enabled."""
574 if not config[CONF_UI_CONTROLS]:
575 return
576
577 # --- Select entities (options sourced from _SELECT_OPTIONS) ---
578 for key, options in _SELECT_OPTIONS.items():
579 await _create_select(config, var, key, options)
580
581 # --- Number entities (bounds sourced from _NUMBER_PARAMS) ---
582 for key, (min_value, max_value, step, unit) in _NUMBER_PARAMS.items():
583 await _create_number(
584 config, var, key, min_value=min_value, max_value=max_value, step=step, unit=unit
585 )
586
587
589 config, var, key, min_value, max_value, step, unit=""
590):
591 """Create a single IOHomeTuningNumber entity.
592
593 The bare {id, name} dict is normalized through number_schema()+COMPONENT_SCHEMA so it
594 carries the entity/component defaults (disabled_by_default, setup_priority, ...) that
595 register_number()/register_component() require.
596 """
597 schema = number.number_schema(
598 IOHomeTuningNumber,
599 unit_of_measurement=unit,
600 entity_category=ENTITY_CATEGORY_CONFIG,
601 )
602 entity_config = schema.extend(cv.COMPONENT_SCHEMA)(
603 {
604 CONF_ID: config[_id_key(key)],
605 CONF_NAME: UI_NAMES[key],
606 }
607 )
608 entity = await number.new_number(
609 entity_config,
610 var,
611 key,
612 min_value=min_value,
613 max_value=max_value,
614 step=step,
615 )
616 await cg.register_component(entity, entity_config)
617
618
619async def _create_select(config, var, key, options):
620 """Create a single IOHomeTuningSelect entity.
621
622 The bare {id, name} dict is normalized through select_schema()+COMPONENT_SCHEMA so it
623 carries the entity/component defaults that register_select()/register_component() require.
624 """
625 entity_config = select.select_schema(
626 IOHomeTuningSelect, entity_category=ENTITY_CATEGORY_CONFIG
627 ).extend(cv.COMPONENT_SCHEMA)(
628 {
629 CONF_ID: config[_id_key(key)],
630 CONF_NAME: UI_NAMES[key],
631 }
632 )
633 entity = await select.new_select(
634 entity_config,
635 var,
636 key,
637 options=options,
638 )
639 await cg.register_component(entity, entity_config)
640
641
642async def to_code(config, var):
643 """Generate code for the tuning block."""
644 _apply_tuning_config(config, var)
645 await _create_tuning_entities(config, var)
_create_tuning_entities(config, var)
Definition tuning.py:572
_apply_tuning_config(config, var)
Definition tuning.py:462
_parse_destination_to_bytes(value)
Definition tuning.py:376
to_code(config, var)
Definition tuning.py:642
_inject_tuning_companion_ids(config)
Definition tuning.py:420
_create_select(config, var, key, options)
Definition tuning.py:619
_assign(struct, field, value)
Definition tuning.py:452
_one_of_string(param_name, options, coerce_number=False)
Definition tuning.py:327
_id_key(param_key)
Definition tuning.py:191
_create_number(config, var, key, min_value, max_value, step, unit="")
Definition tuning.py:590