Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
oneway_transmitter.h
Go to the documentation of this file.
1#pragma once
2
3/// @file oneway_transmitter.h
4/// @brief One-way (1W) transmit collaborator.
5/// @ingroup hioc_hub
6///
7/// The third object that drives the radio, alongside ExchangeEngine and PairingEngine (ADR 0004)
8/// — and the only one that awaits nothing. A 1W command has no reply, no challenge and no
9/// acknowledgement: the frame goes out and that is the whole interaction. Everything this class
10/// does follows from that, most of all the repetition, which is the only reliability mechanism
11/// available when nothing can report a miss.
12
13#include "oneway_controller.h"
15#include "proto_device_model.h"
16#include "proto_frame.h"
17
18#include <array>
19#include <cstdint>
20#include <functional>
21#include <string>
22
23namespace esphome {
24namespace home_io_control {
25
26/// @brief How the transmitter puts a frame on air.
27///
28/// Injected rather than taken as a collaborator reference so this class depends on the *ability*
29/// to transmit rather than on whichever object currently owns the radio. The hub wires it to its
30/// own `transmit_frame_`; a test wires it to a recorder and needs no radio at all.
31/// @param frame Frame to serialize and transmit.
32/// @param freq RF channel frequency in Hz.
33/// @param preamble Preamble length in bytes.
34/// @return true if the frame reached the radio.
35using OneWayTransmitFn = std::function<bool(const IoFrame &frame, uint32_t freq, uint16_t preamble)>;
36
37/// @brief What a 1W command attempt did — the only feedback this feature can ever produce.
38///
39/// 1W has no reply, so nothing here says a device acted; it says what the hub transmitted. That is
40/// the half the hub can know, and without it a user with a wrong key, a desynced counter or a
41/// missing enrollment sees nothing at all.
42/// @ingroup hioc_hub
44 std::string controller_id; ///< Identity that transmitted (empty if unresolved).
45 std::string intent; ///< Decoded intent, e.g. "STOP" or "CLOSE".
46 DeviceType target_type{DeviceType::UNKNOWN}; ///< Device class addressed.
47 uint16_t sequence{0}; ///< Sequence consumed; meaningless unless sequence_reserved.
48 bool sequence_reserved{false}; ///< True if a sequence was consumed (0 is a valid sequence).
49 bool transmitted{false}; ///< True if at least one copy reached the radio.
50};
51
52/// @brief Invoked once per attempted 1W command, successful or not.
53using OneWayCommandReportFn = std::function<void(const OneWayCommandReport &report)>;
54
55/// @brief Sends 1W commands as the repeated bursts real remotes send.
56/// @ingroup hioc_hub
58 public:
59 /// @param transmit How to put a frame on air; must stay valid for this object's lifetime.
60 explicit OneWayTransmitter(OneWayTransmitFn transmit) : transmit_(std::move(transmit)) {}
61
62 // === Controller identities ===
63
64 /// @brief Register a configured controller identity. Called once per `oneway_controllers:` entry.
65 ///
66 /// Config only — it does not touch persistent storage, because generated wiring runs before
67 /// preferences are usable. setup() is what opens each identity's counter.
68 /// @param identity Fully-resolved identity (address and key already decided at schema time).
69 void add_identity(const OneWayControllerIdentity &identity) { this->identities_.add(identity); }
70
71 /// @brief Open each registered identity's persistent sequence counter.
72 /// Call once from the hub's `setup()`, never from generated wiring.
73 void setup();
74
75 /// @return The configured controller identities.
76 [[nodiscard]] const OneWayControllerRegistry &identities() const { return this->identities_; }
77
78 /// @brief Register the callback that receives a report after every command attempt.
79 /// @param callback Invoked once per logical command, including failed ones — a command that
80 /// never left the hub is exactly the case a user needs to see, and 1W will not tell them.
81 void set_command_report_callback(OneWayCommandReportFn callback) { this->report_ = std::move(callback); }
82
83 // === Commands ===
84
85 /// @brief Send a named command as the identity's controller.
86 ///
87 /// Resolves the identity, reserves exactly one sequence for the whole command, builds and signs
88 /// the frame with that identity's key, and bursts it.
89 ///
90 /// **Addresses a device class, not a device.** Every device of `io_device_type` in range that
91 /// holds the signing key acts on it — that is what 1W is, not a limitation to work around. Two
92 /// devices of one class are separable only if they can be given separate identities.
93 /// @param controller_id YAML handle of the controller identity to transmit as.
94 /// @param cmd Named command (STOP, FAVORITE, VENT). CoverCommand::FORCE_OPEN has no 1W
95 /// encoding and cannot be built — see create_1w_execute_command() (proto_commands.h).
96 /// @return true if at least one copy reached the radio; false if the identity is unknown, the
97 /// sequence could not be reserved, or the frame could not be built.
98 bool send_command(const std::string &controller_id, CoverCommand cmd);
99
100 /// @brief Send a numeric position as the identity's controller.
101 ///
102 /// Same contract as send_command(). Every position 0–100 is ordinary; none is a special code.
103 /// @param controller_id YAML handle of the controller identity to transmit as.
104 /// @param position Target position 0–100 (0 = fully open, 100 = fully closed).
105 /// @return true if at least one copy reached the radio.
106 bool send_position(const std::string &controller_id, uint8_t position);
107
108 /// @brief Transmit one already-built, already-signed 1W frame as a burst.
109 ///
110 /// Sends the frame ONEWAY_BURST_REPEATS times, ONEWAY_BURST_INTERVAL_MS apart, on FREQ_CH2 with
111 /// LONG_PREAMBLE — the cadence real remotes use (proto_timing.h).
112 ///
113 /// **It retransmits identical bytes.** The sequence and the MAC were fixed by the caller before
114 /// this was called, and all copies must carry them unchanged: a device treats one sequence as
115 /// one command, so four copies bearing four sequences are four commands, of which it will
116 /// accept one and reject three as replays. This function therefore never rebuilds a frame,
117 /// never touches a sequence counter, and takes the frame by const reference so it cannot.
118 ///
119 /// **It blocks for the whole burst**, feeding the watchdog in the gaps. The three inter-copy
120 /// gaps alone are 3 * ONEWAY_BURST_INTERVAL_MS = ~120 ms of pure delay; add each of the four
121 /// copies' own airtime and the wall-clock total this function blocks for is closer to ~160 ms
122 /// (proto_timing.h's ONEWAY_BURST_INTERVAL_MS comment has the same two numbers). Per ADR 0013
123 /// all radio work happens on the ESPHome loop and the operation queue is the concurrency model;
124 /// an authenticated 2W exchange already blocks far longer than this. Scheduling the repeats
125 /// through a timeout would add a second concurrency model and would let a queued 2W exchange
126 /// interleave between copies of one command.
127 /// @param frame Signed 1W frame to send.
128 /// @return true if at least one copy reached the radio. Partial success is still reported as
129 /// success because it is genuinely what the caller wants to know — with no reply frame,
130 /// "some copies went out" is the most any layer here can ever establish, and a device
131 /// needs only one of them.
132 bool send_burst(const IoFrame &frame);
133
134 /// @brief Register this identity as a controller on every device currently in association mode
135 /// (a physical PROG hold on the receiver, ADR 0026), using the gesture its manufacturer expects
136 /// (`resolve_oneway_wire_profile()`, ADR 0032).
137 ///
138 /// **`EnrollGesture::SOMFY`** (somfy / unset / any unprofiled vendor): `0x39` (remove,
139 /// self-directed) then `0x30` (add) — the documented 1W handshake (the iown-homecontrol
140 /// link-layer notes), both to the identity's own `io_device_type`, one burst each, matched by a
141 /// real Smoove capture landing the two 128 ms apart
142 /// (`tests/corpus/captures/enrollment/somfy_smoove_enrollment_add_and_remove_controller_sx1276.yaml`).
143 ///
144 /// **`EnrollGesture::VELUX_KLI`** (manufacturer velux): `0x39` to the all-devices address, then
145 /// a `0x30` burst to **each** class in `effective_enrollment_classes()` under one shared
146 /// sequence, then a STOP and a DOWN EXECUTE to the all-devices address at the VELUX ACEI — the
147 /// KLI-manual "press PAIR, then STOP then DOWN within 3 seconds" registration completion. Matches
148 /// the issue #74 KLI 310 capture and `samr037/iohc-flipper` `tx_runner.c`. The STOP+DOWN half is
149 /// unconfirmed against a VELUX capture
150 /// (`tests/corpus/captures/enrollment/synthetic_enrollment_velux_kli_prog_sweep.yaml`).
151 ///
152 /// **The `0x30`'s MAC trailer** is configurable via `enrollment_with_mac:` (default `false`, no
153 /// MAC — see create_1w_add_controller()'s `@warning`). Real VELUX (#74) and real Somfy captures
154 /// both use the no-MAC form; a real Izymo has separately accepted the MAC-bearing form too.
155 ///
156 /// **Blocks for the whole gesture** feeding the watchdog in the gaps — up to ~6 s for the VELUX
157 /// path (6 bursts: `0x39` + 3-class `0x30` sweep + STOP + DOWN, each ~1 s with `LONG_PREAMBLE` on
158 /// every copy). This is a user-initiated, once-per-device action, the same shape as the pairing
159 /// button (`pairing_discovery_wait_ms` → 5000); ADR 0032 records the exemption and the risk that
160 /// the sweep+follow-up may not fit the KLI manual's own 3-second window at this cadence.
161 /// @param controller_id YAML handle of the controller identity to register.
162 /// @return true if the credential frame(s) that actually register this identity reached the
163 /// radio — the `0x30` (SOMFY) or the sweep (VELUX_KLI). The VELUX STOP+DOWN follow-up is
164 /// skipped entirely if the sweep transmitted nothing; a failed `0x39` prelude, or a
165 /// partial STOP/DOWN after a good sweep, only logs and does not flip this.
166 bool send_enrollment(const std::string &controller_id);
167
168 /// @brief Un-register this identity from every device of its class currently in association
169 /// mode (CMD 0x39) alone — also the prelude send_enrollment() fires before its own `0x30`.
170 ///
171 /// Reachable directly through the explicitly-named `oneway_remove_controller` native API
172 /// action, for un-enrolling without immediately re-enrolling.
173 ///
174 /// @warning **Unconfirmed standalone on real hardware.** Firing `0x39` alone (outside the
175 /// enrollment handshake) has had no observable effect on this project's test hardware; the
176 /// leading hypothesis is that it needs the same association-mode window enrollment does. See
177 /// ADR 0026 § Consequences.
178 /// @param controller_id YAML handle of the controller identity to remove.
179 /// @return true if at least one copy reached the radio.
180 bool send_unenrollment(const std::string &controller_id);
181
182 private:
183 /// Shared tail of send_command()/send_position()/send_enrollment()/send_unenrollment(): reserve
184 /// one sequence, then burst whatever `build` makes of it. The reservation happens **once per
185 /// logical command** and outside the burst loop — a sequence per frame would turn one press into
186 /// four commands, of which a device accepts one and rejects three.
187 /// @param explicit_intent Overrides the report's decoded intent (decode_1w_frame() cannot label
188 /// a 0x30/0x39, so send_enrollment()/send_unenrollment() pass "ENROLL"/"UNENROLL" here;
189 /// empty means "derive from the built frame as usual", every other caller's behavior).
190 bool send_(const std::string &controller_id,
191 const std::function<bool(IoFrame &, const OneWayControllerIdentity &, uint16_t)> &build,
192 const char *explicit_intent = "");
193
194 /// send_enrollment()'s two gestures, split so each stays simple. The dispatcher resolves the
195 /// identity once and hands it down.
196 bool send_somfy_enrollment_(const OneWayControllerIdentity &identity);
197 bool send_velux_kli_enrollment_(const OneWayControllerIdentity &identity);
198
199 /// Reserve **one** sequence, then 0x30-enroll to each non-UNKNOWN class in `classes` under that
200 /// one sequence — the VELUX class sweep a real KLI remote sends. One report for the whole sweep.
201 /// @return true if at least one class's burst reached the radio.
202 bool send_enroll_sweep_(const OneWayControllerIdentity &identity, const std::array<DeviceType, 3> &classes);
203
204 /// The one place a OneWayCommandReport is built and fired (no-op without a callback). Every
205 /// report — success, sweep, or failure — goes through here so a new field on the struct, or a
206 /// change to how a field is chosen, lands in exactly one spot.
207 void report_attempt_(const std::string &controller_id, const std::string &intent, DeviceType target_type,
208 uint16_t sequence, bool sequence_reserved, bool transmitted);
209
210 /// Emit a report for an attempt that never got as far as a frame.
211 void report_failure_(const std::string &controller_id, uint16_t sequence, bool sequence_reserved);
212
213 OneWayTransmitFn transmit_;
214 OneWayCommandReportFn report_;
215 OneWayControllerRegistry identities_;
216 OneWaySequenceStore sequences_;
217};
218
219} // namespace home_io_control
220} // namespace esphome
The configured 1W controller identities, in YAML declaration order.
Per-controller-identity rolling sequence counters, persisted across reboots.
bool send_position(const std::string &controller_id, uint8_t position)
Send a numeric position as the identity's controller.
void setup()
Open each registered identity's persistent sequence counter.
void set_command_report_callback(OneWayCommandReportFn callback)
Register the callback that receives a report after every command attempt.
void add_identity(const OneWayControllerIdentity &identity)
Register a configured controller identity.
bool send_burst(const IoFrame &frame)
Transmit one already-built, already-signed 1W frame as a burst.
const OneWayControllerRegistry & identities() const
bool send_unenrollment(const std::string &controller_id)
Un-register this identity from every device of its class currently in association mode (CMD 0x39) alo...
bool send_enrollment(const std::string &controller_id)
Register this identity as a controller on every device currently in association mode (a physical PROG...
bool send_command(const std::string &controller_id, CoverCommand cmd)
Send a named command as the identity's controller.
DeviceType
Device type identifiers reported by IO‑Homecontrol products.
@ UNKNOWN
Unknown/unspecified device.
CoverCommand
Named device commands for cover-type actuators.
std::function< void(const OneWayCommandReport &report)> OneWayCommandReportFn
Invoked once per attempted 1W command, successful or not.
std::function< bool(const IoFrame &frame, uint32_t freq, uint16_t preamble)> OneWayTransmitFn
How the transmitter puts a frame on air.
Controller identities for the one-way (1W) protocol.
Persistent rolling-sequence counters for one-way (1W) transmit.
IO-Homecontrol device-type model, capabilities and runtime device state.
IO-Homecontrol 2W frame container: control bytes, IoFrame and (de)serialization.
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:88
What a 1W command attempt did — the only feedback this feature can ever produce.
std::string intent
Decoded intent, e.g. "STOP" or "CLOSE".
bool sequence_reserved
True if a sequence was consumed (0 is a valid sequence).
DeviceType target_type
Device class addressed.
bool transmitted
True if at least one copy reached the radio.
std::string controller_id
Identity that transmitted (empty if unresolved).
uint16_t sequence
Sequence consumed; meaningless unless sequence_reserved.
One configured 1W controller identity.