17#include "esphome/core/application.h"
18#include "esphome/core/hal.h"
20#if defined(USE_API_USER_DEFINED_ACTIONS) && defined(USE_API_CUSTOM_SERVICES)
21#include "esphome/core/helpers.h"
43constexpr const char *MANAGEMENT_ACTION_RENAME_DEVICE =
"rename_device";
44constexpr const char *MANAGEMENT_ACTION_IDENTIFY_DEVICE =
"identify_device";
45constexpr const char *MANAGEMENT_ACTION_FORCE_OPEN_DEVICE =
"force_open_device";
46constexpr const char *MANAGEMENT_ACTION_SCAN_PAIRED_DEVICES =
"scan_paired_devices";
47constexpr const char *MANAGEMENT_ACTION_ONEWAY_SET_POSITION =
"oneway_set_position";
48constexpr const char *MANAGEMENT_ACTION_ONEWAY_REMOVE_CONTROLLER =
"oneway_remove_controller";
49constexpr const char *MANAGEMENT_ACTION_PROBE_DEVICE =
"probe_device";
50constexpr const char *MANAGEMENT_ACTION_PROBE_SWEEP =
"probe_sweep";
51constexpr const char *MANAGEMENT_ACTION_HEATING_CONTROL =
"heating_control";
52constexpr const char *MANAGEMENT_RESULT_EVENT =
"esphome.home_io_control_action_result";
53constexpr size_t RESULT_CODE_BUFFER_SIZE = 5;
54constexpr size_t UNEXPECTED_RESPONSE_MESSAGE_BUFFER_SIZE = 64;
55constexpr size_t HEATING_RANGE_MESSAGE_BUFFER_SIZE = 64;
58constexpr const char *PROBE_NAME_PRIVATE_FN =
"private_fn";
59constexpr const char *PROBE_NAME_PRIVATE_FN_SUB =
"private_fn_sub";
60constexpr const char *PROBE_NAME_STATUS_EXT =
"status_ext";
61constexpr const char *PROBE_NAME_STATUS_EXT_FN6 =
"status_ext_fn6";
62constexpr const char *PROBE_NAME_STATUS_EXT_FN9 =
"status_ext_fn9";
63constexpr const char *PROBE_NAME_GET_INFO1 =
"get_info1";
64constexpr const char *PROBE_NAME_GET_INFO2 =
"get_info2";
65constexpr const char *PROBE_NAME_GENERAL_INFO3 =
"general_info3";
66constexpr const char *PROBE_NAME_PRIVATE2 =
"private2";
67constexpr const char *PROBE_NAME_PRIVATE2_SHORT =
"private2_short";
72constexpr uint8_t PROBE_EXT_FUNCTION_ID_06 = 0x06;
73constexpr uint8_t PROBE_EXT_FUNCTION_ID_09 = 0x09;
75constexpr uint8_t PROBE_PRIVATE_FN_SUB_FUNCTION_ID = 0x09;
79constexpr uint8_t PROBE_STATUS_EXT_SELECTOR = 0x80;
82constexpr uint8_t PROBE_SWEEP_MAX_INDICES = 16;
86constexpr uint32_t PROBE_SWEEP_DELAY_MS = 1000;
93using ProbeBuilderFn = bool (*)(
IoFrame &,
const uint8_t *,
const uint8_t *, uint8_t index,
bool low_power);
95bool build_probe_private_fn(
IoFrame &f,
const uint8_t *own,
const uint8_t *dst, uint8_t index,
bool low_power) {
98bool build_probe_private_fn_sub(
IoFrame &f,
const uint8_t *own,
const uint8_t *dst, uint8_t index,
bool low_power) {
101bool build_probe_status_ext(
IoFrame &f,
const uint8_t *own,
const uint8_t *dst, uint8_t index,
bool low_power) {
104bool build_probe_status_ext_fn6(
IoFrame &f,
const uint8_t *own,
const uint8_t *dst, uint8_t index,
bool low_power) {
107bool build_probe_status_ext_fn9(
IoFrame &f,
const uint8_t *own,
const uint8_t *dst, uint8_t index,
bool low_power) {
110bool build_probe_get_info1(
IoFrame &f,
const uint8_t *own,
const uint8_t *dst, uint8_t ,
bool low_power) {
113bool build_probe_get_info2(
IoFrame &f,
const uint8_t *own,
const uint8_t *dst, uint8_t ,
bool low_power) {
116bool build_probe_general_info3(
IoFrame &f,
const uint8_t *own,
const uint8_t *dst, uint8_t ,
bool low_power) {
119bool build_probe_private2_long(
IoFrame &f,
const uint8_t *own,
const uint8_t *dst, uint8_t index,
bool low_power) {
122bool build_probe_private2_short(
IoFrame &f,
const uint8_t *own,
const uint8_t *dst, uint8_t index,
bool low_power) {
127struct ProbeDescriptor {
131 ProbeBuilderFn builder;
139constexpr ProbeDescriptor PROBE_TABLE[] = {
140 {PROBE_NAME_PRIVATE_FN,
true, build_probe_private_fn},
141 {PROBE_NAME_PRIVATE_FN_SUB,
true, build_probe_private_fn_sub},
142 {PROBE_NAME_STATUS_EXT,
true, build_probe_status_ext},
143 {PROBE_NAME_STATUS_EXT_FN6,
true, build_probe_status_ext_fn6},
144 {PROBE_NAME_STATUS_EXT_FN9,
true, build_probe_status_ext_fn9},
145 {PROBE_NAME_GET_INFO1,
false, build_probe_get_info1},
146 {PROBE_NAME_GET_INFO2,
false, build_probe_get_info2},
147 {PROBE_NAME_GENERAL_INFO3,
false, build_probe_general_info3},
148 {PROBE_NAME_PRIVATE2,
true, build_probe_private2_long},
149 {PROBE_NAME_PRIVATE2_SHORT,
true, build_probe_private2_short},
151constexpr uint8_t PROBE_TABLE_SIZE =
sizeof(PROBE_TABLE) /
sizeof(PROBE_TABLE[0]);
155const ProbeDescriptor *find_probe_descriptor(
const std::string &probe) {
156 for (
const auto &descriptor : PROBE_TABLE) {
157 if (probe == descriptor.name)
165std::string unknown_probe_message(
const std::string &probe) {
167 for (uint8_t i = 0; i < PROBE_TABLE_SIZE; i++) {
169 names += (i + 1 == PROBE_TABLE_SIZE) ?
", or " :
", ";
170 names += PROBE_TABLE[i].name;
172 return "unknown probe \"" + probe +
"\" (expected " + names +
")";
180const ProbeDescriptor *resolve_probe_descriptor(
const std::string &probe,
ManagementActionResult &result) {
181 const ProbeDescriptor *descriptor = find_probe_descriptor(probe);
182 if (descriptor ==
nullptr)
183 result.message = unknown_probe_message(probe);
196constexpr uint8_t SCAN_MAX_REPLIES = 24;
204struct ScanResponder {
205 uint8_t src[NODE_ID_SIZE];
210 uint8_t manufacturer;
213 bool metadata_complete;
231constexpr uint32_t SCAN_CHANNELS[] = {FREQ_CH2, FREQ_CH1, FREQ_CH3};
232constexpr uint8_t SCAN_CHANNEL_COUNT =
sizeof(SCAN_CHANNELS) /
sizeof(SCAN_CHANNELS[0]);
241 uint8_t power_save_mode;
266constexpr RollCallPass ROLL_CALL_PASSES[] = {
274constexpr bool no_roll_call_pass_holds_request_channel() {
275 return std::ranges::all_of(ROLL_CALL_PASSES, [](
const RollCallPass &pass) {
279static_assert(no_roll_call_pass_holds_request_channel(),
280 "a broadcast roll-call pass has no pinned conversation to hold the request channel for");
286#if defined(USE_API_USER_DEFINED_ACTIONS) && defined(USE_API_CUSTOM_SERVICES)
298#if defined(ESPHOME_VERSION_CODE) && defined(VERSION_CODE)
299#if ESPHOME_VERSION_CODE >= VERSION_CODE(2026, 9, 0)
300#define IOHOME_USERSERVICE_ENCODE_TAKES_SCRATCH 1
317class ManagementServiceDescriptor :
public api::UserServiceDescriptor {
319 ManagementServiceDescriptor(
const char *name, std::vector<const char *> arg_names,
320 std::function<
void(
const api::ExecuteServiceRequest &)> callback)
321 : name_(name), key_(fnv1_hash(name)), arg_names_(std::move(arg_names)), callback_(std::move(callback)) {}
326#ifdef IOHOME_USERSERVICE_ENCODE_TAKES_SCRATCH
327 api::ListEntitiesServicesResponse encode_list_service_response(std::span<char> )
override {
329 api::ListEntitiesServicesResponse encode_list_service_response()
override {
331 api::ListEntitiesServicesResponse response;
332 response.name = StringRef(this->name_);
333 response.key = this->key_;
334 response.supports_response = api::enums::SUPPORTS_RESPONSE_NONE;
335 response.args.init(this->arg_names_.size());
336 for (
const char *arg_name : this->arg_names_) {
337 auto &arg = response.args.emplace_back();
338 arg.name = StringRef(arg_name);
339 arg.type = api::enums::SERVICE_ARG_TYPE_STRING;
344 bool execute_service(
const api::ExecuteServiceRequest &request)
override {
345 if (request.key != this->key_ || request.args.size() != this->arg_names_.size())
347 this->callback_(request);
351#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES
352 bool execute_service(
const api::ExecuteServiceRequest &request, uint32_t)
override {
353 return this->execute_service(request);
360 std::vector<const char *> arg_names_;
361 std::function<void(
const api::ExecuteServiceRequest &)> callback_;
364#undef IOHOME_USERSERVICE_ENCODE_TAKES_SCRATCH
373 std::transform(normalized.begin(), normalized.end(), normalized.begin(),
374 [](
unsigned char ch) { return static_cast<char>(std::toupper(ch)); });
378static std::string
bool_to_string(
bool value) {
return value ?
"true" :
"false"; }
384 std::array<char, RESULT_CODE_BUFFER_SIZE> buffer{};
385 std::snprintf(buffer.data(), buffer.size(),
"%02X", value);
386 return std::string(buffer.data());
408 result.
message =
"device returned an empty error response";
433 if (text.size() > 1 && text[0] ==
'0' && text[1] !=
'x' && text[1] !=
'X')
439 const uint32_t value = std::strtoul(text.c_str(), &end, 0);
440 if (end != text.c_str() + text.size())
442 if (errno == ERANGE || value > std::numeric_limits<uint8_t>::max())
444 out =
static_cast<uint8_t
>(value);
451 std::transform(normalized.begin(), normalized.end(), normalized.begin(),
452 [](
unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
465 for (
size_t i = 0; i < table_len; i++) {
466 if (token == table[i].token) {
467 out = table[i].
value;
479 const float parsed = std::strtof(text.c_str(), &end);
480 if (text.empty() || end != text.c_str() + text.size() || errno == ERANGE || !std::isfinite(parsed)) {
481 error =
"temperature must be a number in degrees Celsius";
485 std::array<char, HEATING_RANGE_MESSAGE_BUFFER_SIZE> buffer{};
486 std::snprintf(buffer.data(), buffer.size(),
"temperature must be between %.1f and %.1f",
488 error = buffer.data();
512 float &value_out, std::string &error) {
519 static constexpr HeatingNamedValue PRESENCE_VALUES[] = {{
"on", 1.0F}, {
"off", 0.0F}};
520 static constexpr HeatingNamedValue WINDOW_VALUES[] = {{
"open", 1.0F}, {
"close", 0.0F}};
525 if (fn ==
"power_on") {
529 if (fn ==
"midnight_sync") {
533 if (fn ==
"set_temperature") {
537 if (fn ==
"set_mode") {
541 error =
"mode must be one of auto, manual, prog, off";
544 if (fn ==
"set_presence") {
549 error =
"presence must be 'on' or 'off'";
552 if (fn ==
"set_window") {
556 error =
"window must be 'open' or 'close'";
570 system_key_(system_key),
574 initialized_(initialized),
578#if defined(USE_API_USER_DEFINED_ACTIONS) && defined(USE_API_CUSTOM_SERVICES)
579 if (api::global_api_server ==
nullptr) {
580 ESP_LOGW(
detail::TAG,
"Native API server not available, management actions will not be registered");
590 std::vector<const char *> arg_names;
591 bool diagnostic_only;
592 std::function<void(
const api::ExecuteServiceRequest &)> callback;
594 const ActionReg actions[] = {
595 {MANAGEMENT_ACTION_RENAME_DEVICE,
596 {
"device_id",
"new_name"},
598 [
this](
const api::ExecuteServiceRequest &r) {
601 {MANAGEMENT_ACTION_IDENTIFY_DEVICE,
604 [
this](
const api::ExecuteServiceRequest &r) { this->
api_identify_device(r.args[0].string_.str()); }},
605 {MANAGEMENT_ACTION_FORCE_OPEN_DEVICE,
608 [
this](
const api::ExecuteServiceRequest &r) { this->
api_force_open_device(r.args[0].string_.str()); }},
609 {MANAGEMENT_ACTION_SCAN_PAIRED_DEVICES,
613 {MANAGEMENT_ACTION_ONEWAY_SET_POSITION,
614 {
"controller_id",
"position"},
616 [
this](
const api::ExecuteServiceRequest &r) {
619 {MANAGEMENT_ACTION_ONEWAY_REMOVE_CONTROLLER,
623 {MANAGEMENT_ACTION_HEATING_CONTROL,
624 {
"device_id",
"function",
"value"},
626 [
this](
const api::ExecuteServiceRequest &r) {
627 this->
api_heating_control(r.args[0].string_.str(), r.args[1].string_.str(), r.args[2].string_.str());
629 {MANAGEMENT_ACTION_PROBE_DEVICE,
630 {
"device_id",
"probe",
"index"},
632 [
this](
const api::ExecuteServiceRequest &r) {
633 this->
api_probe_device(r.args[0].string_.str(), r.args[1].string_.str(), r.args[2].string_.str());
635 {MANAGEMENT_ACTION_PROBE_SWEEP,
636 {
"device_id",
"probe",
"first_index",
"last_index"},
638 [
this](
const api::ExecuteServiceRequest &r) {
639 this->
api_probe_sweep(r.args[0].string_.str(), r.args[1].string_.str(), r.args[2].string_.str(),
640 r.args[3].string_.str());
649 const bool probes_enabled = hub_->diagnostic_probes_enabled();
650 for (
const auto &action : actions) {
651 if (action.diagnostic_only && !probes_enabled)
653 api::global_api_server->register_user_service(
654 new detail::ManagementServiceDescriptor(action.name, action.arg_names, action.callback));
659IoDevice *ManagementActions::resolve_device_(
const char *action,
const std::string &device_id,
664 if (!*initialized_) {
665 result.
message =
"hub is not initialized";
669 uint8_t parsed_device_id[NODE_ID_SIZE]{};
670 if (!
hex_to_bytes(normalized_device_id, parsed_device_id, NODE_ID_SIZE)) {
671 result.
message =
"device ID must be exactly 6 hexadecimal characters";
675 auto *dev = registry_.get(normalized_device_id);
676 if (dev ==
nullptr) {
677 result.
message =
"device is not registered on this hub";
684bool ManagementActions::send_authenticated_request_(
const IoFrame &request,
IoFrame &response,
const char *action_verb,
686 const ExchangeOutcome outcome = engine_.send_and_receive(request, response, FREQ_CH2);
689 engine_.log_debug(result.device_id.c_str());
694 ? std::string(
"device accepted the ") + action_verb +
" request but sent no response"
695 : std::string(
"no valid response to ") + action_verb +
" request";
706 const bool has_device = !result.
device_id.empty();
707 std::string prefix =
"Management action " + result.
action;
709 prefix +=
" for device " + result.
device_id;
710 prefix += result.
success ?
": " :
" failed: ";
713 if (!hub_->is_connected())
716 std::map<std::string, std::string> event_data{{
"action", result.
action},
745 hub_->fire_homeassistant_event(MANAGEMENT_RESULT_EVENT, event_data);
750 auto *dev = resolve_device_(MANAGEMENT_ACTION_RENAME_DEVICE, device_id, result);
754 uint8_t payload[DEVICE_NAME_WRITE_PAYLOAD_SIZE];
755 std::string normalized_name;
764 if (!
create_set_name(request, node_id_, dev->node_id, dev->low_power, payload)) {
765 result.
message =
"failed to build rename request";
770 if (!send_authenticated_request_(request, response,
"rename", result))
773 if (response.
cmd == CMD_ERROR_RESP) {
781 if (response.
cmd != CMD_SET_NAME_RESP) {
782 std::array<char, UNEXPECTED_RESPONSE_MESSAGE_BUFFER_SIZE> buffer{};
783 std::snprintf(buffer.data(), buffer.size(),
"unexpected rename response 0x%02X", response.
cmd);
784 result.
message = buffer.data();
789 result.
message =
"rename acknowledged by device";
791 if (!hub_->request_device_name(result.
device_id)) {
792 result.
message =
"rename acknowledged but verification readback failed";
796 auto *updated_device = registry_.get(result.
device_id);
797 if (updated_device !=
nullptr)
802 result.
message =
"rename verified by device readback";
806 result.
message =
"rename acknowledged but readback did not match the requested name";
818 auto *dev = resolve_device_(MANAGEMENT_ACTION_IDENTIFY_DEVICE, device_id, result);
823 if (!
create_identify(request, node_id_, dev->node_id, dev->low_power)) {
824 result.
message =
"failed to build identify request";
829 if (!send_authenticated_request_(request, response,
"identify", result))
832 if (response.
cmd == CMD_ERROR_RESP) {
840 result.
message =
"identify triggered (device returned an empty error response)";
848 result.
message =
"identify acknowledged by device";
858 auto *dev = resolve_device_(MANAGEMENT_ACTION_FORCE_OPEN_DEVICE, device_id, result);
865 result.
message =
"device does not accept cover commands";
871 "force open queued (elevated-priority open; wind/rain lock bypass unconfirmed; movement result arrives via "
877 const std::string &value) {
882 const std::string &value) {
884 auto *dev = resolve_device_(MANAGEMENT_ACTION_HEATING_CONTROL, device_id, result);
889 float encoded_value = 0.0F;
890 std::string parse_error;
898 result.
message =
"device is not a climate device";
904 const uint8_t prior_result_code = dev->last_result_code;
905 const uint32_t prior_result_at_ms = dev->last_result_at_ms;
909 if (!hub_->send_heating_command(result.
device_id, fn, encoded_value)) {
911 if (
const auto *updated = registry_.get(result.
device_id);
912 updated !=
nullptr && updated->last_result_code != 0 &&
913 (updated->last_result_code != prior_result_code || updated->last_result_at_ms != prior_result_at_ms)) {
936 if (hub_->oneway_controllers().get(controller_id) ==
nullptr) {
937 result.
message =
"no oneway_controllers identity with that id";
946 if (trimmed.empty() || trimmed.find_first_not_of(
"0123456789") != std::string::npos) {
947 result.
message =
"position must be a whole number between 0 and 100";
951 const unsigned long parsed = strtoul(trimmed.c_str(),
nullptr, 10);
952 if (parsed > ONEWAY_POSITION_FULLY_CLOSED) {
953 result.
message =
"position must be between 0 and 100";
958 hub_->send_oneway_position(controller_id,
static_cast<uint8_t
>(parsed));
962 result.
message =
"1W position command queued";
970 if (hub_->oneway_controllers().get(controller_id) ==
nullptr) {
971 result.
message =
"no oneway_controllers identity with that id";
976 hub_->send_oneway_unenroll(controller_id);
980 result.
message =
"1W remove-controller (0x39) queued";
997 const bool known = registered !=
nullptr;
998 std::string line =
" " + device_id +
": " +
device_type_name(responder.type) +
999 " subtype=" + std::to_string(responder.subtype) +
" rssi=" + std::to_string(responder.rssi_dbm) +
1001 uint8_t power_save = 0;
1002 if (responder.has_extended) {
1005 line += std::string(
" manufacturer=") +
manufacturer_name(responder.manufacturer) +
1008 line += known ?
" [known]\n" :
" [unknown]\n";
1016 if (responder.has_extended) {
1017 if (power_save == POWER_SAVE_LOW_POWER && !registered->
low_power) {
1019 " but its YAML has no low_power: true; add it or directed commands may miss it while it "
1021 }
else if (power_save == POWER_SAVE_ALWAYS_ALIVE && registered->
low_power) {
1023 " but its YAML sets low_power: true; remove it, the " + std::to_string(LONG_PREAMBLE) +
1024 "-byte wake-up preamble can stop an always-alive receiver answering\n";
1030 const bool low_power = responder.has_extended && power_save == POWER_SAVE_LOW_POWER;
1032 responder.metadata_complete, responder.inverted, low_power);
1033 if (!snippet.empty())
1034 return line +
" Paste this into your YAML to register it:\n" + snippet;
1036 return line +
" no ready-to-paste YAML: no ESPHome platform for io_device_type: " +
1063 const IoFrame &frame, int16_t rssi_dbm,
const ScanResponder *&entry) {
1064 for (uint8_t i = 0; i < count; i++) {
1065 if (memcmp(responders[i].src, frame.
src, NODE_ID_SIZE) == 0) {
1066 entry = &responders[i];
1070 if (count >= capacity) {
1080 std::string unused_device_id;
1083 ScanResponder &new_entry = responders[count++];
1084 memcpy(new_entry.src, frame.
src, NODE_ID_SIZE);
1085 new_entry.type = device.
type;
1086 new_entry.subtype = device.
subtype;
1087 new_entry.inverted = device.
inverted;
1088 new_entry.rssi_dbm = rssi_dbm;
1090 new_entry.flags = info.
flags;
1107 for (
const RollCallPass &pass : ROLL_CALL_PASSES) {
1131 const ScanResponder *entry =
nullptr;
1137 const char *power_save = (entry !=
nullptr && entry->has_extended)
1141 "Roll-call reply src=%s pass=%s attempt=%u tx=%" PRIu32
" rx=%" PRIu32
" +%" PRIu32
1142 "ms rssi=%d power_save=%s",
1162 const RollCallPass &pass, uint32_t tx_freq_hz, uint8_t attempt,
1163 uint8_t total_attempts,
ScanState &state) {
1165 const bool low_power = pass.power_save_mode == POWER_SAVE_LOW_POWER;
1166 if (!
create_discovery_request(request, node_id, CMD_DISCOVER_SPE_REQ, BROADCAST_DISCOVER, low_power, pass.ack_capable,
1167 false, 0, system_key)) {
1171 const uint8_t before = state.
count;
1176 record_roll_call_reply(state, ctx, frame, info);
1178 pass.listen_policy);
1184 const uint8_t new_count = state.
count - before;
1186 "Roll-call attempt %u/%u (pass=%s, ctrl1=0x%02X, tx %" PRIu32
" Hz, %u ms window): %u repl%s heard, %u new",
1198 std::string known_body;
1199 std::string unknown_body;
1200 uint8_t unknown_count = 0;
1201 for (uint8_t i = 0; i < state.
count; i++) {
1203 const IoDevice *registered = registry.
get(device_id);
1204 if (registered !=
nullptr) {
1213 if (!known_body.empty())
1214 body +=
"Known:\n" + known_body;
1215 if (!unknown_body.empty())
1216 body +=
"Unknown:\n" + unknown_body;
1218 std::string message =
"Roll-call: " + std::to_string(state.
count) +
" device" + (state.
count == 1 ?
"" :
"s") +
1219 " detected (" + std::to_string(state.
count - unknown_count) +
" known, " +
1220 std::to_string(unknown_count) +
" unknown)\n";
1226 for (
const RollCallPass &pass : ROLL_CALL_PASSES) {
1236 message +=
"NOTE: more than " + std::to_string(SCAN_MAX_REPLIES) +
1237 " devices answered; the list below is truncated. Re-run to see whether other devices "
1238 "appear, and raise SCAN_MAX_REPLIES if this install really is larger.\n";
1247 if (!*initialized_) {
1248 result.
message =
"hub is not initialized";
1261 uint8_t attempt = 0;
1262 for (
const RollCallPass &pass : ROLL_CALL_PASSES) {
1265 for (
const uint32_t tx_freq_hz : SCAN_CHANNELS) {
1267 if (
run_roll_call_attempt(engine_, node_id_, system_key_, *tuning_, pass, tx_freq_hz, attempt, total_attempts,
1269 result.
message =
"failed to build roll-call request";
1281 const std::string &index) {
1286 const std::string &index) {
1291 auto *dev = resolve_device_(MANAGEMENT_ACTION_PROBE_DEVICE, device_id, resolve_result);
1292 if (dev ==
nullptr) {
1295 return resolve_result;
1302 if (!hub_->diagnostic_probes_enabled()) {
1303 result.
message =
"diagnostic probes are not enabled; set diagnostic_probes: true in YAML";
1310 result.
message =
"device is moving; refusing to probe mid-transaction";
1315 const ProbeDescriptor *descriptor = resolve_probe_descriptor(probe, result);
1316 if (descriptor ==
nullptr)
1319 uint8_t index_byte = 0;
1321 result.
message =
"index must be a decimal or 0x-prefixed byte value (0-255)";
1326 if (!descriptor->builder(request, node_id_, dev->node_id, index_byte, dev->low_power)) {
1327 result.
message =
"failed to build probe request";
1335 const ExchangeOutcome outcome = engine_.send_and_receive(request, response, FREQ_CH2);
1337 engine_.log_debug(result.
device_id.c_str());
1339 ?
"device accepted the probe request but sent no response"
1340 :
"no reply after " + std::to_string(EXCHANGE_RETRY_COUNT) +
1341 " attempts (device asleep, unreachable, or silently ignoring this opcode)";
1349 uint8_t raw[FRAME_MAX_SIZE] = {0};
1350 const uint8_t raw_len =
serialize(response, raw,
sizeof(raw));
1380 const std::string &first_index,
const std::string &last_index) {
1385 const std::string &first_index,
const std::string &last_index) {
1395 if (resolve_device_(MANAGEMENT_ACTION_PROBE_SWEEP, device_id, resolve_result) ==
nullptr) {
1399 const ProbeDescriptor *descriptor = resolve_probe_descriptor(probe, result);
1400 if (descriptor ==
nullptr)
1402 if (!descriptor->needs_index) {
1403 result.
message =
"probe \"" + probe +
"\" takes no index; use probe_device";
1410 result.
message =
"first_index/last_index must be decimal or 0x-prefixed byte values (0-255)";
1414 result.
message =
"last_index must be >= first_index";
1417 const uint32_t span =
static_cast<uint32_t
>(last) - first + 1;
1418 if (span > PROBE_SWEEP_MAX_INDICES) {
1420 "sweep range too wide: " + std::to_string(span) +
" indices, max " + std::to_string(PROBE_SWEEP_MAX_INDICES);
1425 uint32_t answered_count = 0;
1426 bool stopped_early =
false;
1427 for (uint32_t idx = first; idx <= last; idx++) {
1430 delay(PROBE_SWEEP_DELAY_MS);
1432 const std::string index_str = std::to_string(idx);
1434 report +=
"index=0x" +
format_hex_byte(
static_cast<uint8_t
>(idx)) +
": ";
1439 report += step.
message +
"\n";
1446 report +=
"(stopping sweep: " + step.
message +
")\n";
1447 stopped_early =
true;
1456 result.
success = !stopped_early;
1462 "]: " + std::to_string(answered_count) +
" answered\n" + report;
Owns the per-hub device table, update callbacks, and linked-remote associations.
IoDevice * get(const std::string &device_id)
Retrieve a registered device by ID.
uint8_t collect_broadcast_responses(const IoFrame &request, uint32_t freq, uint8_t expected_cmd, uint32_t window_ms, const BroadcastReplyHandler &on_reply, ListenPolicy policy=ListenPolicy::ROTATE_SKIPPING_REQUEST)
Transmit request once and hand every matching broadcast reply to on_reply within window_ms.
The main IO-Homecontrol component.
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 an already-paired device and report the raw reply.
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 ®istry, 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.
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_device() call per index, in one user gesture.
IO-Homecontrol ESPHome component — protocol controller.
Hub-layer log tag and log/format helpers shared by the hub and its collaborators.
Hub-level management operations exposed as Home Assistant actions.
constexpr const char * TAG
Shared log tag for hub-level messages.
void log_multiline_result(const char *tag, bool is_warning, const std::string &prefix, const std::string &message)
Log prefix followed by message, one line per log call rather than one call for the whole (possibly mu...
void log_component_capture(const RadioDriver *radio, const char *stage, const uint8_t *buf, uint8_t len, const IoFrame *frame=nullptr)
Log a frame at the "io_capture" tag with structured fields.
bool create_identify(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power)
Build an authenticated device-identify request (0x1E).
const char * manufacturer_name(uint8_t id)
Get a human-readable manufacturer name from the protocol manufacturer byte.
static bool parse_heating_temperature(const std::string &value, float &value_out, std::string &error)
Parse set_temperature's value into degrees Celsius, range-checked.
static bool parse_probe_index(const std::string &text, uint8_t &out)
Parse a probe_device()/probe_sweep() index argument into a byte.
uint8_t discovery_power_save_mode(uint8_t flags)
Extract the power save mode field from a discovery response's Multi Information Byte.
std::string format_device_type_for_yaml(DeviceType type)
Build the YAML value for a device's io_device_type key.
bool create_set_name(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, const uint8_t payload[DEVICE_NAME_WRITE_PAYLOAD_SIZE])
Build an authenticated set-name request (0x52) using a fixed zero-padded Latin-1 payload.
ScanAddResult
Outcome of add_scan_responder(), so callers can tell a harmless repeat from real loss.
@ ADDED
New responder recorded.
@ DUPLICATE
Already recorded from an earlier reply; nothing changed.
@ FULL
Dropped: SCAN_MAX_REPLIES distinct responders already recorded.
DeviceType
Device type identifiers reported by IO‑Homecontrol products.
static bool match_heating_named_value(const std::string &token, const HeatingNamedValue *table, size_t table_len, float &out)
Match token against table; on a hit set out and return true.
HeatingFunction
Heating functions, one per user-pressable radiator button in the reference.
@ MIDNIGHT_SYNC
Reads register 0x0130 — the comfort/eco/auto setpoint block (iohcCozyDevice2W.cpp:248; AtlanticThermo...
@ POWER_ON
Wake / retrieve paired devices (iohcCozyDevice2W.cpp:105).
@ SET_PRESENCE
Presence / absence (iohcCozyDevice2W.cpp:195).
@ SET_TEMPERATURE
Setpoint in degrees Celsius (iohcCozyDevice2W.cpp:125).
@ SET_MODE
Operating mode (iohcCozyDevice2W.cpp:155).
@ SET_WINDOW
Open-window / frost-protection (iohcCozyDevice2W.cpp:218).
const char * att_class_name(uint8_t att_class)
Get a human-readable turnaround time string for an ATT class value.
bool create_private2_read(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t modifier, bool long_form, bool low_power)
Build a CMD_PRIVATE2 (0x0C) request in either of the two field-observed shapes.
const char * power_save_mode_name(uint8_t mode)
Get a human-readable power save mode name.
static ManagementActionResult make_management_result(const std::string &action, const std::string &device_id)
@ FORCE_OPEN
Move to fully open at elevated priority; intended to bypass soft locks and environmental limits (conf...
static std::string bool_to_string(bool value)
bool device_supports_climate_control(DeviceType type)
Does this device type support 2W climate/heating control (CMD_WRITE_PRIVATE 0x20)?
constexpr float HEATING_TEMP_MAX_C
Highest setpoint this codec will encode.
const char * command_name(uint8_t cmd)
Get a human-readable name for any IO-Homecontrol command ID.
const char * device_type_name(DeviceType type)
Convert a DeviceType to a lowercase string identifier.
bool create_get_info1(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power)
Build a CMD_GET_INFO1 (0x54) request. No payload. See proto_commands.h for the evidence note.
ScanPowerClasses
Which power classes scan_paired_devices() calls.
@ BOTH
Low-power pass, then always-alive pass (default).
const char * heating_function_name(HeatingFunction fn)
Stable lowercase name for a heating function ("power_on", "set_temperature", ...).
bool scan_power_classes_include(ScanPowerClasses selection, uint8_t power_save_mode)
Whether selection calls the roll-call pass for power_save_mode.
const char * command_result_description(uint8_t result)
Return a human-readable explanation for a CMD_ERROR_RESP result code.
static uint8_t count_enabled_passes(ScanPowerClasses selection)
How many of ROLL_CALL_PASSES selection actually calls.
bool create_general_info3(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power)
Build a CMD_GET_GENERAL_INFO3 (0x58) request.
bool create_get_info2(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power)
Build a CMD_GET_INFO2 (0x56) request. No payload. See proto_commands.h for the evidence note.
void render_frame_hex_redacted(const uint8_t *data, uint8_t len, char *out, size_t out_size)
Render a frame's bytes as spaced hex text, masking the payload when the command carries key material ...
const char * command_result_name(uint8_t result)
Return a stable symbolic name for a CMD_ERROR_RESP result code.
ExchangeOutcome
Authenticated exchange engine — outbound and inbound protocol flows.
@ SUCCESS_WITH_RESPONSE
Device replied; the caller's response frame is populated.
@ SUCCESS_UNCONFIRMED
Device authenticated the request — so it received and accepted it — but sent no final response.
static std::string normalize_lower_argument(const std::string &value)
Lowercase + ASCII-trim a native-API string argument.
constexpr size_t FRAME_LOG_HEX_BUFFER_SIZE
Fits a full 32-byte frame rendered as spaced hex text.
DeviceNameValidationError
Validation result for outbound device-name writes.
@ NONE
Name is valid and encodable.
constexpr float HEATING_TEMP_MIN_C
Lowest setpoint this codec will encode.
RollCallAttemptResult
Outcome of run_roll_call_attempt(), so the sweep loop can surface a build failure the same way scan_p...
@ BUILD_FAILED
create_discovery_request() failed; the caller should abort the whole scan.
@ OK
Request built and transmitted (with or without replies).
ListenPolicy
Which channels a listen covers.
@ ROTATE_SKIPPING_REQUEST
The two channels that are not the request channel.
@ ROTATE_ALL_CHANNELS
CH1->CH2->CH3->CH1.
@ HOLD_REQUEST_CHANNEL
Never retunes, never slices. Unicast replies.
std::string trim_ascii_whitespace(const std::string &value)
Trim leading and trailing ASCII whitespace from a string.
std::string build_device_yaml_snippet(DeviceType type, uint8_t subtype, const std::string &device_id, bool metadata_complete, bool inverted, bool low_power)
Build the ready-to-paste YAML block describing a device, for both a fully-decoded device and one whos...
bool effective_is_stopped(const IoDevice &dev)
Whether a consumer should treat the device as at rest, prediction first.
DeviceNameValidationError encode_device_name_payload(const std::string &name, uint8_t payload[DEVICE_NAME_WRITE_PAYLOAD_SIZE], std::string &normalized_name)
Validate and encode a user-supplied UTF-8 device name into the fixed Latin-1 write payload.
@ OFF
Off / standby; note the value is 0x04, not 0x03 (that is the reference's commented-out "special" mode...
@ PROG
Program mode; device runs its own stored weekly schedule (iohcCozyDevice2W.cpp:160).
@ MANUAL
Manual mode; follows the last SET_TEMPERATURE setpoint (iohcCozyDevice2W.cpp:159).
@ AUTO
Automatic mode; device manages the setpoint itself (iohcCozyDevice2W.cpp:158).
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.
bool create_private_function(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t function_id, uint8_t sub_index)
Build a CMD_PRIVATE (0x03) request for an arbitrary function ID.
bool create_discovery_request(IoFrame &f, const uint8_t *own, uint8_t command, const uint8_t *dst, bool low_power, bool ack_capable, bool payload_enabled, uint8_t payload, const uint8_t *system_key)
Build a configurable discovery request command (0x28, 0x2A, or 0x2E).
static RollCallAttemptResult run_roll_call_attempt(ExchangeEngine &engine, const uint8_t *node_id, const uint8_t *system_key, const TuningConfig &tuning, const RollCallPass &pass, uint32_t tx_freq_hz, uint8_t attempt, uint8_t total_attempts, ScanState &state)
Transmit one roll-call attempt and collect its replies into state.
uint8_t discovery_att_class(uint8_t flags)
Extract the ATT class field from a discovery response's Multi Information Byte.
bool create_get_status_extended(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t selector, uint8_t block, uint8_t function_id)
Build an extended CMD_PRIVATE (0x03) request with a selector/block pair — the shape real hubs use for...
static void record_roll_call_reply(ScanState &state, const RollCallAttemptContext &ctx, const IoFrame &frame, const ExchangeEngine::BroadcastReplyInfo &info)
Record one accepted roll-call reply into the scan state, and log it.
static std::string normalize_device_id_argument(const std::string &device_id)
static std::string format_hex_byte(uint8_t value)
Format a byte as two uppercase hex digits, no prefix.
DiscoveryResponseInfo decode_discovery_response(const IoFrame &frame, IoDevice &device, std::string &device_id)
Decode a discovery-response payload (CMD_DISCOVER_RESP 0x29 or CMD_DISCOVER_SPE_RESP 0x2B — both carr...
const char * device_name_validation_error_description(DeviceNameValidationError error)
Return a human-readable explanation for a device-name validation result.
static ScanAddResult add_scan_responder(ScanResponder *responders, uint8_t &count, uint8_t capacity, const IoFrame &frame, int16_t rssi_dbm, const ScanResponder *&entry)
Record a responder unless its address is already present.
static bool apply_error_response(const IoFrame &response, ManagementActionResult &result)
Decode a CMD_ERROR_RESP frame's result code into result.
bool hex_to_bytes(const std::string &hex, uint8_t *out, uint8_t len)
Convert a hex string (e.g., "123ABC") to a byte array.
std::string scan_power_classes_to_string(ScanPowerClasses value)
Format a ScanPowerClasses value for YAML/logs.
static bool parse_heating_arguments(const std::string &function, const std::string &value, HeatingFunction &fn_out, float &value_out, std::string &error)
Parse the (function, value) argument pair of the heating_control action.
uint8_t serialize(const IoFrame &f, uint8_t *buf, uint8_t buf_size)
Serialize a parsed frame into a wire buffer (without CRC).
static std::string format_scan_reply_line(const ScanResponder &responder, const std::string &device_id, const IoDevice *registered)
Format one roll-call responder's report line(s).
static std::string build_scan_report(const ScanState &state, DeviceRegistry ®istry, ScanPowerClasses selection)
Build the final report: known/unknown grouping, header, the selection NOTE, and the truncation NOTE.
Command builders for the IO‑Homecontrol protocol.
Extended discovery-response fields (manufacturer, Multi Information Byte, backbone address,...
bool has_extended
data_len >= DISCOVERY_RESP_FULL_SIZE (mfr/flags/timestamp present).
bool metadata_complete
data_len >= DEVICE_METADATA_SIZE (type/subtype present).
uint8_t manufacturer
Raw manufacturer ID; name via manufacturer_name().
uint8_t flags
Multi Information Byte; decode with DISCOVERY_FLAGS_* masks.
Per-reply facts collect_broadcast_responses() hands its caller alongside the frame.
uint32_t rx_freq_hz
Channel the reply was received on (RadioRxPacket::freq_hz).
int16_t rssi_dbm
RSSI of this reply (from the radio's last capture).
uint32_t after_tx_ms
Milliseconds from the request's transmit completing to this reply's delivery.
A value token that maps to a fixed float (used by set_mode / set_presence / set_window).
Runtime state of a paired IO‑Homecontrol device.
bool inverted
True if open/close positions are swapped (e.g., horizontal awning).
bool low_power
YAML-declared: this target is a low-power / duty-cycled receiver, so directed frames set CTRL1_LOW_PO...
uint8_t subtype
Device subtype (manufacturer‑specific).
DeviceType type
Device type (shutter, awning, etc.).
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
uint8_t data[FRAME_MAX_DATA_SIZE]
Command parameters (0–23 bytes). Never includes mac.
uint8_t src[NODE_ID_SIZE]
Source node ID (3 bytes).
uint8_t data_len
Actual length of data.
uint8_t ctrl1
Control byte 1: low power, beacon, etc.
Result of a hub-level management action such as rename.
bool verified
Whether a follow-up readback confirmed the applied state.
std::string device_id
Target IO-homecontrol device ID.
bool has_response_cmd
True when response_cmd contains a probe reply's command byte.
std::string probe_index
Requested index argument, as received (before parsing).
uint8_t result_code
Optional CMD_ERROR_RESP result byte.
std::string action
Action name, e.g. "rename_device".
uint8_t response_cmd
Probe reply's command byte (distinct from a CMD_ERROR_RESP result_code above, which is a different by...
bool terminal_refusal
True when probe_device() failed for a reason that will recur identically for every remaining index in...
std::string applied_name
Verified cached UTF-8 name after a readback, when available.
std::string probe_name
Probe name for probe_device()/probe_sweep(), e.g. "private_fn".
std::string response_hex
Probe reply's full raw wire hex, for pasting into scripts/corpus/ingest.py.
bool success
Whether the requested management action succeeded.
bool has_result_code
True when result_code contains a decoded CMD_ERROR_RESP byte.
std::string message
Human-readable outcome summary.
std::string requested_name
Requested normalized UTF-8 name for rename actions.
Per-attempt facts a roll-call reply's DEBUG line needs, kept in one small struct so the reply lambda'...
uint32_t tx_freq_hz
Channel this attempt transmitted the request on.
uint8_t attempt
Global attempt number (1-based, across all passes).
const RollCallPass * pass
Accumulated state for one scan_paired_devices() call, threaded through every attempt.
ScanResponder responders[SCAN_MAX_REPLIES]
All runtime tunable parameters for pairing and radio diagnostics.
uint16_t pairing_discovery_wait_ms
Total wait window after sending discovery commands.