Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
oneway_controller.h
Go to the documentation of this file.
1#pragma once
2
3/// @file oneway_controller.h
4/// @brief Controller identities for the one-way (1W) protocol.
5/// @ingroup hioc_protocol
6///
7/// 1W frames are **class-addressed**: a command goes to a typed broadcast address
8/// `(io_device_type << 6) | 0x3F`, not to an individual device. Nothing on the wire names a
9/// device, so a 1W entity has no node address to bind to. What distinguishes one 1W control
10/// surface from another is the *controller* doing the transmitting — its source address, its
11/// network key, and the device class it speaks to. That triple is a controller identity, and it
12/// takes the place node addressing has for 2W. See ADR 0027.
13///
14/// A hub holds several, deliberately: adopting a foreign 1W network's key (see
15/// oneway_key_adoption.cpp) produces an identity whose key is *not* the hub's own, and it
16/// must coexist with identities on the hub's own network rather than replace them.
17///
18/// @note Ownership. These identities belong to the `OneWayTransmitter` collaborator
19/// (oneway_transmitter.h), held by value in the hub, which keeps only the wiring (ADR 0004).
20/// This header owns the types; it does not own an instance of them.
21
22#include "proto_codecs.h"
23#include "proto_constants.h"
24#include "proto_device_model.h"
25#include "proto_sizes.h"
26
27#include <array>
28#include <cstdint>
29#include <optional>
30#include <string>
31#include <vector>
32
33namespace esphome {
34namespace home_io_control {
35
36/// @brief Which preamble/CTRL1 shape a 1W identity's bursts actually go out with.
37///
38/// Every enumerator is a real on-air shape, so a value of this type is always transmittable —
39/// "not configured" is *not* one of them. That state lives in
40/// `OneWayControllerIdentity::power_class_override`, an empty optional, and is resolved to one of
41/// these by effective_power_class() before any of it reaches the transmitter. Keeping the two
42/// apart is what lets oneway_burst_copy_shape() switch exhaustively with no unreachable case.
43///
44/// See ADR 0038 for the shapes and ADR 0041 for how an unset key resolves.
45enum class OneWayPowerClass : uint8_t {
46 LEGACY_LONG, ///< LONG_PREAMBLE on every copy, CTRL1 0x00 everywhere.
47 ALWAYS_ALIVE, ///< `low_power: false`: normal start preamble on every copy, CTRL1 0x00.
48 LOW_POWER, ///< `low_power: true`: copy 1 LONG_PREAMBLE + CTRL1_LOW_POWER, repeats normal.
49};
50
51/// @brief Human-readable name for a OneWayPowerClass, as it appears in the boot log.
52/// @param power_class Power class to name.
53/// @return Null-terminated name such as "always-alive".
54const char *oneway_power_class_name(OneWayPowerClass power_class);
55
56/// @brief Which wake-up shape one copy of a 1W burst gets.
57enum class OneWayPreamble : uint8_t {
58 WAKE, ///< The long wake-up preamble (`LONG_PREAMBLE`), paired with `CTRL1_LOW_POWER` set.
59 NORMAL, ///< The runtime-tunable `normal_start_preamble`, CTRL1's low-power bit clear.
60};
61
62/// @brief The preamble and CTRL1 shape one copy of a 1W burst gets.
63///
64/// Chip-neutral by design: this header names bytes, not chips, and does not know about
65/// `TuningConfig` — resolving `OneWayPreamble::NORMAL` to an actual byte count is
66/// `OneWayTransmitter`'s job (oneway_transmitter.h), the one place in the controller layer that
67/// holds a `TuningConfig *`.
69 OneWayPreamble preamble; ///< Which preamble this copy transmits with.
70 bool low_power_flag; ///< Whether this copy sets `CTRL1_LOW_POWER`.
71};
72
73/// @brief Resolve which preamble/CTRL1 shape one copy of a burst gets, from the identity's power
74/// class and the copy's position in the burst.
75///
76/// Pure: no radio, no tuning, testable on its own. Implements the table in ADR 0038 — `LEGACY_LONG`
77/// and `ALWAYS_ALIVE` are uniform across every copy; only `LOW_POWER` varies by position, putting
78/// the wake-up shape on copy 0 alone — what a real remote's GEAR/EXECUTE burst does, and the
79/// mirror of how `ExchangeEngine` (ADR 0029) already picks a 2W start frame's preamble from the
80/// target's own low-power declaration.
81/// @param power_class The identity's resolved power class, from effective_power_class().
82/// @param copy_index 0-based position of this copy within the burst.
83/// @return The preamble and CTRL1 shape for that copy.
84inline OneWayCopyShape oneway_burst_copy_shape(OneWayPowerClass power_class, uint8_t copy_index) {
85 switch (power_class) {
87 return {OneWayPreamble::NORMAL, /*low_power_flag=*/false};
89 return copy_index == 0 ? OneWayCopyShape{OneWayPreamble::WAKE, /*low_power_flag=*/true}
90 : OneWayCopyShape{OneWayPreamble::NORMAL, /*low_power_flag=*/false};
92 default:
93 return {OneWayPreamble::WAKE, /*low_power_flag=*/false};
94 }
95}
96
97/// @brief One configured 1W controller identity.
98///
99/// Fixed-size key and address material; the only heap is the `id` handle, which mirrors how
100/// device IDs are held elsewhere in this component.
101/// @ingroup hioc_protocol
103 std::string id; ///< YAML handle entities reference.
104 uint8_t node_id[NODE_ID_SIZE]{}; ///< Source address we transmit as (configured or derived).
105 uint8_t system_key[AES_KEY_SIZE]{}; ///< Network key for this identity; may differ per identity.
106 uint8_t manufacturer{0}; ///< Manufacturer ID; unused until the enrollment phase.
107 DeviceType io_device_type{DeviceType::UNKNOWN}; ///< Device class this identity commands.
108 uint16_t initial_sequence{0}; ///< Seed for this identity's rolling counter on first use.
109 bool node_id_derived{false}; ///< True when `node_id` was derived rather than configured.
111 false}; ///< Whether the enroll button's 0x30 carries a MAC trailer (`enrollment_with_mac:`).
112 /// EXECUTE ACEI override (`execute_acei:`). 0 = "not overridden, use resolve_oneway_wire_profile()";
113 /// any non-zero value wins. 0 is a safe sentinel: ACEI_VALID_BIT is bit 0, so a valid ACEI is
114 /// always odd — the schema rejects `execute_acei: 0` so this cannot be reached by mistake.
115 uint8_t execute_acei{0};
116 /// When true (`execute_broadcast: all`), 1W EXECUTE frames go to the all-devices address
117 /// `00 00 3F` regardless of `io_device_type` — what a handheld cover remote of either vendor
118 /// does. false = the typed per-class destination (current default). Not a vendor axis; see
119 /// ADR 0031.
121 /// Override for the device classes a VELUX enrollment `0x30` sweep targets (`enrollment_classes:`).
122 /// All-`UNKNOWN` (the default) means "not set — use the manufacturer profile's list"
123 /// (`resolve_oneway_wire_profile()`), which only fits exterior shading; a KLI 312 interior blind
124 /// needs `{BLIND, VENETIAN_BLIND}` here. `UNKNOWN` entries are skipped when the sweep runs, so a
125 /// one- or two-class override is expressed by leaving the rest `UNKNOWN`. Ignored by the Somfy
126 /// enrollment gesture, which always uses `io_device_type`. See ADR 0032.
128 /// `low_power:` exactly as configured, or **empty when the key is absent** — in which case the
129 /// shape comes from the manufacturer profile (ADR 0041). Never read this directly to transmit;
130 /// call effective_power_class(), which is the only place the two cases are collapsed.
131 ///
132 /// Whatever it resolves to applies to every 1W TX of the identity -- commands, positions,
133 /// enrollment (both gestures), un-enrollment. Last field: codegen emits a designated initialiser
134 /// in declaration order. See ADR 0038 for the shapes themselves.
135 std::optional<OneWayPowerClass> power_class_override;
136
137 /// @brief Enrollment / typed-class destination address for this identity.
138 ///
139 /// 1W addresses a device *class*, never a node. Delegates to encode_broadcast_address()
140 /// (proto_codecs.h), the single place the bit layout is documented, so this and
141 /// broadcast_target_type() (its decode counterpart) cannot drift apart. Note: an EXECUTE frame
142 /// uses the all-devices address instead when `execute_broadcast_all` is set — this helper is the
143 /// typed-class destination only.
144 /// @param out Output: 3-byte destination address.
145 void broadcast_address(uint8_t out[NODE_ID_SIZE]) const { encode_broadcast_address(this->io_device_type, out); }
146};
147
148/// @brief Which 1W enrollment gesture a manufacturer's actuators expect.
149///
150/// SOMFY: one `0x30` add-controller burst to the identity's own `io_device_type` (the shape this
151/// project has hardware-validated). VELUX_KLI: a `0x39` clear to the all-devices address, then a
152/// `0x30` burst to **each** class in `OneWayWireProfile::enrollment_classes`, then a STOP+DOWN
153/// EXECUTE follow-up — the gesture a real KLI remote produces (issue #74 capture +
154/// the KLI manual), confirmed on a VELUX SML roller shutter and on KLI 312
155/// interior blinds. See ADR 0032.
156enum class EnrollGesture : uint8_t { SOMFY, VELUX_KLI };
157
158/// @brief Vendor-divergent 1W wire settings for a controller identity.
159///
160/// The EXECUTE ACEI is decided per ADR 0031; the enrollment gesture and the class sweep per
161/// ADR 0032.
163 uint8_t execute_acei; ///< payload[1] of a 1W CMD_EXECUTE (0x00) frame.
164 bool profile_is_a_guess; ///< true when `manufacturer` matched no known 1W wire profile.
165 EnrollGesture enroll_gesture; ///< Which enrollment gesture this manufacturer's actuators expect.
166 /// Device classes a VELUX_KLI `0x30` sweep targets, `UNKNOWN` entries skipped. All-`UNKNOWN` for
167 /// SOMFY, whose `0x30` goes to the identity's own `io_device_type` instead.
168 std::array<DeviceType, 3> enrollment_classes;
169 /// Burst shape for an identity that does not set `low_power:` at all. Per ADR 0041: VELUX gets
170 /// `ALWAYS_ALIVE`, because an awake VELUX receiver does not accept a frame behind the 1024-byte
171 /// preamble and 1W has no acknowledgement to reveal that. Every other manufacturer — and every
172 /// unrecognised one — keeps `LEGACY_LONG`, so a profile nobody has measured never silently
173 /// acquires a shape nobody tested for it.
175};
176
177/// The default `0x30` sweep for `manufacturer: velux`: the exterior-shading classes a KLI 310/313
178/// names — roller shutter, awning, dual shutter (issue #74 capture, decoded with
179/// `broadcast_target_type()`; matches `samr037/iohc-flipper`'s `PAIR_DST_{WINDOW,SHUTTER,OTHER}`).
180/// Not universal: a KLI 312 interior blind uses blind + venetian blind, so those identities set
181/// `enrollment_classes:` from the classes their remote's own `0x2E` names.
184
185/// @brief Resolve an identity's 1W wire profile from its manufacturer byte.
186///
187/// Pure. Somfy (0x02) and unset (0x00) both map to the historical Somfy-shaped default
188/// (`ONEWAY_EXECUTE_ACEI`, `EnrollGesture::SOMFY`); only VELUX (0x01) is special so far
189/// (`ONEWAY_EXECUTE_ACEI_VELUX`, `EnrollGesture::VELUX_KLI`, the class sweep). Any other
190/// explicitly-set manufacturer returns the Somfy default with `profile_is_a_guess=true` so the
191/// Python schema can warn (`oneway_controllers.py` `validate_oneway_controllers()` — keep the {somfy,
192/// velux} set here in sync with the warning there; there is no automated check).
193/// @param manufacturer The identity's manufacturer byte (`MANUFACTURER_*`, or a raw value).
194inline OneWayWireProfile resolve_oneway_wire_profile(uint8_t manufacturer) {
195 constexpr std::array<DeviceType, 3> none{DeviceType::UNKNOWN, DeviceType::UNKNOWN, DeviceType::UNKNOWN};
196 switch (manufacturer) {
197 case MANUFACTURER_VELUX:
198 return {ONEWAY_EXECUTE_ACEI_VELUX, /*profile_is_a_guess=*/false, EnrollGesture::VELUX_KLI,
200 case MANUFACTURER_SOMFY:
201 case 0x00:
202 return {ONEWAY_EXECUTE_ACEI, /*profile_is_a_guess=*/false, EnrollGesture::SOMFY, none,
204 default:
205 return {ONEWAY_EXECUTE_ACEI, /*profile_is_a_guess=*/true, EnrollGesture::SOMFY, none,
207 }
208}
209
210/// @brief The device classes this identity's `0x30` enrollment sweep will actually target.
211/// @param identity The controller identity.
212/// @return `enrollment_classes` when the identity overrode it (any entry non-`UNKNOWN`), else the
213/// manufacturer profile's list. `UNKNOWN` entries are skipped by the caller.
214inline std::array<DeviceType, 3> effective_enrollment_classes(const OneWayControllerIdentity &identity) {
215 const bool overridden = identity.enrollment_classes[0] != DeviceType::UNKNOWN ||
218 return overridden ? identity.enrollment_classes
220}
221
222/// @brief The ACEI byte a given identity will put on air for a 1W EXECUTE frame.
223/// @param identity The controller identity.
224/// @return `execute_acei` when overridden (non-zero), else the manufacturer's profile default.
225inline uint8_t effective_execute_acei(const OneWayControllerIdentity &identity) {
226 return identity.execute_acei != 0 ? identity.execute_acei
228}
229
230/// @brief Whether this identity's ACEI comes from an explicit `execute_acei:` rather than the profile.
231/// @param identity The controller identity.
232/// @return true when `execute_acei:` was set (non-zero) and overrides the manufacturer profile default.
233inline bool has_execute_acei_override(const OneWayControllerIdentity &identity) { return identity.execute_acei != 0; }
234
235/// @brief The burst shape this identity actually transmits with.
236///
237/// The one place an absent `low_power:` is turned into a real shape, and therefore the only
238/// function the transmitter and the boot log may ask. An explicit `low_power:` always wins; with
239/// the key absent the manufacturer profile decides (ADR 0041).
240/// @param identity The controller identity.
241/// @return `power_class_override` when set, else the manufacturer profile's `default_power_class`.
245
246/// @brief Whether this identity's burst shape comes from an explicit `low_power:` rather than the profile.
247/// @param identity The controller identity.
248/// @return true when `low_power:` was set in YAML and overrides the manufacturer profile default.
250 return identity.power_class_override.has_value();
251}
252
253// === Control surface ===
254
255/// Wire-scale position meaning "fully closed" (0 means fully open). Named here because the two
256/// values are what OPEN and CLOSE actually are — see encode_oneway_action().
257static constexpr uint8_t ONEWAY_POSITION_FULLY_OPEN = 0;
258static constexpr uint8_t ONEWAY_POSITION_FULLY_CLOSED = 100;
259
260/// @brief The command a generated 1W button sends.
261///
262/// The vocabulary of the `commands:` list on a `oneway_controllers:` entry. Kept separate from
263/// CoverCommand because two of these are not commands at all on the wire: OPEN and CLOSE are
264/// positions 0 and 100, and only look like named commands to a user.
265/// @ingroup hioc_protocol
266enum class OneWayButtonAction : uint8_t {
267 OPEN, ///< Position 0 (fully open).
268 CLOSE, ///< Position 100 (fully closed).
269 STOP, ///< CoverCommand::STOP.
270 VENT, ///< CoverCommand::VENT.
271 FAVORITE, ///< CoverCommand::FAVORITE.
272};
273
274/// @brief How a OneWayButtonAction reaches the wire.
275/// @ingroup hioc_protocol
277 bool is_position{false}; ///< True when the action is sent as a numeric position.
278 uint8_t position{0}; ///< Position to send when `is_position`.
279 CoverCommand command{CoverCommand::STOP}; ///< Named command to send otherwise.
280};
281
282/// @brief Resolve a button action to the call that sends it.
283///
284/// Pure, so the mapping can be tested without a radio, an entity or a hub. OPEN and CLOSE resolve
285/// to positions because that is what they are on the wire — there is no distinct open/close
286/// opcode, and treating them as named commands would need a second encoding path for no gain.
287/// @param action Button action to encode.
288/// @return The position-or-command the transmitter should send.
289/// @ingroup hioc_protocol
291 OneWayActionEncoding encoding{};
292 switch (action) {
294 encoding.is_position = true;
296 break;
298 encoding.is_position = true;
300 break;
302 encoding.command = CoverCommand::VENT;
303 break;
306 break;
308 default:
309 encoding.command = CoverCommand::STOP;
310 break;
311 }
312 return encoding;
313}
314
315/// @brief Human-readable name for a button action, as it appears in the diagnostic sensor.
316/// @param action Button action to name.
317/// @return Null-terminated name such as "OPEN".
318/// @ingroup hioc_protocol
320
321/// @brief The configured 1W controller identities, in YAML declaration order.
322///
323/// Lookup is by `id` and linear: a hub has a handful of identities, not hundreds, and keeping
324/// insertion order makes boot logging read the same as the YAML that produced it.
325/// @ingroup hioc_protocol
327 public:
328 /// @brief Add a configured identity. Called from generated code at setup.
329 /// @param identity Fully-resolved identity (key inherited or explicit, address configured or derived).
330 void add(const OneWayControllerIdentity &identity) { this->identities_.push_back(identity); }
331
332 /// @brief Look up an identity by its YAML handle.
333 /// @param id Handle to find.
334 /// @return Pointer to the identity, or nullptr if no such handle is configured.
335 [[nodiscard]] const OneWayControllerIdentity *get(const std::string &id) const {
336 for (const auto &identity : this->identities_) {
337 if (identity.id == id)
338 return &identity;
339 }
340 return nullptr;
341 }
342
343 /// @brief All configured identities, in declaration order.
344 [[nodiscard]] const std::vector<OneWayControllerIdentity> &all() const { return this->identities_; }
345
346 /// @brief Whether any identity is configured.
347 [[nodiscard]] bool empty() const { return this->identities_.empty(); }
348
349 private:
350 std::vector<OneWayControllerIdentity> identities_;
351};
352
353} // namespace home_io_control
354} // namespace esphome
The configured 1W controller identities, in YAML declaration order.
void add(const OneWayControllerIdentity &identity)
Add a configured identity.
const OneWayControllerIdentity * get(const std::string &id) const
Look up an identity by its YAML handle.
bool empty() const
Whether any identity is configured.
const std::vector< OneWayControllerIdentity > & all() const
All configured identities, in declaration order.
const char * oneway_button_action_name(OneWayButtonAction action)
Human-readable name for a button action, as it appears in the diagnostic sensor.
OneWayActionEncoding encode_oneway_action(OneWayButtonAction action)
Resolve a button action to the call that sends it.
OneWayButtonAction
The command a generated 1W button sends.
EnrollGesture
Which 1W enrollment gesture a manufacturer's actuators expect.
OneWayPreamble
Which wake-up shape one copy of a 1W burst gets.
@ NORMAL
The runtime-tunable normal_start_preamble, CTRL1's low-power bit clear.
@ WAKE
The long wake-up preamble (LONG_PREAMBLE), paired with CTRL1_LOW_POWER set.
DeviceType
Device type identifiers reported by IO‑Homecontrol products.
@ UNKNOWN
Unknown/unspecified device.
void encode_broadcast_address(DeviceType type, uint8_t out[NODE_ID_SIZE])
Encode a device type into its typed-broadcast destination address — the exact inverse of broadcast_ta...
static constexpr uint8_t ONEWAY_POSITION_FULLY_OPEN
Wire-scale position meaning "fully closed" (0 means fully open).
CoverCommand
Named device commands for cover-type actuators.
@ FAVORITE
Move to stored favorite/"My" position.
@ VENT
Move to ventilation position (window-type devices).
OneWayPowerClass effective_power_class(const OneWayControllerIdentity &identity)
The burst shape this identity actually transmits with.
std::array< DeviceType, 3 > effective_enrollment_classes(const OneWayControllerIdentity &identity)
The device classes this identity's 0x30 enrollment sweep will actually target.
uint8_t effective_execute_acei(const OneWayControllerIdentity &identity)
The ACEI byte a given identity will put on air for a 1W EXECUTE frame.
static constexpr std::array< DeviceType, 3 > VELUX_KLI_ENROLLMENT_CLASSES
The default 0x30 sweep for manufacturer: velux: the exterior-shading classes a KLI 310/313 names — ro...
OneWayPowerClass
Which preamble/CTRL1 shape a 1W identity's bursts actually go out with.
@ LOW_POWER
low_power: true: copy 1 LONG_PREAMBLE + CTRL1_LOW_POWER, repeats normal.
@ ALWAYS_ALIVE
low_power: false: normal start preamble on every copy, CTRL1 0x00.
@ LEGACY_LONG
LONG_PREAMBLE on every copy, CTRL1 0x00 everywhere.
const char * oneway_power_class_name(OneWayPowerClass power_class)
Human-readable name for a OneWayPowerClass, as it appears in the boot log.
bool has_power_class_override(const OneWayControllerIdentity &identity)
Whether this identity's burst shape comes from an explicit low_power: rather than the profile.
static constexpr uint8_t ONEWAY_POSITION_FULLY_CLOSED
OneWayWireProfile resolve_oneway_wire_profile(uint8_t manufacturer)
Resolve an identity's 1W wire profile from its manufacturer byte.
bool has_execute_acei_override(const OneWayControllerIdentity &identity)
Whether this identity's ACEI comes from an explicit execute_acei: rather than the profile.
OneWayCopyShape oneway_burst_copy_shape(OneWayPowerClass power_class, uint8_t copy_index)
Resolve which preamble/CTRL1 shape one copy of a burst gets, from the identity's power class and the ...
Device-name, address-classification and 1W-frame codecs.
IO-Homecontrol command IDs, result codes and protocol enumerations.
IO-Homecontrol device-type model, capabilities and runtime device state.
Fundamental IO-Homecontrol frame and crypto size constants.
How a OneWayButtonAction reaches the wire.
bool is_position
True when the action is sent as a numeric position.
uint8_t position
Position to send when is_position.
CoverCommand command
Named command to send otherwise.
One configured 1W controller identity.
uint8_t node_id[NODE_ID_SIZE]
Source address we transmit as (configured or derived).
std::string id
YAML handle entities reference.
bool execute_broadcast_all
When true (execute_broadcast: all), 1W EXECUTE frames go to the all-devices address 00 00 3F regardle...
bool enrollment_with_mac
Whether the enroll button's 0x30 carries a MAC trailer (enrollment_with_mac:).
uint8_t system_key[AES_KEY_SIZE]
Network key for this identity; may differ per identity.
void broadcast_address(uint8_t out[NODE_ID_SIZE]) const
Enrollment / typed-class destination address for this identity.
uint8_t execute_acei
EXECUTE ACEI override (execute_acei:).
uint16_t initial_sequence
Seed for this identity's rolling counter on first use.
bool node_id_derived
True when node_id was derived rather than configured.
std::optional< OneWayPowerClass > power_class_override
low_power: exactly as configured, or empty when the key is absent — in which case the shape comes fro...
DeviceType io_device_type
Device class this identity commands.
uint8_t manufacturer
Manufacturer ID; unused until the enrollment phase.
std::array< DeviceType, 3 > enrollment_classes
Override for the device classes a VELUX enrollment 0x30 sweep targets (enrollment_classes:).
The preamble and CTRL1 shape one copy of a 1W burst gets.
OneWayPreamble preamble
Which preamble this copy transmits with.
bool low_power_flag
Whether this copy sets CTRL1_LOW_POWER.
Vendor-divergent 1W wire settings for a controller identity.
bool profile_is_a_guess
true when manufacturer matched no known 1W wire profile.
std::array< DeviceType, 3 > enrollment_classes
Device classes a VELUX_KLI 0x30 sweep targets, UNKNOWN entries skipped.
EnrollGesture enroll_gesture
Which enrollment gesture this manufacturer's actuators expect.
uint8_t execute_acei
payload[1] of a 1W CMD_EXECUTE (0x00) frame.
OneWayPowerClass default_power_class
Burst shape for an identity that does not set low_power: at all.