Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
platform_common.py
Go to the documentation of this file.
1## @file
2## @brief Shared ESPHome codegen for IO-Homecontrol device-bound platforms.
3## @ingroup hioc_codegen
4##
5## cover.py, light.py, switch.py and lock.py all bind an ESPHome entity to a hub
6## device: the same config keys, the same auto-generated companion diagnostic sensors
7## (device name, active issue, RSSI, last contact, exchange failures), and the same
8## to_code() wiring (set_parent / set_device_id / device type / subtype / poll
9## interval / linked remotes). This module is the single home for that shared logic so
10## a change lands in one place instead of four — platform files call one
11## inject_companion_sensor_ids() post-validator and one create_companion_sensors()
12## to_code() helper. Platform-specific pieces — entity construction/registration, the
13## cover's ``invert_position`` option, and the cover's favorite/vent companion
14## buttons — deliberately stay in the platform files.
15
16import esphome.codegen as cg
17import esphome.config_validation as cv
18from esphome.components import sensor, text_sensor
19from esphome.const import (
20 CONF_ACCURACY_DECIMALS,
21 CONF_DEVICE_CLASS,
22 CONF_DISABLED_BY_DEFAULT,
23 CONF_FORCE_UPDATE,
24 CONF_ID,
25 CONF_NAME,
26 CONF_STATE_CLASS,
27 CONF_UNIT_OF_MEASUREMENT,
28 ENTITY_CATEGORY_DIAGNOSTIC,
29 STATE_CLASS_MEASUREMENT,
30 STATE_CLASS_TOTAL_INCREASING,
31)
32from esphome.components.sensor import DEVICE_CLASS_SIGNAL_STRENGTH
33from esphome.core import ID
34
35from . import (
36 home_io_control_ns,
37 IOHomeControlComponent,
38 CONF_HOME_IO_CONTROL_ID,
39 device_type_expression,
40 validate_device_id,
41 validate_device_type,
42 validate_linked_remote_entry,
43 validate_status_poll_interval,
44)
45
46# Shared YAML config keys used by every device-bound platform.
47CONF_DEVICE_ID = "io_device_id"
48CONF_LINKED_REMOTES = "linked_remotes"
49CONF_DEVICE_TYPE = "io_device_type"
50CONF_SUBTYPE = "io_subtype"
51CONF_STATUS_POLL_INTERVAL = "status_poll_interval"
52
53# Internal config key for the companion device-name sensor ID (injected by post-validator).
54CONF_DEVICE_NAME_SENSOR_ID = "_device_name_sensor_id"
55# Internal config key for the companion active-issue sensor ID (injected by post-validator).
56CONF_ACTIVE_ISSUE_SENSOR_ID = "_active_issue_sensor_id"
57# Internal config keys for the companion link-health sensor IDs (injected by post-validator).
58CONF_RSSI_SENSOR_ID = "_rssi_sensor_id"
59CONF_LAST_CONTACT_SENSOR_ID = "_last_contact_sensor_id"
60CONF_EXCHANGE_FAILURES_SENSOR_ID = "_exchange_failures_sensor_id"
61
62IOHomeDeviceNameTextSensor = home_io_control_ns.class_(
63 "IOHomeDeviceNameTextSensor", text_sensor.TextSensor, cg.Component
64)
65IOHomeActiveIssueTextSensor = home_io_control_ns.class_(
66 "IOHomeActiveIssueTextSensor", text_sensor.TextSensor, cg.Component
67)
68IOHomeRssiSensor = home_io_control_ns.class_("IOHomeRssiSensor", sensor.Sensor, cg.Component)
69IOHomeLastContactSensor = home_io_control_ns.class_(
70 "IOHomeLastContactSensor", sensor.Sensor, cg.Component
71)
72IOHomeExchangeFailuresSensor = home_io_control_ns.class_(
73 "IOHomeExchangeFailuresSensor", sensor.Sensor, cg.Component
74)
75
76
77# (config key, ID suffix, codegen class) for every auto-generated companion sensor.
78_COMPANION_SENSOR_IDS = (
79 (CONF_DEVICE_NAME_SENSOR_ID, "device_name_sensor", IOHomeDeviceNameTextSensor),
80 (CONF_ACTIVE_ISSUE_SENSOR_ID, "active_issue_sensor", IOHomeActiveIssueTextSensor),
81 (CONF_RSSI_SENSOR_ID, "rssi_sensor", IOHomeRssiSensor),
82 (CONF_LAST_CONTACT_SENSOR_ID, "last_contact_sensor", IOHomeLastContactSensor),
83 (CONF_EXCHANGE_FAILURES_SENSOR_ID, "exchange_failures_sensor", IOHomeExchangeFailuresSensor),
84)
85
86
87def _companion_sensor_name(config, suffix):
88 """Derive a companion sensor's entity name from the parent entity name."""
89 base_name = config.get(CONF_NAME, "")
90 if base_name:
91 return f"{base_name} {suffix}"
92 return suffix
93
94
95def companion_id_base(config, parent_id_key):
96 """Return the shared ID prefix for a platform's companion entity IDs.
97
98 ESPHome 2026.x sizes its runtime component vector (StaticVector) from the number of
99 component IDs known at the end of schema validation — before to_code() runs. If
100 companion entities are only created inside to_code(), their IDs are not counted and
101 the StaticVector overflows at runtime, silently dropping later components whose
102 setup() then never executes. Companion IDs must therefore be declared during
103 validation, and they all share the prefix returned here.
104
105 ``parent_id_key`` differs per platform: light reads the entity ID from
106 CONF_OUTPUT_ID, while cover, switch and lock read it from CONF_ID.
107 """
108 from esphome.helpers import sanitize
109
110 parent_id = config[parent_id_key]
111 # When no explicit id: is given, ESPHome auto-generates it after validation.
112 # At this point .id may still be None, so derive from the entity name instead.
113 return parent_id.id if parent_id.id else sanitize(config[CONF_NAME]).lower()
114
115
116def inject_companion_sensor_ids(config, parent_id_key):
117 """Declare every auto-generated companion sensor ID during schema validation.
118
119 Shared post-validator body for every device-bound platform, covering all entries in
120 _COMPANION_SENSOR_IDS. See companion_id_base() for why the IDs must be declared at
121 validation time rather than in to_code().
122 """
123 base = companion_id_base(config, parent_id_key)
124 for conf_key, id_suffix, sensor_class in _COMPANION_SENSOR_IDS:
125 config[conf_key] = ID(
126 f"{base}_{id_suffix}", is_declaration=True, type=sensor_class
127 )
128 return config
129
130
132 """Return the shared schema keys every device-bound platform extends with."""
133 return {
134 cv.Required(CONF_NAME): cv.string,
135 cv.GenerateID(CONF_HOME_IO_CONTROL_ID): cv.use_id(IOHomeControlComponent),
136 cv.Required(CONF_DEVICE_ID): validate_device_id,
137 cv.Optional(CONF_DEVICE_TYPE): validate_device_type,
138 cv.Optional(CONF_SUBTYPE): cv.int_range(min=0, max=63),
139 cv.Optional(CONF_LINKED_REMOTES): cv.ensure_list(validate_linked_remote_entry),
140 cv.Optional(CONF_STATUS_POLL_INTERVAL): validate_status_poll_interval,
141 }
142
143
144async def wire_device_binding(var, parent, config):
145 """Emit the shared to_code() wiring that binds an entity to its hub device.
146
147 Covers set_parent / set_device_id, the optional device type / subtype / status
148 poll interval, and the linked-remotes registration loop — identical across all
149 four device-bound platforms.
150 """
151 cg.add(var.set_parent(parent))
152 cg.add(var.set_device_id(config[CONF_DEVICE_ID]))
153
154 if CONF_DEVICE_TYPE in config:
155 cg.add(var.set_device_type(device_type_expression(config[CONF_DEVICE_TYPE])))
156 if CONF_SUBTYPE in config:
157 cg.add(var.set_subtype(config[CONF_SUBTYPE]))
158 if CONF_STATUS_POLL_INTERVAL in config:
159 cg.add(
160 var.set_status_poll_interval(
161 config[CONF_STATUS_POLL_INTERVAL].total_milliseconds
162 )
163 )
164
165 if CONF_LINKED_REMOTES in config:
166 for remote_id in config[CONF_LINKED_REMOTES]:
167 if remote_id.startswith("class:"):
168 # validate_linked_remote_entry() already normalized this to 'class:0x<HH>'.
169 type_value = int(remote_id.split(":", 1)[1], 16)
170 cg.add(
171 parent.add_linked_remote_class(
172 device_type_expression(type_value),
173 config[CONF_DEVICE_ID],
174 )
175 )
176 else:
177 cg.add(parent.add_linked_remote(remote_id, config[CONF_DEVICE_ID]))
178
179
180async def _create_companion_text_sensor(config, parent, sensor_id, name, disabled_by_default):
181 """Shared body for the auto-generated companion `text_sensor:` entities."""
182 companion_config = {
183 CONF_ID: sensor_id,
184 CONF_NAME: name,
185 CONF_DISABLED_BY_DEFAULT: disabled_by_default,
186 "entity_category": ENTITY_CATEGORY_DIAGNOSTIC,
187 }
188 companion = await text_sensor.new_text_sensor(companion_config)
189 await cg.register_component(companion, companion_config)
190 cg.add(companion.set_parent(parent))
191 cg.add(companion.set_device_id(config[CONF_DEVICE_ID]))
192
193
194async def _create_link_health_sensor(config, parent, sensor_id, name, **sensor_kwargs):
195 """Shared body for the three auto-generated link-health `sensor:` companions.
196
197 All three (RSSI, Last Contact, Exchange Failures) are numeric, diagnostic, and disabled by
198 default (noise control); only the name and sensor-specific schema keys
199 (unit/device_class/state_class/accuracy_decimals) differ between them, so those are the
200 only things each call in create_companion_sensors() supplies.
201 """
202 if CONF_STATE_CLASS in sensor_kwargs:
203 # set_state_class() takes a C++ enum, not a raw string; validate_state_class() is what
204 # normal YAML schema validation would apply to turn the string constant into the
205 # EnumValue codegen expects. We build this dict by hand rather than running it through
206 # sensor_schema(), so it must be applied here.
207 sensor_kwargs[CONF_STATE_CLASS] = sensor.validate_state_class(sensor_kwargs[CONF_STATE_CLASS])
208
209 link_health_config = {
210 CONF_ID: sensor_id,
211 CONF_NAME: name,
212 CONF_DISABLED_BY_DEFAULT: True,
213 "entity_category": ENTITY_CATEGORY_DIAGNOSTIC,
214 CONF_FORCE_UPDATE: False,
215 **sensor_kwargs,
216 }
217 var = await sensor.new_sensor(link_health_config)
218 await cg.register_component(var, link_health_config)
219 cg.add(var.set_parent(parent))
220 cg.add(var.set_device_id(config[CONF_DEVICE_ID]))
221
222
223async def create_companion_sensors(config, parent):
224 """Create and register every auto-generated companion diagnostic sensor.
225
226 Single to_code() entry point for the device-bound platforms (the counterpart of
227 inject_companion_sensor_ids()), so adding a companion touches this module only:
228
229 - Device Name: disabled by default (clutter control).
230 - Active Issue: the one enabled-by-default companion — it is the headline diagnostic
231 value that turns a silently-ignored command into a self-explained one (e.g. a
232 wind/rain lockout), so users should see it without an opt-in step. Empty except while
233 a CMD_ERROR_RESP reason is outstanding; see IOHomeActiveIssueTextSensor.
234 - RSSI / Last Contact / Exchange Failures: numeric link-health diagnostics, disabled by
235 default (noise control). Last Contact publishes seconds since the last frame from the
236 device (an age, not a Home Assistant timestamp) and keeps counting up between frames via
237 its own heartbeat; see IOHomeLastContactSensor.
238 """
240 config,
241 parent,
242 config[CONF_DEVICE_NAME_SENSOR_ID],
243 _companion_sensor_name(config, "Device Name"),
244 disabled_by_default=True,
245 )
247 config,
248 parent,
249 config[CONF_ACTIVE_ISSUE_SENSOR_ID],
250 _companion_sensor_name(config, "Active Issue"),
251 disabled_by_default=False,
252 )
254 config,
255 parent,
256 config[CONF_RSSI_SENSOR_ID],
257 _companion_sensor_name(config, "RSSI"),
258 **{
259 CONF_UNIT_OF_MEASUREMENT: "dBm",
260 CONF_DEVICE_CLASS: DEVICE_CLASS_SIGNAL_STRENGTH,
261 CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT,
262 CONF_ACCURACY_DECIMALS: 0,
263 },
264 )
266 config,
267 parent,
268 config[CONF_LAST_CONTACT_SENSOR_ID],
269 _companion_sensor_name(config, "Last Contact"),
270 **{
271 CONF_UNIT_OF_MEASUREMENT: "s",
272 CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT,
273 CONF_ACCURACY_DECIMALS: 0,
274 },
275 )
277 config,
278 parent,
279 config[CONF_EXCHANGE_FAILURES_SENSOR_ID],
280 _companion_sensor_name(config, "Exchange Failures"),
281 **{
282 CONF_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING,
283 CONF_ACCURACY_DECIMALS: 0,
284 },
285 )
inject_companion_sensor_ids(config, parent_id_key)
wire_device_binding(var, parent, config)
_create_companion_text_sensor(config, parent, sensor_id, name, disabled_by_default)
companion_id_base(config, parent_id_key)
_create_link_health_sensor(config, parent, sensor_id, name, **sensor_kwargs)