Initial iBeacon rotator
This commit is contained in:
90
components/ibeacon_rotator/__init__.py
Normal file
90
components/ibeacon_rotator/__init__.py
Normal file
@@ -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)
|
||||||
230
components/ibeacon_rotator/ibeacon_rotator.cpp
Normal file
230
components/ibeacon_rotator/ibeacon_rotator.cpp
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
#include "ibeacon_rotator.h"
|
||||||
|
|
||||||
|
#ifdef USE_ESP32
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#include <esp_bt_main.h>
|
||||||
|
#include <esp_gap_ble_api.h>
|
||||||
|
|
||||||
|
#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<uint16_t>(this->min_interval_ / 0.625f),
|
||||||
|
.adv_int_max = static_cast<uint16_t>(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<int32_t>(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<unsigned>(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<uint8_t>(this->major_ >> 8);
|
||||||
|
payload[26] = static_cast<uint8_t>(this->major_ & 0xFF);
|
||||||
|
payload[27] = static_cast<uint8_t>(this->minor_ >> 8);
|
||||||
|
payload[28] = static_cast<uint8_t>(this->minor_ & 0xFF);
|
||||||
|
payload[29] = static_cast<uint8_t>(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
|
||||||
66
components/ibeacon_rotator/ibeacon_rotator.h
Normal file
66
components/ibeacon_rotator/ibeacon_rotator.h
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "esphome/components/esp32_ble/ble.h"
|
||||||
|
#include "esphome/core/component.h"
|
||||||
|
|
||||||
|
#ifdef USE_ESP32
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <esp_gap_ble_api.h>
|
||||||
|
|
||||||
|
namespace esphome::ibeacon_rotator {
|
||||||
|
|
||||||
|
using namespace esp32_ble;
|
||||||
|
|
||||||
|
class IBeaconRotator : public Component {
|
||||||
|
public:
|
||||||
|
explicit IBeaconRotator(const std::array<uint8_t, 10> &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<uint16_t>(ms); }
|
||||||
|
void set_max_interval(uint32_t ms) { this->max_interval_ = static_cast<uint16_t>(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<uint8_t, 10> prefix_;
|
||||||
|
std::vector<Entry> 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
|
||||||
Reference in New Issue
Block a user