20#include "esphome/core/application.h"
21#include "esphome/core/hal.h"
23#if defined(USE_API_USER_DEFINED_ACTIONS) && defined(USE_API_CUSTOM_SERVICES)
24#include "esphome/core/helpers.h"
47constexpr const char *MANAGEMENT_ACTION_RENAME_DEVICE =
"rename_device";
48constexpr const char *MANAGEMENT_ACTION_IDENTIFY_DEVICE =
"identify_device";
49constexpr const char *MANAGEMENT_ACTION_FORCE_OPEN_DEVICE =
"force_open_device";
50constexpr const char *MANAGEMENT_ACTION_SCAN_PAIRED_DEVICES =
"scan_paired_devices";
51constexpr const char *MANAGEMENT_ACTION_ONEWAY_SET_POSITION =
"oneway_set_position";
52constexpr const char *MANAGEMENT_ACTION_ONEWAY_REMOVE_CONTROLLER =
"oneway_remove_controller";
53constexpr const char *MANAGEMENT_ACTION_PROBE_DEVICE =
"probe_device";
54constexpr const char *MANAGEMENT_ACTION_PROBE_SWEEP =
"probe_sweep";
55constexpr const char *MANAGEMENT_ACTION_HEATING_CONTROL =
"heating_control";
56constexpr const char *MANAGEMENT_RESULT_EVENT =
"esphome.home_io_control_action_result";
57constexpr size_t RESULT_CODE_BUFFER_SIZE = 5;
58constexpr size_t UNEXPECTED_RESPONSE_MESSAGE_BUFFER_SIZE = 64;
59constexpr size_t HEATING_RANGE_MESSAGE_BUFFER_SIZE = 64;
62constexpr const char *PROBE_NAME_PRIVATE_FN =
"private_fn";
63constexpr const char *PROBE_NAME_PRIVATE_FN_SUB =
"private_fn_sub";
64constexpr const char *PROBE_NAME_STATUS_EXT =
"status_ext";
65constexpr const char *PROBE_NAME_STATUS_EXT_FN6 =
"status_ext_fn6";
66constexpr const char *PROBE_NAME_STATUS_EXT_FN9 =
"status_ext_fn9";
67constexpr const char *PROBE_NAME_GET_INFO1 =
"get_info1";
68constexpr const char *PROBE_NAME_GET_INFO2 =
"get_info2";
69constexpr const char *PROBE_NAME_GENERAL_INFO3 =
"general_info3";
70constexpr const char *PROBE_NAME_PRIVATE2 =
"private2";
71constexpr const char *PROBE_NAME_PRIVATE2_SHORT =
"private2_short";
72constexpr const char *PROBE_NAME_STATUS_MP_FP =
"status_mp_fp";
73constexpr const char *PROBE_NAME_LIMITATION =
77constexpr uint8_t PROBE_LIMITATION_MAX_INDEX = 1;
78constexpr uint8_t PROBE_LIMITATION_INDEX_MINIMUM = 0;
83constexpr uint8_t PROBE_EXT_FUNCTION_ID_06 = 0x06;
84constexpr uint8_t PROBE_EXT_FUNCTION_ID_09 = 0x09;
86constexpr uint8_t PROBE_PRIVATE_FN_SUB_FUNCTION_ID = 0x09;
90constexpr uint8_t PROBE_STATUS_EXT_SELECTOR = 0x80;
93constexpr uint8_t PROBE_SWEEP_MAX_INDICES = 16;
97constexpr uint32_t PROBE_SWEEP_DELAY_MS = 1000;
104using ProbeBuilderFn = bool (*)(
IoFrame &,
const uint8_t *,
const uint8_t *, uint8_t index,
bool low_power);
106bool build_probe_private_fn(
IoFrame &f,
const uint8_t *own,
const uint8_t *dst, uint8_t index,
bool low_power) {
109bool build_probe_private_fn_sub(
IoFrame &f,
const uint8_t *own,
const uint8_t *dst, uint8_t index,
bool low_power) {
112bool build_probe_status_ext(
IoFrame &f,
const uint8_t *own,
const uint8_t *dst, uint8_t index,
bool low_power) {
115bool build_probe_status_ext_fn6(
IoFrame &f,
const uint8_t *own,
const uint8_t *dst, uint8_t index,
bool low_power) {
118bool build_probe_status_ext_fn9(
IoFrame &f,
const uint8_t *own,
const uint8_t *dst, uint8_t index,
bool low_power) {
121bool build_probe_get_info1(
IoFrame &f,
const uint8_t *own,
const uint8_t *dst, uint8_t ,
bool low_power) {
124bool build_probe_get_info2(
IoFrame &f,
const uint8_t *own,
const uint8_t *dst, uint8_t ,
bool low_power) {
127bool build_probe_general_info3(
IoFrame &f,
const uint8_t *own,
const uint8_t *dst, uint8_t ,
bool low_power) {
130bool build_probe_private2_long(
IoFrame &f,
const uint8_t *own,
const uint8_t *dst, uint8_t index,
bool low_power) {
133bool build_probe_private2_short(
IoFrame &f,
const uint8_t *own,
const uint8_t *dst, uint8_t index,
bool low_power) {
136bool build_probe_status_mp_fp(
IoFrame &f,
const uint8_t *own,
const uint8_t *dst, uint8_t ,
bool low_power) {
140bool build_probe_limitation(
IoFrame &f,
const uint8_t *own,
const uint8_t *dst, uint8_t index,
bool low_power) {
141 if (index > PROBE_LIMITATION_MAX_INDEX) {
150struct ProbeDescriptor {
152 ProbeBuilderFn builder;
153 const char *index_help;
164constexpr ProbeDescriptor PROBE_TABLE[] = {
165 {PROBE_NAME_PRIVATE_FN, build_probe_private_fn,
nullptr,
true, UINT8_MAX},
166 {PROBE_NAME_PRIVATE_FN_SUB, build_probe_private_fn_sub,
nullptr,
true, UINT8_MAX},
167 {PROBE_NAME_STATUS_EXT, build_probe_status_ext,
nullptr,
true, UINT8_MAX},
168 {PROBE_NAME_STATUS_EXT_FN6, build_probe_status_ext_fn6,
nullptr,
true, UINT8_MAX},
169 {PROBE_NAME_STATUS_EXT_FN9, build_probe_status_ext_fn9,
nullptr,
true, UINT8_MAX},
170 {PROBE_NAME_GET_INFO1, build_probe_get_info1,
nullptr,
false, UINT8_MAX},
171 {PROBE_NAME_GET_INFO2, build_probe_get_info2,
nullptr,
false, UINT8_MAX},
172 {PROBE_NAME_GENERAL_INFO3, build_probe_general_info3,
nullptr,
false, UINT8_MAX},
173 {PROBE_NAME_PRIVATE2, build_probe_private2_long,
nullptr,
true, UINT8_MAX},
174 {PROBE_NAME_PRIVATE2_SHORT, build_probe_private2_short,
nullptr,
true, UINT8_MAX},
175 {PROBE_NAME_STATUS_MP_FP, build_probe_status_mp_fp,
nullptr,
false, UINT8_MAX},
176 {PROBE_NAME_LIMITATION, build_probe_limitation,
"index 0 (minimum) or 1 (maximum)",
true,
177 PROBE_LIMITATION_MAX_INDEX},
179constexpr uint8_t PROBE_TABLE_SIZE =
sizeof(PROBE_TABLE) /
sizeof(PROBE_TABLE[0]);
183const ProbeDescriptor *find_probe_descriptor(
const std::string &probe) {
184 for (
const auto &descriptor : PROBE_TABLE) {
185 if (probe == descriptor.name)
193std::string unknown_probe_message(
const std::string &probe) {
195 for (uint8_t i = 0; i < PROBE_TABLE_SIZE; i++) {
197 names += (i + 1 == PROBE_TABLE_SIZE) ?
", or " :
", ";
198 names += PROBE_TABLE[i].name;
200 return "unknown probe \"" + probe +
"\" (expected " + names +
")";
204std::string index_out_of_range_message(
const ProbeDescriptor &descriptor) {
205 return "probe \"" + std::string(descriptor.name) +
"\" takes " + descriptor.index_help;
213const ProbeDescriptor *resolve_probe_descriptor(
const std::string &probe,
ManagementActionResult &result) {
214 const ProbeDescriptor *descriptor = find_probe_descriptor(probe);
215 if (descriptor ==
nullptr)
216 result.message = unknown_probe_message(probe);
224#if defined(USE_API_USER_DEFINED_ACTIONS) && defined(USE_API_CUSTOM_SERVICES)
229template<
typename Base>
230concept EncodeTakesScratch =
requires(Base &base) { base.encode_list_service_response(std::span<char>{}); };
237template<
bool TakesScratch,
typename Base = api::UserServiceDescriptor>
class EncodeListAdapter :
public Base {
239 api::ListEntitiesServicesResponse encode_list_service_response()
override {
return this->build_list_response(); }
242 virtual api::ListEntitiesServicesResponse build_list_response() = 0;
245template<
typename Base>
class EncodeListAdapter<true, Base> :
public Base {
247 api::ListEntitiesServicesResponse encode_list_service_response(std::span<char> )
override {
248 return this->build_list_response();
252 virtual api::ListEntitiesServicesResponse build_list_response() = 0;
268class ManagementServiceDescriptor :
public EncodeListAdapter<EncodeTakesScratch<api::UserServiceDescriptor>> {
270 ManagementServiceDescriptor(
const char *name, std::vector<const char *> arg_names,
271 std::function<
void(
const api::ExecuteServiceRequest &)> callback)
272 : name_(name), key_(fnv1_hash(name)), arg_names_(std::move(arg_names)), callback_(std::move(callback)) {}
274 bool execute_service(
const api::ExecuteServiceRequest &request)
override {
275 if (request.key != this->key_ || request.args.size() != this->arg_names_.size())
277 this->callback_(request);
281#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES
282 bool execute_service(
const api::ExecuteServiceRequest &request, uint32_t)
override {
283 return this->execute_service(request);
290 template<
typename T>
static T make_ref(
const char *s) {
291 if constexpr (std::is_assignable_v<T &, StringRef>) {
294 return T(StringRef(s));
300 api::ListEntitiesServicesResponse build_list_response()
override {
301 api::ListEntitiesServicesResponse response;
302 response.name = make_ref<
decltype(response.name)>(this->name_);
303 response.key = this->key_;
304 response.supports_response = api::enums::SUPPORTS_RESPONSE_NONE;
305 response.args.init(this->arg_names_.size());
306 for (
const char *arg_name : this->arg_names_) {
307 auto &arg = response.args.emplace_back();
308 arg.name = make_ref<
decltype(arg.name)>(arg_name);
309 arg.type = api::enums::SERVICE_ARG_TYPE_STRING;
316 std::vector<const char *> arg_names_;
317 std::function<void(
const api::ExecuteServiceRequest &)> callback_;
328 std::transform(normalized.begin(), normalized.end(), normalized.begin(),
329 [](
unsigned char ch) { return static_cast<char>(std::toupper(ch)); });
333static std::string
bool_to_string(
bool value) {
return value ?
"true" :
"false"; }
339 std::array<char, RESULT_CODE_BUFFER_SIZE> buffer{};
340 std::snprintf(buffer.data(), buffer.size(),
"%02X", value);
341 return std::string(buffer.data());
363 result.
message =
"device returned an empty error response";
388 if (text.size() > 1 && text[0] ==
'0' && text[1] !=
'x' && text[1] !=
'X')
395 const uint32_t value = std::strtoul(text.c_str(), &end, 0);
396 if (end != text.c_str() + text.size())
398 if (errno == ERANGE || value > std::numeric_limits<uint8_t>::max())
400 out =
static_cast<uint8_t
>(value);
407 std::transform(normalized.begin(), normalized.end(), normalized.begin(),
408 [](
unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
422 for (
size_t i = 0; i < table_len; i++) {
423 if (token == table[i].token) {
424 out = table[i].
value;
437 const float parsed = std::strtof(text.c_str(), &end);
438 if (text.empty() || end != text.c_str() + text.size() || errno == ERANGE || !std::isfinite(parsed)) {
439 error =
"temperature must be a number in degrees Celsius";
443 std::array<char, HEATING_RANGE_MESSAGE_BUFFER_SIZE> buffer{};
444 std::snprintf(buffer.data(), buffer.size(),
"temperature must be between %.1f and %.1f",
446 error = buffer.data();
470 float &value_out, std::string &error) {
477 static constexpr HeatingNamedValue PRESENCE_VALUES[] = {{
"on", 1.0F}, {
"off", 0.0F}};
478 static constexpr HeatingNamedValue DETECTION_VALUES[] = {{
"on", 1.0F}, {
"off", 0.0F}};
483 if (fn ==
"power_on") {
487 if (fn ==
"midnight_sync") {
491 if (fn ==
"set_temperature") {
495 if (fn ==
"set_mode") {
499 error =
"mode must be one of auto, manual, prog, off";
502 if (fn ==
"set_presence") {
507 error =
"presence must be 'on' or 'off'";
510 if (fn ==
"set_open_window_detection") {
515 error =
"open-window detection must be 'on' or 'off'";
518 if (fn ==
"window_open") {
533 system_key_(system_key),
537 initialized_(initialized),
541#if defined(USE_API_USER_DEFINED_ACTIONS) && defined(USE_API_CUSTOM_SERVICES)
542 if (api::global_api_server ==
nullptr) {
543 ESP_LOGW(
detail::TAG,
"Native API server not available, management actions will not be registered");
553 std::vector<const char *> arg_names;
554 bool diagnostic_only;
555 std::function<void(
const api::ExecuteServiceRequest &)> callback;
557 const ActionReg actions[] = {
558 {MANAGEMENT_ACTION_RENAME_DEVICE,
559 {
"device_id",
"new_name"},
561 [
this](
const api::ExecuteServiceRequest &r) {
564 {MANAGEMENT_ACTION_IDENTIFY_DEVICE,
567 [
this](
const api::ExecuteServiceRequest &r) { this->
api_identify_device(r.args[0].string_.str()); }},
568 {MANAGEMENT_ACTION_FORCE_OPEN_DEVICE,
571 [
this](
const api::ExecuteServiceRequest &r) { this->
api_force_open_device(r.args[0].string_.str()); }},
572 {MANAGEMENT_ACTION_SCAN_PAIRED_DEVICES,
576 {MANAGEMENT_ACTION_ONEWAY_SET_POSITION,
577 {
"controller_id",
"position"},
579 [
this](
const api::ExecuteServiceRequest &r) {
582 {MANAGEMENT_ACTION_ONEWAY_REMOVE_CONTROLLER,
586 {MANAGEMENT_ACTION_HEATING_CONTROL,
587 {
"device_id",
"function",
"value"},
589 [
this](
const api::ExecuteServiceRequest &r) {
590 this->
api_heating_control(r.args[0].string_.str(), r.args[1].string_.str(), r.args[2].string_.str());
592 {MANAGEMENT_ACTION_PROBE_DEVICE,
593 {
"device_id",
"probe",
"index"},
595 [
this](
const api::ExecuteServiceRequest &r) {
596 this->
api_probe_device(r.args[0].string_.str(), r.args[1].string_.str(), r.args[2].string_.str());
598 {MANAGEMENT_ACTION_PROBE_SWEEP,
599 {
"device_id",
"probe",
"first_index",
"last_index"},
601 [
this](
const api::ExecuteServiceRequest &r) {
602 this->
api_probe_sweep(r.args[0].string_.str(), r.args[1].string_.str(), r.args[2].string_.str(),
603 r.args[3].string_.str());
612 const bool probes_enabled = hub_->diagnostic_probes_enabled();
613 for (
const auto &action : actions) {
614 if (action.diagnostic_only && !probes_enabled)
616 api::global_api_server->register_user_service(
617 new detail::ManagementServiceDescriptor(action.name, action.arg_names, action.callback));
622IoDevice *ManagementActions::resolve_device_(
const char *action,
const std::string &device_id,
627 if (!*initialized_) {
628 result.
message =
"hub is not initialized";
632 uint8_t parsed_device_id[NODE_ID_SIZE]{};
633 if (!
hex_to_bytes(normalized_device_id, parsed_device_id, NODE_ID_SIZE)) {
634 result.
message =
"device ID must be exactly 6 hexadecimal characters";
638 auto *dev = registry_.get(normalized_device_id);
639 if (dev ==
nullptr) {
640 result.
message =
"device is not registered on this hub";
647bool ManagementActions::send_authenticated_request_(
const IoFrame &request,
IoFrame &response,
const char *action_verb,
649 const ExchangeOutcome outcome = engine_.send_and_receive(request, response, FREQ_CH2);
652 engine_.log_debug(result.device_id.c_str());
657 ? std::string(
"device accepted the ") + action_verb +
" request but sent no response"
658 : std::string(
"no valid response to ") + action_verb +
" request";
669 const bool has_device = !result.
device_id.empty();
670 std::string prefix =
"Management action " + result.
action;
672 prefix +=
" for device " + result.
device_id;
673 prefix += result.
success ?
": " :
" failed: ";
677 if (!hub_->is_connected())
680 std::map<std::string, std::string> event_data{{
"action", result.
action},
709 hub_->fire_homeassistant_event(MANAGEMENT_RESULT_EVENT, event_data);
714 auto *dev = resolve_device_(MANAGEMENT_ACTION_RENAME_DEVICE, device_id, result);
718 uint8_t payload[DEVICE_NAME_WRITE_PAYLOAD_SIZE];
719 std::string normalized_name;
728 if (!
create_set_name(request, node_id_, dev->node_id, dev->low_power, payload)) {
729 result.
message =
"failed to build rename request";
734 if (!send_authenticated_request_(request, response,
"rename", result))
737 if (response.
cmd == CMD_ERROR_RESP) {
745 if (response.
cmd != CMD_SET_NAME_RESP) {
746 std::array<char, UNEXPECTED_RESPONSE_MESSAGE_BUFFER_SIZE> buffer{};
747 std::snprintf(buffer.data(), buffer.size(),
"unexpected rename response 0x%02X", response.
cmd);
748 result.
message = buffer.data();
753 result.
message =
"rename acknowledged by device";
755 if (!hub_->request_device_name(result.
device_id)) {
756 result.
message =
"rename acknowledged but verification readback failed";
760 auto *updated_device = registry_.get(result.
device_id);
761 if (updated_device !=
nullptr)
766 result.
message =
"rename verified by device readback";
770 result.
message =
"rename acknowledged but readback did not match the requested name";
782 auto *dev = resolve_device_(MANAGEMENT_ACTION_IDENTIFY_DEVICE, device_id, result);
787 if (!
create_identify(request, node_id_, dev->node_id, dev->low_power)) {
788 result.
message =
"failed to build identify request";
793 if (!send_authenticated_request_(request, response,
"identify", result))
796 if (response.
cmd == CMD_ERROR_RESP) {
804 result.
message =
"identify triggered (device returned an empty error response)";
812 result.
message =
"identify acknowledged by device";
822 auto *dev = resolve_device_(MANAGEMENT_ACTION_FORCE_OPEN_DEVICE, device_id, result);
829 result.
message =
"device does not accept cover commands";
835 "force open queued (elevated-priority open; wind/rain lock bypass unconfirmed; movement result arrives via "
841 const std::string &value) {
846 const std::string &value) {
848 auto *dev = resolve_device_(MANAGEMENT_ACTION_HEATING_CONTROL, device_id, result);
853 float encoded_value = 0.0F;
854 std::string parse_error;
862 result.
message =
"device is not a climate device";
866 result.
message =
"device is commanded with heating levels, not private registers";
872 const uint8_t prior_result_code = dev->last_result_code;
873 const uint32_t prior_result_at_ms = dev->last_result_at_ms;
878 float read_value = 0.0F;
880 const bool acknowledged = read_with_value
881 ? hub_->read_heating_register(result.
device_id, fn, &read_value, EXCHANGE_RETRY_COUNT)
882 : hub_->send_heating_command(result.
device_id, fn, encoded_value);
885 if (
const auto *updated = registry_.get(result.
device_id);
886 updated !=
nullptr && updated->last_result_code != 0 &&
887 (updated->last_result_code != prior_result_code || updated->last_result_at_ms != prior_result_at_ms)) {
899 if (read_with_value) {
901 std::string(
"heating ") +
heating_function_name(fn) +
": window " + (read_value != 0.0F ?
"open" :
"closed");
915 if (hub_->oneway_controllers().get(controller_id) ==
nullptr) {
916 result.
message =
"no oneway_controllers identity with that id";
925 if (trimmed.empty() || trimmed.find_first_not_of(
"0123456789") != std::string::npos) {
926 result.
message =
"position must be a whole number between 0 and 100";
930 const unsigned long parsed = strtoul(trimmed.c_str(),
nullptr, 10);
931 if (parsed > ONEWAY_POSITION_FULLY_CLOSED) {
932 result.
message =
"position must be between 0 and 100";
937 hub_->send_oneway_position(controller_id,
static_cast<uint8_t
>(parsed));
941 result.
message =
"1W position command queued";
949 if (hub_->oneway_controllers().get(controller_id) ==
nullptr) {
950 result.
message =
"no oneway_controllers identity with that id";
955 hub_->send_oneway_unenroll(controller_id);
959 result.
message =
"1W remove-controller (0x39) queued";
964using ScanKeys = std::vector<std::array<uint8_t, AES_KEY_SIZE>>;
971 memcpy(keys[0].data(), hub_key, AES_KEY_SIZE);
972 for (
auto &entry : registry) {
976 const bool known = std::ranges::any_of(
977 keys, [&dev](
const auto &key) {
return memcmp(key.data(), dev.
system_key, AES_KEY_SIZE) == 0; });
981 memcpy(keys.back().data(), dev.
system_key, AES_KEY_SIZE);
989 const bool is_hub_key = memcmp(key, hub_key, AES_KEY_SIZE) == 0;
991 for (
auto &entry : registry) {
994 if (memcmp(device_key, key, AES_KEY_SIZE) == 0 && (is_hub_key || dev.
has_system_key))
1003 const ScanKeys &keys,
bool reveal_keys) {
1007 std::string known_body;
1008 std::string unknown_body;
1009 uint8_t unknown_count = 0;
1010 for (uint8_t i = 0; i < state.
count; i++) {
1015 const RollCallReplyKeys reply_keys{keys[slot].data(), keys[0].data(), slot != 0};
1017 if (registered !=
nullptr) {
1026 if (!known_body.empty())
1027 body +=
"Known:\n" + known_body;
1028 if (!unknown_body.empty())
1029 body +=
"Unknown:\n" + unknown_body;
1031 std::string message =
"Roll-call: " + std::to_string(state.
count) +
" device" + (state.
count == 1 ?
"" :
"s") +
1032 " detected (" + std::to_string(state.
count - unknown_count) +
" known, " +
1033 std::to_string(unknown_count) +
" unknown)\n";
1050 " devices answered; the list below is truncated. Re-run to see whether other devices "
1051 "appear, and raise SCAN_MAX_REPLIES if this install really is larger.\n";
1060 if (!*initialized_) {
1061 result.
message =
"hub is not initialized";
1074 uint8_t attempt = 0;
1075 for (
size_t slot = 0; slot < keys.size(); slot++) {
1077 ESP_LOGI(
detail::TAG,
"Roll-call with key check value %s (%u registered device%s)",
1079 using_key == 1 ?
"" :
"s");
1080 if (
run_roll_call_sweep(engine_, node_id_, keys[slot].data(),
static_cast<uint8_t
>(slot), *tuning_, state, attempt,
1082 result.
message =
"failed to build roll-call request";
1091 if (keys.size() > 1)
1097 const std::string &index) {
1102 const std::string &index) {
1107 auto *dev = resolve_device_(MANAGEMENT_ACTION_PROBE_DEVICE, device_id, resolve_result);
1108 if (dev ==
nullptr) {
1111 return resolve_result;
1118 if (!hub_->diagnostic_probes_enabled()) {
1119 result.
message =
"diagnostic probes are not enabled; set diagnostic_probes: true in YAML";
1126 result.
message =
"device is moving; refusing to probe mid-transaction";
1131 const ProbeDescriptor *descriptor = resolve_probe_descriptor(probe, result);
1132 if (descriptor ==
nullptr)
1135 uint8_t index_byte = 0;
1137 result.
message =
"index must be a decimal or 0x-prefixed byte value (0-255)";
1140 if (descriptor->needs_index && index_byte > descriptor->max_index) {
1141 result.
message = index_out_of_range_message(*descriptor);
1146 if (!descriptor->builder(request, node_id_, dev->node_id, index_byte, dev->low_power)) {
1147 result.
message =
"failed to build probe request";
1155 const ExchangeOutcome outcome = engine_.send_and_receive(request, response, FREQ_CH2);
1157 engine_.log_debug(result.
device_id.c_str());
1159 ?
"device accepted the probe request but sent no response"
1160 :
"no reply after " + std::to_string(EXCHANGE_RETRY_COUNT) +
1161 " attempts (device asleep, unreachable, or silently ignoring this opcode)";
1169 uint8_t raw[FRAME_MAX_SIZE] = {0};
1170 const uint8_t raw_len =
serialize(response, raw,
sizeof(raw));
1198 if (response.
cmd == CMD_LIMITATION_STATUS_RESP) {
1206 const std::string &first_index,
const std::string &last_index) {
1211 const std::string &first_index,
const std::string &last_index) {
1221 if (resolve_device_(MANAGEMENT_ACTION_PROBE_SWEEP, device_id, resolve_result) ==
nullptr) {
1225 const ProbeDescriptor *descriptor = resolve_probe_descriptor(probe, result);
1226 if (descriptor ==
nullptr)
1228 if (!descriptor->needs_index) {
1229 result.
message =
"probe \"" + probe +
"\" takes no index; use probe_device";
1236 result.
message =
"first_index/last_index must be decimal or 0x-prefixed byte values (0-255)";
1240 result.
message =
"last_index must be >= first_index";
1243 if (last > descriptor->max_index) {
1244 result.
message = index_out_of_range_message(*descriptor);
1247 const uint32_t span =
static_cast<uint32_t
>(last) - first + 1;
1248 if (span > PROBE_SWEEP_MAX_INDICES) {
1250 "sweep range too wide: " + std::to_string(span) +
" indices, max " + std::to_string(PROBE_SWEEP_MAX_INDICES);
1255 uint32_t answered_count = 0;
1256 bool stopped_early =
false;
1257 for (uint32_t idx = first; idx <= last; idx++) {
1260 delay(PROBE_SWEEP_DELAY_MS);
1262 const std::string index_str = std::to_string(idx);
1264 report +=
"index=0x" +
format_hex_byte(
static_cast<uint8_t
>(idx)) +
": ";
1269 report +=
" [" + step.
decoded +
"]";
1273 report += step.
message +
"\n";
1280 report +=
"(stopping sweep: " + step.
message +
")\n";
1281 stopped_early =
true;
1290 result.
success = !stopped_early;
1296 "]: " + std::to_string(answered_count) +
" answered\n" + report;
Owns the per-hub device table, update callbacks, and linked-remote associations.
IoDevice * get_declared(const std::string &device_id)
Retrieve a device the YAML declares, ignoring one a pairing added in this boot.
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.
Conversions and renderers shared by the hub and its Home Assistant entities.
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.
std::string describe_limitation_reply(const IoFrame &frame)
Render a CMD_LIMITATION_STATUS_RESP frame for a log line or probe report.
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.
std::string format_key_check_value(const uint8_t key[AES_KEY_SIZE])
A key check value: the first three bytes of the key's AES encryption of a zero block.
RollCallAttemptResult run_roll_call_sweep(ExchangeEngine &engine, const uint8_t *node_id, const uint8_t *key, uint8_t key_slot, const TuningConfig &tuning, ScanState &state, uint8_t &attempt, uint8_t total_attempts)
Run the full roll-call sweep under one key: every enabled pass on every channel.
bool create_identify(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power)
Build an authenticated device-identify request (0x1E).
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 count_enabled_passes(ScanPowerClasses selection)
How many of ROLL_CALL_PASSES selection actually calls.
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.
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
Registers and functions of a 2W heater, one row of the descriptor table each.
@ MIDNIGHT_SYNC
Register 0x0130, read: the comfort/eco/auto setpoint block. Logged, never decoded.
@ POWER_ON
Register 0x012C, read: the paired-device list. Logged, never decoded.
@ SET_PRESENCE
Register 0x0110, write-only: someone is in the room (1) or not (0).
@ WINDOW_OPEN
Register 0x010E, read-only: the heater's window sensor, 0 closed / 1 open.
@ SET_OPEN_WINDOW_DETECTION
Register 0x0106, read/write: open-window detection on (1) or off (0).
@ SET_TEMPERATURE
Register 0x0103, read/write: comfort setpoint, tenths of a degree.
@ SET_MODE
Register 0x0100, read/write: operating mode (HeatingMode).
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.
static ScanKeys collect_scan_keys(const uint8_t *hub_key, DeviceRegistry ®istry)
Collect the keys to sweep: the hub key, then each per-device key that differs from it and from every ...
static std::string build_scan_report(const ScanState &state, DeviceRegistry ®istry, ScanPowerClasses selection, const ScanKeys &keys, bool reveal_keys)
Build the final report: known/unknown grouping, header, the selection NOTE, and the truncation NOTE.
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)
bool create_limitation_status_read(IoFrame &f, const uint8_t *own, const uint8_t *dst, LimitationType type, bool low_power)
Build the limitation read (see the header for the evidence and the read-only guarantee).
@ 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)
constexpr float HEATING_TEMP_MAX_C
Highest setpoint this codec will encode.
constexpr uint8_t SCAN_CHANNEL_COUNT
const char * command_name(uint8_t cmd)
Get a human-readable name for any IO-Homecontrol command ID.
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.
constexpr RollCallPass ROLL_CALL_PASSES[]
The roll-call's two fixed frame shapes, low-power first.
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", ...).
static size_t count_devices_using_key(DeviceRegistry ®istry, const uint8_t *hub_key, const uint8_t *key)
How many registered devices exchange with key: those with it as their own key, or,...
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.
bool device_supports_climate_control(DeviceType type, uint8_t subtype)
Can a climate entity drive this device?
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_status_mp_fp_read(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power)
Build the KLF-style status read of the main parameter plus FP1-FP7 (see the header for the evidence).
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.
std::string format_roll_call_responder(const ScanResponder &responder, const std::string &device_id, const IoDevice *registered, const RollCallReplyKeys &keys, RollCallKeyDisplay display)
Format one roll-call responder's report line(s).
@ BUILD_FAILED
create_discovery_request() failed; the caller should abort the whole scan.
std::string trim_ascii_whitespace(const std::string &value)
Trim leading and trailing ASCII whitespace from a string.
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.
@ PROG
Program mode; the device runs its own stored weekly schedule.
@ MANUAL
Manual mode; follows the last written setpoint.
@ AUTO
Automatic mode; the device manages the setpoint itself.
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.
std::vector< std::array< uint8_t, AES_KEY_SIZE > > ScanKeys
The keys one scan sweeps with: slot 0 is the hub key, the rest are the distinct per-device keys.
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 device_uses_heating_registers(DeviceType type, uint8_t subtype)
Does a climate entity drive this device over the private register protocol?
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...
LimitationType
On-air selector byte (request data[0]) choosing which resulting limit to read.
@ MAXIMUM
Resulting maximum limit.
@ MINIMUM
Resulting minimum limit; rain protection raises it (a floor on the position value).
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.
constexpr uint8_t SCAN_MAX_REPLIES
Maximum distinct responders reported by one scan_paired_devices() call.
const char * device_name_validation_error_description(DeviceNameValidationError error)
Return a human-readable explanation for a device-name validation result.
RollCallKeyDisplay
How a responder line shows the key it answered under.
@ HIDDEN
Never print the key: its check value only. For anything Home Assistant records.
@ BANNERED
Print the key in a snippet, wrapped in the line's own DO-NOT-SHARE block.
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).
Command builders for the IO‑Homecontrol protocol.
The IO node-type and subtype table: names, entity class, platform and behaviour flags.
The roll-call sweep: one keyed 0x2A broadcast, repeated over channels and power classes.
A value token that maps to a fixed float (used by set_mode / set_presence / set_open_window_detection...
Runtime state of a paired IO‑Homecontrol device.
uint8_t system_key[AES_KEY_SIZE]
The device's own key; meaningful only when has_system_key.
bool has_system_key
YAML-declared (system_key:): this device keeps a key of its own.
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 data_len
Actual length of data.
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...
std::string log_message
When non-empty, the device log shows this instead of message.
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 decoded
Rendering of the probe reply for the replies this codebase can decode (currently a limitation reply,...
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.
One roll-call frame shape, and how to listen for its replies.
The keys a responder line needs: the one a responder answered under, and the hub's.
uint8_t src[NODE_ID_SIZE]
Responder's node ID; also the dedup key.
uint8_t key_slot
Which key of the sweep it answered under: 0 is the hub key, n the n-th per-device key (RollCallKeys).
Accumulated state of one scan, threaded through every sweep.
uint8_t count
Entries in use.
bool truncated
A responder was dropped because the array was full.
ScanResponder responders[SCAN_MAX_REPLIES]
Distinct responders, in arrival order.
All runtime tunable parameters for pairing and radio diagnostics.