Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
management_actions.h
Go to the documentation of this file.
1#pragma once
2
3/// @file management_actions.h
4/// @brief Hub-level management operations exposed as Home Assistant actions.
5/// @ingroup hioc_hub
6///
7/// ManagementActions owns the family of advanced, non-entity hub actions (device rename,
8/// identify, force-open, ...) and the native API registration that wires them into Home
9/// Assistant. The opt-in diagnostic probes live in DiagnosticProbes (diagnostic_probes.h), which
10/// ManagementActions owns and registers the two probe actions for. Every action is registered through one data-driven
11/// API service descriptor (detail::ManagementServiceDescriptor); adding a new action does not require a new descriptor
12/// class. The descriptor holds a ManagementActions* and calls only public methods on it, so no friend declarations into
13/// the hub are needed.
14///
15/// IOHomeControlComponent exposes this collaborator through thin protected wrappers
16/// (api_rename_device_, api_identify_device_, api_force_open_device_, api_scan_paired_devices_,
17/// register_management_actions_).
18
19#include "device_registry.h"
20#include "diagnostic_probes.h"
21#include "exchange_engine.h"
22#include "management_helpers.h"
23#include "proto_constants.h"
24
25#include <cstdint>
26#include <string>
27
28namespace esphome {
29namespace home_io_control {
30
31// Forward declarations — full definitions included only in management_actions.cpp.
33struct TuningConfig;
34
35/// Encapsulates hub-level management operations exposed as Home Assistant actions.
36///
37/// ManagementActions is constructed once by IOHomeControlComponent. It stores pointers
38/// and references into hub members; it is non-copyable for the same reason as ExchangeEngine.
39/// @ingroup hioc_hub
41 public:
42 /// Construct with all required collaborators.
43 ///
44 /// @param node_id Controller 3-byte node ID, owned by the hub.
45 /// @param system_key Controller 16-byte AES system key, owned by the hub. Only
46 /// scan_paired_devices() uses this — it builds a self-authenticating
47 /// CMD_DISCOVER_SPE_REQ itself, unlike rename/identify/force-open, which
48 /// authenticate through ExchangeEngine's challenge-response.
49 /// @param tuning Runtime tuning config, owned by the hub. Only scan_paired_devices()
50 /// uses this — it reuses `pairing_discovery_wait_ms` as the listen window
51 /// for each of its own per-channel attempts, and reads `scan_power_classes`
52 /// to decide which passes to run.
53 /// @param engine Shared exchange engine for radio transactions.
54 /// @param registry Device registry for device lookups.
55 /// @param initialized Pointer to the hub's initialized flag.
56 /// @param hub Hub pointer for ESPHome API calls and public hub methods.
57 ManagementActions(const uint8_t *node_id, const uint8_t *system_key, const TuningConfig *tuning,
58 ExchangeEngine &engine, DeviceRegistry &registry, const bool *initialized,
60
61 /// Non-copyable — stores references and pointers into hub member addresses.
64
65 /// Register all management actions (rename, identify, force-open, ...) with ESPHome's
66 /// native API server.
67 void register_actions();
68
69 /// Native API callback: rename a device and publish the result as a HA event.
70 void api_rename_device(const std::string &device_id, const std::string &new_name);
71
72 /// Rename a registered device and verify the result by reading the name back.
73 /// @param device_id Target device ID (hex string, case-insensitive).
74 /// @param new_name Requested UTF-8 device name.
75 /// @return Structured result describing success, verification, and any error.
76 ManagementActionResult rename_device(const std::string &device_id, const std::string &new_name);
77
78 /// Native API callback: trigger a device's physical identify and publish the result as a HA event.
79 void api_identify_device(const std::string &device_id);
80
81 /// @brief Trigger a registered device's physical identify (brief jog/flash).
82 ///
83 /// Deliberately performs no device-type gating beyond "is it registered on this hub" — identify
84 /// exists precisely to let a user work out what an unknown registry entry physically is, so
85 /// restricting it to specific device types would defeat the purpose. A CMD_ERROR_RESP reply is
86 /// treated as a successful trigger (not a failure): per the protocol, devices may answer an
87 /// identify request that way and still perform the jog.
88 /// @param device_id Target device ID (hex string, case-insensitive).
89 /// @return Structured result describing success and any validation failure. `verified` is
90 /// always false — there is no readback that confirms a physical jog happened.
91 ManagementActionResult identify_device(const std::string &device_id);
92
93 /// Native API callback: force-open a device and publish the result as a HA event.
94 void api_force_open_device(const std::string &device_id);
95
96 /// @brief Move a registered cover device to fully open at elevated priority, intended to
97 /// bypass wind/rain soft locks.
98 ///
99 /// Safety-sensitive: this deliberately requires an explicit `device_id` (a hub-level action,
100 /// not a permanent per-device button) because it can override legitimate environmental
101 /// protection. Queues the command onto the hub's normal cover-command dispatch path
102 /// (IOHomeControlComponent::queue_device_command()), which already owns capability gating,
103 /// poll tracking, and backoff — this method does not talk to the radio directly. The result
104 /// therefore only confirms that the command was queued; the actual movement outcome arrives
105 /// later via the device's normal cover-state/polling pipeline, so `verified` is always false.
106 /// @note The elevated-priority mechanism (see create_force_open() in proto_commands.cpp) is
107 /// confirmed on real hardware to correctly move a device to fully open, but bypassing
108 /// an *active* environmental lock is still experimental — only that the device accepts
109 /// the frame and opens when nothing is locking it has been observed so far.
110 /// @param device_id Target device ID (hex string, case-insensitive).
111 /// @return Structured result describing whether the command was queued.
112 ManagementActionResult force_open_device(const std::string &device_id);
113
114 /// Native API callback: run a roll-call scan and publish the result as a HA event.
116
117 /// @brief Native API callback: queue a 1W position for a controller identity.
118 ///
119 /// An action rather than an entity per percentage (ADR 0006). `position` arrives as a string
120 /// because every argument on this action surface is a string; it is parsed and range-checked
121 /// here, and a bad value is reported rather than coerced — a malformed position that silently
122 /// became 0 would send a fully-open command to a whole device class.
123 ///
124 /// The published result says only that the command was *queued*. 1W has no reply, so nothing
125 /// downstream can ever upgrade that to "the device moved"; the per-identity "Last 1W Command"
126 /// sensor reports what was transmitted.
127 /// @param controller_id Controller-identity handle from `oneway_controllers:`.
128 /// @param position Target position 0-100 as a decimal string.
129 void api_oneway_set_position(const std::string &controller_id, const std::string &position);
130
131 /// @brief Native API callback: queue a standalone 1W un-enrollment (remove-controller, CMD
132 /// 0x39) for a controller identity.
133 ///
134 /// The direct caller of `0x39` in this codebase (ADR 0006 — a rare rollback path gets an
135 /// action, not a permanent entity) — the "Enroll" button also fires this same frame, but only
136 /// as the documented prelude immediately before its own `0x30`, never on its own. Same "queued,
137 /// not confirmed" framing as api_oneway_set_position() — 1W has no reply, so nothing here can
138 /// ever say a device actually forgot this identity.
139 ///
140 /// @warning **Works only inside the receiver's programming window.** Confirmed on a Somfy Izymo
141 /// dimmer only: put the receiver in its programming mode (PROG window), then call this action;
142 /// the Izymo blinks and stops obeying this identity. With no window open the frame changes
143 /// nothing, and the hub keeps controlling the Izymo. Other receivers are untested.
144 /// @param controller_id Controller-identity handle from `oneway_controllers:`.
145 void api_oneway_remove_controller(const std::string &controller_id);
146
147 /// @brief Broadcast a roll-call and report every device that answers.
148 ///
149 /// Not a discovery mechanism for new devices: only devices that already hold this hub's
150 /// system key answer a CMD_DISCOVER_SPE_REQ (the 0x2A payload is self-authenticating — 6
151 /// random bytes plus a 6-byte HMAC over the command byte, computed with `system_key_`), so a
152 /// device that has never paired with this hub stays silent. See
153 /// `tests/corpus/captures/discovery/somfy_awning_discovery_spe_paired_rollcall.yaml` for a captured
154 /// exchange. Never writes DeviceRegistry — every responder is only looked up, never
155 /// registered; an unknown responder usually means a device paired earlier whose YAML config
156 /// was never saved, not an intruder. `result.device_id` stays empty (there is no single
157 /// target) and `verified` stays false (nothing here is read back). `success` is true whenever
158 /// the broadcast went out, including with zero replies — "nothing answered" is a valid
159 /// result, not a failure. `result.message` carries the full multi-line report: a header line
160 /// (devices detected, how many known vs. unknown), then a `Known:` section followed by an
161 /// `Unknown:` section (either omitted if empty) — known responders get a summary line each,
162 /// unknown responders additionally get a lead-in sentence and a ready-to-paste YAML block.
163 /// Devices are grouped by known/unknown rather than left in arrival order, since the two
164 /// groups need different follow-up and interleaving them made an unknown responder easy to
165 /// miss between known ones. At most `SCAN_MAX_REPLIES` distinct responders are reported; if
166 /// more answer, the report says so explicitly rather than quietly listing a subset.
167 ///
168 /// Sweeps two passes, low-power first, then always-alive — six attempts total (three channels
169 /// per pass): CH2/CH1/CH3 with `CTRL1 = LOW_POWER | ACK` and `LONG_PREAMBLE` (the header a real
170 /// VELUX KLR300 uses for its own roll-call, whose low-power shutters answered it), then
171 /// CH2/CH1/CH3 with `CTRL1 = 0x00` and the tunable `normal_start_preamble`. Low-power first so a sleeper that answers
172 /// late is still collected by the later always-alive windows; in the opposite order a late
173 /// low-power reply would fall off the end of the scan. The low-power pass listens on all three
174 /// channels including the one it transmitted on (real VELUX low-power replies have been
175 /// observed landing there); the always-alive pass listens only on the two it did not transmit
176 /// on (a Somfy always-alive reply lands on the request channel about 1 in 149 times). The
177 /// `scan_power_classes` tuning select (default `both`) narrows this to one pass, for bisecting
178 /// an installation in the field or restoring the shorter single-pass scan on an install with no
179 /// solar/battery devices. Each attempt gets its own full `pairing_discovery_wait_ms` listen
180 /// window, merging distinct responders across all six attempts — a paired device only hears the
181 /// broadcast if it happens to be awake on the channel the hub transmits on at that instant, and
182 /// real hardware testing found that single-channel duty-cycling paired devices are not reliably
183 /// caught by a one-shot broadcast. A responder that answers more than one attempt still appears
184 /// exactly once. Each attempt is a single transmit followed by its own window, never
185 /// back-to-back transmits, so it does not reintroduce the different failure mode a
186 /// multi-channel-burst transmit caused elsewhere: firing several long-preamble transmits
187 /// back-to-back with no listening in between blew through the tight per-try response wait
188 /// windows and broke exchanges in both directions (see
189 /// `KeyExtractionResponder::send_reply_()`'s implementation comment).
190 ///
191 /// This still blocks the caller for the full six-window duration (roughly
192 /// `6 × pairing_discovery_wait_ms` plus three ~213 ms LONG_PREAMBLE transmits, ~12.7 s at the
193 /// 2000 ms default) and therefore trips ESPHome's "operation took a long time" warning on
194 /// *every* invocation. That warning uses a per-component ratchet
195 /// (`Component::should_warn_of_blocking()`): it starts at 50 ms and, each time it fires, raises
196 /// its own threshold to the observed duration plus a margin — but the threshold is stored in
197 /// centiseconds in a `uint8_t`, so it saturates at **2550 ms**. Anything that blocks longer than
198 /// that can never ratchet out of warning range; doubling the scan adds no new warning class,
199 /// since the single-pass scan was already permanently past the cap.
200 ///
201 /// Accepting that is a deliberate tradeoff (confirmed on real hardware 2026-08-10). A shorter
202 /// fixed window — 500 ms per attempt, ~2.4 s for a three-attempt sweep (a single-pass total
203 /// under the 2550 ms cap) — was tried: it would have gone quiet after one warning, but it also
204 /// caused real, correctly-decoded replies from registered devices to arrive after the window
205 /// had already closed, where the passive path drops them (`hub_status.cpp`'s `unhandled_cmd`
206 /// catch-all). Losing devices from the report is worse than a recurring log line, so the long
207 /// window won. Getting both would require restructuring this action to run across multiple
208 /// scheduled `loop()` ticks so no single blocking unit approaches 2550 ms — not done here.
209 /// @return Structured result whose `message` is the full report.
211
212 /// Native API callback: run a single diagnostic probe and publish the result as a HA event.
213 void api_probe_device(const std::string &device_id, const std::string &probe, const std::string &index);
214
215 /// @brief Send a single diagnostic probe frame to a paired device; delegates to
216 /// DiagnosticProbes::probe_device(), which documents the probe names, the gating and the result.
217 /// @param device_id Target device ID (hex string, case-insensitive).
218 /// @param probe Probe name.
219 /// @param index Probe index, a decimal or `0x`-prefixed hex string.
220 /// @return Structured result carrying the raw reply.
221 ManagementActionResult probe_device(const std::string &device_id, const std::string &probe, const std::string &index);
222
223 /// Native API callback: run a bounded probe sweep and publish the result as a HA event.
224 void api_probe_sweep(const std::string &device_id, const std::string &probe, const std::string &first_index,
225 const std::string &last_index);
226
227 /// @brief Walk a bounded index range, one probe per index; delegates to DiagnosticProbes::probe_sweep().
228 /// @param device_id Target device ID (hex string, case-insensitive).
229 /// @param probe Probe name.
230 /// @param first_index First index (inclusive).
231 /// @param last_index Last index (inclusive).
232 /// @return Structured result with one line per index.
233 ManagementActionResult probe_sweep(const std::string &device_id, const std::string &probe,
234 const std::string &first_index, const std::string &last_index);
235
236 /// Native API callback: run a heating/climate function and publish the result as a HA event.
237 void api_heating_control(const std::string &device_id, const std::string &function, const std::string &value);
238
239 /// @brief Send one 2W heating/climate function (CMD_WRITE_PRIVATE 0x20) to a registered climate
240 /// device.
241 ///
242 /// A real user feature, but an unvalidated one — the register protocol has never been exercised
243 /// against real Atlantic/Thermor/Sauter hardware (see docs/configuration/climate.md's experimental
244 /// banner). It is NOT diagnostic-gated; the guard is documentation, not `diagnostic_probes:`.
245 ///
246 /// `function` is one of `power_on` / `set_temperature` / `set_mode` / `set_presence` /
247 /// `set_open_window_detection` / `midnight_sync` / `window_open`. `value` is parsed per function:
248 /// a float in degrees Celsius (7.0-28.0) for `set_temperature`, `auto|manual|prog|off` for
249 /// `set_mode`, `on|off` for `set_presence` and `set_open_window_detection`, ignored for the
250 /// register reads. A malformed value is reported, never coerced.
251 ///
252 /// Delegates to IOHomeControlComponent::send_heating_command() (and, for `window_open`,
253 /// read_heating_register()) so the entity and the action share one transmit path. `verified` is
254 /// always false: an acknowledged write is not the device showing the new value. `window_open`
255 /// reports the heater's window sensor in the result message; `power_on` and `midnight_sync` log
256 /// their ACK payload at DEBUG.
257 /// @param device_id Target device ID (hex string, case-insensitive).
258 /// @param function Heating function name (see above).
259 /// @param value Function-specific value string (see above).
260 /// @return Structured result describing success and any validation/exchange failure.
261 ManagementActionResult heating_control(const std::string &device_id, const std::string &function,
262 const std::string &value);
263
264 /// Publish a management result as one or more structured log lines (one call per line of
265 /// `result.message`, see log_multiline_result() in log_helpers.h) and a Home Assistant event.
266 void publish_result(const ManagementActionResult &result);
267
268 private:
269 /// @brief Shared preamble for every management action: validate the hub and device ID,
270 /// then resolve the target device (see resolve_management_device()).
271 ///
272 /// Initializes `result` with the action name and normalized device ID, then checks (in
273 /// order) that the hub is initialized, that `device_id` is a well-formed 6-hex-digit ID,
274 /// and that it names a device registered on this hub. On any failure, sets `result.message`
275 /// to a stable, caller-facing string and returns nullptr; callers should return `result`
276 /// immediately in that case.
277 /// @param action Action name to record on `result` (e.g. MANAGEMENT_ACTION_RENAME_DEVICE).
278 /// @param device_id Requested device ID (hex string, case-insensitive, as received from HA).
279 /// @param result Populated with the action name and normalized device ID, plus an error
280 /// message on failure.
281 /// @return Pointer to the resolved device, or nullptr if any validation step failed.
282 IoDevice *resolve_device_(const char *action, const std::string &device_id, ManagementActionResult &result);
283
284 /// @brief Send an authenticated request and report a stock timeout failure on no response.
285 ///
286 /// Shared by every action that talks to the device directly (rename, identify): both send
287 /// their built request via ExchangeEngine::send_and_receive() and, on failure, log the same
288 /// exchange debug snapshot and report the same "no valid response to <action_verb> request"
289 /// message shape. Actions that don't talk to the radio directly (force-open, which queues
290 /// through the hub's cover-command dispatch path instead) have no use for this helper.
291 /// @param request Frame to send.
292 /// @param response Populated with the device's reply on success.
293 /// @param action_verb Bare verb used only in the failure message, e.g. "rename" or "identify".
294 /// @param result Result to update with `message` on failure; `result.device_id` must already be
295 /// set (via resolve_device_()) since it is used for the debug log.
296 /// @return true if a response was received; false on timeout (result.message set, debug
297 /// snapshot logged).
298 bool send_authenticated_request_(const IoFrame &request, IoFrame &response, const char *action_verb,
299 ManagementActionResult &result);
300
301 const uint8_t *node_id_;
302 const uint8_t *system_key_;
303 const TuningConfig *tuning_;
304 ExchangeEngine &engine_;
305 DeviceRegistry &registry_;
306 const bool *initialized_;
308 DiagnosticProbes probes_; ///< The opt-in probes; the registration table routes the two probe actions here.
309};
310
311} // namespace home_io_control
312} // namespace esphome
Owns the per-hub device table, update callbacks, and linked-remote associations.
Sends diagnostic probe frames to already-paired devices and reports the raw replies.
The main IO-Homecontrol component.
Definition hub_core.h:100
ManagementActions(const ManagementActions &)=delete
Non-copyable — stores references and pointers into hub member addresses.
void api_probe_device(const std::string &device_id, const std::string &probe, const std::string &index)
Native API callback: run a single diagnostic probe and publish the result as a HA event.
void register_actions()
Register all management actions (rename, identify, force-open, ...) with ESPHome's native API server.
void api_rename_device(const std::string &device_id, const std::string &new_name)
Native API callback: rename a device and publish the result as a HA event.
void api_oneway_set_position(const std::string &controller_id, const std::string &position)
Native API callback: queue a 1W position for a controller identity.
ManagementActionResult scan_paired_devices()
Broadcast a roll-call and report every device that answers.
ManagementActionResult probe_device(const std::string &device_id, const std::string &probe, const std::string &index)
Send a single diagnostic probe frame to a paired device; delegates to DiagnosticProbes::probe_device(...
ManagementActionResult rename_device(const std::string &device_id, const std::string &new_name)
Rename a registered device and verify the result by reading the name back.
void api_oneway_remove_controller(const std::string &controller_id)
Native API callback: queue a standalone 1W un-enrollment (remove-controller, CMD 0x39) for a controll...
void api_heating_control(const std::string &device_id, const std::string &function, const std::string &value)
Native API callback: run a heating/climate function and publish the result as a HA event.
void publish_result(const ManagementActionResult &result)
Publish a management result as one or more structured log lines (one call per line of result....
void api_force_open_device(const std::string &device_id)
Native API callback: force-open a device and publish the result as a HA event.
ManagementActions(const uint8_t *node_id, const uint8_t *system_key, const TuningConfig *tuning, ExchangeEngine &engine, DeviceRegistry &registry, const bool *initialized, IOHomeControlComponent *hub)
Construct with all required collaborators.
ManagementActionResult identify_device(const std::string &device_id)
Trigger a registered device's physical identify (brief jog/flash).
ManagementActionResult force_open_device(const std::string &device_id)
Move a registered cover device to fully open at elevated priority, intended to bypass wind/rain soft ...
void api_probe_sweep(const std::string &device_id, const std::string &probe, const std::string &first_index, const std::string &last_index)
Native API callback: run a bounded probe sweep and publish the result as a HA event.
void api_scan_paired_devices()
Native API callback: run a roll-call scan and publish the result as a HA event.
void api_identify_device(const std::string &device_id)
Native API callback: trigger a device's physical identify and publish the result as a HA event.
ManagementActionResult heating_control(const std::string &device_id, const std::string &function, const std::string &value)
Send one 2W heating/climate function (CMD_WRITE_PRIVATE 0x20) to a registered climate device.
ManagementActions & operator=(const ManagementActions &)=delete
ManagementActionResult probe_sweep(const std::string &device_id, const std::string &probe, const std::string &first_index, const std::string &last_index)
Walk a bounded index range, one probe per index; delegates to DiagnosticProbes::probe_sweep().
Per-hub device table, update-callback fan-out, and linked-remote map.
Opt-in diagnostic probes: single frames and bounded sweeps for protocol research.
Self-contained authenticated exchange engine for IO-Homecontrol 2W.
Result type and argument/result helpers shared by ManagementActions and DiagnosticProbes.
IO-Homecontrol command IDs, result codes and protocol enumerations.
Runtime state of a paired IO‑Homecontrol device.
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:93
Result of a hub-level management action such as rename.
All runtime tunable parameters for pairing and radio diagnostics.