30constexpr uint8_t PRIVATE_RESPONSE_MIN_DATA_LEN = 6;
33constexpr uint8_t STATUS_UPDATE_MIN_DATA_LEN = 11;
34constexpr uint8_t GET_NAME_RESPONSE_MIN_DATA_LEN = 1;
35constexpr uint8_t GET_INFO2_RESPONSE_MIN_DATA_LEN = 12;
36constexpr uint8_t ERROR_RESPONSE_MIN_DATA_LEN = 1;
37constexpr uint8_t EXTENDED_TILT_RESPONSE_MIN_DATA_LEN =
39constexpr uint8_t STATUS_STOPPED_FLAGS_OFFSET = 0;
40constexpr uint8_t PRIVATE_RESPONSE_DELAY_HINT_OFFSET = 7;
51constexpr uint8_t PRIVATE_RESPONSE_TARGET_OFFSET = 2;
52constexpr uint8_t PRIVATE_RESPONSE_CURRENT_OFFSET = 4;
53constexpr uint8_t STATUS_UPDATE_TARGET_OFFSET = 5;
54constexpr uint8_t STATUS_UPDATE_CURRENT_OFFSET = 7;
55constexpr uint8_t GET_INFO2_TYPE_OFFSET = 10;
56constexpr uint8_t GET_INFO2_TYPE_SUBTYPE_OFFSET = 11;
57constexpr uint8_t EXTENDED_TILT_SELECTOR_OFFSET = 12;
58constexpr uint8_t EXTENDED_TILT_MSB_OFFSET = 13;
59constexpr uint8_t EXTENDED_TILT_LSB_OFFSET = 14;
60constexpr uint8_t PRIVATE_RESPONSE_HINT_UNUSED = 0xFF;
61constexpr uint8_t PRIVATE_RESPONSE_HINT_ZERO =
63constexpr uint32_t PRIVATE_RESPONSE_HINT_SCALE_MS = 1000;
64constexpr uint32_t PRIVATE_RESPONSE_HINT_BIAS_MS =
74void decode_status_fields(
IoDevice &dev,
const IoFrame &frame, uint8_t target_offset, uint8_t current_offset,
75 bool allow_tilt_from_extended_response) {
76 uint16_t
const tgt = (frame.data[target_offset] << 8) | frame.data[target_offset + 1];
77 uint16_t
const cur = (frame.data[current_offset] << 8) | frame.data[current_offset + 1];
81 dev.optimistic.clear_position();
84 frame.data_len >= EXTENDED_TILT_RESPONSE_MIN_DATA_LEN &&
86 uint16_t
const tilt_raw = (frame.data[EXTENDED_TILT_MSB_OFFSET] << 8) | frame.data[EXTENDED_TILT_LSB_OFFSET];
88 dev.optimistic.clear_tilt();
99 const std::string &
id) {
106 uint32_t hint_delay_ms = 0;
107 if (frame.data_len > PRIVATE_RESPONSE_DELAY_HINT_OFFSET &&
108 frame.data[PRIVATE_RESPONSE_DELAY_HINT_OFFSET] != PRIVATE_RESPONSE_HINT_UNUSED &&
109 frame.data[PRIVATE_RESPONSE_DELAY_HINT_OFFSET] != PRIVATE_RESPONSE_HINT_ZERO) {
110 hint_delay_ms = (frame.data[PRIVATE_RESPONSE_DELAY_HINT_OFFSET] * PRIVATE_RESPONSE_HINT_SCALE_MS) +
111 PRIVATE_RESPONSE_HINT_BIAS_MS;
123uint32_t compute_status_update_delay_ms(
const IoDevice &dev,
const StatusPollPolicy &policy,
const std::string &
id) {
142 bool trust_position =
true) {
143 dev.is_stopped = (frame.data[STATUS_STOPPED_FLAGS_OFFSET] &
STATUS_STOPPED) != 0;
144 dev.last_status = millis();
145 if (trust_position) {
146 decode_status_fields(dev, frame, PRIVATE_RESPONSE_TARGET_OFFSET, PRIVATE_RESPONSE_CURRENT_OFFSET,
true);
156 uint32_t
const delay_ms = compute_private_response_delay_ms(dev, frame, policy,
id);
157 const bool hint_present = frame.data_len > PRIVATE_RESPONSE_DELAY_HINT_OFFSET;
158 const uint8_t hint_byte =
159 hint_present ? frame.data[PRIVATE_RESPONSE_DELAY_HINT_OFFSET] : PRIVATE_RESPONSE_HINT_UNUSED;
160 const bool has_hint =
161 hint_present && hint_byte != PRIVATE_RESPONSE_HINT_UNUSED && hint_byte != PRIVATE_RESPONSE_HINT_ZERO;
163 detail::TAG,
"Device %s: next status poll in %" PRIu32
" ms (device hint=%s, configured interval=%" PRIu32
" ms)",
164 id.c_str(), delay_ms, has_hint ? std::to_string(hint_byte).append(
"s").c_str() :
"none", policy.get_interval(
id));
165 uint32_t
const new_deadline = dev.last_status + delay_ms;
166 uint32_t
const existing_deadline = policy.get_next_update(
id);
169 policy.set_next_update(
170 id, (existing_deadline != 0 && existing_deadline < new_deadline) ? existing_deadline : new_deadline);
178void apply_unsolicited_status_update(
const std::string &
id,
IoDevice &dev,
const IoFrame &frame,
180 dev.is_stopped = (frame.data[STATUS_STOPPED_FLAGS_OFFSET] &
STATUS_STOPPED) != 0;
181 dev.last_status = millis();
182 decode_status_fields(dev, frame, STATUS_UPDATE_TARGET_OFFSET, STATUS_UPDATE_CURRENT_OFFSET,
false);
189 policy.set_next_update(
id, dev.last_status + compute_status_update_delay_ms(dev, policy,
id));
210 memset(dev.name, 0,
sizeof(dev.name));
212 memcpy(dev.name, name.c_str(), name.length());
220 this->
poll_policy_.begin_tracking(device_id, initial_delay_ms, millis());
226 const std::string timeout_name =
"remote_poll_" + device_id;
227 this->set_timeout(timeout_name.c_str(), delay_ms,
228 [
this, device_id]() { this->queue_request_device_status(device_id); });
232 for (
const auto &device_id : device_ids) {
239 const std::vector<std::string> *linked = this->
registry_.linked_devices(remote_id);
240 if (linked ==
nullptr)
246 const std::string &src_id)
const {
247 std::vector<std::string> devices;
248 if (
const std::vector<std::string> *id_linked = this->
registry_.linked_devices(src_id)) {
249 devices = *id_linked;
252 if (
const std::vector<std::string> *class_linked = this->
registry_.linked_devices_for_class(info.
target_type)) {
253 for (
const auto &device_id : *class_linked) {
254 if (std::find(devices.begin(), devices.end(), device_id) == devices.end())
255 devices.push_back(device_id);
263 const std::vector<std::string> &device_ids) {
270 for (
const auto &device_id : device_ids) {
277 this->
registry_.apply_optimistic_stop(device_id);
278 }
else if (target.has_value()) {
279 this->
registry_.apply_optimistic_target(device_id, *target);
286 const std::string &src_id) {
294 if (!this->is_connected()) {
295 ESP_LOGD(
detail::TAG,
"1W sender %s has intent but the API is not connected, skipping %s", src_id.c_str(),
300 ESP_LOGD(
detail::TAG,
"1W sender %s has intent but is not in exposed_senders, skipping %s", src_id.c_str(),
311 if (device_ptr ==
nullptr) {
319 if (frame.
data_len < PRIVATE_RESPONSE_MIN_DATA_LEN) {
328 apply_private_response_status(
id, dev, frame, this->
poll_policy_, trust_position);
333 if (trust_position) {
344 if (frame.
data_len < STATUS_UPDATE_MIN_DATA_LEN) {
351 apply_unsolicited_status_update(
id, dev, frame, this->
poll_policy_);
366 if (!originator.empty())
367 ESP_LOGD(
detail::TAG,
"Device %s: status update originator=%s",
id.c_str(), originator.c_str());
375 if (frame.
data_len < GET_NAME_RESPONSE_MIN_DATA_LEN) {
380 apply_name_response(dev, frame);
381 ESP_LOGI(
detail::TAG,
"Device %s: name=%s",
id.c_str(), dev.
name[0] ==
'\0' ?
"" : dev.
name);
387 if (frame.
data_len < GET_INFO2_RESPONSE_MIN_DATA_LEN) {
395 apply_info2_response(dev, frame);
396 ESP_LOGI(
detail::TAG,
"Device %s: type=%s (%u) class=%s profile=%s subtype=%u",
id.c_str(),
401 "Device %s: type learned at runtime, not declared in YAML — add `%s` to skip "
402 "re-learning it on every future boot",
409 if (frame.
data_len < ERROR_RESPONSE_MIN_DATA_LEN) {
472 const uint32_t now = millis();
504 const std::vector<std::string> *linked = this->
registry_.linked_devices(src_id);
552 ESP_LOGD(
detail::TAG,
"rx remote_activity src=%s dst=%s cmd=%s(0x%02X), scheduling status poll",
563 if (this->
registry_.linked_devices(src_id) !=
nullptr) {
564 ESP_LOGD(
detail::TAG,
"rx remote_activity (linked) remote=%s cmd=%s(0x%02X), scheduling status poll",
576 const bool addressed_to_us = memcmp(frame.
dst, this->node_id_,
NODE_ID_SIZE) == 0;
577 if (addressed_to_us && std::strcmp(
command_name(frame.
cmd),
"UNKNOWN_CMD") == 0) {
580 "Received unknown command 0x%02X from %s. "
581 "If you see this repeatedly, please file a GitHub issue with this command ID, "
582 "your device model, and the log context so protocol support can be extended.",
583 frame.
cmd, src_id.c_str());
void maybe_fire_sender_event_(const OneWayFrameInfo &info, bool linked, const std::string &src_id)
Fire the sender HA event for a decoded 1W frame, if the sender is exposed.
uint32_t last_1w_activity_ms_
millis() of the most recent 1W frame of any kind, including ones dropped as duplicates — a repeat sti...
void begin_status_poll_tracking_(const std::string &device_id, uint32_t initial_delay_ms)
Begin bounded follow-up polling for a device after a command or overheard remote activity.
virtual IoDevice * get_device(const std::string &device_id)
Retrieve a device by ID; returns nullptr if not found.
void record_oneway_pairing_gesture_(const IoFrame &frame, uint32_t now)
If frame matches a 1W remote's pairing gesture (decisions::is_one_way_pairing_gesture()),...
std::vector< std::string > resolve_1w_target_devices_(const OneWayFrameInfo &info, const std::string &src_id) const
Resolve the set of devices a 1W frame should affect: devices linked to the sending remote by node ID,...
StatusPollPolicy poll_policy_
void schedule_status_poll_(const std::string &device_id, uint32_t delay_ms)
Schedule a delayed status poll for a registered device using the Component timeout API.
void update_device_status_(const IoFrame &frame, bool trust_position=true)
Extract supported position or metadata info from a response frame and merge it into the device record...
RecentOneWayPairingSighting recent_oneway_pairing_sighting_
Most recent 1W pairing-gesture frame seen on the hub's normal passive RX path (e.g.
void schedule_device_polls_(const std::vector< std::string > &device_ids, uint32_t delay_ms)
Schedule status polls for a fixed list of devices (shared by the id-linked and class-linked 1W paths,...
void record_1w_activity_(uint32_t now)
Record that a 1W frame just went out on the radio — ours or someone else's — updating last_1w_activit...
bool transmit_frame_(const IoFrame &frame, uint32_t freq, uint16_t preamble)
Transmit a raw IoFrame on the current frequency with given preamble length.
void schedule_linked_remote_polls_(const std::string &remote_id, uint32_t delay_ms=REMOTE_ACTIVITY_STATUS_POLL_DELAY_MS)
Schedule status polls for all devices associated with a linked remote.
OnewayKeyAdoption oneway_key_adoption_
Opt-in, receive-only 1W controller-key adoption listener (oneway_key_adoption.cpp).
void notify_device_update_(const std::string &id)
Fire all registered device update callbacks for the given device ID.
decisions::OneWayDedupState last_1w_logged_
Identity of the last processed 1W frame, for burst suppression; see decisions::is_duplicate_1w_frame(...
KeyExtractionResponder key_extraction_
Device-role responder for the "Recover System Key" feature (key_extraction_responder....
uint8_t node_id_[NODE_ID_SIZE]
std::vector< std::string > exposed_senders_
1W sender node IDs (remotes or sensors) allowed to fire the sender HA event (add_exposed_sender).
void process_received_packet_(const RadioRxPacket &packet)
Parse a received frame, merge supported device state or metadata, and notify callbacks.
bool authenticate_request_(const IoFrame &request, uint32_t freq)
Handle an inbound authenticated command from a device (status updates, etc.).
uint32_t first_1w_activity_ms_
millis() of the first 1W frame in the current burst.
bool apply_optimistic_linked_state_(const OneWayFrameInfo &info, const std::vector< std::string > &device_ids)
Apply optimistic target state to every device in device_ids, when the decoded frame carries a resolva...
Per-hub poll scheduling and failure-backoff policy.
Pure transition helpers for hub-owned exchange and pairing frame decisions.
Internal helpers shared by the hub implementation .cpp files.
bool is_one_way_pairing_gesture(bool oneway, const uint8_t dst[NODE_ID_SIZE], uint8_t cmd)
True if a frame's shape matches a 1W remote's pairing gesture (issue #27/#65): CTRL0 1W bit set,...
bool is_duplicate_1w_frame(const OneWayDedupState &last, const OneWayDedupState &incoming, uint32_t window_ms)
Decide whether an incoming 1W frame repeats the previous one inside the burst window.
bool is_exchange_internal_command(uint8_t cmd)
Returns true for commands that are internal to an exchange handshake and carry no useful information ...
bool oneway_burst_started_fresh(uint32_t last_1w_activity_ms, uint32_t now, uint32_t quiet_ms)
Whether a 1W frame arriving at now starts a new burst rather than extending the current one — true if...
std::map< std::string, std::string > build_sender_event_data(const OneWayFrameInfo &info, bool linked)
Build the Home Assistant event data map for a decoded 1W sender frame.
constexpr const char * TAG
Shared log tag for hub-level messages.
constexpr uint8_t PRIVATE_RESPONSE_LAST_COMMAND_OFFSET
Offset of the last-command record within each status-bearing payload.
std::string describe_learned_device_type(DeviceType type)
Build the YAML line to add once a device's type is learned at runtime.
void apply_last_command_record(IoDevice &dev, const LastCommandRecord &record)
Store a decoded record on the device, if it is valid.
void log_1w_remote_frame(const OneWayFrameInfo &info, const std::vector< std::string > *linked_devices=nullptr)
Log an already-decoded 1W remote frame at DEBUG level.
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 describe_status_update_originator(const IoFrame &frame)
Describe a 0x71 status update's Command Originator as "name(0xXX)".
bool is_exposed_sender(const std::vector< std::string > &exposed_senders, const std::string &sender_id)
Whether a 1W sender is on the exposed_senders allowlist for the sender HA event.
constexpr const char * ONEWAY_SENDER_EVENT
Home Assistant event fired when a decoded 1W frame carries a command intent from an exposed sender (a...
void update_link_health(IoDevice &dev, RadioDriver *radio)
Update per-device link-health stats from the radio's last capture.
constexpr uint8_t STATUS_UPDATE_LAST_COMMAND_OFFSET
void normalize_stopped_state(IoDevice &dev)
Normalize stopped state: some devices briefly report stopped before target/current converge.
constexpr uint32_t ONEWAY_DEDUP_WINDOW_MS
Suppress a repeated 1W log/poll for the same remote and the same intent within this window.
void log_status_update(const std::string &id, const IoDevice &dev, const char *suffix="")
Log a concise status‑update line used by inbound handlers.
void log_frame_issue(IOHomeControlComponent *component, const char *direction, const char *reason, const IoFrame &frame, uint8_t len)
Log a frame‑level issue (unregistered endpoints, unsupported commands).
void clear_command_result(IoDevice &dev)
Clear a previously recorded CMD_ERROR_RESP result, if any.
LastCommandRecord decode_last_command_record(const IoFrame &frame, uint8_t base)
Decode the last-command record at base from a status-bearing payload.
void record_command_result(IoDevice &dev, const std::string &id, uint8_t result, uint8_t request_cmd=0, bool include_request_cmd=false)
Store a decoded CMD_ERROR_RESP result on the device and log it.
const char * device_operation_profile_name(DeviceType type)
Human‑readable operation profile name for a device type.
uint32_t settle_delay_ms(uint32_t interval_ms, uint32_t hint_delay_ms, bool cap_for_stop)
Resolve the follow-up settle-poll delay while a device may still be moving.
static constexpr uint32_t ONEWAY_QUIET_PERIOD_MS
Hold queued background polls back for this long after any 1W frame, so a poll the hub itself schedule...
static constexpr uint8_t NODE_ID_SIZE
Device/node addresses are 3 bytes (e.g., "123ABC").
static constexpr uint8_t CMD_ERROR_RESP
Error response to any command.
static constexpr uint8_t CMD_GET_NAME_RESP
Device name response.
@ UNKNOWN
Unknown/unspecified device.
static constexpr uint8_t CMD_STATUS_UPDATE
Device-initiated status update (needs auth).
bool default_inverted_for_type(DeviceType type)
Determine whether a device type has inverted position mapping by default.
std::optional< float > oneway_intent_to_target(uint8_t main0, uint8_t main1)
Resolve a 1W main-byte pair to an optimistic IO target position, if unambiguous.
static constexpr uint32_t FREQ_CH1
The protocol uses 3 frequency channels in the 868 MHz ISM band.
static constexpr uint32_t FREQ_CH3
Channel 3: 869.85 MHz (2W only).
static constexpr uint8_t CTRL0_PROTOCOL_1W
Bit 5: 1=OneWay protocol, 0=TwoWay protocol.
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.
float decode_tilt_report(uint16_t tilt_raw)
Decode tilt angle from raw 16‑bit value.
OneWayFrameInfo decode_1w_frame(const IoFrame &frame)
Decode a parsed 1W frame into a structured OneWayFrameInfo.
static constexpr uint32_t REMOTE_ACTIVITY_STATUS_POLL_DELAY_MS
Delay before polling after overheard remote traffic.
uint8_t frame_length(const IoFrame &f)
Get total frame length from ctrl0.
DeviceType decode_packed_device_type(uint8_t type_msb, uint8_t type_subtype)
Decode a protocol-packed device type from two metadata bytes.
bool parse(const uint8_t *buf, uint8_t buf_len, IoFrame &f)
Parse a wire buffer into a parsed IoFrame (validates length and CTRL0).
static constexpr uint8_t CMD_PRIVATE_RESP
Response to 0x00 and 0x03 (contains position data).
const char * device_capability_class_name(DeviceType type)
Get a human‑readable name for a capability class.
static constexpr uint32_t FREQ_CH2
Channel 2: 868.95 MHz (1W and 2W, TX channel).
bool effective_is_stopped(const IoDevice &dev)
Whether a consumer should treat the device as at rest, prediction first.
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::string decode_device_name_payload(const uint8_t *data, uint8_t len)
Decode a device-name payload from IO-homecontrol's Latin-1 wire format into UTF-8.
uint8_t decode_packed_device_subtype(uint8_t type_subtype)
Decode a protocol-packed device subtype from the second metadata byte.
@ BROADCAST_TYPE
Broadcast to specific device type with non-standard suffix.
static constexpr uint8_t STATUS_TILT_SELECTOR
Extended status payload marker for tilt-capable devices.
static constexpr uint16_t SHORT_PREAMBLE
8 bytes for response/continuation frames
static constexpr uint8_t CMD_GET_INFO2_RESP
Device type/model response.
static constexpr uint8_t POS_STOP
Position values in the IO protocol.
void decode_position_report(uint16_t target_raw, uint16_t current_raw, bool is_stopped, float &target, float &position)
Decode target/current position values from a status frame.
bool device_supports_tilt(DeviceType type)
Does this device type support tilt (slat angle) control?
bool create_status_update_resp(IoFrame &f, const uint8_t *own, const uint8_t *dst)
Build a status-update acknowledgment (0x72).
static constexpr uint8_t STATUS_STOPPED
Status byte flags in CMD_PRIVATE_RESP and CMD_STATUS_UPDATE.
Command builders for the IO‑Homecontrol protocol.
Runtime state of a paired IO‑Homecontrol device.
char name[DEVICE_NAME_BUFFER_SIZE]
Cached UTF-8 device name decoded from Latin-1 wire payloads.
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 ctrl0
Control byte 0: flags + length.
uint8_t src[NODE_ID_SIZE]
Source node ID (3 bytes).
uint8_t dst[NODE_ID_SIZE]
Destination node ID (3 bytes).
uint8_t data_len
Actual length of data.
Decoded representation of a 1W remote frame.
bool has_intent
True if originator/ACEI/intent fields were decoded.
DeviceType target_type
Target device class from broadcast address.
uint8_t main0
Raw first main byte (has_intent only); feeds oneway_intent_to_target().
uint8_t main1
Raw second main byte (has_intent only); feeds oneway_intent_to_target().
AddressClass address_class
Classification of the broadcast address.
Raw packet received from the radio.
uint8_t len
Length of packet in bytes.
uint32_t freq_hz
Frequency the packet was received on (Hz).
uint8_t data[RADIO_PACKET_BUFFER_SIZE]
Raw packet data buffer.
Key fields of the last processed 1W frame, used to collapse a remote's repeat burst.