Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
oneway_transmitter.cpp
Go to the documentation of this file.
1/// @file oneway_transmitter.cpp
2/// @brief One-way (1W) transmit collaborator.
3/// @ingroup hioc_hub
4
6
7#include "proto_codecs.h"
8#include "proto_commands.h"
9#include "proto_timing.h"
10
11#include "esphome/core/application.h"
12#include "esphome/core/hal.h"
13#include "esphome/core/log.h"
14
15namespace esphome {
16namespace home_io_control {
17
18namespace {
19
20constexpr const char *const TAG = "home_io_control.oneway_tx";
21
22} // namespace
23
24uint16_t oneway_copy_preamble_bytes(OneWayCopyShape shape, uint16_t normal_start_preamble) {
25 return shape.preamble == OneWayPreamble::WAKE ? LONG_PREAMBLE : normal_start_preamble;
26}
27
28std::string format_oneway_preamble_list(OneWayPowerClass power_class, uint16_t normal_start_preamble) {
29 std::string out;
30 for (uint8_t copy = 0; copy < ONEWAY_BURST_REPEATS; copy++) {
31 const OneWayCopyShape shape = oneway_burst_copy_shape(power_class, copy);
32 if (copy > 0)
33 out += '/';
34 out += std::to_string(oneway_copy_preamble_bytes(shape, normal_start_preamble));
35 }
36 return out;
37}
38
40 for (const auto &identity : this->identities_.all())
41 this->sequences_.add_identity(identity.node_id, identity.initial_sequence);
42}
43
44bool OneWayTransmitter::send_(const std::string &controller_id,
45 const std::function<bool(IoFrame &, const OneWayControllerIdentity &, uint16_t)> &build,
46 const char *explicit_intent) {
47 const OneWayControllerIdentity *identity = this->identities_.get(controller_id);
48 if (identity == nullptr) {
49 ESP_LOGW(TAG, "1W tx: no controller identity '%s'", controller_id.c_str());
50 this->report_failure_(controller_id, 0, /*sequence_reserved=*/false);
51 return false;
52 }
53
54 // One sequence per logical command, reserved before anything is built or sent.
55 uint16_t sequence = 0;
56 if (!this->sequences_.next(identity->node_id, sequence)) {
57 // The store logs why; it refuses rather than risk reusing a sequence.
58 this->report_failure_(controller_id, 0, /*sequence_reserved=*/false);
59 return false;
60 }
61
62 IoFrame frame{};
63 if (!build(frame, *identity, sequence)) {
64 // The sequence is spent either way. Skipping one is free; reusing it is not, so it is not
65 // returned to the store.
66 ESP_LOGW(TAG, "1W tx: could not build a frame for '%s'", controller_id.c_str());
67 this->report_failure_(controller_id, sequence, /*sequence_reserved=*/true);
68 return false;
69 }
70
71 const bool transmitted =
72 this->send_burst(frame, effective_power_class(*identity), explicit_intent[0] != '\0' ? explicit_intent : nullptr);
73
74 // 0x30/0x39 carry no intent decode_1w_frame() can read (it only understands
75 // CMD_EXECUTE/CMD_ACTIVATE_MODE payloads) -- send_enrollment() etc. pass the label directly
76 // rather than leave the "Last 1W Command" sensor blank on the one feature whose entire
77 // diagnostic story is that sensor.
78 std::string intent = explicit_intent;
79 if (intent.empty()) {
80 const OneWayFrameInfo info = decode_1w_frame(frame);
81 if (info.has_intent)
82 intent = info.intent;
83 }
84 // Always the identity's own class, never the frame's decoded destination: with
85 // `execute_broadcast: all` the wire dst is `00 00 3F` -> DeviceType::UNKNOWN, which would render
86 // "unknown" in the sensor (the sensor has no distinct "all" case the way the frame log does via
87 // oneway_target_label()). The literal destination is already in the frame log.
88 this->report_attempt_(controller_id, intent, identity->io_device_type, sequence, /*sequence_reserved=*/true,
89 transmitted);
90 return transmitted;
91}
92
93void OneWayTransmitter::report_attempt_(const std::string &controller_id, const std::string &intent,
94 DeviceType target_type, uint16_t sequence, bool sequence_reserved,
95 bool transmitted) {
96 if (!this->report_)
97 return;
98 OneWayCommandReport report{};
99 report.controller_id = controller_id;
100 report.intent = intent;
101 report.target_type = target_type;
102 report.sequence = sequence;
103 report.sequence_reserved = sequence_reserved;
104 report.transmitted = transmitted;
105 this->report_(report);
106}
107
108void OneWayTransmitter::report_failure_(const std::string &controller_id, uint16_t sequence, bool sequence_reserved) {
109 this->report_attempt_(controller_id, "", DeviceType::UNKNOWN, sequence, sequence_reserved, /*transmitted=*/false);
110}
111
112bool OneWayTransmitter::send_command(const std::string &controller_id, CoverCommand cmd) {
113 return this->send_(controller_id, [cmd](IoFrame &frame, const OneWayControllerIdentity &identity, uint16_t sequence) {
114 return create_1w_execute_command(frame, identity.node_id, identity.io_device_type, cmd, sequence,
115 identity.system_key, effective_execute_acei(identity),
116 identity.execute_broadcast_all);
117 });
118}
119
120bool OneWayTransmitter::send_position(const std::string &controller_id, uint8_t position) {
121 // The caller's position is the user's (0 = open). An inverted kind wants wire 0 = closed, so the
122 // value is mirrored here and only here. The intent label is computed from the user position
123 // before mirroring, because decoding the built frame would report CLOSE for a user OPEN.
124 // Spelled out rather than decoded from the wire byte: a user 50% would decode as FORCE_OPEN.
125 char intent[ONEWAY_INTENT_BUFFER_SIZE]{};
126 if (position == ONEWAY_POSITION_FULLY_OPEN) {
127 snprintf(intent, sizeof(intent), "OPEN");
128 } else if (position == ONEWAY_POSITION_FULLY_CLOSED) {
129 snprintf(intent, sizeof(intent), "CLOSE");
130 } else {
131 snprintf(intent, sizeof(intent), "position %u%%", static_cast<unsigned>(position));
132 }
133 return this->send_(
134 controller_id,
135 [position](IoFrame &frame, const OneWayControllerIdentity &identity, uint16_t sequence) {
136 // Out-of-range input is passed through so the builder refuses it, never wrapped by the mirror.
137 const bool mirror = effective_inverted(identity) && position <= ONEWAY_POSITION_FULLY_CLOSED;
138 const uint8_t wire_position = mirror ? static_cast<uint8_t>(ONEWAY_POSITION_FULLY_CLOSED - position) : position;
139 return create_1w_execute_position(frame, identity.node_id, identity.io_device_type, wire_position, sequence,
140 identity.system_key, effective_execute_acei(identity),
141 identity.execute_broadcast_all);
142 },
143 intent);
144}
145
146bool OneWayTransmitter::send_enrollment(const std::string &controller_id) {
147 const OneWayControllerIdentity *identity = this->identities_.get(controller_id);
148 if (identity == nullptr) {
149 ESP_LOGW(TAG, "1W tx: no controller identity '%s'", controller_id.c_str());
150 this->report_failure_(controller_id, 0, /*sequence_reserved=*/false);
151 return false;
152 }
154 return this->send_velux_kli_enrollment_(*identity);
155 return this->send_somfy_enrollment_(*identity);
156}
157
158bool OneWayTransmitter::send_somfy_enrollment_(const OneWayControllerIdentity &identity) {
159 // The 1W pairing handshake is `0x39` immediately followed by `0x30`, both from the same controller, back to back
160 // within one gesture -- a real Smoove capture landed them 128 ms apart, same burst (see
161 // tests/corpus/captures/enrollment/somfy_smoove_enrollment_add_and_remove_controller_sx1276.yaml).
162 // Both go to the all-devices broadcast (00 00 3F), as every real Somfy remote we hold sends them
163 // (Smoove, Situo 5, Situo 1): only a receiver already in association mode reacts, so naming a
164 // class adds nothing, and a class the receiver does not count itself in (a patio roof that is a
165 // horizontal awning, not an awning) would never be reached. The identity's `io_device_type` still
166 // addresses its commands.
167 // `0x39` here carries only this
168 // identity's own `src` address, so on the wire it can only mean "clear my own prior entry
169 // before I re-register" -- it cannot name or displace a different controller. Sending it right
170 // before `0x30` clears a stale slot from an earlier enrollment attempt under this identity,
171 // which a bare `0x30` re-add is not guaranteed to overwrite.
172 const bool removed = this->send_(
173 identity.id,
174 [](IoFrame &frame, const OneWayControllerIdentity &id, uint16_t sequence) {
175 return create_1w_remove_controller(frame, id.node_id, DeviceType::UNKNOWN, sequence, id.system_key);
176 },
177 "UNENROLL");
178 if (!removed) {
179 ESP_LOGW(TAG, "1W tx: enrollment's 0x39 prelude did not reach the radio for '%s' -- trying 0x30 anyway",
180 identity.id.c_str());
181 }
182
183 return this->send_(
184 identity.id,
185 [](IoFrame &frame, const OneWayControllerIdentity &id, uint16_t sequence) {
186 return create_1w_add_controller(frame, id.node_id, DeviceType::UNKNOWN, id.manufacturer, sequence,
187 id.system_key, id.enrollment_with_mac);
188 },
189 "ENROLL");
190}
191
192bool OneWayTransmitter::send_velux_kli_enrollment_(const OneWayControllerIdentity &identity) {
193 // The gesture a real KLI 310/313 PROG press produces (issue #74 capture + the KLI
194 // manual):
195 // 0x39 -> 00 00 3F (clear self, to the all-devices address like the Somfy gesture's)
196 // 0x30 -> each of {roller_shutter, vertical_exterior_awning, dual_roller_shutter} under one sequence
197 // (the class sweep)
198 // EXECUTE STOP, then EXECUTE DOWN, both -> 00 00 3F at the VELUX ACEI (registration completion)
199 const bool removed = this->send_(
200 identity.id,
201 [](IoFrame &frame, const OneWayControllerIdentity &id, uint16_t sequence) {
202 return create_1w_remove_controller(frame, id.node_id, DeviceType::UNKNOWN, sequence, id.system_key);
203 },
204 "UNENROLL");
205 if (!removed) {
206 ESP_LOGW(TAG, "1W tx: VELUX enrollment's 0x39 prelude did not reach the radio for '%s' -- continuing",
207 identity.id.c_str());
208 }
209
210 const bool enrolled = this->send_enroll_sweep_(identity, effective_enrollment_classes(identity));
211 if (!enrolled) {
212 // Nothing registered us, so the STOP+DOWN would be an unprovoked close broadcast to every 1W
213 // device on the network holding the key. Skip it.
214 ESP_LOGW(TAG, "1W tx: VELUX 0x30 sweep transmitted nothing for '%s' -- skipping the STOP+DOWN follow-up",
215 identity.id.c_str());
216 return false;
217 }
218
219 // STOP then DOWN, per the KLI manual's "press PAIR, then STOP then DOWN" registration-completion
220 // step (the manual's own timing window is not established as a hard requirement here -- see ADR
221 // 0032). Broadcast, the identity's effective ACEI, one sequence each. A partial miss
222 // here only warns -- the sweep above is what registers us.
223 const uint8_t acei = effective_execute_acei(identity);
224 const bool stopped = this->send_(
225 identity.id,
226 [acei](IoFrame &frame, const OneWayControllerIdentity &id, uint16_t sequence) {
227 return create_1w_execute_command(frame, id.node_id, id.io_device_type, CoverCommand::STOP, sequence,
228 id.system_key, acei, /*broadcast_all=*/true);
229 },
230 "ENROLL STOP");
231 const bool lowered = this->send_(
232 identity.id,
233 [acei](IoFrame &frame, const OneWayControllerIdentity &id, uint16_t sequence) {
234 // Raw wire value on purpose, not mirrored: this is the enrollment gesture a real KLI remote
235 // produces, not a user position.
236 return create_1w_execute_position(frame, id.node_id, id.io_device_type, ONEWAY_POSITION_FULLY_CLOSED, sequence,
237 id.system_key, acei, /*broadcast_all=*/true);
238 },
239 "ENROLL DOWN");
240 if (!stopped || !lowered) {
241 ESP_LOGW(TAG, "1W tx: VELUX enrollment's STOP+DOWN follow-up did not fully reach the radio for '%s'",
242 identity.id.c_str());
243 }
244 return true; // the sweep registered us; STOP+DOWN are completion, not the credential
245}
246
247bool OneWayTransmitter::send_enroll_sweep_(const OneWayControllerIdentity &identity,
248 const std::array<DeviceType, 3> &classes) {
249 // One sequence for the whole sweep -- every 0x30 in it carries the same value, matching a real
250 // KLI remote and how send_() treats a burst's copies as one logical command.
251 uint16_t sequence = 0;
252 if (!this->sequences_.next(identity.node_id, sequence)) {
253 this->report_failure_(identity.id, 0, /*sequence_reserved=*/false);
254 return false;
255 }
256
257 bool any_transmitted = false;
258 for (const DeviceType target_type : classes) {
259 if (target_type == DeviceType::UNKNOWN)
260 continue;
261 IoFrame frame{};
262 if (!create_1w_add_controller(frame, identity.node_id, target_type, identity.manufacturer, sequence,
263 identity.system_key, identity.enrollment_with_mac)) {
264 ESP_LOGW(TAG, "1W tx: could not build a 0x30 for class 0x%02X on '%s'", static_cast<unsigned>(target_type),
265 identity.id.c_str());
266 continue;
267 }
268 if (this->send_burst(frame, effective_power_class(identity)))
269 any_transmitted = true;
270 }
271
272 this->report_attempt_(identity.id, "ENROLL", identity.io_device_type, sequence, /*sequence_reserved=*/true,
273 any_transmitted);
274 return any_transmitted;
275}
276
277bool OneWayTransmitter::send_unenrollment(const std::string &controller_id) {
278 return this->send_(
279 controller_id,
280 [](IoFrame &frame, const OneWayControllerIdentity &identity, uint16_t sequence) {
281 // All-devices broadcast, like the enrollment prelude: a real remote's 0x39 never names a class.
282 return create_1w_remove_controller(frame, identity.node_id, DeviceType::UNKNOWN, sequence, identity.system_key);
283 },
284 "UNENROLL");
285}
286
287bool OneWayTransmitter::send_burst(const IoFrame &frame, OneWayPowerClass power_class, const char *intent_label) {
288 // Logged once for the whole burst rather than once per copy: four log lines per button press
289 // would suggest four commands, which is exactly the misreading the shared sequence exists to
290 // prevent. Only frame-header facts are logged — never payload bytes — so this stays safe even
291 // for the commands redaction.h flags as carrying key material.
292 const OneWayFrameInfo info = decode_1w_frame(frame);
293 // Same vocabulary as the RX line (hub_internal.h's log_1w_remote_frame()): "all" for the
294 // all-devices broadcast, else the target class's bare name -- one decision, made once, by
295 // oneway_target_label() (proto_codecs.h), so the two paths cannot render the same address
296 // differently. "to <class>" is a fair claim here and only here: we choose the class we transmit
297 // to (enrollment_classes). The RX line says dst-class= instead, because a received class says
298 // nothing about what the sending remote drives.
299 const char *target = oneway_target_label(info);
300 // Built from the same oneway_burst_copy_shape() the transmit loop below calls, so this can never
301 // claim a preamble shape the burst does not actually send.
302 const std::string preambles = format_oneway_preamble_list(power_class, this->tuning_->normal_start_preamble);
303 if (info.has_intent || intent_label != nullptr) {
304 ESP_LOGI(TAG, "1W tx: from %s to %s intent %s (%ux, preamble %s)", node_id_to_string(frame.src).c_str(), target,
305 intent_label != nullptr ? intent_label : info.intent, ONEWAY_BURST_REPEATS, preambles.c_str());
306 } else {
307 ESP_LOGI(TAG, "1W tx: from %s to %s cmd 0x%02X (%ux, preamble %s)", node_id_to_string(frame.src).c_str(), target,
308 frame.cmd, ONEWAY_BURST_REPEATS, preambles.c_str());
309 }
310
311 uint8_t sent = 0;
312 for (uint8_t copy = 0; copy < ONEWAY_BURST_REPEATS; copy++) {
313 if (copy > 0) {
314 // The gap is part of the protocol, so it is taken before the copy rather than after the
315 // last one — a trailing delay would hold the loop for nothing.
316 App.feed_wdt();
317 delay(ONEWAY_BURST_INTERVAL_MS);
318 }
319 // The transmitter owns CTRL1's low-power bit and the preamble, per copy -- the frame builders
320 // always hand back ctrl1=0 (proto_commands.cpp), and this clears the bit unconditionally on a
321 // NORMAL-shaped copy too, whatever the builder produced. Everything else about the copy
322 // (cmd/data/sequence/MAC) stays exactly what the caller built, per send_burst()'s own contract.
323 const OneWayCopyShape shape = oneway_burst_copy_shape(power_class, copy);
324 IoFrame copy_frame = frame;
325 if (shape.low_power_flag) {
326 copy_frame.ctrl1 |= CTRL1_LOW_POWER;
327 } else {
328 copy_frame.ctrl1 &= static_cast<uint8_t>(~CTRL1_LOW_POWER);
329 }
330 const uint16_t preamble = oneway_copy_preamble_bytes(shape, this->tuning_->normal_start_preamble);
331 if (this->transmit_(copy_frame, FREQ_CH2, preamble)) {
332 sent++;
333 }
334 }
335
336 if (sent != ONEWAY_BURST_REPEATS) {
337 // Worth a warning even when some copies made it: the burst is the reliability mechanism, so a
338 // partial one is a degraded command, and nothing downstream will ever notice on its own.
339 ESP_LOGW(TAG, "1W tx: only %u of %u copies transmitted", sent, ONEWAY_BURST_REPEATS);
340 }
341 return sent > 0;
342}
343
344} // namespace home_io_control
345} // namespace esphome
const OneWayControllerIdentity * get(const std::string &id) const
Look up an identity by its YAML handle.
bool next(const uint8_t node_id[NODE_ID_SIZE], uint16_t &out)
Reserve and return the next sequence for an identity.
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.
bool send_burst(const IoFrame &frame, OneWayPowerClass power_class, const char *intent_label=nullptr)
Transmit one already-built, already-signed 1W frame as a burst.
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.
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.
@ WAKE
The long wake-up preamble (LONG_PREAMBLE), paired with CTRL1_LOW_POWER set.
static constexpr const char * TAG
CoverCommand
Named device commands for cover-type actuators.
bool create_1w_remove_controller(IoFrame &f, const uint8_t src[NODE_ID_SIZE], DeviceType target_type, uint16_t sequence, const uint8_t controller_key[AES_KEY_SIZE])
Build a 1W remove-controller frame (CMD 0x39).
OneWayPowerClass effective_power_class(const OneWayControllerIdentity &identity)
The burst shape this identity actually transmits with.
bool effective_inverted(const OneWayControllerIdentity &identity)
Whether this identity's OPEN/CLOSE/position values are mirrored on the wire.
const char * oneway_target_label(const OneWayFrameInfo &info)
The destination label a decoded 1W frame renders as in a log line.
DeviceType
IO-Homecontrol node types.
@ UNKNOWN
Unknown/unspecified device.
bool create_1w_execute_command(IoFrame &f, const uint8_t src[NODE_ID_SIZE], DeviceType target_type, CoverCommand cmd, uint16_t sequence, const uint8_t controller_key[AES_KEY_SIZE], uint8_t acei, bool broadcast_all)
Build a 1W named-command execute frame (CMD 0x00) targeting a device class.
OneWayFrameInfo decode_1w_frame(const IoFrame &frame)
Decode a parsed 1W frame into a structured OneWayFrameInfo.
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.
OneWayPowerClass
Which preamble/CTRL1 shape a 1W identity's bursts actually go out with.
std::string node_id_to_string(const uint8_t id[NODE_ID_SIZE])
Format a 3‑byte node ID as a 6‑character uppercase hex string.
OneWayWireProfile resolve_oneway_wire_profile(uint8_t manufacturer)
Resolve an identity's 1W wire profile from its manufacturer byte.
bool create_1w_execute_position(IoFrame &f, const uint8_t src[NODE_ID_SIZE], DeviceType target_type, uint8_t position, uint16_t sequence, const uint8_t controller_key[AES_KEY_SIZE], uint8_t acei, bool broadcast_all)
Build a 1W position execute frame (CMD 0x00) targeting a device class.
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 ...
bool create_1w_add_controller(IoFrame &f, const uint8_t src[NODE_ID_SIZE], DeviceType target_type, uint8_t manufacturer, uint16_t sequence, const uint8_t controller_key[AES_KEY_SIZE], bool with_mac)
Build a 1W add-controller frame (CMD 0x30).
static const char *const TAG
One-way (1W) transmit collaborator.
Device-name, address-classification and 1W-frame codecs.
Command builders for the IO‑Homecontrol protocol.
Physical-layer radio and timing parameters for the IO-Homecontrol protocol.
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:93
uint8_t src[NODE_ID_SIZE]
Source node ID (3 bytes).
Definition proto_frame.h:97
uint8_t ctrl1
Control byte 1: low power, beacon, etc.
Definition proto_frame.h:95
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...
uint8_t system_key[AES_KEY_SIZE]
Network key for this identity; may differ per identity.
DeviceType io_device_type
Device class this identity commands.
uint8_t manufacturer
Manufacturer ID; unused until the enrollment phase.
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.
Decoded representation of a 1W remote frame.
bool has_intent
True if originator/ACEI/intent fields were decoded.
char intent[ONEWAY_INTENT_BUFFER_SIZE]
Human-readable command intent (e.g., "CLOSE").
EnrollGesture enroll_gesture
Which enrollment gesture this manufacturer's actuators expect.