Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
proto_device_model.h
Go to the documentation of this file.
1#pragma once
2
3/// @file proto_device_model.h
4/// @brief IO-Homecontrol device-type model, capabilities and runtime device state.
5/// @ingroup hioc_protocol
6///
7/// DeviceType/DeviceCapabilityClass, the capability predicates, packed-metadata
8/// decoders, cover commands, position/tilt report decoding and the IoDevice
9/// runtime struct. The per-(type, subtype) names and flags live in the table in proto_device_types.h.
10
11#include "proto_heating.h"
12#include "proto_sizes.h"
13
14#include <cstdint>
15#include <string>
16
17namespace esphome {
18namespace home_io_control {
19
20// ============================================================================
21// Device Types — from the IO-Homecontrol specification
22// ============================================================================
23
24/// @brief IO-Homecontrol node types.
25/// The numeric values follow the specification's 10-bit node-type field, so the enum is 16 bits
26/// wide: a remote controller (1023) and the test type (1008) do not fit in 8 bits. Do not reassign
27/// or reorder these. Names, subtype names and capabilities live in the table in proto_device_types.h.
28enum class DeviceType : uint16_t {
29 UNKNOWN = 0, ///< Unknown/unspecified device.
30 INTERIOR_VENETIAN_BLIND = 1, ///< Interior venetian blind.
31 ROLLER_SHUTTER = 2, ///< Roller shutter.
32 VERTICAL_EXTERIOR_AWNING = 3, ///< Vertical exterior awning.
33 WINDOW_OPENER = 4, ///< Window opening actuator.
34 GARAGE_OPENER = 5, ///< Garage door opener.
35 LIGHT = 6, ///< Light.
36 GATE_OPENER = 7, ///< Gate opener.
37 ROLLING_DOOR_OPENER = 8, ///< Rolling door opener.
38 LOCK = 9, ///< Lock.
39 VERTICAL_INTERIOR_BLIND = 10, ///< Vertical interior blind.
40 SECURE_CONFIGURATION_DEVICE = 11, ///< Secure configuration device (a commissioning tool, not an actuator).
41 BEACON = 12, ///< Beacon (unpaired/announcement). Not YAML-selectable: a
42 ///< discovery pseudo-type, not a configurable actuator.
43 DUAL_ROLLER_SHUTTER = 13, ///< Dual-section roller shutter.
44 HEATING_TEMPERATURE_INTERFACE = 14, ///< Heating temperature interface.
45 ON_OFF_SWITCH = 15, ///< Generic on/off switch.
46 HORIZONTAL_AWNING = 16, ///< Horizontal awning (open/close inverted).
47 EXTERIOR_VENETIAN_BLIND = 17, ///< Exterior venetian blind.
48 LOUVRE_BLIND = 18, ///< Louvre blind.
49 CURTAIN_TRACK = 19, ///< Curtain track.
50 VENTILATION_POINT = 20, ///< Ventilation point.
51 EXTERIOR_HEATING = 21, ///< Exterior heating.
52 HEAT_PUMP = 22, ///< Heat pump.
53 INTRUSION_ALARM = 23, ///< Intrusion alarm.
54 SWINGING_SHUTTER = 24, ///< Swinging shutter.
55 // 25 and 26 are not defined; the enum is non-contiguous from here on.
56 SLIDING_WINDOW = 27, ///< Sliding window.
57 ZONE_CONTROL = 28, ///< Zone control.
58 BIOCLIMATIC_PERGOLA = 29, ///< Bioclimatic pergola (louvre opening as the position).
59 SIREN = 30, ///< Siren.
60 DOMESTIC_HOT_WATER = 51, ///< Domestic hot water.
61 ELECTRICAL_HEATER = 52, ///< Electrical heater.
62 HEAT_RECOVERY_VENTILATION = 53, ///< Heat recovery ventilation.
63 CENTRAL_HOUSE_CONTROL = 255, ///< Central house control.
64 TEST_AND_EVALUATION = 1008, ///< Test and evaluation equipment.
65 REMOTE_CONTROLLER = 1023, ///< Remote controller (all ten type bits set, packed bytes `FF C0`). An
66 ///< Atlantic AGILIA IO radiator answers discovery with this type while in
67 ///< its display's pairing mode, and then refuses a pushed key with
68 ///< RESULT_COMMAND_UNKNOWN (tests/corpus/captures/pairing/
69 ///< atlantic_agilia_pairing_key_transfer_command_unknown.yaml).
70};
71
72/// @brief High‑level capability class derived from DeviceType.
73enum class DeviceCapabilityClass : uint8_t {
74 UNKNOWN = 0x00, ///< Unknown capability.
75 COVER = 0x01, ///< Position‑controlled cover (shutter/blind/awning).
76 LIGHT = 0x02, ///< Binary on/off light.
77 SWITCH = 0x03, ///< Binary on/off switch.
78 SENSOR = 0x04, ///< Sensor device.
79 BEACON = 0x05, ///< Beacon.
80 CLIMATE = 0x06, ///< Climate device (heating/cooling).
81 LOCK = 0x07, ///< Lock.
82 HEATER_LEVEL = 0x08, ///< Heater with a heat level in the position field (exterior heating).
83};
84
85/// @brief Convert a DeviceType to a lowercase string identifier.
86/// @param type Device type enum.
87/// @return Null‑terminated string name (e.g., "roller_shutter"), or "unknown" for a value the
88/// table does not name.
89const char *device_type_name(DeviceType type);
90
91/// @brief True when a raw 10-bit device-type value is a named DeviceType enumerator.
92/// @details The enum is not contiguous (25 and 26 are undefined), so a range check is not enough.
93/// UNKNOWN (0) is not "known".
94/// @param raw Raw type value as decoded from an address or info frame.
95bool is_known_device_type(uint16_t raw);
96
97/// @brief Return the YAML-friendly device-type name for types exposed in the Python schema.
98/// @param type Device type enum.
99/// @return Null‑terminated string (e.g., "exterior_venetian_blind"), or nullptr if the type
100/// has no symbolic YAML alias (user must use a raw numeric value).
101const char *yaml_device_type_name(DeviceType type);
102
103/// @brief Map a raw IO‑Homecontrol type to the closest ESPHome/Home Assistant entity family.
104/// @param type Raw device type.
105/// @return Capability class (COVER, LIGHT, SWITCH, etc.).
107
108/// @brief Get a human‑readable name for a capability class.
109/// @param type Device type (unused, kept for signature compatibility).
110/// @return String like "cover", "light", "switch", "unknown".
112
113/// @brief Does this device type support precise position control (0–100)?
114/// @param type Device type.
115/// @return true for cover‑family devices.
117
118/// @brief Does this device type support binary on/off control?
119/// @param type Device type.
120/// @return true for lights and switches.
122
123/// @brief Does this device type support status request commands (0x03)?
124/// @param type Device type.
125/// @return true for covers, binary devices, and lock devices.
127
128/// @brief Does this device type support binary lock/unlock control via execute commands?
129/// @param type Device type.
130/// @return true for lock devices.
132
133/// @brief Does this device take a heat level as its position (exterior heating)?
134/// @param type Device type.
135/// @return true for the HEATER_LEVEL capability class.
137
138/// @brief Does this device never move, so a status reply must not start the movement settle loop?
139/// @details Heaters commanded with an EXECUTE (heating levels or temperatures; climate profile) and exterior heating (a
140/// heat level in the
141/// position field) have no motion; their status "stopped" flag is not consulted.
142/// @param type Device type.
143/// @param subtype Device subtype.
144/// @return true when the device has no motion.
145bool device_has_no_motion(DeviceType type, uint8_t subtype);
146
147/// @brief Can a climate entity drive this device?
148///
149/// True exactly when a heating profile exists for (type, subtype) (proto_heating_profiles.h): the
150/// electrical heater with an adjustable setpoint (52/1, private registers) and the plain electrical
151/// heater (52/0, heating levels). This only says a climate entity may bind; whether the private
152/// register protocol may be sent to the device is device_uses_heating_registers().
153/// @param type Device type.
154/// @param subtype Device subtype.
155/// @return true for devices that have a heating profile.
156bool device_supports_climate_control(DeviceType type, uint8_t subtype);
157
158/// @brief Does this device type support tilt (slat angle) control?
159/// @param type Device type.
160/// @return true for interior venetian blinds, vertical interior blinds, exterior venetian blinds
161/// and louvre blinds.
163
164/// @brief Does this device type support the ventilation position command?
165///
166/// The ventilation command moves window-type actuators to a predefined
167/// partially-open position suitable for air exchange without fully opening.
168/// @param type Device type.
169/// @return true for WINDOW_OPENER and VENTILATION_POINT.
171
172/// @brief Does this device take movement commands as the main parameter only?
173///
174/// True for every garage opener: a Wisniowski / Somfy MOTO 1000 io refuses the functional-parameter
175/// block of the regular position execute (the travel-speed profile roller motors use) and the
176/// "favourite" alias with RESULT_PARAMETER_UNKNOWN, and accepts a frame without them (issue #163).
177/// True for subtype 58 of any node type too: the public KLF 200 API specification defines it as the
178/// position-only variant. Other gate and rolling-door openers may behave the same but have no field
179/// report yet, so they keep the regular frame.
180/// @param type Device type.
181/// @param subtype Device subtype.
182/// @return true if movement must use create_execute_main_parameter() and FAVORITE is unsupported.
183bool device_takes_main_parameter_only(DeviceType type, uint8_t subtype);
184
185/// @brief Decode the 10-bit node type from two packed metadata bytes.
186/// @param type_msb First metadata byte.
187/// @param type_subtype Second metadata byte containing the remaining type bits and subtype.
188/// @return Decoded node type; a value the enum has no enumerator for is returned as is.
189DeviceType decode_packed_device_type(uint8_t type_msb, uint8_t type_subtype);
190
191/// @brief Decode a protocol-packed device subtype from the second metadata byte.
192/// @param type_subtype Second metadata byte containing subtype in bits [5:0].
193/// @return Manufacturer-specific subtype.
194uint8_t decode_packed_device_subtype(uint8_t type_subtype);
195
196/// @brief Encode a DeviceType/subtype pair into the two-byte packed metadata format used by
197/// discovery responses — the inverse of decode_packed_device_type()/decode_packed_device_subtype().
198/// @param type Device type to encode.
199/// @param subtype Manufacturer-specific subtype; only bits [5:0] are used.
200/// @param type_msb Output: first metadata byte. Only the low ten bits of `type` fit the wire field;
201/// higher bits are dropped.
202/// @param type_subtype Output: second metadata byte (top 2 type bits + 6-bit subtype).
203void encode_packed_device_type(DeviceType type, uint8_t subtype, uint8_t &type_msb, uint8_t &type_subtype);
204
205/// @brief Human‑readable operation profile name for a device type.
206/// Used for logging and diagnostics.
207/// @param type Device type.
208/// @return String such as "cover_position", "cover_position_tilt", "binary_on_off", "lock", etc.
210
211/// @brief Human-readable device type string for diagnostics, including the raw numeric value.
212/// @param type Device type.
213/// @return String such as "horizontal_awning (0x10)", or the raw hex form ("0x1A") when the
214/// type has no symbolic name. The value is printed in full ("0x3FF").
216
217/// @brief Build the YAML value for a device's `io_device_type` key.
218/// @param type Device type.
219/// @return A quoted symbolic name (e.g. `"horizontal_awning"`) when one exists, otherwise the
220/// raw hex form (e.g. `0x1A`, `0x3FF`) for a type with no YAML alias.
222
223/// @brief Build the ready-to-paste YAML block describing a device, for both a fully-decoded
224/// device and one whose type/subtype wasn't reported.
225///
226/// With `metadata_complete == false`, the type/subtype are unknown, so the returned snippet
227/// uses a `<cover|light|switch|lock>` platform placeholder and a commented-out
228/// `io_device_type` explanation; `type`, `subtype`, and `inverted` are ignored in this case.
229///
230/// With `metadata_complete == true`, the snippet names the concrete ESPHome platform for
231/// `type` and fills in `io_subtype`, `invert_position: true` for an inverted cover and
232/// `dimmable: true` for a brightness-only light subtype. If the (type, subtype) has no ESPHome
233/// platform yet (device_has_platform() in proto_device_types.h), an empty string is returned
234/// instead — the caller decides what to say when there's no snippet to show.
235///
236/// `low_power: true` is emitted in **both** shapes when `low_power` is set (the device
237/// self-reported POWER_SAVE_LOW_POWER in its discovery Multi Information Byte); it is omitted
238/// entirely otherwise, since its absence is a valid, correct config.
239///
240/// The emitted keys must track the device-bound platform schemas (`platform_schema_extension()`
241/// in platform_common.py, plus each of cover.py/light.py/switch.py/lock.py's own extra keys) by
242/// hand. `make yaml-emitter-sync` (scripts/check-yaml-emitters.py) catches drift between the two
243/// statically.
244/// @param type Decoded device type.
245/// @param subtype Decoded device subtype; only used when `metadata_complete` is true.
246/// @param device_id Hex device ID string (e.g. "38B4A1").
247/// @param metadata_complete Whether the discovery response included type/subtype metadata.
248/// @param inverted Whether the device's open/close positions are swapped; only used when
249/// `metadata_complete` is true and `type` is a cover.
250/// @param low_power Whether the device self-reported a low-power / duty-cycled class; emits
251/// `low_power: true` when set, in both snippet shapes.
252/// @param system_key_hex The device's own system key as 32 hex characters, or empty when it uses the
253/// hub's. Emits `system_key: "<hex>"` in both snippet shapes. Key material: a caller that
254/// passes one must print the snippet only inside a DO-NOT-SHARE block.
255/// @return Multi-line YAML snippet, or an empty string when `metadata_complete` is true but
256/// the (type, subtype) maps to no ESPHome platform.
257std::string build_device_yaml_snippet(DeviceType type, uint8_t subtype, const std::string &device_id,
258 bool metadata_complete, bool inverted, bool low_power,
259 const std::string &system_key_hex = std::string());
260
261// ============================================================================
262// Cover Commands
263// ============================================================================
264
265/// @brief Named device commands for cover-type actuators.
266///
267/// These represent the discrete non-positional actions a controller can send to
268/// a cover device. Each maps to a specific wire encoding in the CMD_EXECUTE payload.
269/// Using this enum avoids conflating numeric positions (0–100) with command codes.
270enum class CoverCommand : uint8_t {
271 STOP = 0, ///< Stop movement immediately.
272 FAVORITE = 1, ///< Move to stored favorite/"My" position.
273 VENT = 2, ///< Move to ventilation position (window-type devices).
274 FORCE_OPEN = 3, ///< Move to fully open at elevated priority; intended to bypass soft locks
275 ///< and environmental limits (confirmed on real hardware to move correctly;
276 ///< bypassing an active lock is still unconfirmed — see create_force_open()
277 ///< in proto_commands.cpp).
278};
279
280/// @brief Get a human-readable name for a CoverCommand.
281/// @param cmd The cover command to name.
282/// @return Null-terminated string such as "STOP", "FAVORITE", or "VENT".
283const char *cover_command_name(CoverCommand cmd);
284
285/// @brief What the hub's latest minimum-limitation read says about a rain-protection limit.
286enum class RainSensorState : uint8_t {
287 UNKNOWN = 0, ///< No usable reading: never read, or the last reads went unanswered.
288 DRY = 1, ///< The device answered and reports no rain-sized opening limit.
289 RAIN = 2, ///< The device answered and reports an opening limit of rain size.
290};
291
292/// @brief Get a human-readable name for a RainSensorState.
293/// @param state The state to name.
294/// @return Null-terminated lowercase string: "unknown", "dry" or "rain".
295const char *rain_sensor_state_name(RainSensorState state);
296
297/// @brief A device's coarse battery level, as it reports it in byte 1 of its status payload.
298///
299/// Three levels, not a percentage. Only FULL_CHARGE has been seen on real hardware so far (every
300/// solar Somfy capture in the corpus, in daylight); LOW_CHARGE and NORMAL_CHARGE are the two values
301/// below it and have not been observed. Mains devices report NOT_REPORTED.
302/// The names avoid a bare `LOW`, which the Arduino core defines as a macro.
303enum class BatteryState : uint8_t {
304 NOT_REPORTED = 0, ///< The device does not report a battery (mains powered), or no status was read yet.
305 LOW_CHARGE = 1, ///< Battery low.
306 NORMAL_CHARGE = 2, ///< Battery neither low nor full.
307 FULL_CHARGE = 3, ///< Battery full.
308};
309
310/// @brief Get the name a battery level is published under.
311/// @param state The level to name.
312/// @return "low", "normal" or "full", or "not_reported" for NOT_REPORTED.
313const char *battery_state_name(BatteryState state);
314
315/// @brief Decode the coarse battery level from byte 1 of a status payload (CMD_PRIVATE_RESP to a
316/// status poll, or CMD_STATUS_UPDATE).
317/// @param status_byte data[1] of the payload.
318/// @return The level held in bits 5-6 (STATUS_BATTERY_MASK); the other bits are ignored.
319BatteryState decode_battery_state(uint8_t status_byte);
320
321// ============================================================================
322// Position Report Decoding
323// ============================================================================
324
325/// In status responses, position is encoded as a 16-bit value where
326/// 0x0000 = fully open (0%) and 0xC800 = fully closed (100%).
327static constexpr uint16_t STATUS_POS_MAX = 0xC800;
328/// Target-reached tolerance expressed in raw IO-homecontrol position units.
329/// 100 raw units out of 51200 full-scale is about 0.195%, so this only absorbs
330/// tiny target/current mismatches from device rounding or early stopped flags.
331static constexpr uint16_t STATUS_POS_TOLERANCE_RAW = 100;
332
333/// Packed device metadata uses two bytes where the high 8 bits carry the upper type bits and the
334/// low byte carries both the remaining type bits and the 6-bit manufacturer subtype.
335static constexpr uint8_t DEVICE_METADATA_SIZE = 2;
336static constexpr uint8_t DEVICE_TYPE_LOW_BITS_SHIFT = 2;
337static constexpr uint8_t DEVICE_TYPE_HIGH_BITS_SHIFT = 6;
338static constexpr uint16_t DEVICE_TYPE_WIRE_MASK = 0x3FF; ///< The node-type wire field is ten bits wide.
339static constexpr uint8_t DEVICE_SUBTYPE_MASK = 0x3F;
340
341// ============================================================================
342// Device State
343// ============================================================================
344
345/// @brief Sentinel value meaning "position is not known yet".
346/// Matches POS_UNKNOWN (0xD4 = 212 decimal) for easy debugging.
347static constexpr float UNKNOWN_POSITION = 212.0F;
348static constexpr uint8_t DEVICE_NAME_BUFFER_SIZE = 32; ///< Device name storage including null terminator
349
350/// @brief Sentinel value meaning "no RSSI sample recorded yet" for `last_rssi_dbm`/`rssi_ema_scaled`.
351/// A real RSSI reading from these radios is always well above `INT16_MIN` — and so is any real
352/// `rssi_ema_scaled` fixed-point value.
353static constexpr int16_t RSSI_UNKNOWN_DBM = INT16_MIN;
354
355/// @brief EMA weight denominator and fixed-point scale for `IoDevice::rssi_ema_scaled`.
356/// One constant serves both roles by construction: the update `S += x − round(S/N)` blends each
357/// new sample `x` in at weight 1/N while keeping `S = N × EMA`.
358static constexpr int16_t RSSI_EMA_SCALE = 8;
359
360/// @brief What the hub predicted ahead of confirmation, kept apart from what the device reported.
361///
362/// The observed fields on IoDevice (`position`, `target`, `tilt`, `is_stopped`) mean "this is what
363/// the device last told us" and are never written by a guess. Anything the hub predicts — to give
364/// the Home Assistant UI immediate feedback across the queue-dispatch and exchange gap — lands
365/// here instead, so it can be withdrawn when the command that produced it fails
366/// (DeviceRegistry::rollback_optimistic()) without risking a real observation.
367///
368/// Consumers read through effective_target() / effective_tilt() / effective_is_stopped() rather
369/// than either set of fields directly.
371 /// Predicted movement. NONE defers to the observed `is_stopped`; a bool could not express
372 /// "predict stopped" (apply_optimistic_stop) distinctly from "no prediction".
373 enum class Motion : uint8_t {
374 NONE = 0, ///< No movement prediction; the observed `is_stopped` decides.
375 MOVING, ///< Predicted to be travelling (a position command was issued).
376 STOPPED, ///< Predicted to be at rest (a STOP was issued).
377 };
378
379 float target{UNKNOWN_POSITION}; ///< Predicted main-position target, or UNKNOWN_POSITION.
380 float tilt{UNKNOWN_POSITION}; ///< Predicted slat angle, or UNKNOWN_POSITION.
381 Motion motion{Motion::NONE}; ///< Predicted movement state.
382 /// True while a pending STOP replaced a `Motion::MOVING` prediction that a failed STOP should
383 /// bring back (DeviceRegistry::rollback_optimistic()). A failed STOP means the device keeps
384 /// travelling, and a device that reports nothing mid-travel has no observation to fall back on
385 /// that still carries the direction. Only set alongside `Motion::STOPPED`.
387 float stop_replaced_target{UNKNOWN_POSITION}; ///< The replaced `target`; meaningful only with the flag.
388
389 /// @return true when nothing is predicted, so a rollback would be a no-op.
390 [[nodiscard]] bool empty() const {
392 }
393 /// Withdraw every prediction.
394 void clear() { *this = {}; }
395 /// Withdraw the position prediction; call when a decoded position observation supersedes it.
396 /// Clears `motion` with `target` because apply_optimistic_target() sets the two together, and
397 /// the replaced movement with them: an observation outranks every guess about travel.
403 /// Drop the movement a STOP replaced, once nothing can roll the STOP back any more.
408 /// Withdraw the tilt prediction; call when a decoded tilt observation supersedes it.
410};
411
412/// @brief Runtime state of a paired IO‑Homecontrol device.
413struct IoDevice {
414 uint8_t node_id[NODE_ID_SIZE]{}; ///< Device's 3‑byte radio address.
415 DeviceType type{DeviceType::UNKNOWN}; ///< Device type (shutter, awning, etc.).
416 uint8_t subtype{0}; ///< Device subtype (manufacturer‑specific).
417 char name[DEVICE_NAME_BUFFER_SIZE]{}; ///< Cached UTF-8 device name decoded from Latin-1 wire payloads.
418 float position{UNKNOWN_POSITION}; ///< Current position: 0=open, 100=closed, or UNKNOWN_POSITION.
419 float tilt{UNKNOWN_POSITION}; ///< Current tilt: 0=closed, 100=open, or UNKNOWN_POSITION.
420 float target{UNKNOWN_POSITION}; ///< Target position the device is moving toward.
421 bool is_stopped{true}; ///< True if device is not moving.
422 bool inverted{false}; ///< True if open/close positions are swapped (e.g., horizontal awning).
423 bool silent{false}; ///< True to send position moves with the reference hub's "silent
424 ///< operation" extended block, which makes the motor travel more
425 ///< slowly. Like `inverted`/`dimmable` this is a YAML-declared
426 ///< preference, not a protocol-reported fact — nothing on the wire
427 ///< tells us whether a device is in that mode.
428 bool optimistic_state{true}; ///< True if `target` may be set ahead of a confirming poll/response.
429 bool low_power{false}; ///< YAML-declared: this target is a low-power / duty-cycled receiver, so
430 ///< directed frames set CTRL1_LOW_POWER and use the long wake-up preamble.
431 ///< A protocol-reported class exists (discovery Multi Information Byte) but
432 ///< is surfaced through the pairing/scan snippet, never applied at runtime.
433 bool dimmable{false}; ///< True for a LIGHT-class device configured `dimmable: true` in YAML.
434 ///< Set from platform_light.cpp's YAML config via
435 ///< DeviceRegistry::set_dimmable(), purely for accurate profile-name
436 ///< logging. Light subtype 58 (0x01BA) is defined as on/off only
437 ///< (VELUX KLF 200 API v3.18, App. 2); the discovery subtype is
438 ///< not consulted for this flag yet.
439 uint8_t last_result_code{0}; ///< Last CMD_ERROR_RESP result byte (0 = none recorded). See
440 ///< command_result_name()/is_limitation_result() in proto_constants.h. Cleared by
441 ///< the next successful status/command reply for this device. Note: 0 is also the
442 ///< real RESULT_UNKNOWN_STATUS_REPLY wire value, so that specific explicit reply is
443 ///< indistinguishable here from "nothing recorded yet" — a known, accepted tradeoff.
444 uint32_t last_result_at_ms{0}; ///< millis() timestamp of last_result_code, 0 when none recorded.
445 uint8_t last_commander[NODE_ID_SIZE]{}; ///< Node ID of the controller that last commanded this device, as
446 ///< reported verbatim by the device in its own status payload. All
447 ///< zeroes until a status reply carrying the record has been decoded
448 ///< (see decode_last_command_record() in proto_codecs.h).
449 uint8_t last_command_originator{0}; ///< That command's Command Originator byte (ORIGINATOR_* in
450 ///< proto_constants.h). Only meaningful when `has_last_command`.
451 bool has_last_command{false}; ///< True once a status reply carried a well-formed last-command record.
452 bool limited_by_rain{false}; ///< Derived from observed status replies, never from a prediction
453 ///< (decisions::is_rain_limited(); ADR 0030 keeps predictions apart):
454 ///< the last move was ordered by the rain sensor, or a position
455 ///< command was clamped while rain protection was recently active.
456 ///< Separate from `last_result_code`, which only an explicit
457 ///< CMD_ERROR_RESP sets.
458 uint32_t last_rain_evidence_ms{0}; ///< millis() of the last status reply that named the rain sensor as the
459 ///< last commander; 0 = never. Memory for the clamp rule, aged out
460 ///< by RAIN_EVIDENCE_HOLD_MS.
461 uint32_t last_status{0}; ///< millis() timestamp of last received status.
462 int16_t last_rssi_dbm{RSSI_UNKNOWN_DBM}; ///< Most recent raw RSSI sample (dBm), or RSSI_UNKNOWN_DBM.
463 int16_t rssi_ema_scaled{RSSI_UNKNOWN_DBM}; ///< Smoothed RSSI as fixed point in 1/RSSI_EMA_SCALE dBm — read
464 ///< through device_rssi_ema_dbm(), updated by
465 ///< detail::update_link_health() in hub_internal.h. Fixed point
466 ///< (rather than whole dBm) keeps sub-dBm EMA contributions from
467 ///< vanishing to integer truncation, so the average converges on a
468 ///< stable signal instead of stalling up to RSSI_EMA_SCALE−1 dBm
469 ///< away. RSSI_UNKNOWN_DBM before the first sample.
470 uint32_t last_seen_ms{0}; ///< millis() of the last frame received from this device (any command), 0 = never.
472 0}; ///< millis() of the last sign this device is travelling (see
473 ///< note_moving_evidence(), clear_moving_evidence()), 0 = never. Hub-side belief, not observation.
474 uint16_t exchange_timeout_count{0}; ///< Cumulative count of outbound exchanges to this device with no valid
475 ///< response (see detail::record_exchange_timeout() in hub_internal.h).
476 uint16_t exchange_attempt_count{0}; ///< Cumulative attempts (`ExchangeEngine::DebugInfo::tries`, 1-based per
477 ///< exchange) across those timed-out exchanges only — attempts within an
478 ///< ultimately successful exchange are not counted (deliberate scope limit).
479 uint16_t exchange_unconfirmed_count{0}; ///< Cumulative count of exchanges this device authenticated and then never
480 ///< closed: it answered with a 0x3C challenge, we answered that, and no
481 ///< final reply arrived. Counted separately from exchange_timeout_count
482 ///< because the two have different causes and different fixes — silence
483 ///< from the start means the device never heard us, while silence after a
484 ///< challenge means the reply was lost on the way back (or our challenge
485 ///< answer never landed). For a CMD_EXECUTE this outcome is treated as
486 ///< success and raises no failure count, so without this counter it is
487 ///< invisible in the diagnostics.
488 bool confirms_execute{false}; ///< True once this device has answered a CMD_EXECUTE with a reply that closed the
489 ///< exchange (a status, or an error). Learned at runtime and never stored: it
490 ///< separates a device whose missing reply is an anomaly from one that never sends
491 ///< one (see decisions::retry_after_unconfirmed_accept_is_safe()).
492 OptimisticState optimistic{}; ///< Hub-side predictions; see OptimisticState. Never observation.
493 RainSensorState rain_sensor{RainSensorState::UNKNOWN}; ///< Rain state from the opt-in minimum-limitation poll.
494 ///< Set only from a limitation reply inside an exchange the
495 ///< hub started; independent of `limited_by_rain`, which is
496 ///< derived from status replies.
497 BatteryState battery{BatteryState::NOT_REPORTED}; ///< Coarse battery level from byte 1 of the device's own
498 ///< status: a reply to a status poll or an authenticated
499 ///< CMD_STATUS_UPDATE. Never from an execute ack (its layout
500 ///< follows the request) or an overheard frame (ADR 0022).
502 HEATING_TEMPERATURE_RANGE_MIN_C}; ///< Lower end of the temperature encoding range of a heater that
503 ///< carries a temperature in its main parameter (YAML `min_temperature:`).
505 HEATING_TEMPERATURE_RANGE_MAX_C}; ///< Upper end of that range (YAML `max_temperature:`).
506 HeatingReadings heating{}; ///< What the hub has read from a heater over the private register protocol
507 ///< (room temperature, setpoint, mode, regulator state). Set only from decoded
508 ///< replies; stays "never read" for every other device type.
509 bool battery_entities{false}; ///< YAML-declared (`battery: true`): the battery entities exist, so the status log
510 ///< line names the level even when the device reports none.
511 bool has_system_key{false}; ///< YAML-declared (`system_key:`): this device keeps a key of its own. Every
512 ///< exchange with it uses `system_key` below instead of the hub's key.
513 uint8_t system_key[AES_KEY_SIZE]{}; ///< The device's own key; meaningful only when `has_system_key`. Key material:
514 ///< never logged outside a bannered report block.
515 bool registered_by_pairing{false}; ///< Added by a pairing in this boot, not declared in YAML. The record is
516 ///< lost at reboot, so reports still print its YAML block, and a later
517 ///< pairing may replace it (DeviceRegistry::get_declared()).
518};
519
520/// @brief Record that a device is (believed to be) travelling right now.
521///
522/// With clear_moving_evidence(), one of the two writers of IoDevice::last_moving_evidence_ms.
523/// Called wherever the hub learns a receiver is moving *because of something that reached it*: a
524/// decoded status that says "not stopped", a movement command the device accepted, or an overheard
525/// linked-remote movement command. Deliberately NOT called for the hub's own optimistic prediction:
526/// that is applied before the command is even sent, so treating it as proof of movement would make a
527/// resting receiver look awake to the very command that starts it. A low-power receiver that is
528/// moving ignores the long wake-up preamble, so this stamp steers the exchange engine to the short
529/// one first (decisions::wake_belief()).
530/// @param dev Device to stamp.
531/// @param now_ms Current millis(). Must be non-zero in practice; 0 would read as "never".
532inline void note_moving_evidence(IoDevice &dev, uint32_t now_ms) { dev.last_moving_evidence_ms = now_ms; }
533
534/// @brief Forget that a device was travelling: it reported itself stopped (including in the ack to a
535/// STOP), a linked remote was heard stopping it, or it stayed silent to an exchange sent while it
536/// was believed moving.
537///
538/// The counterpart of note_moving_evidence(). Without it a receiver that has demonstrably stopped
539/// would keep reading as "moving" for the rest of LOW_POWER_MAX_TRAVEL_MS, although it goes back to
540/// duty-cycling within seconds and then hears only the wake-up preamble.
541/// @param dev Device to clear.
543
544/// @brief Convert an `rssi_ema_scaled` fixed-point value to whole dBm (round half away from zero).
545/// @param scaled Fixed-point EMA value in 1/RSSI_EMA_SCALE dBm units (not the sentinel).
546/// @return Rounded dBm value.
547inline int16_t rssi_scaled_to_dbm(int16_t scaled) {
548 constexpr int16_t half = RSSI_EMA_SCALE / 2;
549 return static_cast<int16_t>((scaled + (scaled >= 0 ? half : -half)) / RSSI_EMA_SCALE);
550}
551
552/// @brief A device's smoothed RSSI in whole dBm.
553/// @param dev Device record to read.
554/// @return Rounded EMA in dBm, or RSSI_UNKNOWN_DBM when no sample has been recorded yet.
558
559/// @brief The main-position target a consumer should act on: the prediction when one stands,
560/// otherwise the device's own last reported target.
561/// @param dev Device record to read.
562/// @return The predicted target when set, otherwise `dev.target`.
563inline float effective_target(const IoDevice &dev) {
564 return dev.optimistic.target != UNKNOWN_POSITION ? dev.optimistic.target : dev.target;
565}
566
567/// @brief The slat angle a consumer should act on, prediction first.
568/// @param dev Device record to read.
569/// @return The predicted tilt when set, otherwise `dev.tilt`.
570inline float effective_tilt(const IoDevice &dev) {
571 return dev.optimistic.tilt != UNKNOWN_POSITION ? dev.optimistic.tilt : dev.tilt;
572}
573
574/// @brief Whether a consumer should treat the device as at rest, prediction first.
575/// @param dev Device record to read.
576/// @return The predicted movement state when one stands, otherwise `dev.is_stopped`.
577inline bool effective_is_stopped(const IoDevice &dev) {
578 switch (dev.optimistic.motion) {
580 return false;
582 return true;
584 default:
585 return dev.is_stopped;
586 }
587}
588
589/// @brief Determine whether a device type has inverted position mapping by default.
590/// @param type Device type.
591/// @return true for horizontal awnings; false otherwise.
593
594/// @brief Decode target/current position values from a status frame.
595/// @param target_raw 16‑bit raw target value.
596/// @param current_raw 16‑bit raw current value.
597/// @param is_stopped True if device reports stopped.
598/// @param target Output target position (0–100 or UNKNOWN_POSITION).
599/// @param position Output current position (0–100 or UNKNOWN_POSITION).
600void decode_position_report(uint16_t target_raw, uint16_t current_raw, bool is_stopped, float &target, float &position);
601/// @brief Has the device reached its target within tolerance?
602/// @param target Target position (0–100 or UNKNOWN_POSITION).
603/// @param position Current position (0–100 or UNKNOWN_POSITION).
604/// @return true if positions match within STATUS_POS_TOLERANCE_RAW.
605bool has_reached_target_position(float target, float position);
606/// @brief Decode tilt angle from raw 16‑bit value.
607/// @param tilt_raw Raw tilt value from status frame.
608/// @return Tilt percentage (0 = closed, 100 = open) or UNKNOWN_POSITION.
609float decode_tilt_report(uint16_t tilt_raw);
610
611} // namespace home_io_control
612} // namespace esphome
static constexpr uint8_t DEVICE_NAME_BUFFER_SIZE
Device name storage including null terminator.
const char * device_operation_profile_name(DeviceType type)
Human‑readable operation profile name for a device type.
void encode_packed_device_type(DeviceType type, uint8_t subtype, uint8_t &type_msb, uint8_t &type_subtype)
Encode a DeviceType/subtype pair into the two-byte packed metadata format used by discovery responses...
static constexpr uint8_t DEVICE_METADATA_SIZE
Packed device metadata uses two bytes where the high 8 bits carry the upper type bits and the low byt...
static constexpr float UNKNOWN_POSITION
Sentinel value meaning "position is not known yet".
static constexpr uint16_t STATUS_POS_MAX
In status responses, position is encoded as a 16-bit value where 0x0000 = fully open (0%) and 0xC800 ...
bool device_takes_main_parameter_only(DeviceType type, uint8_t subtype)
Does this device take movement commands as the main parameter only?
std::string format_device_type_for_yaml(DeviceType type)
Build the YAML value for a device's io_device_type key.
RainSensorState
What the hub's latest minimum-limitation read says about a rain-protection limit.
@ UNKNOWN
No usable reading: never read, or the last reads went unanswered.
@ DRY
The device answered and reports no rain-sized opening limit.
@ RAIN
The device answered and reports an opening limit of rain size.
static constexpr uint8_t DEVICE_TYPE_HIGH_BITS_SHIFT
const char * rain_sensor_state_name(RainSensorState state)
Get a human-readable name for a RainSensorState.
static constexpr uint16_t STATUS_POS_TOLERANCE_RAW
Target-reached tolerance expressed in raw IO-homecontrol position units.
DeviceCapabilityClass device_capability_class(DeviceType type)
Map a raw IO‑Homecontrol type to the closest ESPHome/Home Assistant entity family.
bool default_inverted_for_type(DeviceType type)
Determine whether a device type has inverted position mapping by default.
float effective_target(const IoDevice &dev)
The main-position target a consumer should act on: the prediction when one stands,...
CoverCommand
Named device commands for cover-type actuators.
@ FORCE_OPEN
Move to fully open at elevated priority; intended to bypass soft locks and environmental limits (conf...
bool device_supports_position_control(DeviceType type)
Does this device type support precise position control (0–100)?
bool device_supports_vent(DeviceType type)
Does this device type support the ventilation position command?
int16_t rssi_scaled_to_dbm(int16_t scaled)
Convert an rssi_ema_scaled fixed-point value to whole dBm (round half away from zero).
const char * device_type_name(DeviceType type)
Convert a DeviceType to a lowercase string identifier.
DeviceType
IO-Homecontrol node types.
@ EXTERIOR_VENETIAN_BLIND
Exterior venetian blind.
@ VERTICAL_INTERIOR_BLIND
Vertical interior blind.
@ CENTRAL_HOUSE_CONTROL
Central house control.
@ VERTICAL_EXTERIOR_AWNING
Vertical exterior awning.
@ HEATING_TEMPERATURE_INTERFACE
Heating temperature interface.
@ BEACON
Beacon (unpaired/announcement).
@ SECURE_CONFIGURATION_DEVICE
Secure configuration device (a commissioning tool, not an actuator).
@ INTERIOR_VENETIAN_BLIND
Interior venetian blind.
@ UNKNOWN
Unknown/unspecified device.
@ WINDOW_OPENER
Window opening actuator.
@ HEAT_RECOVERY_VENTILATION
Heat recovery ventilation.
@ HORIZONTAL_AWNING
Horizontal awning (open/close inverted).
@ TEST_AND_EVALUATION
Test and evaluation equipment.
@ DUAL_ROLLER_SHUTTER
Dual-section roller shutter.
@ REMOTE_CONTROLLER
Remote controller (all ten type bits set, packed bytes FF C0).
@ BIOCLIMATIC_PERGOLA
Bioclimatic pergola (louvre opening as the position).
bool device_supports_binary_control(DeviceType type)
Does this device type support binary on/off control?
float decode_tilt_report(uint16_t tilt_raw)
Decode tilt angle from raw 16‑bit value.
bool device_supports_lock_control(DeviceType type)
Does this device type support binary lock/unlock control via execute commands?
bool device_supports_climate_control(DeviceType type, uint8_t subtype)
Can a climate entity drive this device?
DeviceType decode_packed_device_type(uint8_t type_msb, uint8_t type_subtype)
Decode the 10-bit node type from two packed metadata bytes.
BatteryState
A device's coarse battery level, as it reports it in byte 1 of its status payload.
@ NORMAL_CHARGE
Battery neither low nor full.
@ NOT_REPORTED
The device does not report a battery (mains powered), or no status was read yet.
const char * cover_command_name(CoverCommand cmd)
Get a human-readable name for a CoverCommand.
bool device_supports_heat_level_control(DeviceType type)
Does this device take a heat level as its position (exterior heating)?
const char * device_capability_class_name(DeviceType type)
Get a human‑readable name for a capability class.
std::string build_device_yaml_snippet(DeviceType type, uint8_t subtype, const std::string &device_id, bool metadata_complete, bool inverted, bool low_power, const std::string &system_key_hex)
Build the ready-to-paste YAML block describing a device, for both a fully-decoded device and one whos...
bool effective_is_stopped(const IoDevice &dev)
Whether a consumer should treat the device as at rest, prediction first.
float effective_tilt(const IoDevice &dev)
The slat angle a consumer should act on, prediction first.
bool is_known_device_type(uint16_t raw)
True when a raw 10-bit device-type value is a named DeviceType enumerator.
void note_moving_evidence(IoDevice &dev, uint32_t now_ms)
Record that a device is (believed to be) travelling right now.
static constexpr int16_t RSSI_EMA_SCALE
EMA weight denominator and fixed-point scale for IoDevice::rssi_ema_scaled.
uint8_t decode_packed_device_subtype(uint8_t type_subtype)
Decode a protocol-packed device subtype from the second metadata byte.
bool device_supports_status_requests(DeviceType type)
Does this device type support status request commands (0x03)?
bool device_has_no_motion(DeviceType type, uint8_t subtype)
Does this device never move, so a status reply must not start the movement settle loop?
static constexpr uint16_t DEVICE_TYPE_WIRE_MASK
The node-type wire field is ten bits wide.
static constexpr uint8_t DEVICE_SUBTYPE_MASK
bool has_reached_target_position(float target, float position)
Has the device reached its target within tolerance?
const char * yaml_device_type_name(DeviceType type)
Return the YAML-friendly device-type name for types exposed in the Python schema.
DeviceCapabilityClass
High‑level capability class derived from DeviceType.
@ CLIMATE
Climate device (heating/cooling).
@ HEATER_LEVEL
Heater with a heat level in the position field (exterior heating).
@ COVER
Position‑controlled cover (shutter/blind/awning).
int16_t device_rssi_ema_dbm(const IoDevice &dev)
A device's smoothed RSSI in whole dBm.
BatteryState decode_battery_state(uint8_t status_byte)
Decode the coarse battery level from byte 1 of a status payload (CMD_PRIVATE_RESP to a status poll,...
static constexpr int16_t RSSI_UNKNOWN_DBM
Sentinel value meaning "no RSSI sample recorded yet" for last_rssi_dbm/rssi_ema_scaled.
static constexpr uint8_t DEVICE_TYPE_LOW_BITS_SHIFT
const char * battery_state_name(BatteryState state)
Get the name a battery level is published under.
std::string format_device_type_diagnostic(DeviceType type)
Human-readable device type string for diagnostics, including the raw numeric value.
void clear_moving_evidence(IoDevice &dev)
Forget that a device was travelling: it reported itself stopped (including in the ack to a STOP),...
void decode_position_report(uint16_t target_raw, uint16_t current_raw, bool is_stopped, float &target, float &position)
Decode target/current position values from a status frame.
bool device_supports_tilt(DeviceType type)
Does this device type support tilt (slat angle) control?
Pure codec for the private register protocol of 2W heaters (CMD_WRITE_PRIVATE 0x20).
Fundamental IO-Homecontrol frame and crypto size constants.
What the hub has read from one heater so far.
Runtime state of a paired IO‑Homecontrol device.
uint32_t last_result_at_ms
millis() timestamp of last_result_code, 0 when none recorded.
float target
Target position the device is moving toward.
uint16_t exchange_attempt_count
Cumulative attempts (ExchangeEngine::DebugInfo::tries, 1-based per exchange) across those timed-out e...
float tilt
Current tilt: 0=closed, 100=open, or UNKNOWN_POSITION.
float temperature_range_min_c
Lower end of the temperature encoding range of a heater that carries a temperature in its main parame...
uint16_t exchange_unconfirmed_count
Cumulative count of exchanges this device authenticated and then never closed: it answered with a 0x3...
uint8_t last_result_code
Last CMD_ERROR_RESP result byte (0 = none recorded).
HeatingReadings heating
What the hub has read from a heater over the private register protocol (room temperature,...
uint32_t last_status
millis() timestamp of last received status.
uint8_t last_commander[NODE_ID_SIZE]
Node ID of the controller that last commanded this device, as reported verbatim by the device in its ...
uint8_t last_command_originator
That command's Command Originator byte (ORIGINATOR_* in proto_constants.h).
char name[DEVICE_NAME_BUFFER_SIZE]
Cached UTF-8 device name decoded from Latin-1 wire payloads.
uint32_t last_moving_evidence_ms
millis() of the last sign this device is travelling (see note_moving_evidence(), clear_moving_evidenc...
OptimisticState optimistic
Hub-side predictions; see OptimisticState. Never observation.
bool inverted
True if open/close positions are swapped (e.g., horizontal awning).
int16_t last_rssi_dbm
Most recent raw RSSI sample (dBm), or RSSI_UNKNOWN_DBM.
bool limited_by_rain
Derived from observed status replies, never from a prediction (decisions::is_rain_limited(); ADR 0030...
bool silent
True to send position moves with the reference hub's "silent operation" extended block,...
bool low_power
YAML-declared: this target is a low-power / duty-cycled receiver, so directed frames set CTRL1_LOW_PO...
bool registered_by_pairing
Added by a pairing in this boot, not declared in YAML.
float position
Current position: 0=open, 100=closed, or UNKNOWN_POSITION.
uint8_t subtype
Device subtype (manufacturer‑specific).
uint32_t last_seen_ms
millis() of the last frame received from this device (any command), 0 = never.
bool battery_entities
YAML-declared (battery: true): the battery entities exist, so the status log line names the level eve...
int16_t rssi_ema_scaled
Smoothed RSSI as fixed point in 1/RSSI_EMA_SCALE dBm — read through device_rssi_ema_dbm(),...
float temperature_range_max_c
Upper end of that range (YAML max_temperature:).
uint8_t node_id[NODE_ID_SIZE]
Device's 3‑byte radio address.
bool dimmable
True for a LIGHT-class device configured dimmable: true in YAML.
uint8_t system_key[AES_KEY_SIZE]
The device's own key; meaningful only when has_system_key.
bool has_last_command
True once a status reply carried a well-formed last-command record.
bool has_system_key
YAML-declared (system_key:): this device keeps a key of its own.
uint32_t last_rain_evidence_ms
millis() of the last status reply that named the rain sensor as the last commander; 0 = never.
RainSensorState rain_sensor
Rain state from the opt-in minimum-limitation poll.
bool optimistic_state
True if target may be set ahead of a confirming poll/response.
DeviceType type
Device type (shutter, awning, etc.).
uint16_t exchange_timeout_count
Cumulative count of outbound exchanges to this device with no valid response (see detail::record_exch...
BatteryState battery
Coarse battery level from byte 1 of the device's own status: a reply to a status poll or an authentic...
bool is_stopped
True if device is not moving.
bool confirms_execute
True once this device has answered a CMD_EXECUTE with a reply that closed the exchange (a status,...
What the hub predicted ahead of confirmation, kept apart from what the device reported.
@ STOPPED
Predicted to be at rest (a STOP was issued).
@ MOVING
Predicted to be travelling (a position command was issued).
@ NONE
No movement prediction; the observed is_stopped decides.
void clear()
Withdraw every prediction.
float stop_replaced_target
The replaced target; meaningful only with the flag.
Motion motion
Predicted movement state.
void clear_tilt()
Withdraw the tilt prediction; call when a decoded tilt observation supersedes it.
bool stop_replaced_movement
True while a pending STOP replaced a Motion::MOVING prediction that a failed STOP should bring back (...
void forget_replaced_movement()
Drop the movement a STOP replaced, once nothing can roll the STOP back any more.
void clear_position()
Withdraw the position prediction; call when a decoded position observation supersedes it.
float tilt
Predicted slat angle, or UNKNOWN_POSITION.
float target
Predicted main-position target, or UNKNOWN_POSITION.