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
16home_io_control_ns = cg.esphome_ns.namespace(
"home_io_control")
19CONF_UI_CONTROLS =
"ui_controls"
22_COMPANION_ID_BASE =
"home_io_control"
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_LBT_MAX_RETRIES =
"lbt_max_retries"
37CONF_LBT_RSSI_THRESHOLD_DBM =
"lbt_rssi_threshold_dbm"
38CONF_EXCHANGE_START_RESPONSE_WAIT_MS =
"exchange_start_response_wait_ms"
39CONF_EXCHANGE_RESPONSE_WAIT_MS =
"exchange_response_wait_ms"
42CONF_PAIRING_DISCOVERY_COMMANDS =
"pairing_discovery_commands"
43CONF_PAIRING_DISCOVERY_DESTINATION =
"pairing_discovery_destination"
44CONF_PAIRING_DISCOVERY_PAYLOAD =
"pairing_discovery_payload"
45CONF_PAIRING_DISCOVERY_LOW_POWER =
"pairing_discovery_low_power"
46CONF_PAIRING_DISCOVERY_WAIT_MS =
"pairing_discovery_wait_ms"
47CONF_PAIRING_DISCOVERY_INITIAL_DWELL_MS =
"pairing_discovery_initial_dwell_ms"
48CONF_PAIRING_KEY_EXCHANGE_RETRIES =
"pairing_key_exchange_retries"
51TuningConfig = home_io_control_ns.class_(
"TuningConfig")
52IOHomeTuningNumber = home_io_control_ns.class_(
53 "IOHomeTuningNumber", number.Number, cg.Component
55IOHomeTuningSelect = home_io_control_ns.class_(
56 "IOHomeTuningSelect", select.Select, cg.Component
62SX1262RxBandwidth = home_io_control_ns.enum(
"SX1262RxBandwidth", is_class=
True)
63SX1276RxBandwidth = home_io_control_ns.enum(
"SX1276RxBandwidth", is_class=
True)
64LR1121RxBandwidth = home_io_control_ns.enum(
"LR1121RxBandwidth", is_class=
True)
65DiscoveryCommand = home_io_control_ns.enum(
"DiscoveryCommand", is_class=
True)
69SX1262_BANDWIDTH_OPTIONS = {
70 "58.6": SX1262RxBandwidth.BW_58_6_KHZ,
71 "78.2": SX1262RxBandwidth.BW_78_2_KHZ,
72 "117.3": SX1262RxBandwidth.BW_117_3_KHZ,
73 "156.2": SX1262RxBandwidth.BW_156_2_KHZ,
74 "187.2": SX1262RxBandwidth.BW_187_2_KHZ,
77SX1276_BANDWIDTH_OPTIONS = {
78 "20.8": SX1276RxBandwidth.BW_20_8_KHZ,
79 "41.7": SX1276RxBandwidth.BW_41_7_KHZ,
80 "62.5": SX1276RxBandwidth.BW_62_5_KHZ,
81 "83.3": SX1276RxBandwidth.BW_83_3_KHZ,
82 "125.0": SX1276RxBandwidth.BW_125_0_KHZ,
89LR1121_BANDWIDTH_OPTIONS = {
90 "39.0": LR1121RxBandwidth.BW_39_0_KHZ,
91 "46.9": LR1121RxBandwidth.BW_46_9_KHZ,
92 "58.6": LR1121RxBandwidth.BW_58_6_KHZ,
93 "78.2": LR1121RxBandwidth.BW_78_2_KHZ,
94 "117.3": LR1121RxBandwidth.BW_117_3_KHZ,
95 "156.2": LR1121RxBandwidth.BW_156_2_KHZ,
96 "187.2": LR1121RxBandwidth.BW_187_2_KHZ,
99DISCOVERY_COMMAND_OPTIONS = {
100 "0x28": DiscoveryCommand.DISCOVER,
101 "0x2E": DiscoveryCommand.DISCOVER_ALT,
112DISCOVERY_COMMAND_PRESETS = [
118DISCOVERY_DESTINATION_OPTIONS = [
"auto",
"0x00003B",
"0x00003F"]
120PAIRING_DISCOVERY_PAYLOAD_OPTIONS = [
"none",
"0x00"]
123 """Config-dict key under which a parameter's injected companion entity ID is stored."""
124 return f
"_{param_key}_id"
131 CONF_SX1262_RX_BANDWIDTH:
"Radio SX1262 RX Bandwidth (kHz)",
132 CONF_SX1262_RESPONSE_PREAMBLE:
"Radio SX1262 Response Preamble",
133 CONF_SX1262_POST_TX_SETTLE_US:
"Radio SX1262 Post-TX Settle",
134 CONF_SX1276_RX_BANDWIDTH:
"Radio SX1276 RX Bandwidth (kHz)",
135 CONF_SX1276_RESPONSE_PREAMBLE:
"Radio SX1276 Response Preamble",
136 CONF_SX1276_DISCOVERY_HOP_SLICE_MS:
"Radio SX1276 Discovery Hop Slice",
137 CONF_SX1262_DISCOVERY_HOP_SLICE_MS:
"Radio SX1262 Discovery Hop Slice",
138 CONF_LR1121_RX_BANDWIDTH:
"Radio LR1121 RX Bandwidth (kHz)",
139 CONF_LR1121_RESPONSE_PREAMBLE:
"Radio LR1121 Response Preamble",
140 CONF_LR1121_POST_TX_SETTLE_US:
"Radio LR1121 Post-TX Settle",
141 CONF_LR1121_DISCOVERY_HOP_SLICE_MS:
"Radio LR1121 Discovery Hop Slice",
142 CONF_LBT_MAX_RETRIES:
"Radio LBT Max Retries",
143 CONF_LBT_RSSI_THRESHOLD_DBM:
"Radio LBT RSSI Threshold",
144 CONF_EXCHANGE_START_RESPONSE_WAIT_MS:
"Exchange Start Response Wait",
145 CONF_EXCHANGE_RESPONSE_WAIT_MS:
"Exchange Response Wait",
146 CONF_PAIRING_DISCOVERY_COMMANDS:
"Pairing Discovery Commands",
147 CONF_PAIRING_DISCOVERY_DESTINATION:
"Pairing Discovery Destination",
148 CONF_PAIRING_DISCOVERY_PAYLOAD:
"Pairing Discovery Payload",
149 CONF_PAIRING_DISCOVERY_LOW_POWER:
"Pairing Discovery Low Power",
150 CONF_PAIRING_DISCOVERY_WAIT_MS:
"Pairing Discovery Wait",
151 CONF_PAIRING_DISCOVERY_INITIAL_DWELL_MS:
"Pairing Discovery Initial Dwell",
152 CONF_PAIRING_KEY_EXCHANGE_RETRIES:
"Pairing Key Exchange Retries",
162 CONF_SX1262_RESPONSE_PREAMBLE: (8, 256, 1,
"B"),
163 CONF_SX1262_POST_TX_SETTLE_US: (0, 2000, 10,
"µs"),
164 CONF_SX1276_RESPONSE_PREAMBLE: (8, 256, 1,
"B"),
165 CONF_SX1276_DISCOVERY_HOP_SLICE_MS: (5, 200, 1,
"ms"),
166 CONF_SX1262_DISCOVERY_HOP_SLICE_MS: (50, 500, 1,
"ms"),
169 CONF_LR1121_RESPONSE_PREAMBLE: (8, 256, 1,
"B"),
170 CONF_LR1121_POST_TX_SETTLE_US: (0, 2000, 10,
"µs"),
171 CONF_LR1121_DISCOVERY_HOP_SLICE_MS: (50, 500, 1,
"ms"),
172 CONF_LBT_MAX_RETRIES: (0, 10, 1,
""),
173 CONF_LBT_RSSI_THRESHOLD_DBM: (-95, -70, 1,
"dBm"),
177 CONF_EXCHANGE_START_RESPONSE_WAIT_MS: (200, 4000, 50,
"ms"),
178 CONF_EXCHANGE_RESPONSE_WAIT_MS: (200, 4000, 50,
"ms"),
179 CONF_PAIRING_DISCOVERY_WAIT_MS: (500, 5000, 50,
"ms"),
180 CONF_PAIRING_DISCOVERY_INITIAL_DWELL_MS: (0, 500, 10,
"ms"),
181 CONF_PAIRING_KEY_EXCHANGE_RETRIES: (1, 5, 1,
""),
187 CONF_PAIRING_DISCOVERY_COMMANDS: DISCOVERY_COMMAND_PRESETS,
188 CONF_PAIRING_DISCOVERY_DESTINATION: DISCOVERY_DESTINATION_OPTIONS,
189 CONF_PAIRING_DISCOVERY_PAYLOAD: PAIRING_DISCOVERY_PAYLOAD_OPTIONS,
190 CONF_PAIRING_DISCOVERY_LOW_POWER: [
"Off",
"On"],
191 CONF_SX1262_RX_BANDWIDTH: list(SX1262_BANDWIDTH_OPTIONS),
192 CONF_SX1276_RX_BANDWIDTH: list(SX1276_BANDWIDTH_OPTIONS),
193 CONF_LR1121_RX_BANDWIDTH: list(LR1121_BANDWIDTH_OPTIONS),
198 """Build a validator that requires a string value present in `options`.
200 Works for both list and dict `options` (dict membership tests the keys), so a single
201 factory replaces a hand-written validator per option set while keeping a per-parameter
202 error message. With `coerce_number`, a numeric YAML value (e.g. ``117.3``) is accepted and
203 normalized to its string form, so bare-number options do not force the user to quote them.
207 if coerce_number
and isinstance(value, (int, float))
and not isinstance(value, bool):
209 value = cv.string_strict(value)
210 if value
not in options:
211 raise cv.Invalid(f
"{param_name} must be one of {list(options)}")
218 CONF_SX1262_RX_BANDWIDTH, SX1262_BANDWIDTH_OPTIONS, coerce_number=
True
221 CONF_SX1276_RX_BANDWIDTH, SX1276_BANDWIDTH_OPTIONS, coerce_number=
True
224 CONF_LR1121_RX_BANDWIDTH, LR1121_BANDWIDTH_OPTIONS, coerce_number=
True
227 f
"{CONF_PAIRING_DISCOVERY_COMMANDS} entries", DISCOVERY_COMMAND_OPTIONS
230 CONF_PAIRING_DISCOVERY_DESTINATION, DISCOVERY_DESTINATION_OPTIONS
233 CONF_PAIRING_DISCOVERY_PAYLOAD, PAIRING_DISCOVERY_PAYLOAD_OPTIONS
238 """Convert an explicit destination string like '0x00003B' to a list of 3 byte values."""
240 return [int(digits[i : i + 2], 16)
for i
in range(0, 6, 2)]
244 """Convert a payload option string like '0x00' to its numeric byte value."""
245 return int(value, 16)
254TUNING_SCHEMA = cv.Schema(
256 cv.Optional(CONF_UI_CONTROLS, default=
False): cv.boolean,
257 cv.Optional(CONF_SX1262_RX_BANDWIDTH): _validate_bandwidth,
258 cv.Optional(CONF_SX1276_RX_BANDWIDTH): _validate_sx1276_bandwidth,
259 cv.Optional(CONF_LR1121_RX_BANDWIDTH): _validate_lr1121_bandwidth,
260 cv.Optional(CONF_PAIRING_DISCOVERY_COMMANDS): cv.All(
261 cv.ensure_list(_validate_discovery_command),
264 cv.Optional(CONF_PAIRING_DISCOVERY_DESTINATION): _validate_discovery_destination,
265 cv.Optional(CONF_PAIRING_DISCOVERY_PAYLOAD): _validate_discovery_payload,
266 cv.Optional(CONF_PAIRING_DISCOVERY_LOW_POWER): cv.boolean,
269 cv.Optional(key): cv.int_range(min=lo, max=hi)
270 for key, (lo, hi, _step, _unit)
in _NUMBER_PARAMS.items()
277 """Declare companion entity IDs for tuning UI controls.
279 ESPHome 2026.x sizes the runtime component vector from the number of IDs
280 declared during validation. If UI entities were created only in to_code(),
281 they could be silently dropped. This post-validator injects declared IDs
282 before the vector is sized.
284 if not config[CONF_UI_CONTROLS]:
290 base = _COMPANION_ID_BASE
292 for key
in _SELECT_OPTIONS:
293 config[
_id_key(key)] = ID(f
"{base}_{key}", is_declaration=
True, type=IOHomeTuningSelect)
294 for key
in _NUMBER_PARAMS:
295 config[
_id_key(key)] = ID(f
"{base}_{key}", is_declaration=
True, type=IOHomeTuningNumber)
300TUNING_CONFIG_SCHEMA = cv.All(TUNING_SCHEMA, _inject_tuning_companion_ids)
304 """Format a Python bool as a C++ boolean literal."""
305 return "true" if value
else "false"
309 """Emit a `struct.field = value;` C++ assignment statement at codegen time.
311 ESPHome's MockObj does not support Python attribute assignment, so struct fields
312 are populated with raw assignment statements. `value` must already be valid C++
313 (an int, or a string such as an enum value or brace-init list).
315 cg.add(cg.RawExpression(f
"{struct}.{field} = {value}"))
319 """Generate C++ code that builds a TuningConfig from the validated YAML.
321 Only keys the user actually provided are emitted; every omitted key keeps the
322 default baked into the C++ TuningConfig struct (sourced from proto_frame.h), so
323 the defaults are never restated here.
325 tuning_id = cv.declare_id(TuningConfig)(
"tuning_config")
326 tuning = cg.new_variable(tuning_id, cg.RawExpression(f
"{TuningConfig}()"))
329 _assign(tuning,
"active",
"true")
332 if CONF_SX1262_RX_BANDWIDTH
in config:
335 "sx1262_rx_bandwidth",
336 SX1262_BANDWIDTH_OPTIONS[config[CONF_SX1262_RX_BANDWIDTH]],
339 if CONF_SX1276_RX_BANDWIDTH
in config:
342 "sx1276_rx_bandwidth",
343 SX1276_BANDWIDTH_OPTIONS[config[CONF_SX1276_RX_BANDWIDTH]],
346 if CONF_LR1121_RX_BANDWIDTH
in config:
349 "lr1121_rx_bandwidth",
350 LR1121_BANDWIDTH_OPTIONS[config[CONF_LR1121_RX_BANDWIDTH]],
355 if CONF_PAIRING_DISCOVERY_COMMANDS
in config:
356 cg.add(tuning.pairing_discovery_commands.clear())
357 for cmd
in config[CONF_PAIRING_DISCOVERY_COMMANDS]:
359 tuning.pairing_discovery_commands.push_back(
360 DISCOVERY_COMMAND_OPTIONS[cmd]
364 if CONF_PAIRING_DISCOVERY_DESTINATION
in config:
365 dest = config[CONF_PAIRING_DISCOVERY_DESTINATION]
367 _assign(tuning,
"pairing_discovery_destination_auto",
"true")
369 _assign(tuning,
"pairing_discovery_destination_auto",
"false")
373 "pairing_discovery_destination",
374 f
"{{0x{b[0]:02X}, 0x{b[1]:02X}, 0x{b[2]:02X}}}",
377 if CONF_PAIRING_DISCOVERY_PAYLOAD
in config:
378 payload = config[CONF_PAIRING_DISCOVERY_PAYLOAD]
379 if payload ==
"none":
380 _assign(tuning,
"pairing_discovery_payload_enabled",
"false")
382 _assign(tuning,
"pairing_discovery_payload_enabled",
"true")
383 _assign(tuning,
"pairing_discovery_payload", f
"0x{_parse_payload(payload):02X}")
385 if CONF_PAIRING_DISCOVERY_LOW_POWER
in config:
388 "pairing_discovery_low_power",
389 _cpp_bool(config[CONF_PAIRING_DISCOVERY_LOW_POWER]),
393 for key
in _NUMBER_PARAMS:
395 _assign(tuning, key, config[key])
397 cg.add(var.set_tuning_config(tuning))
401 """Generate number/select entities for the tuning parameters when enabled."""
402 if not config[CONF_UI_CONTROLS]:
406 for key, options
in _SELECT_OPTIONS.items():
410 for key, (min_value, max_value, step, unit)
in _NUMBER_PARAMS.items():
412 config, var, key, min_value=min_value, max_value=max_value, step=step, unit=unit
417 config, var, key, min_value, max_value, step, unit=""
419 """Create a single IOHomeTuningNumber entity.
421 The bare {id, name} dict is normalized through number_schema()+COMPONENT_SCHEMA so it
422 carries the entity/component defaults (disabled_by_default, setup_priority, ...) that
423 register_number()/register_component() require.
425 schema = number.number_schema(
427 unit_of_measurement=unit,
428 entity_category=ENTITY_CATEGORY_CONFIG,
430 entity_config = schema.extend(cv.COMPONENT_SCHEMA)(
433 CONF_NAME: UI_NAMES[key],
436 entity = await number.new_number(
444 await cg.register_component(entity, entity_config)
448 """Create a single IOHomeTuningSelect entity.
450 The bare {id, name} dict is normalized through select_schema()+COMPONENT_SCHEMA so it
451 carries the entity/component defaults that register_select()/register_component() require.
453 entity_config = select.select_schema(
454 IOHomeTuningSelect, entity_category=ENTITY_CATEGORY_CONFIG
455 ).extend(cv.COMPONENT_SCHEMA)(
458 CONF_NAME: UI_NAMES[key],
461 entity = await select.new_select(
467 await cg.register_component(entity, entity_config)
471 """Generate code for the tuning block."""
_inject_tuning_companion_ids(config)
_one_of_string(param_name, options, coerce_number=False)
_create_select(config, var, key, options)
_create_tuning_entities(config, var)
_apply_tuning_config(config, var)
_parse_destination_to_bytes(value)
_create_number(config, var, key, min_value, max_value, step, unit="")
_assign(struct, field, value)