diff --git a/components/ibeacon_rotator/__init__.py b/components/ibeacon_rotator/__init__.py new file mode 100644 index 0000000..352d8e8 --- /dev/null +++ b/components/ibeacon_rotator/__init__.py @@ -0,0 +1,90 @@ +import re + +import esphome.codegen as cg +from esphome.components import esp32_ble +from esphome.components.esp32 import add_idf_sdkconfig_option +from esphome.components.esp32_ble import CONF_BLE_ID +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.core import TimePeriod + +AUTO_LOAD = ["esp32_ble"] +DEPENDENCIES = ["esp32"] + +ibeacon_rotator_ns = cg.esphome_ns.namespace("ibeacon_rotator") +IBeaconRotator = ibeacon_rotator_ns.class_("IBeaconRotator", cg.Component) + +CONF_UUID_PREFIX = "uuid_prefix" +CONF_BROADCAST_LENGTH = "broadcast_length" +CONF_MIN_INTERVAL = "min_interval" +CONF_MAX_INTERVAL = "max_interval" +CONF_MAJOR = "major" +CONF_MINOR = "minor" +CONF_MEASURED_POWER = "measured_power" + + +def validate_uuid_prefix(value): + if not isinstance(value, str): + raise cv.Invalid("uuid_prefix must be a string of hex bytes") + stripped = re.sub(r"[:\-\s]", "", value) + if len(stripped) != 20: + raise cv.Invalid( + f"uuid_prefix must be exactly 10 bytes (20 hex chars after stripping separators); got {len(stripped)}" + ) + if not re.fullmatch(r"[0-9A-Fa-f]+", stripped): + raise cv.Invalid("uuid_prefix contains non-hex characters") + return [int(stripped[i : i + 2], 16) for i in range(0, 20, 2)] + + +def validate_config(config): + if config[CONF_MIN_INTERVAL] > config[CONF_MAX_INTERVAL]: + raise cv.Invalid("min_interval must be <= max_interval") + return config + + +_INTERVAL = cv.All( + cv.positive_time_period_milliseconds, + cv.Range(min=TimePeriod(milliseconds=20), max=TimePeriod(milliseconds=10240)), +) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(IBeaconRotator), + cv.GenerateID(CONF_BLE_ID): cv.use_id(esp32_ble.ESP32BLE), + cv.Required(CONF_UUID_PREFIX): validate_uuid_prefix, + cv.Optional( + CONF_BROADCAST_LENGTH, default="30s" + ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_MIN_INTERVAL, default="100ms"): _INTERVAL, + cv.Optional(CONF_MAX_INTERVAL, default="100ms"): _INTERVAL, + cv.Optional(CONF_MAJOR, default=0): cv.uint16_t, + cv.Optional(CONF_MINOR, default=0): cv.uint16_t, + cv.Optional(CONF_MEASURED_POWER, default=-59): cv.int_range( + min=-128, max=0 + ), + } + ).extend(cv.COMPONENT_SCHEMA), + validate_config, +) + +FINAL_VALIDATE_SCHEMA = esp32_ble.validate_variant + + +async def to_code(config): + prefix_arr = [cg.RawExpression(f"0x{b:02X}") for b in config[CONF_UUID_PREFIX]] + var = cg.new_Pvariable(config[CONF_ID], prefix_arr) + + parent = await cg.get_variable(config[CONF_BLE_ID]) + esp32_ble.register_gap_event_handler(parent, var) + + await cg.register_component(var, config) + cg.add(var.set_broadcast_length(config[CONF_BROADCAST_LENGTH])) + cg.add(var.set_min_interval(config[CONF_MIN_INTERVAL])) + cg.add(var.set_max_interval(config[CONF_MAX_INTERVAL])) + cg.add(var.set_major(config[CONF_MAJOR])) + cg.add(var.set_minor(config[CONF_MINOR])) + cg.add(var.set_measured_power(config[CONF_MEASURED_POWER])) + + add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) + add_idf_sdkconfig_option("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True) diff --git a/components/ibeacon_rotator/ibeacon_rotator.cpp b/components/ibeacon_rotator/ibeacon_rotator.cpp new file mode 100644 index 0000000..0af1f78 --- /dev/null +++ b/components/ibeacon_rotator/ibeacon_rotator.cpp @@ -0,0 +1,230 @@ +#include "ibeacon_rotator.h" + +#ifdef USE_ESP32 + +#include +#include + +#include +#include + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::ibeacon_rotator { + +static const char *const TAG = "ibeacon_rotator"; + +// Apple iBeacon advertising prefix: flags + Apple manufacturer-specific header. +static const uint8_t IBEACON_HEAD[9] = { + 0x02, 0x01, 0x06, // flags AD + 0x1A, // length of mfg AD payload + 0xFF, // type: manufacturer specific + 0x4C, 0x00, // Apple company id + 0x02, 0x15, // iBeacon sub-type +}; + +void IBeaconRotator::setup() { + this->ble_adv_params_ = { + .adv_int_min = static_cast(this->min_interval_ / 0.625f), + .adv_int_max = static_cast(this->max_interval_ / 0.625f), + .adv_type = ADV_TYPE_NONCONN_IND, + .own_addr_type = BLE_ADDR_TYPE_PUBLIC, + .peer_addr = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + .peer_addr_type = BLE_ADDR_TYPE_PUBLIC, + .channel_map = ADV_CHNL_ALL, + .adv_filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY, + }; +} + +void IBeaconRotator::dump_config() { + char prefix[21]; + for (int i = 0; i < 10; i++) { + snprintf(prefix + 2 * i, 3, "%02X", this->prefix_[i]); + } + ESP_LOGCONFIG(TAG, "iBeacon Rotator:"); + ESP_LOGCONFIG(TAG, " UUID Prefix: %s (last 6 bytes filled from MAC)", prefix); + ESP_LOGCONFIG(TAG, " Broadcast Length: %ums", this->broadcast_length_ms_); + ESP_LOGCONFIG(TAG, " Min Interval: %ums, Max Interval: %ums", this->min_interval_, this->max_interval_); + ESP_LOGCONFIG(TAG, " Major: %u, Minor: %u, Measured Power: %ddBm", this->major_, this->minor_, + this->measured_power_); +} + +void IBeaconRotator::loop() { + this->evict_expired_(); + + if (this->entries_.empty()) { + if (this->advertising_) { + this->stop_advertising_(); + } + return; + } + + uint32_t now = millis(); + if (!this->advertising_) { + this->advertising_ = true; + this->cursor_ = 0; + this->last_rotate_ms_ = now; + this->apply_current_payload_(); + } else if (this->entries_.size() > 1 && (now - this->last_rotate_ms_) >= this->max_interval_) { + this->cursor_ = (this->cursor_ + 1) % this->entries_.size(); + this->last_rotate_ms_ = now; + this->apply_current_payload_(); + } +} + +void IBeaconRotator::evict_expired_() { + uint32_t now = millis(); + // Capture the MAC at cursor before erase so we can re-locate it after. + uint8_t cursor_mac[6]; + bool had_cursor = !this->entries_.empty() && this->cursor_ < this->entries_.size(); + if (had_cursor) { + std::memcpy(cursor_mac, this->entries_[this->cursor_].mac, 6); + } + + auto before = this->entries_.size(); + this->entries_.erase(std::remove_if(this->entries_.begin(), this->entries_.end(), + [now](const Entry &e) { + // Signed compare handles millis() wraparound. + return static_cast(now - e.expires_at_ms) >= 0; + }), + this->entries_.end()); + + if (this->entries_.size() == before) { + return; + } + + if (this->entries_.empty()) { + this->cursor_ = 0; + return; + } + + // Restore cursor to the same MAC if it survived; otherwise wrap. + if (had_cursor) { + for (size_t i = 0; i < this->entries_.size(); i++) { + if (std::memcmp(this->entries_[i].mac, cursor_mac, 6) == 0) { + this->cursor_ = i; + return; + } + } + } + if (this->cursor_ >= this->entries_.size()) { + this->cursor_ = 0; + } +} + +void IBeaconRotator::add_mac(const uint8_t mac[6]) { + uint32_t now = millis(); + for (auto &e : this->entries_) { + if (std::memcmp(e.mac, mac, 6) == 0) { + e.expires_at_ms = now + this->broadcast_length_ms_; + ESP_LOGD(TAG, "Refreshed %02X:%02X:%02X:%02X:%02X:%02X (+%ums)", mac[0], mac[1], mac[2], mac[3], mac[4], + mac[5], this->broadcast_length_ms_); + return; + } + } + Entry e; + std::memcpy(e.mac, mac, 6); + e.expires_at_ms = now + this->broadcast_length_ms_; + this->entries_.push_back(e); + ESP_LOGD(TAG, "Added %02X:%02X:%02X:%02X:%02X:%02X (now %u active)", mac[0], mac[1], mac[2], mac[3], mac[4], + mac[5], static_cast(this->entries_.size())); +} + +bool IBeaconRotator::add_mac_from_string(const std::string &mac_str) { + uint8_t mac[6]; + int idx = 0; + uint8_t cur = 0; + int nibbles = 0; + for (char c : mac_str) { + if (c == ':' || c == '-' || c == ' ') { + continue; + } + uint8_t v; + if (c >= '0' && c <= '9') { + v = c - '0'; + } else if (c >= 'a' && c <= 'f') { + v = c - 'a' + 10; + } else if (c >= 'A' && c <= 'F') { + v = c - 'A' + 10; + } else { + ESP_LOGW(TAG, "Invalid MAC string: '%s'", mac_str.c_str()); + return false; + } + cur = (cur << 4) | v; + nibbles++; + if (nibbles == 2) { + if (idx >= 6) { + ESP_LOGW(TAG, "MAC string too long: '%s'", mac_str.c_str()); + return false; + } + mac[idx++] = cur; + cur = 0; + nibbles = 0; + } + } + if (idx != 6 || nibbles != 0) { + ESP_LOGW(TAG, "MAC string wrong length: '%s'", mac_str.c_str()); + return false; + } + this->add_mac(mac); + return true; +} + +void IBeaconRotator::apply_current_payload_() { + uint8_t payload[30]; + std::memcpy(payload, IBEACON_HEAD, 9); + std::memcpy(payload + 9, this->prefix_.data(), 10); + std::memcpy(payload + 19, this->entries_[this->cursor_].mac, 6); + payload[25] = static_cast(this->major_ >> 8); + payload[26] = static_cast(this->major_ & 0xFF); + payload[27] = static_cast(this->minor_ >> 8); + payload[28] = static_cast(this->minor_ & 0xFF); + payload[29] = static_cast(this->measured_power_); + + esp_err_t err = esp_ble_gap_config_adv_data_raw(payload, sizeof(payload)); + if (err != ESP_OK) { + ESP_LOGE(TAG, "config_adv_data_raw failed: %s", esp_err_to_name(err)); + } +} + +void IBeaconRotator::stop_advertising_() { + this->advertising_ = false; + esp_err_t err = esp_ble_gap_stop_advertising(); + if (err != ESP_OK) { + ESP_LOGW(TAG, "stop_advertising failed: %s", esp_err_to_name(err)); + } + ESP_LOGD(TAG, "All MACs expired; stopped advertising"); +} + +void IBeaconRotator::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { + if (!this->advertising_) { + return; + } + switch (event) { + case ESP_GAP_BLE_ADV_DATA_RAW_SET_COMPLETE_EVT: { + esp_err_t err = esp_ble_gap_start_advertising(&this->ble_adv_params_); + // start_advertising is benign-redundant when already advertising; log only hard errors. + if (err != ESP_OK) { + ESP_LOGD(TAG, "start_advertising returned: %s", esp_err_to_name(err)); + } + break; + } + case ESP_GAP_BLE_ADV_START_COMPLETE_EVT: + if (param->adv_start_cmpl.status != ESP_BT_STATUS_SUCCESS) { + ESP_LOGD(TAG, "adv_start status %d", param->adv_start_cmpl.status); + } + break; + case ESP_GAP_BLE_ADV_STOP_COMPLETE_EVT: + if (param->adv_stop_cmpl.status != ESP_BT_STATUS_SUCCESS) { + ESP_LOGW(TAG, "adv_stop status %d", param->adv_stop_cmpl.status); + } + break; + default: + break; + } +} + +} // namespace esphome::ibeacon_rotator + +#endif // USE_ESP32 diff --git a/components/ibeacon_rotator/ibeacon_rotator.h b/components/ibeacon_rotator/ibeacon_rotator.h new file mode 100644 index 0000000..306693a --- /dev/null +++ b/components/ibeacon_rotator/ibeacon_rotator.h @@ -0,0 +1,66 @@ +#pragma once + +#include "esphome/components/esp32_ble/ble.h" +#include "esphome/core/component.h" + +#ifdef USE_ESP32 + +#include +#include +#include + +#include + +namespace esphome::ibeacon_rotator { + +using namespace esp32_ble; + +class IBeaconRotator : public Component { + public: + explicit IBeaconRotator(const std::array &prefix) : prefix_(prefix) {} + + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::AFTER_BLUETOOTH; } + + void set_broadcast_length(uint32_t ms) { this->broadcast_length_ms_ = ms; } + void set_min_interval(uint32_t ms) { this->min_interval_ = static_cast(ms); } + void set_max_interval(uint32_t ms) { this->max_interval_ = static_cast(ms); } + void set_major(uint16_t v) { this->major_ = v; } + void set_minor(uint16_t v) { this->minor_ = v; } + void set_measured_power(int8_t v) { this->measured_power_ = v; } + + void add_mac(const uint8_t mac[6]); + bool add_mac_from_string(const std::string &mac_str); + + void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param); + + protected: + struct Entry { + uint8_t mac[6]; + uint32_t expires_at_ms; + }; + + void apply_current_payload_(); + void stop_advertising_(); + void evict_expired_(); + + std::array prefix_; + std::vector entries_; + size_t cursor_{0}; + uint32_t last_rotate_ms_{0}; + bool advertising_{false}; + + uint32_t broadcast_length_ms_{30000}; + uint16_t min_interval_{100}; + uint16_t max_interval_{100}; + uint16_t major_{0}; + uint16_t minor_{0}; + int8_t measured_power_{-59}; + esp_ble_adv_params_t ble_adv_params_{}; +}; + +} // namespace esphome::ibeacon_rotator + +#endif // USE_ESP32