From 89d5611d43a93905e2c781c7e3f1580c8b410084 Mon Sep 17 00:00:00 2001 From: Fritiof Hedman Date: Tue, 9 Jun 2026 17:31:08 +0200 Subject: [PATCH] Initial iBeacon rotator --- components/ibeacon_rotator/__init__.py | 90 +++++++ .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 5767 bytes .../ibeacon_rotator/ibeacon_rotator.cpp | 230 ++++++++++++++++++ components/ibeacon_rotator/ibeacon_rotator.h | 66 +++++ 4 files changed, 386 insertions(+) create mode 100644 components/ibeacon_rotator/__init__.py create mode 100644 components/ibeacon_rotator/__pycache__/__init__.cpython-314.pyc create mode 100644 components/ibeacon_rotator/ibeacon_rotator.cpp create mode 100644 components/ibeacon_rotator/ibeacon_rotator.h 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/__pycache__/__init__.cpython-314.pyc b/components/ibeacon_rotator/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b5baee4021fdf09170921ac7ac4214c6b4aee25a GIT binary patch literal 5767 zcmbstTTC0-^^QHZ@e|uX;t&!HBm{?NOcL@SAqEUMON=u-Htj;DgFV2cV0&lAghZ<3 zWQ=$1&4y8YO{D6CX%f41k2Jsu}Z z+N?5??|IyF&OP^W&OO)74!Z@x_ldRX+J+6Ge-c9dstd?YJ&jNrT}2EPLUJBbLpoVs zOK@F?9x})V1=oj+Lnhgz;B=^L$Sj)`oC;Zntg=nP4I%rGLv|>*F;qU}l%2JRF||>$ ziz$QFJ*AH{sxD|S;?r;yW6Z~?JT6yFRYh7=z6Kc!SZmX06+5oWW3q>_%KI3bT+P_! z{ftAdVanwLj8i_yxa3;Ky%)(|rUKf!DO;pp)z)Ag0{(zTs~B*HR9u4~?W{vrE1Al! zg5QK@sx+F}(^c@S=;7i2G4ilxTg4QQ!YZY~I&{21fW3&uS257@sH+JQyIPnkt~W?9 zs-_0Ph9NSh(JEGAcS7Z95VZCcCA?tyiq@xMAXClkWjyNYfYk1*zPs*zW?zXeS<&6) zYF<+A0G#_$U%s#UslgYHaqty%YnlDej611~r($rPYG8!oIchKNS%g((Gzeb)K%-Qw zxEpoFQ+Nz^Hm3G#Q0`=^Av%sTR0HxI_>y#leH1qHQnJ&}PS5i|c(_=M6=E~26#HQ` z5uXue*+fzn5^?CW_Kk!G*xunF8|nwrq>u_~850)x2rmkWn5YM{*r3?N<-#=2ff-gz z$ef%Iu_M&0Fqi^uO{Ihwn-uvOVF^2@#RM0Na+1u>^YK}E4%-%lI4i_uUR>nnf#Q}l z%CNv)1B(U_A%+)tPD+V97)xB|#b_a9mC2i-C0_0P3AkxAS?rrYlZ60ANL{=miV8xr zdl9NZlXQVZ5u=J}5VjAg%Rfg=mZ?1vB~7bT9)c{z=qAu{;t707YSNy^5SpM=D_1K| zT}LR^@KR&zce7waew}LV8J`^LXc`2cXc)V&2H$b!hLTZSs?DpAFt6&=IXcm}E~RTj zDcyhvW~`g6P~~2CPw1}dT2vAsg|0ePPr|y_;5sv-gP@tzH9%XdU;^?PZ~97(jCUa= z$=+$+%XuYP6ymeq#Ef^2U-C}hka_8*>5Vg!t&`FeMmNWH)8d!7s62ned#od`;BE4E zDC*HUPL#ad3>-Yga5713OMH?O$uX9EXT7rt*?ZIWMn~)EKPQ92sA-v*|y{jT&Z})$vj&!L9rf2B1MPv zTWHc~+gkN5|MSsJYu&oFZrkC!d+pA(%)b7tBbX@*ZkN06F5X#u_u5MR3ZE@+Ne^wC zEVoa+^`m87&Qx8H-f~RmqBEJR-3|i(~X=9etUyzHNWk1L}eQ!JD77XRW>W+gC6DwC~-r<;yFskH$03h85I)q~1%RFdGVw1(}J!u*wQtDYC>p;HC(!fMsmVCv%D9 zzDBVQT3lY-)_k8x5&-#0&CsOLmdT!T)NYu(D<^YD&u%ny=StRaJa7$R<*&&f@BgW5gsd*V2j&*b_NYAW2$)In+ zZ|6U-1NEpDym-<2LvEo8x#fV8O^ca4Qq0-Julz?c^#sLG_LA%fS*A9hWs*9`#+v@F znI8No=J(S5p3tdTE2%HBvFi;0HlS?Jf2B6mOgab!`ZbvO5|!2u8!RYEPZqOw#Hng( zunxW6r;c7*a+gKinv9BpY=-lfD`Ka@HDf|0X zOBw|GH7fPW_F=D||G>Ui<-M}~2Gx=V!TuqYdS(0S^{g5M2=*b!^4}}lCqe(*2lk6s z^#8HnuBtM6hE~rR?7PM{l=2zvR;L7HLP2VHNofa9=mpAW#JUbh7#;pA;e28X4YIvs zY%m;v>a%|gH<0|7=aF*sxSt&e2FAvj;3zvf9*K-FW5Ir(2RoHCIX)ihXCq8-Aan(; z*;#C7xa+|ss5vDG>RN1%^ViwPA}0zQl)xeher%iNWwyvN04rbCR*U?sAi*8Z&T>hX zUxcfZo#Wy$(8KOxU-4SSdZ?yxr3A*lRoYf5zV$LAf&RY0=omX33=fVCVJ8tTy#+*a zf(q=C@>e`e*k(mqse>)~Dq@SsiaYbG&7#M}sgJl|dJ;;b8 zf%*($BN>94#+H12&T^uN^{^_~2z3yt8py&xvElXRFBFrUV55l`FJ1&b`AHGDZoWit zm7KYd3tWD9hRM1{*Um8aE`LzJ>8@XQ*RL?E)M~@Z^_A|2?hfclU(C2hw=K3!OZB>? zdimtXM?UIVy_mIhrTf09sQR$y{hpPRzi!@WIGL^J+O&722exVZCf&47H?8i;(I>tj znziee+7*4)(y~dnWayS{+W7Xst%2K}o7RRkYs1PwuKB{op#anfIl8Z;vv$o|yJE~8 zIkn+Eowc6H(Pv+-^X|EK&Moix*s`&&Icsgn(XB<%e z&lhw}=0L~2Lpi!@o1nda?te&!zJ6MZ++);JBXXRA#?bn;L_+G8bMP$ne#2k~{kuRJ zpr4(i22aqRo$mzv^JCQDDf)ANkOtzfrzmKJzkKt}H$oa3-wBbVY+PQb1>4Am%|exF zDj?zs((Tr|)e|^qKQ~G2gb)n*kOn$Z0iq3g5myPxGxkO#g>9B8<(&-&Mg4R zLE-IwuIMG*#^^jJNh~(^skO04u5yw5A0wV44T(o3`osXi`bZNb4Jn9~8Vc(J^Ygea znUDloSmaq59>;7_c^yM&Yb6pkFiJUZE|zTAI2xVf7dUJgt6Gt?g#3l;>GF#jbI$rY4O z0A<|^iCAi$KQG3CM@miU@6bNcQxx?NWd0Ybd4!rCp>vPW@kglf5vqHH8Xns{w=bp5 zTh6_=Z=`LHtM-04_1@HGRm)mcOQ!XFwyGzC+*_`OP1n&i*U?P#*$2$0lYf}Zx-O<2 zk1M^-)i&^_2UG4cW`@4%`{oqK%L@ z*61cc-X6L&^j`Ic zpNuRsznJ>j)av={fiqiq-g0x+*0`$sjrmvRd%jJ7|C+x)+Y}_7uibeq<7vv;e3|B+ z0=?xtyqfw-ud_QIm%BF0JJ-rPi;}*3T{+v?2j6=UW7)DA$l2QN>3(m$Z!HMz#>;q` z?~Uecryuw~?fzr;R;6c|gAuzOPxd;@mL0v*VEnfine0EFxy@|ay=!)F)>yYiSL__F daOu+aCr8n~`c032&Etpl{mSU1E>g-~{}&#^&ZYnW literal 0 HcmV?d00001 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