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