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#include "tuning_config.h"
18
19#include <array>
20#include <cstdint>
21#include <functional>
22#include <string>
23
24namespace esphome {
25namespace home_io_control {
26
27/// @brief How the transmitter puts a frame on air.
28///
29/// Injected rather than taken as a collaborator reference so this class depends on the *ability*
30/// to transmit rather than on whichever object currently owns the radio. The hub wires it to its
31/// own `transmit_frame_`; a test wires it to a recorder and needs no radio at all.
32/// @param frame Frame to serialize and transmit.
33/// @param freq RF channel frequency in Hz.
34/// @param preamble Preamble length in bytes.
35/// @return true if the frame reached the radio.
36using OneWayTransmitFn = std::function<bool(const IoFrame &frame, uint32_t freq, uint16_t preamble)>;
37
38/// @brief What a 1W command attempt did — the only feedback this feature can ever produce.
39///
40/// 1W has no reply, so nothing here says a device acted; it says what the hub transmitted. That is
41/// the half the hub can know, and without it a user with a wrong key, a desynced counter or a
42/// missing enrollment sees nothing at all.
43/// @ingroup hioc_hub
45 std::string controller_id; ///< Identity that transmitted (empty if unresolved).
46 std::string intent; ///< Decoded intent, e.g. "STOP" or "CLOSE".
47 DeviceType target_type{DeviceType::UNKNOWN}; ///< Device class addressed.
48 uint16_t sequence{0}; ///< Sequence consumed; meaningless unless sequence_reserved.
49 bool sequence_reserved{false}; ///< True if a sequence was consumed (0 is a valid sequence).
50 bool transmitted{false}; ///< True if at least one copy reached the radio.
51};
52
53/// @brief Invoked once per attempted 1W command, successful or not.
54using OneWayCommandReportFn = std::function<void(const OneWayCommandReport &report)>;
55
56/// @brief Resolve one copy's shape (oneway_burst_copy_shape()) to the actual preamble byte count.
57///
58/// The one place `OneWayPreamble::WAKE` -> `LONG_PREAMBLE` / `OneWayPreamble::NORMAL` ->
59/// `normal_start_preamble` is written — send_burst(), format_oneway_preamble_list(), and this
60/// header's tests all go through it, so the burst, the TX log line describing it, and anything
61/// asserting on it can never resolve the mapping three different ways.
62/// @param shape A copy's shape, from oneway_burst_copy_shape().
63/// @param normal_start_preamble Live tuning value a `NORMAL`-shaped copy resolves to.
64/// @return The preamble length in bytes this copy transmits with.
65/// @ingroup hioc_hub
66uint16_t oneway_copy_preamble_bytes(OneWayCopyShape shape, uint16_t normal_start_preamble);
67
68/// @brief Render the preamble bytes each copy of a burst will use, e.g. `"1024/32/32/32"`.
69///
70/// Built from oneway_burst_copy_shape() and oneway_copy_preamble_bytes() — the exact functions
71/// send_burst() itself calls per copy — so the TX log line can never disagree with what actually
72/// went on air. Pure and free-standing so it is unit-testable: the host ESP_LOG stub discards its
73/// arguments, so a formatter buried in the log call could not be asserted on at all.
74/// @param power_class The identity's power class.
75/// @param normal_start_preamble Live tuning value a `NORMAL`-shaped copy resolves to.
76/// @return Slash-separated preamble byte counts, one per copy, in burst order.
77/// @ingroup hioc_hub
78std::string format_oneway_preamble_list(OneWayPowerClass power_class, uint16_t normal_start_preamble);
79
80/// @brief Sends 1W commands as the repeated bursts real remotes send.
81/// @ingroup hioc_hub
83 public:
84 /// @param transmit How to put a frame on air; must stay valid for this object's lifetime.
85 /// @param tuning Hub's live TuningConfig; must outlive this object. Read per burst (never
86 /// cached) so a live change to `normal_start_preamble` (the Home Assistant number entity)
87 /// takes effect on the next command without a reboot -- the same pattern `ExchangeEngine`
88 /// already uses for the identical field.
90 : transmit_(std::move(transmit)), tuning_(tuning) {}
91
92 // === Controller identities ===
93
94 /// @brief Register a configured controller identity. Called once per `oneway_controllers:` entry.
95 ///
96 /// Config only — it does not touch persistent storage, because generated wiring runs before
97 /// preferences are usable. setup() is what opens each identity's counter.
98 /// @param identity Fully-resolved identity (address and key already decided at schema time).
99 void add_identity(const OneWayControllerIdentity &identity) { this->identities_.add(identity); }
100
101 /// @brief Open each registered identity's persistent sequence counter.
102 /// Call once from the hub's `setup()`, never from generated wiring.
103 void setup();
104
105 /// @return The configured controller identities.
106 [[nodiscard]] const OneWayControllerRegistry &identities() const { return this->identities_; }
107
108 /// @brief Register the callback that receives a report after every command attempt.
109 /// @param callback Invoked once per logical command, including failed ones — a command that
110 /// never left the hub is exactly the case a user needs to see, and 1W will not tell them.
111 void set_command_report_callback(OneWayCommandReportFn callback) { this->report_ = std::move(callback); }
112
113 // === Commands ===
114
115 /// @brief Send a named command as the identity's controller.
116 ///
117 /// Resolves the identity, reserves exactly one sequence for the whole command, builds and signs
118 /// the frame with that identity's key, and bursts it.
119 ///
120 /// **Addresses a device class, not a device.** Every device of `io_device_type` in range that
121 /// holds the signing key acts on it — that is what 1W is, not a limitation to work around. Two
122 /// devices of one class are separable only if they can be given separate identities.
123 /// @param controller_id YAML handle of the controller identity to transmit as.
124 /// @param cmd Named command (STOP, FAVORITE, VENT). CoverCommand::FORCE_OPEN has no 1W
125 /// encoding and cannot be built — see create_1w_execute_command() (proto_commands.h).
126 /// @return true if at least one copy reached the radio; false if the identity is unknown, the
127 /// sequence could not be reserved, or the frame could not be built.
128 bool send_command(const std::string &controller_id, CoverCommand cmd);
129
130 /// @brief Send a numeric position as the identity's controller.
131 ///
132 /// Same contract as send_command(). Every position 0–100 is ordinary; none is a special code.
133 /// @param controller_id YAML handle of the controller identity to transmit as.
134 /// @param position Target position 0–100 (0 = fully open, 100 = fully closed).
135 /// @return true if at least one copy reached the radio.
136 bool send_position(const std::string &controller_id, uint8_t position);
137
138 /// @brief Transmit one already-built, already-signed 1W frame as a burst.
139 ///
140 /// Sends the frame ONEWAY_BURST_REPEATS times, ONEWAY_BURST_INTERVAL_MS apart, on FREQ_CH2.
141 /// `power_class` decides each copy's preamble and CTRL1 via oneway_burst_copy_shape() (ADR 0038);
142 /// a required parameter, never defaulted — a default here would silently reintroduce a
143 /// hard-coded preamble at a new call site, exactly the mistake ADR 0029 records.
144 ///
145 /// **It retransmits identical bytes, except CTRL1 and the preamble.** The sequence and the MAC
146 /// were fixed by the caller before this was called, and every copy carries them unchanged: a
147 /// device treats one sequence as one command, so four copies bearing four sequences are four
148 /// commands, of which it will accept one and reject three as replays. This function therefore
149 /// never rebuilds the frame's cmd/data/sequence/MAC and never touches a sequence counter — it
150 /// only sets or clears `CTRL1_LOW_POWER` per copy (clearing it too, whatever the builder
151 /// produced: the transmitter owns this bit unconditionally) and picks that copy's preamble. This
152 /// is safe because the 1W MAC span covers only cmd+data (never CTRL1) and the CRC is computed
153 /// per transmission by the driver, so neither authenticates or depends on CTRL1.
154 ///
155 /// **It blocks for the whole burst**, feeding the watchdog in the gaps. The three inter-copy
156 /// gaps alone are 3 * ONEWAY_BURST_INTERVAL_MS = ~120 ms of pure delay; how much airtime the
157 /// four copies themselves add depends on `power_class`: `LEGACY_LONG` puts `LONG_PREAMBLE` on
158 /// every copy, ≈1.0–1.2 s per burst total (measured on SX1276, issue #74's logs: ~1.2 s);
159 /// `ALWAYS_ALIVE` puts the live `normal_start_preamble` on every copy, ≈200 ms per burst
160 /// (measured on SX1276, issue #74); `LOW_POWER` sits between the two (one long copy,
161 /// three normal). Per ADR 0013 all radio work happens on the ESPHome loop and the operation
162 /// queue is the concurrency model; an authenticated 2W exchange already blocks far longer than
163 /// this. Scheduling the repeats through a timeout would add a second concurrency model and would
164 /// let a queued 2W exchange interleave between copies of one command.
165 /// @param frame Signed 1W frame to send.
166 /// @param power_class Which preamble/CTRL1 shape each copy gets (ADR 0038).
167 /// @return true if at least one copy reached the radio. Partial success is still reported as
168 /// success because it is genuinely what the caller wants to know — with no reply frame,
169 /// "some copies went out" is the most any layer here can ever establish, and a device
170 /// needs only one of them.
171 bool send_burst(const IoFrame &frame, OneWayPowerClass power_class);
172
173 /// @brief Register this identity as a controller on every device currently in association mode
174 /// (the receiver's own association-mode gesture, ADR 0026: a multi-second PROG hold on a Somfy
175 /// actuator, or a ~1 s Gear press on an already-registered VELUX control followed by the
176 /// product's own ready sequence), using the gesture its
177 /// manufacturer expects (`resolve_oneway_wire_profile()`, ADR 0032).
178 ///
179 /// **`EnrollGesture::SOMFY`** (somfy / unset / any unprofiled vendor): `0x39` (remove,
180 /// self-directed) then `0x30` (add) — the documented 1W handshake (the iown-homecontrol
181 /// link-layer notes), both to the identity's own `io_device_type`, one burst each, matched by a
182 /// real Smoove capture landing the two 128 ms apart
183 /// (`tests/corpus/captures/enrollment/somfy_smoove_enrollment_add_and_remove_controller_sx1276.yaml`).
184 ///
185 /// **`EnrollGesture::VELUX_KLI`** (manufacturer velux): `0x39` to the all-devices address, then
186 /// a `0x30` burst to **each** class in `effective_enrollment_classes()` under one shared
187 /// sequence, then a STOP and a DOWN EXECUTE to the all-devices address at the VELUX ACEI — the
188 /// KLI manual's STOP-then-DOWN registration completion. Matches the issue #74 KLI 310 capture
189 /// and has enrolled a VELUX SML roller shutter and KLI 312 interior blinds on
190 /// real hardware (the closing DOWN is the visible success signal, unless the cover already sits
191 /// fully closed). The STOP+DOWN frames themselves
192 /// are not matched against a VELUX capture
193 /// (`tests/corpus/captures/enrollment/synthetic_enrollment_velux_kli_prog_sweep.yaml`).
194 ///
195 /// **The `0x30`'s MAC trailer** is configurable via `enrollment_with_mac:` (default `false`, no
196 /// MAC — see create_1w_add_controller()'s `@warning`). Real VELUX (#74) and real Somfy captures
197 /// both use the no-MAC form; a real Izymo has separately accepted the MAC-bearing form too.
198 ///
199 /// **Blocks for the whole gesture** feeding the watchdog in the gaps. The VELUX path is 6 bursts
200 /// (`0x39` + 3-class `0x30` sweep + STOP + DOWN); with the identity's power class unset (legacy),
201 /// each burst carries `LONG_PREAMBLE` on every copy, ≈6–7 s total (SX1276 logs in issue #74
202 /// measured ~7.4 s, ~10.6 s with `enrollment_with_mac: true`); ~1.2 s with `low_power: false`
203 /// (ADR 0038, measured on SX1276 in issue #74). This is a user-initiated,
204 /// once-per-device action, the same shape as the pairing button (`pairing_discovery_wait_ms` →
205 /// 5000).
206 /// @param controller_id YAML handle of the controller identity to register.
207 /// @return true if the credential frame(s) that actually register this identity reached the
208 /// radio — the `0x30` (SOMFY) or the sweep (VELUX_KLI). The VELUX STOP+DOWN follow-up is
209 /// skipped entirely if the sweep transmitted nothing; a failed `0x39` prelude, or a
210 /// partial STOP/DOWN after a good sweep, only logs and does not flip this.
211 bool send_enrollment(const std::string &controller_id);
212
213 /// @brief Un-register this identity from every device of its class currently in association
214 /// mode (CMD 0x39) alone — also the prelude send_enrollment() fires before its own `0x30`.
215 ///
216 /// Reachable directly through the explicitly-named `oneway_remove_controller` native API
217 /// action, for un-enrolling without immediately re-enrolling.
218 ///
219 /// @warning **Unconfirmed standalone on real hardware.** Firing `0x39` alone (outside the
220 /// enrollment handshake) has had no observable effect on this project's test hardware; the
221 /// leading hypothesis is that it needs the same association-mode window enrollment does. See
222 /// ADR 0026 § Consequences.
223 /// @param controller_id YAML handle of the controller identity to remove.
224 /// @return true if at least one copy reached the radio.
225 bool send_unenrollment(const std::string &controller_id);
226
227 private:
228 /// Shared tail of send_command()/send_position()/send_enrollment()/send_unenrollment(): reserve
229 /// one sequence, then burst whatever `build` makes of it. The reservation happens **once per
230 /// logical command** and outside the burst loop — a sequence per frame would turn one press into
231 /// four commands, of which a device accepts one and rejects three.
232 /// @param explicit_intent Overrides the report's decoded intent (decode_1w_frame() cannot label
233 /// a 0x30/0x39, so send_enrollment()/send_unenrollment() pass "ENROLL"/"UNENROLL" here;
234 /// empty means "derive from the built frame as usual", every other caller's behavior).
235 bool send_(const std::string &controller_id,
236 const std::function<bool(IoFrame &, const OneWayControllerIdentity &, uint16_t)> &build,
237 const char *explicit_intent = "");
238
239 /// send_enrollment()'s two gestures, split so each stays simple. The dispatcher resolves the
240 /// identity once and hands it down.
241 bool send_somfy_enrollment_(const OneWayControllerIdentity &identity);
242 bool send_velux_kli_enrollment_(const OneWayControllerIdentity &identity);
243
244 /// Reserve **one** sequence, then 0x30-enroll to each non-UNKNOWN class in `classes` under that
245 /// one sequence — the VELUX class sweep a real KLI remote sends. One report for the whole sweep.
246 /// @return true if at least one class's burst reached the radio.
247 bool send_enroll_sweep_(const OneWayControllerIdentity &identity, const std::array<DeviceType, 3> &classes);
248
249 /// The one place a OneWayCommandReport is built and fired (no-op without a callback). Every
250 /// report — success, sweep, or failure — goes through here so a new field on the struct, or a
251 /// change to how a field is chosen, lands in exactly one spot.
252 void report_attempt_(const std::string &controller_id, const std::string &intent, DeviceType target_type,
253 uint16_t sequence, bool sequence_reserved, bool transmitted);
254
255 /// Emit a report for an attempt that never got as far as a frame.
256 void report_failure_(const std::string &controller_id, uint16_t sequence, bool sequence_reserved);
257
258 OneWayTransmitFn transmit_;
259 OneWayCommandReportFn report_;
260 OneWayControllerRegistry identities_;
261 OneWaySequenceStore sequences_;
262 const TuningConfig *tuning_; ///< Hub's live TuningConfig; read per burst, never cached.
263};
264
265} // namespace home_io_control
266} // 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.
OneWayTransmitter(OneWayTransmitFn transmit, const TuningConfig *tuning)
void add_identity(const OneWayControllerIdentity &identity)
Register a configured controller identity.
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 (the receiver's ...
bool send_command(const std::string &controller_id, CoverCommand cmd)
Send a named command as the identity's controller.
bool send_burst(const IoFrame &frame, OneWayPowerClass power_class)
Transmit one already-built, already-signed 1W frame as a burst.
uint16_t oneway_copy_preamble_bytes(OneWayCopyShape shape, uint16_t normal_start_preamble)
Resolve one copy's shape (oneway_burst_copy_shape()) to the actual preamble byte count.
std::string format_oneway_preamble_list(OneWayPowerClass power_class, uint16_t normal_start_preamble)
Render the preamble bytes each copy of a burst will use, e.g.
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.
OneWayPowerClass
Which preamble/CTRL1 shape a 1W identity's bursts actually go out with.
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:93
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.
The preamble and CTRL1 shape one copy of a 1W burst gets.
All runtime tunable parameters for pairing and radio diagnostics.
Runtime tuning configuration for pairing and radio diagnostics.