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