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
25 for (const auto &identity : this->identities_.all())
26 this->sequences_.add_identity(identity.node_id, identity.initial_sequence);
27}
28
29bool OneWayTransmitter::send_(const std::string &controller_id,
30 const std::function<bool(IoFrame &, const OneWayControllerIdentity &, uint16_t)> &build,
31 const char *explicit_intent) {
32 const OneWayControllerIdentity *identity = this->identities_.get(controller_id);
33 if (identity == nullptr) {
34 ESP_LOGW(TAG, "1W tx: no controller identity '%s'", controller_id.c_str());
35 this->report_failure_(controller_id, 0, /*sequence_reserved=*/false);
36 return false;
37 }
38
39 // One sequence per logical command, reserved before anything is built or sent.
40 uint16_t sequence = 0;
41 if (!this->sequences_.next(identity->node_id, sequence)) {
42 // The store logs why; it refuses rather than risk reusing a sequence.
43 this->report_failure_(controller_id, 0, /*sequence_reserved=*/false);
44 return false;
45 }
46
47 IoFrame frame{};
48 if (!build(frame, *identity, sequence)) {
49 // The sequence is spent either way. Skipping one is free; reusing it is not, so it is not
50 // returned to the store.
51 ESP_LOGW(TAG, "1W tx: could not build a frame for '%s'", controller_id.c_str());
52 this->report_failure_(controller_id, sequence, /*sequence_reserved=*/true);
53 return false;
54 }
55
56 const bool transmitted = this->send_burst(frame);
57
58 // 0x30/0x39 carry no intent decode_1w_frame() can read (it only understands
59 // CMD_EXECUTE/CMD_ACTIVATE_MODE payloads) -- send_enrollment() etc. pass the label directly
60 // rather than leave the "Last 1W Command" sensor blank on the one feature whose entire
61 // diagnostic story is that sensor.
62 std::string intent = explicit_intent;
63 if (intent.empty()) {
64 const OneWayFrameInfo info = decode_1w_frame(frame);
65 if (info.has_intent)
66 intent = info.intent;
67 }
68 // Always the identity's own class, never the frame's decoded destination: with
69 // `execute_broadcast: all` the wire dst is `00 00 3F` -> DeviceType::UNKNOWN, which would render
70 // "unknown" in the TX log and the sensor. The literal destination is already in the frame log.
71 this->report_attempt_(controller_id, intent, identity->io_device_type, sequence, /*sequence_reserved=*/true,
72 transmitted);
73 return transmitted;
74}
75
76void OneWayTransmitter::report_attempt_(const std::string &controller_id, const std::string &intent,
77 DeviceType target_type, uint16_t sequence, bool sequence_reserved,
78 bool transmitted) {
79 if (!this->report_)
80 return;
81 OneWayCommandReport report{};
82 report.controller_id = controller_id;
83 report.intent = intent;
84 report.target_type = target_type;
85 report.sequence = sequence;
86 report.sequence_reserved = sequence_reserved;
87 report.transmitted = transmitted;
88 this->report_(report);
89}
90
91void OneWayTransmitter::report_failure_(const std::string &controller_id, uint16_t sequence, bool sequence_reserved) {
92 this->report_attempt_(controller_id, "", DeviceType::UNKNOWN, sequence, sequence_reserved, /*transmitted=*/false);
93}
94
95bool OneWayTransmitter::send_command(const std::string &controller_id, CoverCommand cmd) {
96 return this->send_(controller_id, [cmd](IoFrame &frame, const OneWayControllerIdentity &identity, uint16_t sequence) {
97 return create_1w_execute_command(frame, identity.node_id, identity.io_device_type, cmd, sequence,
98 identity.system_key, effective_execute_acei(identity),
99 identity.execute_broadcast_all);
100 });
101}
102
103bool OneWayTransmitter::send_position(const std::string &controller_id, uint8_t position) {
104 return this->send_(
105 controller_id, [position](IoFrame &frame, const OneWayControllerIdentity &identity, uint16_t sequence) {
106 return create_1w_execute_position(frame, identity.node_id, identity.io_device_type, position, sequence,
107 identity.system_key, effective_execute_acei(identity),
108 identity.execute_broadcast_all);
109 });
110}
111
112bool OneWayTransmitter::send_enrollment(const std::string &controller_id) {
113 const OneWayControllerIdentity *identity = this->identities_.get(controller_id);
114 if (identity == nullptr) {
115 ESP_LOGW(TAG, "1W tx: no controller identity '%s'", controller_id.c_str());
116 this->report_failure_(controller_id, 0, /*sequence_reserved=*/false);
117 return false;
118 }
120 return this->send_velux_kli_enrollment_(*identity);
121 return this->send_somfy_enrollment_(*identity);
122}
123
124bool OneWayTransmitter::send_somfy_enrollment_(const OneWayControllerIdentity &identity) {
125 // The documented 1W pairing handshake (the iown-homecontrol link-layer notes, "1W Discovery") is
126 // `0x39` immediately followed by `0x30`, both from the same controller, back to back within one
127 // gesture -- a real Smoove capture landed them 128 ms apart, same burst (see
128 // tests/corpus/captures/enrollment/somfy_smoove_enrollment_add_and_remove_controller_sx1276.yaml).
129 // `0x39` here carries only this
130 // identity's own `src` address, so on the wire it can only mean "clear my own prior entry
131 // before I re-register" -- it cannot name or displace a different controller. Sending it right
132 // before `0x30` clears a stale slot from an earlier enrollment attempt under this identity,
133 // which a bare `0x30` re-add is not guaranteed to overwrite.
134 const bool removed = this->send_(
135 identity.id,
136 [](IoFrame &frame, const OneWayControllerIdentity &id, uint16_t sequence) {
137 return create_1w_remove_controller(frame, id.node_id, id.io_device_type, sequence, id.system_key);
138 },
139 "UNENROLL");
140 if (!removed) {
141 ESP_LOGW(TAG, "1W tx: enrollment's 0x39 prelude did not reach the radio for '%s' -- trying 0x30 anyway",
142 identity.id.c_str());
143 }
144
145 return this->send_(
146 identity.id,
147 [](IoFrame &frame, const OneWayControllerIdentity &id, uint16_t sequence) {
148 return create_1w_add_controller(frame, id.node_id, id.io_device_type, id.manufacturer, sequence, id.system_key,
149 id.enrollment_with_mac);
150 },
151 "ENROLL");
152}
153
154bool OneWayTransmitter::send_velux_kli_enrollment_(const OneWayControllerIdentity &identity) {
155 // The gesture a real KLI 310/313 PROG press produces (issue #74 capture + samr037/iohc-flipper
156 // tx_runner.c + the KLI manual):
157 // 0x39 -> 00 00 3F (clear self; VELUX broadcasts it, unlike Somfy's typed 0x39)
158 // 0x30 -> each of {roller_shutter, awning, dual_shutter} under one sequence (the class sweep)
159 // EXECUTE STOP, then EXECUTE DOWN, both -> 00 00 3F at the VELUX ACEI (registration completion)
160 const bool removed = this->send_(
161 identity.id,
162 [](IoFrame &frame, const OneWayControllerIdentity &id, uint16_t sequence) {
163 return create_1w_remove_controller(frame, id.node_id, DeviceType::UNKNOWN, sequence, id.system_key);
164 },
165 "UNENROLL");
166 if (!removed) {
167 ESP_LOGW(TAG, "1W tx: VELUX enrollment's 0x39 prelude did not reach the radio for '%s' -- continuing",
168 identity.id.c_str());
169 }
170
171 const bool enrolled = this->send_enroll_sweep_(identity, effective_enrollment_classes(identity));
172 if (!enrolled) {
173 // Nothing registered us, so the STOP+DOWN would be an unprovoked close broadcast to every 1W
174 // device on the network holding the key. Skip it.
175 ESP_LOGW(TAG, "1W tx: VELUX 0x30 sweep transmitted nothing for '%s' -- skipping the STOP+DOWN follow-up",
176 identity.id.c_str());
177 return false;
178 }
179
180 // STOP then DOWN, per the KLI manual's "then STOP then DOWN within 3 seconds". Broadcast, the
181 // identity's effective ACEI, one sequence each. A partial miss here only warns -- the sweep
182 // above is what registers us.
183 const uint8_t acei = effective_execute_acei(identity);
184 const bool stopped = this->send_(
185 identity.id,
186 [acei](IoFrame &frame, const OneWayControllerIdentity &id, uint16_t sequence) {
187 return create_1w_execute_command(frame, id.node_id, id.io_device_type, CoverCommand::STOP, sequence,
188 id.system_key, acei, /*broadcast_all=*/true);
189 },
190 "ENROLL STOP");
191 const bool lowered = this->send_(
192 identity.id,
193 [acei](IoFrame &frame, const OneWayControllerIdentity &id, uint16_t sequence) {
194 return create_1w_execute_position(frame, id.node_id, id.io_device_type, ONEWAY_POSITION_FULLY_CLOSED, sequence,
195 id.system_key, acei, /*broadcast_all=*/true);
196 },
197 "ENROLL DOWN");
198 if (!stopped || !lowered) {
199 ESP_LOGW(TAG, "1W tx: VELUX enrollment's STOP+DOWN follow-up did not fully reach the radio for '%s'",
200 identity.id.c_str());
201 }
202 return true; // the sweep registered us; STOP+DOWN are completion, not the credential
203}
204
205bool OneWayTransmitter::send_enroll_sweep_(const OneWayControllerIdentity &identity,
206 const std::array<DeviceType, 3> &classes) {
207 // One sequence for the whole sweep -- every 0x30 in it carries the same value, matching a real
208 // KLI remote and how send_() treats a burst's copies as one logical command.
209 uint16_t sequence = 0;
210 if (!this->sequences_.next(identity.node_id, sequence)) {
211 this->report_failure_(identity.id, 0, /*sequence_reserved=*/false);
212 return false;
213 }
214
215 bool any_transmitted = false;
216 for (const DeviceType target_type : classes) {
217 if (target_type == DeviceType::UNKNOWN)
218 continue;
219 IoFrame frame{};
220 if (!create_1w_add_controller(frame, identity.node_id, target_type, identity.manufacturer, sequence,
221 identity.system_key, identity.enrollment_with_mac)) {
222 ESP_LOGW(TAG, "1W tx: could not build a 0x30 for class 0x%02X on '%s'", static_cast<unsigned>(target_type),
223 identity.id.c_str());
224 continue;
225 }
226 if (this->send_burst(frame))
227 any_transmitted = true;
228 }
229
230 this->report_attempt_(identity.id, "ENROLL", identity.io_device_type, sequence, /*sequence_reserved=*/true,
231 any_transmitted);
232 return any_transmitted;
233}
234
235bool OneWayTransmitter::send_unenrollment(const std::string &controller_id) {
236 return this->send_(
237 controller_id,
238 [](IoFrame &frame, const OneWayControllerIdentity &identity, uint16_t sequence) {
239 return create_1w_remove_controller(frame, identity.node_id, identity.io_device_type, sequence,
240 identity.system_key);
241 },
242 "UNENROLL");
243}
244
246 // Logged once for the whole burst rather than once per copy: four log lines per button press
247 // would suggest four commands, which is exactly the misreading the shared sequence exists to
248 // prevent. Only frame-header facts are logged — never payload bytes — so this stays safe even
249 // for the commands redaction.h flags as carrying key material.
250 const OneWayFrameInfo info = decode_1w_frame(frame);
251 if (info.has_intent) {
252 ESP_LOGI(TAG, "1W tx: from %s to class %s intent %s (%ux)", node_id_to_string(frame.src).c_str(),
254 } else {
255 ESP_LOGI(TAG, "1W tx: from %s to class %s cmd 0x%02X (%ux)", node_id_to_string(frame.src).c_str(),
257 }
258
259 uint8_t sent = 0;
260 for (uint8_t repeat = 0; repeat < ONEWAY_BURST_REPEATS; repeat++) {
261 if (repeat > 0) {
262 // The gap is part of the protocol, so it is taken before the copy rather than after the
263 // last one — a trailing delay would hold the loop for nothing.
264 App.feed_wdt();
266 }
267 if (this->transmit_(frame, FREQ_CH2, LONG_PREAMBLE)) {
268 sent++;
269 }
270 }
271
272 if (sent != ONEWAY_BURST_REPEATS) {
273 // Worth a warning even when some copies made it: the burst is the reliability mechanism, so a
274 // partial one is a degraded command, and nothing downstream will ever notice on its own.
275 ESP_LOGW(TAG, "1W tx: only %u of %u copies transmitted", sent, ONEWAY_BURST_REPEATS);
276 }
277 return sent > 0;
278}
279
280} // namespace home_io_control
281} // 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)
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 (a physical PROG...
bool send_command(const std::string &controller_id, CoverCommand cmd)
Send a named command as the identity's controller.
static constexpr uint8_t ONEWAY_BURST_REPEATS
One-way (1W) transmit cadence.
DeviceType
Device type identifiers reported by IO‑Homecontrol products.
@ UNKNOWN
Unknown/unspecified device.
static constexpr uint32_t ONEWAY_BURST_INTERVAL_MS
Gap between those copies.
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).
const char * device_type_name(DeviceType type)
Convert a DeviceType to a lowercase string identifier.
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.
static constexpr uint32_t FREQ_CH2
Channel 2: 868.95 MHz (1W and 2W, TX channel).
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.
static constexpr uint16_t LONG_PREAMBLE
Preamble is a sequence of 0xAA bytes that precedes every frame.
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.
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).
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:88
uint8_t src[NODE_ID_SIZE]
Source node ID (3 bytes).
Definition proto_frame.h:92
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.
Decoded representation of a 1W remote frame.
bool has_intent
True if originator/ACEI/intent fields were decoded.
DeviceType target_type
Target device class from broadcast address.
char intent[ONEWAY_INTENT_BUFFER_SIZE]
Human-readable command intent (e.g., "CLOSE").
EnrollGesture enroll_gesture
Which enrollment gesture this manufacturer's actuators expect.