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
12#include "proto_dhw.h"
13#include "proto_heating.h"
14#include "proto_sizes.h"
15
16#include <cstdint>
17#include <optional>
18#include <string>
19
20namespace esphome {
21namespace home_io_control {
22
23// ============================================================================
24// Device Types — from the IO-Homecontrol specification
25// ============================================================================
26
27/// @brief IO-Homecontrol node types.
28/// The numeric values follow the specification's 10-bit node-type field, so the enum is 16 bits
29/// wide: a remote controller (1023) and the test type (1008) do not fit in 8 bits. Do not reassign
30/// or reorder these. Names, subtype names and capabilities live in the table in proto_device_types.h.
31enum class DeviceType : uint16_t {
32 UNKNOWN = 0, ///< Unknown/unspecified device.
33 INTERIOR_VENETIAN_BLIND = 1, ///< Interior venetian blind.
34 ROLLER_SHUTTER = 2, ///< Roller shutter.
35 VERTICAL_EXTERIOR_AWNING = 3, ///< Vertical exterior awning.
36 WINDOW_OPENER = 4, ///< Window opening actuator.
37 GARAGE_OPENER = 5, ///< Garage door opener.
38 LIGHT = 6, ///< Light.
39 GATE_OPENER = 7, ///< Gate opener.
40 ROLLING_DOOR_OPENER = 8, ///< Rolling door opener.
41 LOCK = 9, ///< Lock.
42 VERTICAL_INTERIOR_BLIND = 10, ///< Vertical interior blind.
43 SECURE_CONFIGURATION_DEVICE = 11, ///< Secure configuration device (a commissioning tool, not an actuator).
44 BEACON = 12, ///< Beacon (unpaired/announcement). Not YAML-selectable: a
45 ///< discovery pseudo-type, not a configurable actuator.
46 DUAL_ROLLER_SHUTTER = 13, ///< Dual-section roller shutter.
47 HEATING_TEMPERATURE_INTERFACE = 14, ///< Heating temperature interface.
48 ON_OFF_SWITCH = 15, ///< Generic on/off switch.
49 HORIZONTAL_AWNING = 16, ///< Horizontal awning (open/close inverted).
50 EXTERIOR_VENETIAN_BLIND = 17, ///< Exterior venetian blind.
51 LOUVRE_BLIND = 18, ///< Louvre blind.
52 CURTAIN_TRACK = 19, ///< Curtain track.
53 VENTILATION_POINT = 20, ///< Ventilation point.
54 EXTERIOR_HEATING = 21, ///< Exterior heating.
55 HEAT_PUMP = 22, ///< Heat pump.
56 INTRUSION_ALARM = 23, ///< Intrusion alarm.
57 SWINGING_SHUTTER = 24, ///< Swinging shutter.
58 // 25 and 26 are not defined; the enum is non-contiguous from here on.
59 SLIDING_WINDOW = 27, ///< Sliding window.
60 ZONE_CONTROL = 28, ///< Zone control.
61 BIOCLIMATIC_PERGOLA = 29, ///< Bioclimatic pergola (louvre opening as the position).
62 SIREN = 30, ///< Siren.
63 DOMESTIC_HOT_WATER = 51, ///< Domestic hot water.
64 ELECTRICAL_HEATER = 52, ///< Electrical heater.
65 HEAT_RECOVERY_VENTILATION = 53, ///< Heat recovery ventilation.
66 CENTRAL_HOUSE_CONTROL = 255, ///< Central house control.
67 TEST_AND_EVALUATION = 1008, ///< Test and evaluation equipment.
68 REMOTE_CONTROLLER = 1023, ///< Remote controller (all ten type bits set, packed bytes `FF C0`). An
69 ///< Atlantic AGILIA IO radiator answers discovery with this type while in
70 ///< its display's pairing mode, and then refuses a pushed key with
71 ///< RESULT_COMMAND_UNKNOWN (tests/corpus/captures/pairing/
72 ///< atlantic_agilia_pairing_key_transfer_command_unknown.yaml).
73};
74
75/// @brief A product model below a (node type, subtype): for products that report the same wire type
76/// but need different handling.
77/// @details The wire carries no model, so it comes from the YAML (`io_model:`) and is never learned.
78/// NONE is the (type, subtype) row itself, so every device declared without a model behaves as
79/// before. Each other value has a model row in the node-type table (proto_device_types.h) under
80/// the one (type, subtype) that offers it; the row's name is the YAML spelling. Values are
81/// stable: hub_validators.py `DEVICE_MODEL_OPTIONS` mirrors them (device_features_sync_test.cpp).
82enum class DeviceModel : uint8_t {
83 NONE = 0, ///< No model: the (type, subtype) row.
84 TOWEL_DRYER = 1, ///< Towel dryer under the electrical heater with an adjustable setpoint (52/1).
85 HEATER_WITH_LIGHT = 2, ///< Heater with a night light under the electrical heater with an adjustable setpoint (52/1).
86};
87
88/// @brief High‑level capability class derived from DeviceType.
89enum class DeviceCapabilityClass : uint8_t {
90 UNKNOWN = 0x00, ///< Unknown capability.
91 COVER = 0x01, ///< Position‑controlled cover (shutter/blind/awning).
92 LIGHT = 0x02, ///< Binary on/off light.
93 SWITCH = 0x03, ///< Binary on/off switch.
94 SENSOR = 0x04, ///< Sensor device.
95 BEACON = 0x05, ///< Beacon.
96 CLIMATE = 0x06, ///< Climate device (heating/cooling).
97 LOCK = 0x07, ///< Lock.
98 HEATER_LEVEL = 0x08, ///< Heater with a heat level in the position field (exterior heating).
99 WATER_HEATER = 0x09, ///< Domestic hot water heater (device_feature::WATER_HEATER).
100};
101
102/// @brief Convert a DeviceType to a lowercase string identifier.
103/// @param type Device type enum.
104/// @return Null‑terminated string name (e.g., "roller_shutter"), or "unknown" for a value the
105/// table does not name.
106const char *device_type_name(DeviceType type);
107
108/// @brief True when a raw 10-bit device-type value is a named DeviceType enumerator.
109/// @details The enum is not contiguous (25 and 26 are undefined), so a range check is not enough.
110/// UNKNOWN (0) is not "known".
111/// @param raw Raw type value as decoded from an address or info frame.
112bool is_known_device_type(uint16_t raw);
113
114/// @brief Return the YAML-friendly device-type name for types exposed in the Python schema.
115/// @param type Device type enum.
116/// @return Null‑terminated string (e.g., "exterior_venetian_blind"), or nullptr if the type
117/// has no symbolic YAML alias (user must use a raw numeric value).
118const char *yaml_device_type_name(DeviceType type);
119
120/// @brief Map a raw IO‑Homecontrol type to the closest ESPHome/Home Assistant entity family.
121/// @param type Raw device type.
122/// @return Capability class (COVER, LIGHT, SWITCH, etc.).
124
125/// @brief Get a human‑readable name for a capability class.
126/// @param type Device type (unused, kept for signature compatibility).
127/// @return String like "cover", "light", "switch", "unknown".
129
130/// @brief Does this device type support precise position control (0–100)?
131/// @param type Device type.
132/// @return true for cover‑family devices.
134
135/// @brief Does this device type support binary on/off control?
136/// @param type Device type.
137/// @return true for lights and switches.
139
140/// @brief Does this device type take position EXECUTEs (a percent, on / off, or stop in the main
141/// parameter)?
142/// @param type Device type.
143/// @return true for covers, lights, switches, locks and heat-level heaters.
145
146/// @brief Does this device type support status request commands (0x03)?
147/// @details Every kind that takes position EXECUTEs reports its state with a status request.
148/// @param type Device type.
149/// @return true exactly where device_takes_execute_position() is.
151
152/// @brief Does this device type support binary lock/unlock control via execute commands?
153/// @param type Device type.
154/// @return true for lock devices.
156
157/// @brief Does this device take a heat level as its position (exterior heating)?
158/// @param type Device type.
159/// @return true for the HEATER_LEVEL capability class.
161
162/// @brief Does this device never move, so a status reply must not start the movement settle loop?
163/// @details Heaters commanded with an EXECUTE (heating levels or temperatures; climate profile) and exterior heating (a
164/// heat level in the
165/// position field) have no motion; their status "stopped" flag is not consulted.
166/// @param type Device type.
167/// @param subtype Device subtype.
168/// @return true when the device has no motion.
169bool device_has_no_motion(DeviceType type, uint8_t subtype);
170
171/// @brief Can a climate entity drive this device?
172///
173/// True exactly when a heating profile exists for (type, subtype) (proto_heating_profiles.h): the
174/// electrical heater with an adjustable setpoint (52/1, private registers) and the plain electrical
175/// heater (52/0, heating levels). This only says a climate entity may bind; whether the private
176/// register protocol may be sent to the device is device_uses_heating_registers().
177/// @param type Device type.
178/// @param subtype Device subtype.
179/// @return true for devices that have a heating profile.
180bool device_supports_climate_control(DeviceType type, uint8_t subtype);
181
182/// @brief Decode the 10-bit node type from two packed metadata bytes.
183/// @param type_msb First metadata byte.
184/// @param type_subtype Second metadata byte containing the remaining type bits and subtype.
185/// @return Decoded node type; a value the enum has no enumerator for is returned as is.
186DeviceType decode_packed_device_type(uint8_t type_msb, uint8_t type_subtype);
187
188/// @brief Decode a protocol-packed device subtype from the second metadata byte.
189/// @param type_subtype Second metadata byte containing subtype in bits [5:0].
190/// @return Manufacturer-specific subtype.
191uint8_t decode_packed_device_subtype(uint8_t type_subtype);
192
193/// @brief Encode a DeviceType/subtype pair into the two-byte packed metadata format used by
194/// discovery responses — the inverse of decode_packed_device_type()/decode_packed_device_subtype().
195/// @param type Device type to encode.
196/// @param subtype Manufacturer-specific subtype; only bits [5:0] are used.
197/// @param type_msb Output: first metadata byte. Only the low ten bits of `type` fit the wire field;
198/// higher bits are dropped.
199/// @param type_subtype Output: second metadata byte (top 2 type bits + 6-bit subtype).
200void encode_packed_device_type(DeviceType type, uint8_t subtype, uint8_t &type_msb, uint8_t &type_subtype);
201
202/// @brief Human‑readable operation profile name for a device kind.
203/// Used for logging and diagnostics.
204/// @param type Device type.
205/// @param subtype Device subtype; a cover is "cover_position_tilt" only when its (type, subtype) has a slat
206/// angle, and "cover_discrete" when it has no intermediate positions.
207/// @return String such as "cover_position", "cover_position_tilt", "cover_discrete", "binary_on_off", "lock", etc.
208const char *device_operation_profile_name(DeviceType type, uint8_t subtype);
209
210/// @brief Human-readable device type string for diagnostics, including the raw numeric value.
211/// @param type Device type.
212/// @return String such as "horizontal_awning (0x10)", or the raw hex form ("0x1A") when the
213/// type has no symbolic name. The value is printed in full ("0x3FF").
215
216/// @brief Build the YAML value for a device's `io_device_type` key.
217/// @param type Device type.
218/// @return A quoted symbolic name (e.g. `"horizontal_awning"`) when one exists, otherwise the
219/// raw hex form (e.g. `0x1A`, `0x3FF`) for a type with no YAML alias.
221
222/// @brief Build the ready-to-paste YAML block describing a device, for both a fully-decoded
223/// device and one whose type/subtype wasn't reported.
224///
225/// With `metadata_complete == false`, the type/subtype are unknown, so the returned snippet
226/// uses a `<cover|light|switch|lock>` platform placeholder and a commented-out
227/// `io_device_type` explanation; `type` and `subtype` are ignored in this case.
228///
229/// With `metadata_complete == true`, the snippet names the concrete ESPHome platform for
230/// `type` and fills in `io_subtype`, `invert_position: true` for a kind inverted by default and
231/// `dimmable: true` for a light subtype with intermediate levels (device_feature::INVERTED and
232/// device_feature::LEVELS of the kind). 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 low_power Whether the device self-reported a low-power / duty-cycled class; emits
249/// `low_power: true` when set, in both snippet shapes.
250/// @param system_key_hex The device's own system key as 32 hex characters, or empty when it uses the
251/// hub's. Emits `system_key: "<hex>"` in both snippet shapes. Key material: a caller that
252/// passes one must print the snippet only inside a DO-NOT-SHARE block.
253/// @return Multi-line YAML snippet, or an empty string when `metadata_complete` is true but
254/// the (type, subtype) maps to no ESPHome platform.
255std::string build_device_yaml_snippet(DeviceType type, uint8_t subtype, const std::string &device_id,
256 bool metadata_complete, bool low_power,
257 const std::string &system_key_hex = std::string());
258
259// ============================================================================
260// Cover Commands
261// ============================================================================
262
263/// @brief Named device commands for cover-type actuators.
264///
265/// These represent the discrete non-positional actions a controller can send to
266/// a cover device. Each maps to a specific wire encoding in the CMD_EXECUTE payload.
267/// Using this enum avoids conflating numeric positions (0–100) with command codes.
268enum class CoverCommand : uint8_t {
269 STOP = 0, ///< Stop movement immediately.
270 FAVORITE = 1, ///< Move to stored favorite/"My" position.
271 VENT = 2, ///< Move to ventilation position (window-type devices).
272 FORCE_OPEN = 3, ///< Move to fully open at elevated priority; intended to bypass soft locks
273 ///< and environmental limits (confirmed on real hardware to move correctly;
274 ///< bypassing an active lock is still unconfirmed — see create_force_open()
275 ///< in proto_commands.cpp).
276 PARTIAL = 4, ///< Move to the stored partial position (garage openers; issue #163).
277 PEDESTRIAN = 5, ///< Move to the pedestrian position (gate openers); experimental.
278 ///< \todo Confirm on a real gate opener that 0xD807 opens it to a pedestrian position.
279};
280
281/// @brief Get a human-readable name for a CoverCommand.
282/// @param cmd The cover command to name.
283/// @return Null-terminated string such as "STOP", "FAVORITE", "VENT", "PARTIAL" or "PEDESTRIAN".
284const char *cover_command_name(CoverCommand cmd);
285
286/// @brief What the hub's latest minimum-limitation read says about a rain-protection limit.
287enum class RainSensorState : uint8_t {
288 UNKNOWN = 0, ///< No usable reading: never read, or the last reads went unanswered.
289 DRY = 1, ///< The device answered and reports no rain-sized opening limit.
290 RAIN = 2, ///< The device answered and reports an opening limit of rain size.
291};
292
293/// @brief Get a human-readable name for a RainSensorState.
294/// @param state The state to name.
295/// @return Null-terminated lowercase string: "unknown", "dry" or "rain".
296const char *rain_sensor_state_name(RainSensorState state);
297
298/// @brief The speed a cover's position moves ask the motor for (ADR 0065).
299///
300/// The 8-byte execute frame carries a speed profile byte; the 6-byte frame a wall pad sends carries
301/// none and leaves the speed to the motor. Like `inverted`, a YAML-declared preference: nothing on
302/// the wire reports which one a device is moved with. The enumerator for "the motor's own speed" is
303/// not a bare `DEFAULT`, which the Arduino core defines as a macro on some targets.
304enum class TravelSpeed : uint8_t {
305 MOTOR_DEFAULT = 0, ///< The 6-byte frame with no profile: the motor's own speed, as its wall pad moves it.
306 SILENT = 1, ///< Profile 0x05, the manufacturer app's silent operation: slower than the motor's own.
307 FAST = 2, ///< Profile 0x06, what the hub sends unless a cover says otherwise.
308};
309
310/// @brief Get the name a travel speed is configured and published under.
311/// @param speed The speed to name.
312/// @return "default", "silent" or "fast".
313const char *travel_speed_name(TravelSpeed speed);
314
315/// @brief Look up a travel speed by the name travel_speed_name() gives it.
316/// @param name "default", "silent" or "fast".
317/// @param speed Set to the matching speed on success, untouched otherwise.
318/// @return true when the name is one of the three.
319bool parse_travel_speed(const std::string &name, TravelSpeed &speed);
320
321/// @brief What a device says it is doing, from bits [2:0] of byte 0 of a status reply (CMD_PRIVATE_RESP,
322/// CMD_STATUS_UPDATE).
323///
324/// The numbering is the public KLF 200 API's node state (v3.18, Table 105 "State"). The byte packs the
325/// state under a result code, `(code << 3) | state`, the same layout as a CMD_ERROR_RESP result byte
326/// (error_resp_state()); every ordinary status reply in the corpus carries 4 while a cover travels and 5
327/// once it is at rest.
328/// \todo Experimental: WAITING_FOR_POWER and WAITING_FOR_EXECUTING are not yet captured in an ordinary
329/// status reply, only WAITING_FOR_POWER in a main-parameter read of a shutter queued behind another
330/// on one supply. Following such a cover rests on that and on the API; it needs a captured ordinary
331/// status reply from a waiting cover.
332enum class ExecutionState : uint8_t {
333 NON_EXECUTING = 0, ///< Unable to execute under the given conditions (example: temperature too high).
334 EXECUTION_ERROR = 1, ///< An error occurred during the previous execution; cleared when the next starts.
335 WAITING_FOR_EXECUTING = 2, ///< Accepted, waiting for its turn to run.
336 WAITING_FOR_POWER = 3, ///< Accepted, waiting for power (queued behind another device on a shared supply).
337 EXECUTING = 4, ///< Running.
338 DONE = 5, ///< Not executing, no error, ready.
339 UNDEFINED = 0xFF, ///< Bits [2:0] hold 6 or 7, which no source defines.
340};
341
342/// @brief Read the execution state out of byte 0 of a status reply.
343/// @param status_byte Byte 0 of the reply's payload.
344/// @return The state, or ExecutionState::UNDEFINED when bits [2:0] hold 6 or 7.
345ExecutionState execution_state_of(uint8_t status_byte);
346
347/// @brief Get a human-readable name for an ExecutionState.
348/// @param state The state to name.
349/// @return Null-terminated lowercase string such as "executing", "waiting_for_power" or "undefined".
350const char *execution_state_name(ExecutionState state);
351
352/// @brief A device's coarse battery level, as it reports it in byte 1 of its status payload.
353///
354/// Three levels, not a percentage. Only FULL_CHARGE has been seen on real hardware so far (every
355/// solar Somfy capture in the corpus, in daylight); LOW_CHARGE and NORMAL_CHARGE are the two values
356/// below it and have not been observed. Mains devices report NOT_REPORTED.
357/// The names avoid a bare `LOW`, which the Arduino core defines as a macro.
358enum class BatteryState : uint8_t {
359 NOT_REPORTED = 0, ///< The device does not report a battery (mains powered), or no status was read yet.
360 LOW_CHARGE = 1, ///< Battery low.
361 NORMAL_CHARGE = 2, ///< Battery neither low nor full.
362 FULL_CHARGE = 3, ///< Battery full.
363};
364
365/// @brief Get the name a battery level is published under.
366/// @param state The level to name.
367/// @return "low", "normal" or "full", or "not_reported" for NOT_REPORTED.
368const char *battery_state_name(BatteryState state);
369
370/// @brief Decode the coarse battery level from byte 1 of a status payload (CMD_PRIVATE_RESP to a
371/// status poll, or CMD_STATUS_UPDATE).
372/// @param status_byte data[1] of the payload.
373/// @return The level held in bits 5-6 (STATUS_BATTERY_MASK); the other bits are ignored.
374BatteryState decode_battery_state(uint8_t status_byte);
375
376// ============================================================================
377// Position Report Decoding
378// ============================================================================
379
380/// In status responses, position is encoded as a 16-bit value where
381/// 0x0000 = fully open (0%) and 0xC800 = fully closed (100%).
382static constexpr uint16_t STATUS_POS_MAX = 0xC800;
383/// Target-reached tolerance expressed in raw IO-homecontrol position units.
384/// 100 raw units out of 51200 full-scale is about 0.195%, so this only absorbs
385/// tiny target/current mismatches from device rounding or early stopped flags.
386static constexpr uint16_t STATUS_POS_TOLERANCE_RAW = 100;
387
388/// Packed device metadata uses two bytes where the high 8 bits carry the upper type bits and the
389/// low byte carries both the remaining type bits and the 6-bit manufacturer subtype.
390static constexpr uint8_t DEVICE_METADATA_SIZE = 2;
391static constexpr uint8_t DEVICE_TYPE_LOW_BITS_SHIFT = 2;
392static constexpr uint8_t DEVICE_TYPE_HIGH_BITS_SHIFT = 6;
393static constexpr uint16_t DEVICE_TYPE_WIRE_MASK = 0x3FF; ///< The node-type wire field is ten bits wide.
394static constexpr uint8_t DEVICE_SUBTYPE_MASK = 0x3F;
395
396// ============================================================================
397// Device State
398// ============================================================================
399
400/// @brief Sentinel value meaning "position is not known yet".
401/// Matches POS_UNKNOWN (0xD4 = 212 decimal) for easy debugging.
402static constexpr float UNKNOWN_POSITION = 212.0F;
403static constexpr uint8_t DEVICE_NAME_BUFFER_SIZE = 32; ///< Device name storage including null terminator
404
405/// @brief Sentinel value meaning "no RSSI sample recorded yet" for `last_rssi_dbm`/`rssi_ema_scaled`.
406/// A real RSSI reading from these radios is always well above `INT16_MIN` — and so is any real
407/// `rssi_ema_scaled` fixed-point value.
408static constexpr int16_t RSSI_UNKNOWN_DBM = INT16_MIN;
409
410/// @brief EMA weight denominator and fixed-point scale for `IoDevice::rssi_ema_scaled`.
411/// One constant serves both roles by construction: the update `S += x − round(S/N)` blends each
412/// new sample `x` in at weight 1/N while keeping `S = N × EMA`.
413static constexpr int16_t RSSI_EMA_SCALE = 8;
414
415/// @brief What the hub predicted ahead of confirmation, kept apart from what the device reported.
416///
417/// The observed fields on IoDevice (`position`, `target`, `tilt`, `is_stopped`) mean "this is what
418/// the device last told us" and are never written by a guess. Anything the hub predicts — to give
419/// the Home Assistant UI immediate feedback across the queue-dispatch and exchange gap — lands
420/// here instead, so it can be withdrawn when the command that produced it fails
421/// (DeviceRegistry::rollback_optimistic()) without risking a real observation.
422///
423/// Consumers read through effective_target() / effective_tilt() / effective_is_stopped() rather
424/// than either set of fields directly.
426 /// Predicted movement. NONE defers to the observed `is_stopped`; a bool could not express
427 /// "predict stopped" (apply_optimistic_stop) distinctly from "no prediction".
428 enum class Motion : uint8_t {
429 NONE = 0, ///< No movement prediction; the observed `is_stopped` decides.
430 MOVING, ///< Predicted to be travelling (a position command was issued).
431 STOPPED, ///< Predicted to be at rest (a STOP was issued).
432 };
433
434 float target{UNKNOWN_POSITION}; ///< Predicted main-position target, or UNKNOWN_POSITION.
435 float tilt{UNKNOWN_POSITION}; ///< Predicted slat angle, or UNKNOWN_POSITION.
436 Motion motion{Motion::NONE}; ///< Predicted movement state.
437 /// True while a pending STOP replaced a `Motion::MOVING` prediction that a failed STOP should
438 /// bring back (DeviceRegistry::rollback_optimistic()). A failed STOP means the device keeps
439 /// travelling, and a device that reports nothing mid-travel has no observation to fall back on
440 /// that still carries the direction. Only set alongside `Motion::STOPPED`.
442 float stop_replaced_target{UNKNOWN_POSITION}; ///< The replaced `target`; meaningful only with the flag.
443
444 /// @return true when nothing is predicted, so a rollback would be a no-op.
445 [[nodiscard]] bool empty() const {
447 }
448 /// Withdraw every prediction.
449 void clear() { *this = {}; }
450 /// Withdraw the position prediction; call when a decoded position observation supersedes it.
451 /// Clears `motion` with `target` because apply_optimistic_target() sets the two together, and
452 /// the replaced movement with them: an observation outranks every guess about travel.
458 /// Drop the movement a STOP replaced, once nothing can roll the STOP back any more.
463 /// Withdraw the tilt prediction; call when a decoded tilt observation supersedes it.
465};
466
467/// @brief The last "stopped" report a device sent away from its own target, kept to confirm the next.
468///
469/// A cover can come to rest a little off its target and say so (a VELUX SSL 119-171 raw short of
470/// an end stop, a Somfy pergola 378-479 raw off an intermediate target; corpus
471/// velux_ssl_statuspoll_stopped_off_target and somfy_pergola_louver_statuspoll_replies_sx1262).
472/// A second report naming the same target and the same current position confirms the device is
473/// at rest there (decisions::classify_stopped_report()). Observed values only, never a prediction.
475 float target{UNKNOWN_POSITION}; ///< Target the device reported with the off-target "stopped".
476 float position{UNKNOWN_POSITION}; ///< Current position it reported with it.
477
478 /// @brief True when a report is remembered.
479 [[nodiscard]] bool armed() const { return target != UNKNOWN_POSITION && position != UNKNOWN_POSITION; }
480 /// @brief Forget the remembered report.
481 void clear() { *this = {}; }
482};
483
484/// @brief Runtime state of a paired IO‑Homecontrol device.
485struct IoDevice {
486 uint8_t node_id[NODE_ID_SIZE]{}; ///< Device's 3‑byte radio address.
487 DeviceType type{DeviceType::UNKNOWN}; ///< Device type (shutter, awning, etc.).
488 uint8_t subtype{0}; ///< Device subtype (manufacturer‑specific).
489 DeviceModel model{DeviceModel::NONE}; ///< YAML-declared product model below the subtype (`io_model:`); never
490 ///< on the wire. Resolver input, like `dimmable`.
491 char name[DEVICE_NAME_BUFFER_SIZE]{}; ///< Cached UTF-8 device name decoded from Latin-1 wire payloads.
492 float position{UNKNOWN_POSITION}; ///< Current position: 0=open, 100=closed, or UNKNOWN_POSITION.
493 float tilt{UNKNOWN_POSITION}; ///< Current tilt: 0=closed, 100=open, or UNKNOWN_POSITION.
494 float target{UNKNOWN_POSITION}; ///< Target position the device is moving toward.
495 bool is_stopped{true}; ///< True if device is not moving.
496 ExecutionState execution_state{ExecutionState::UNDEFINED}; ///< Execution state of the last status report that
497 ///< carried one; UNDEFINED until then, and for a
498 ///< device that has no motion.
499 StopCandidate stop_candidate{}; ///< Last off-target "stopped" report, to confirm the next one.
500 bool reports_remaining_time{false}; ///< The device has sent a nonzero remaining time in a status reply, so
501 ///< its zero means "about to stop" rather than "not provided".
502 DeviceFeatures features{0}; ///< What this device can do, resolved by resolve_device_features(). Every
503 ///< capability check on a registered device reads this word.
505 DeviceCapabilityClass::UNKNOWN}; ///< Class of the entity that declared the
506 ///< device; UNKNOWN for a device no entity declared (the type's class then
507 ///< applies). Resolver input.
508 std::optional<bool> invert_override = std::nullopt; ///< YAML `invert_position:` when declared; wins over the kind's
509 ///< default. Resolver input.
510 bool inverted{false}; ///< True if open/close positions are swapped (e.g., horizontal awning).
511 ///< Resolved: `invert_override`, else device_feature::INVERTED.
512 TravelSpeed travel_speed{TravelSpeed::FAST}; ///< Speed profile of position moves. Like `inverted`, a
513 ///< YAML-declared preference: nothing on the wire reports it.
514 bool optimistic_state{true}; ///< True if `target` may be set ahead of a confirming poll/response.
515 bool low_power{false}; ///< YAML-declared: this target is a low-power / duty-cycled receiver, so
516 ///< directed frames set CTRL1_LOW_POWER and use the long wake-up preamble.
517 ///< A protocol-reported class exists (discovery Multi Information Byte) but
518 ///< is surfaced through the pairing/scan snippet, never applied at runtime.
519 bool dimmable{false}; ///< YAML-declared `dimmable: true` of a LIGHT entity. Resolver input: it
520 ///< alone decides a light's device_feature::LEVELS (unless the kind is main
521 ///< parameter only; light subtype 58 is on/off only, VELUX KLF 200 API v3.18,
522 ///< App. 2).
523 uint8_t last_result_code{0}; ///< Last CMD_ERROR_RESP result byte (0 = none recorded). See
524 ///< command_result_name()/is_limitation_result() in proto_constants.h. Cleared by
525 ///< the next successful status/command reply for this device. Note: 0 is also the
526 ///< real RESULT_UNKNOWN_STATUS_REPLY wire value, so that specific explicit reply is
527 ///< indistinguishable here from "nothing recorded yet" — a known, accepted tradeoff.
528 uint32_t last_result_at_ms{0}; ///< millis() timestamp of last_result_code, 0 when none recorded.
529 uint8_t last_commander[NODE_ID_SIZE]{}; ///< Node ID of the controller that last commanded this device, as
530 ///< reported verbatim by the device in its own status payload. All
531 ///< zeroes until a status reply carrying the record has been decoded
532 ///< (see decode_last_command_record() in proto_codecs.h).
533 uint8_t last_command_originator{0}; ///< That command's Command Originator byte (ORIGINATOR_* in
534 ///< proto_constants.h). Only meaningful when `has_last_command`.
535 bool has_last_command{false}; ///< True once a status reply carried a well-formed last-command record.
536 bool limited_by_rain{false}; ///< Derived from observed status replies, never from a prediction
537 ///< (decisions::is_rain_limited(); ADR 0030 keeps predictions apart):
538 ///< the last move was ordered by the rain sensor, or a position
539 ///< command was clamped while rain protection was recently active.
540 ///< Separate from `last_result_code`, which only an explicit
541 ///< CMD_ERROR_RESP sets.
542 uint32_t last_rain_evidence_ms{0}; ///< millis() of the last status reply that named the rain sensor as the
543 ///< last commander; 0 = never. Memory for the clamp rule, aged out
544 ///< by RAIN_EVIDENCE_HOLD_MS.
545 uint32_t last_status{0}; ///< millis() timestamp of last received status.
546 int16_t last_rssi_dbm{RSSI_UNKNOWN_DBM}; ///< Most recent raw RSSI sample (dBm), or RSSI_UNKNOWN_DBM.
547 int16_t rssi_ema_scaled{RSSI_UNKNOWN_DBM}; ///< Smoothed RSSI as fixed point in 1/RSSI_EMA_SCALE dBm — read
548 ///< through device_rssi_ema_dbm(), updated by
549 ///< detail::update_link_health() in hub_internal.h. Fixed point
550 ///< (rather than whole dBm) keeps sub-dBm EMA contributions from
551 ///< vanishing to integer truncation, so the average converges on a
552 ///< stable signal instead of stalling up to RSSI_EMA_SCALE−1 dBm
553 ///< away. RSSI_UNKNOWN_DBM before the first sample.
554 uint32_t last_seen_ms{0}; ///< millis() of the last frame received from this device (any command), 0 = never.
556 0}; ///< millis() of the last sign this device is travelling (see
557 ///< note_moving_evidence(), clear_moving_evidence()), 0 = never. Hub-side belief, not observation.
558 uint32_t moving_until_ms{0}; ///< millis() at which the remaining time of the last trusted moving report has
559 ///< run out (plus PREDICTED_END_MARGIN_MS), 0 = unknown. Written by
560 ///< note_moving_until(); dropped with the moving evidence.
561 uint16_t exchange_timeout_count{0}; ///< Cumulative count of outbound exchanges to this device with no valid
562 ///< response (see detail::record_exchange_timeout() in hub_internal.h).
563 uint16_t exchange_attempt_count{0}; ///< Cumulative attempts (`ExchangeEngine::DebugInfo::tries`, 1-based per
564 ///< exchange) across those timed-out exchanges only — attempts within an
565 ///< ultimately successful exchange are not counted (deliberate scope limit).
566 uint16_t exchange_unconfirmed_count{0}; ///< Cumulative count of exchanges this device authenticated and then never
567 ///< closed: it answered with a 0x3C challenge, we answered that, and no
568 ///< final reply arrived. Counted separately from exchange_timeout_count
569 ///< because the two have different causes and different fixes — silence
570 ///< from the start means the device never heard us, while silence after a
571 ///< challenge means the reply was lost on the way back (or our challenge
572 ///< answer never landed). For a CMD_EXECUTE this outcome is treated as
573 ///< success and raises no failure count, so without this counter it is
574 ///< invisible in the diagnostics.
575 bool confirms_execute{false}; ///< True once this device has answered a CMD_EXECUTE with a reply that closed the
576 ///< exchange (a status, or an error). Learned at runtime and never stored: it
577 ///< separates a device whose missing reply is an anomaly from one that never sends
578 ///< one (see decisions::retry_after_unconfirmed_accept_is_safe()).
579 OptimisticState optimistic{}; ///< Hub-side predictions; see OptimisticState. Never observation.
580 RainSensorState rain_sensor{RainSensorState::UNKNOWN}; ///< Rain state from the opt-in minimum-limitation poll.
581 ///< Set only from a limitation reply inside an exchange the
582 ///< hub started; independent of `limited_by_rain`, which is
583 ///< derived from status replies.
584 std::optional<uint16_t> sun_level; ///< Raw sun value from the opt-in EVENT_LOCAL_SENSOR poll
585 ///< (decode_sun_level_event()); empty until the first answer and
586 ///< after PERIODIC_POLL_FAILURES_BEFORE_UNKNOWN misses. Set only
587 ///< from the answer to a subscription the hub sent.
588 std::optional<uint16_t>
589 battery_level; ///< Battery level in percent (0-100) from the opt-in EVENT_BATTERY_LEVEL_CHANGE
590 ///< poll (decode_battery_level_event()); same rules as `sun_level`.
591 ///< Independent of `battery`, the coarse status-byte level.
592 BatteryState battery{BatteryState::NOT_REPORTED}; ///< Coarse battery level from byte 1 of the device's own
593 ///< status: a reply to a status poll or an authenticated
594 ///< CMD_STATUS_UPDATE. Never from an execute ack (its layout
595 ///< follows the request) or an overheard frame (ADR 0022).
597 HEATING_TEMPERATURE_RANGE_MIN_C}; ///< Lower end of the temperature encoding range of a heater that
598 ///< carries a temperature in its main parameter (YAML `min_temperature:`).
600 HEATING_TEMPERATURE_RANGE_MAX_C}; ///< Upper end of that range (YAML `max_temperature:`).
601 HeatingReadings heating{}; ///< What the hub has read from a heater over the private register protocol
602 ///< (room temperature, setpoint, mode, regulator state). Set only from decoded
603 ///< replies; stays "never read" for every other device type.
604 DhwReadings dhw{}; ///< What the hub has read from a water heater (device_feature::WATER_HEATER):
605 ///< family-0x00 registers and the status reply's target. Set only from
606 ///< decoded replies; stays "never read" for every other device type.
607 bool battery_entities{false}; ///< YAML-declared (`battery: true`): the battery entities exist, so the status log
608 ///< line names the level even when the device reports none.
609 bool has_system_key{false}; ///< YAML-declared (`system_key:`): this device keeps a key of its own. Every
610 ///< exchange with it uses `system_key` below instead of the hub's key.
611 uint8_t system_key[AES_KEY_SIZE]{}; ///< The device's own key; meaningful only when `has_system_key`. Key material:
612 ///< never logged outside a bannered report block.
613 bool registered_by_pairing{false}; ///< Added by a pairing in this boot, not declared in YAML. The record is
614 ///< lost at reboot, so reports still print its YAML block, and a later
615 ///< pairing may replace it (DeviceRegistry::get_declared()).
616 uint8_t model_check_attempts{0}; ///< Runs of the hub's one-off model check (a heater kind with model rows:
617 ///< model name read, declared-model hint) so far; the check stops after
618 ///< it completes or after a bounded number of runs. Runtime only.
619 bool model_checked{false}; ///< The model check completed: both of its reads were answered.
620};
621
622/// @brief Resolve a device's capability bits and inversion from its kind and its YAML overrides.
623/// @details The one place a device's features are decided. The base is the (type, subtype, model) row
624/// (device_features(), with the declared IoDevice::model); a kind with no row, or an undeclared one, takes
625/// undeclared_kind_features() of the declaring entity's class (the type's own class when no
626/// entity declared the device), made main-parameter-only at subtype 58 like any other kind.
627/// A light's LEVELS then come from the YAML `dimmable:` flag alone, and never on a kind that
628/// takes the main parameter only. `inverted` is the YAML override, else the kind's default.
629/// Called wherever a record gets or changes its kind: DeviceRegistry::add() and put(), and
630/// when a status reply teaches an undeclared device its type.
631/// @param dev Device to resolve; reads type, subtype, model, entity_class, dimmable and invert_override and
632/// writes features and inverted.
633void resolve_device_features(IoDevice &dev);
634
635/// @brief Record that a device is (believed to be) travelling right now.
636///
637/// With clear_moving_evidence(), one of the two writers of IoDevice::last_moving_evidence_ms.
638/// Called wherever the hub learns a receiver is moving *because of something that reached it*: a
639/// decoded status that says "not stopped", a movement command the device accepted, or an overheard
640/// linked-remote movement command. Deliberately NOT called for the hub's own optimistic prediction:
641/// that is applied before the command is even sent, so treating it as proof of movement would make a
642/// resting receiver look awake to the very command that starts it. A low-power receiver that is
643/// moving ignores the long wake-up preamble, so this stamp steers the exchange engine to the short
644/// one first (decisions::wake_belief()).
645/// @param dev Device to stamp.
646/// @param now_ms Current millis(). Must be non-zero in practice; 0 would read as "never".
647inline void note_moving_evidence(IoDevice &dev, uint32_t now_ms) {
648 dev.last_moving_evidence_ms = now_ms;
649 dev.moving_until_ms = 0; // A fresh sign of travel says nothing about when it ends until a count says so.
650}
651
652/// @brief Record when the travel the device is reporting should be over, from its own remaining time.
653///
654/// Call after note_moving_evidence() for the same report: that call forgets an earlier end. While
655/// the end has not been reached the receiver is still travelling by its own account, so a silent
656/// exchange is not taken as proof that it has come to rest (decisions::moving_until_holds()).
657/// @param dev Device to stamp.
658/// @param until_ms millis() at which the reported remaining time, plus a margin, has run out. Non-zero.
659inline void note_moving_until(IoDevice &dev, uint32_t until_ms) { dev.moving_until_ms = until_ms; }
660
661/// @brief Forget that a device was travelling: it reported itself stopped (including in the ack to a
662/// STOP), a linked remote was heard stopping it, or it stayed silent to an exchange sent while it
663/// was believed moving.
664///
665/// The counterpart of note_moving_evidence(). Without it a receiver that has demonstrably stopped
666/// would keep reading as "moving" for the rest of LOW_POWER_MAX_TRAVEL_MS, although it goes back to
667/// duty-cycling within seconds and then hears only the wake-up preamble.
668/// @param dev Device to clear.
671 dev.moving_until_ms = 0;
672}
673
674/// @brief Convert an `rssi_ema_scaled` fixed-point value to whole dBm (round half away from zero).
675/// @param scaled Fixed-point EMA value in 1/RSSI_EMA_SCALE dBm units (not the sentinel).
676/// @return Rounded dBm value.
677inline int16_t rssi_scaled_to_dbm(int16_t scaled) {
678 constexpr int16_t half = RSSI_EMA_SCALE / 2;
679 return static_cast<int16_t>((scaled + (scaled >= 0 ? half : -half)) / RSSI_EMA_SCALE);
680}
681
682/// @brief A device's smoothed RSSI in whole dBm.
683/// @param dev Device record to read.
684/// @return Rounded EMA in dBm, or RSSI_UNKNOWN_DBM when no sample has been recorded yet.
688
689/// @brief The main-position target a consumer should act on: the prediction when one stands,
690/// otherwise the device's own last reported target.
691/// @param dev Device record to read.
692/// @return The predicted target when set, otherwise `dev.target`.
693inline float effective_target(const IoDevice &dev) {
694 return dev.optimistic.target != UNKNOWN_POSITION ? dev.optimistic.target : dev.target;
695}
696
697/// @brief The slat angle a consumer should act on, prediction first.
698/// @param dev Device record to read.
699/// @return The predicted tilt when set, otherwise `dev.tilt`.
700inline float effective_tilt(const IoDevice &dev) {
701 return dev.optimistic.tilt != UNKNOWN_POSITION ? dev.optimistic.tilt : dev.tilt;
702}
703
704/// @brief Whether a consumer should treat the device as at rest, prediction first.
705/// @param dev Device record to read.
706/// @return The predicted movement state when one stands, otherwise `dev.is_stopped`.
707inline bool effective_is_stopped(const IoDevice &dev) {
708 switch (dev.optimistic.motion) {
710 return false;
712 return true;
714 default:
715 return dev.is_stopped;
716 }
717}
718
719/// @brief Decode target/current position values from a status frame.
720/// @param target_raw 16‑bit raw target value.
721/// @param current_raw 16‑bit raw current value.
722/// @param is_stopped True if device reports stopped.
723/// @param target Output target position (0–100 or UNKNOWN_POSITION).
724/// @param position Output current position (0–100 or UNKNOWN_POSITION).
725void decode_position_report(uint16_t target_raw, uint16_t current_raw, bool is_stopped, float &target, float &position);
726/// @brief Has the device reached its target within tolerance?
727/// @param target Target position (0–100 or UNKNOWN_POSITION).
728/// @param position Current position (0–100 or UNKNOWN_POSITION).
729/// @return true if positions match within STATUS_POS_TOLERANCE_RAW.
730bool has_reached_target_position(float target, float position);
731/// @brief Decode tilt angle from raw 16‑bit value.
732/// @param tilt_raw Raw tilt value from status frame.
733/// @return Tilt percentage (0 = closed, 100 = open) or UNKNOWN_POSITION.
734float decode_tilt_report(uint16_t tilt_raw);
735
736} // namespace home_io_control
737} // namespace esphome
static constexpr uint8_t DEVICE_NAME_BUFFER_SIZE
Device name storage including null terminator.
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".
ExecutionState execution_state_of(uint8_t status_byte)
Read the execution state out of byte 0 of a status reply.
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 ...
const char * travel_speed_name(TravelSpeed speed)
Get the name a travel speed is configured and published under.
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.
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...
@ PEDESTRIAN
Move to the pedestrian position (gate openers); experimental.
@ PARTIAL
Move to the stored partial position (garage openers; issue #163).
bool device_supports_position_control(DeviceType type)
Does this device type support precise position control (0–100)?
DeviceModel
A product model below a (node type, subtype): for products that report the same wire type but need di...
@ HEATER_WITH_LIGHT
Heater with a night light under the electrical heater with an adjustable setpoint (52/1).
@ NONE
No model: the (type, subtype) row.
@ TOWEL_DRYER
Towel dryer under the electrical heater with an adjustable setpoint (52/1).
void resolve_device_features(IoDevice &dev)
Resolve a device's capability bits and inversion from its kind and its YAML overrides.
const char * execution_state_name(ExecutionState state)
Get a human-readable name for an ExecutionState.
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.
const char * device_operation_profile_name(DeviceType type, uint8_t subtype)
Human‑readable operation profile name for a device kind.
bool device_supports_lock_control(DeviceType type)
Does this device type support binary lock/unlock control via execute commands?
bool parse_travel_speed(const std::string &name, TravelSpeed &speed)
Look up a travel speed by the name travel_speed_name() gives it.
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)?
std::string build_device_yaml_snippet(DeviceType type, uint8_t subtype, const std::string &device_id, bool metadata_complete, 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...
const char * device_capability_class_name(DeviceType type)
Get a human‑readable name for a capability class.
bool effective_is_stopped(const IoDevice &dev)
Whether a consumer should treat the device as at rest, prediction first.
ExecutionState
What a device says it is doing, from bits [2:0] of byte 0 of a status reply (CMD_PRIVATE_RESP,...
@ WAITING_FOR_POWER
Accepted, waiting for power (queued behind another device on a shared supply).
@ UNDEFINED
Bits [2:0] hold 6 or 7, which no source defines.
@ DONE
Not executing, no error, ready.
@ NON_EXECUTING
Unable to execute under the given conditions (example: temperature too high).
@ WAITING_FOR_EXECUTING
Accepted, waiting for its turn to run.
@ EXECUTION_ERROR
An error occurred during the previous execution; cleared when the next starts.
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.
uint16_t DeviceFeatures
Capability bits of a device kind; see device_feature.
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.
void note_moving_until(IoDevice &dev, uint32_t until_ms)
Record when the travel the device is reporting should be over, from its own remaining time.
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).
@ WATER_HEATER
Domestic hot water heater (device_feature::WATER_HEATER).
bool device_takes_execute_position(DeviceType type)
Does this device type take position EXECUTEs (a percent, on / off, or stop in the main parameter)?
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.
TravelSpeed
The speed a cover's position moves ask the motor for (ADR 0065).
@ SILENT
Profile 0x05, the manufacturer app's silent operation: slower than the motor's own.
@ MOTOR_DEFAULT
The 6-byte frame with no profile: the motor's own speed, as its wall pad moves it.
@ FAST
Profile 0x06, what the hub sends unless a cover says otherwise.
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.
Capability bits of a device kind: what a (node type, subtype) can do.
Pure codec for a domestic hot water heater: its family-0x00 registers and its target temperature.
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 a domestic hot water heater.
Definition proto_dhw.h:162
What the hub has read from one heater so far.
Runtime state of a paired IO‑Homecontrol device.
uint8_t model_check_attempts
Runs of the hub's one-off model check (a heater kind with model rows: model name read,...
uint32_t last_result_at_ms
millis() timestamp of last_result_code, 0 when none recorded.
DeviceCapabilityClass entity_class
Class of the entity that declared the device; UNKNOWN for a device no entity declared (the type's cla...
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...
DeviceFeatures features
What this device can do, resolved by resolve_device_features().
uint16_t exchange_unconfirmed_count
Cumulative count of exchanges this device authenticated and then never closed: it answered with a 0x3...
uint32_t moving_until_ms
millis() at which the remaining time of the last trusted moving report has run out (plus PREDICTED_EN...
ExecutionState execution_state
Execution state of the last status report that carried one; UNDEFINED until then, and for a device th...
std::optional< bool > invert_override
YAML invert_position: when declared; wins over the kind's default.
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 ...
std::optional< uint16_t > sun_level
Raw sun value from the opt-in EVENT_LOCAL_SENSOR poll.
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 reports_remaining_time
The device has sent a nonzero remaining time in a status reply, so its zero means "about to stop" rat...
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.
std::optional< uint16_t > battery_level
Battery level in percent (0-100) from the opt-in EVENT_BATTERY_LEVEL_CHANGE poll (decode_battery_leve...
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...
DeviceModel model
YAML-declared product model below the subtype (io_model:); never on the wire.
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
YAML-declared dimmable: true of a LIGHT entity.
uint8_t system_key[AES_KEY_SIZE]
The device's own key; meaningful only when has_system_key.
DhwReadings dhw
What the hub has read from a water heater (device_feature::WATER_HEATER): family-0x00 registers and t...
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.
bool model_checked
The model check completed: both of its reads were answered.
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,...
TravelSpeed travel_speed
Speed profile of position moves.
StopCandidate stop_candidate
Last off-target "stopped" report, to confirm the next one.
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.
The last "stopped" report a device sent away from its own target, kept to confirm the next.
void clear()
Forget the remembered report.
float position
Current position it reported with it.
bool armed() const
True when a report is remembered.
float target
Target the device reported with the off-target "stopped".