Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf1db45c18 | |||
| 52cfd9ce33 | |||
| cde01204a5 | |||
| d2c1eab348 | |||
| 3651dd9ef9 | |||
| b9195a0dda | |||
| 8992e311ba | |||
| 163a0de93a | |||
| 680522c930 | |||
| 6f313f0145 |
+1
-1
@@ -3,4 +3,4 @@ cmake_minimum_required(VERSION 3.22)
|
||||
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||
# "Trim" the build. Include the minimal set of components, main, and anything it depends on.
|
||||
idf_build_set_property(MINIMAL_BUILD ON)
|
||||
project(esp-anchor)
|
||||
project(esp-anchor VERSION 0.2.0)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
idf_component_register(
|
||||
SRCS "src/ble_scanner.c"
|
||||
INCLUDE_DIRS "include"
|
||||
REQUIRES bt esp_event
|
||||
REQUIRES esp_event bt
|
||||
)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
menu "BLE Scanner"
|
||||
|
||||
config BLE_SCAN_INTERVAL_MS
|
||||
int "Scan interval (ms)"
|
||||
default 100
|
||||
range 3 10240
|
||||
help
|
||||
Time between the start of consecutive scan windows (0.625 ms units
|
||||
internally). Larger values reduce duty cycle and leave more airtime
|
||||
for WiFi.
|
||||
|
||||
config BLE_SCAN_WINDOW_MS
|
||||
int "Scan window (ms)"
|
||||
default 80
|
||||
range 3 10240
|
||||
help
|
||||
Duration of each active scan window. Must be <= scan interval.
|
||||
Default 80 ms gives 80% duty cycle (up from 50% with previous
|
||||
defaults) without starving WiFi TX.
|
||||
|
||||
endmenu
|
||||
@@ -2,16 +2,29 @@
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
/**
|
||||
* Called for every unique BLE advertisement received.
|
||||
* tag_id is a null-terminated string: "aa:bb:cc:dd:ee:ff"
|
||||
* rssi is in dBm (negative).
|
||||
*/
|
||||
typedef void (*ble_scanner_cb_t)(const char *tag_id, int8_t rssi);
|
||||
typedef enum {
|
||||
BLE_BEACON_IBEACON,
|
||||
BLE_BEACON_ALTBEACON,
|
||||
BLE_BEACON_EDDYSTONE_UID,
|
||||
BLE_BEACON_EDDYSTONE_URL,
|
||||
} ble_beacon_type_t;
|
||||
|
||||
typedef struct {
|
||||
ble_beacon_type_t type;
|
||||
char id[64]; /* beacon identifier; encoding varies by type:
|
||||
iBeacon: "UUID-MAJOR-MINOR"
|
||||
AltBeacon: 40-char hex beacon ID
|
||||
Eddystone UID: "NAMESPACE.INSTANCE" hex
|
||||
Eddystone URL: decoded URL string */
|
||||
int8_t tx_power; /* calibrated TX power from beacon payload */
|
||||
int8_t rssi; /* measured signal strength */
|
||||
} ble_beacon_t;
|
||||
|
||||
typedef void (*ble_scanner_cb_t)(const ble_beacon_t *beacon);
|
||||
|
||||
/**
|
||||
* Initialise the Bluedroid BLE stack and register the scan callback.
|
||||
* Must be called once after esp_bt_controller_init / esp_bluedroid_init.
|
||||
* Initialise the NimBLE stack and register the scan callback. Must be called
|
||||
* once before ble_scanner_start(). Starts the NimBLE host task internally.
|
||||
*/
|
||||
void ble_scanner_init(ble_scanner_cb_t cb);
|
||||
|
||||
|
||||
@@ -1,89 +1,218 @@
|
||||
#include "ble_scanner.h"
|
||||
#include "esp_bt.h"
|
||||
#include "esp_bt_main.h"
|
||||
#include "esp_gap_ble_api.h"
|
||||
#include "nimble/nimble_port.h"
|
||||
#include "nimble/nimble_port_freertos.h"
|
||||
#include "host/ble_hs.h"
|
||||
#include "host/ble_gap.h"
|
||||
#include "esp_log.h"
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
static int gap_event_cb(struct ble_gap_event *event, void *arg);
|
||||
|
||||
#define TAG "ble_scanner"
|
||||
|
||||
static ble_scanner_cb_t s_cb = NULL;
|
||||
/* Units: 1 tick = 0.625 ms */
|
||||
#define MS_TO_BLE_TICKS(ms) ((ms) * 1000 / 625)
|
||||
|
||||
/* Passive scan: 100ms interval, 50ms window (50% duty cycle) */
|
||||
static esp_ble_scan_params_t s_scan_params = {
|
||||
.scan_type = BLE_SCAN_TYPE_PASSIVE,
|
||||
.own_addr_type = BLE_ADDR_TYPE_PUBLIC,
|
||||
.scan_filter_policy = BLE_SCAN_FILTER_ALLOW_ALL,
|
||||
.scan_interval = 0xA0, /* 100ms: 0xA0 * 0.625ms */
|
||||
.scan_window = 0x50, /* 50ms: 0x50 * 0.625ms */
|
||||
.scan_duplicate = BLE_SCAN_DUPLICATE_DISABLE,
|
||||
static ble_scanner_cb_t s_cb = NULL;
|
||||
static bool s_scan_active = false;
|
||||
static uint8_t s_own_addr_type;
|
||||
|
||||
/* Eddystone URL expansion codes 0x00-0x0D per spec */
|
||||
static const char *const s_url_expansions[] = {
|
||||
".com/", ".org/", ".edu/", ".net/",
|
||||
".info/", ".biz/", ".gov/", ".com",
|
||||
".org", ".edu", ".net", ".info", ".biz", ".gov",
|
||||
};
|
||||
|
||||
static void gap_event_handler(esp_gap_ble_cb_event_t event,
|
||||
esp_ble_gap_cb_param_t *param)
|
||||
/*
|
||||
* iBeacon: AD type 0xFF, company {0x4C,0x00}, subtype {0x02,0x15}
|
||||
* value layout: company[2] subtype[2] UUID[16] major[2] minor[2] tx_power[1]
|
||||
*/
|
||||
static bool try_ibeacon(const uint8_t *val, uint8_t vlen, ble_beacon_t *out)
|
||||
{
|
||||
switch (event) {
|
||||
case ESP_GAP_BLE_SCAN_PARAM_SET_COMPLETE_EVT:
|
||||
if (param->scan_param_cmpl.status == ESP_BT_STATUS_SUCCESS) {
|
||||
esp_ble_gap_start_scanning(0); /* 0 = scan indefinitely */
|
||||
if (vlen < 25 || val[0] != 0x4C || val[1] != 0x00
|
||||
|| val[2] != 0x02 || val[3] != 0x15)
|
||||
return false;
|
||||
|
||||
const uint8_t *u = val + 4;
|
||||
snprintf(out->id, sizeof(out->id),
|
||||
"%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x"
|
||||
"-%02x%02x%02x%02x%02x%02x-%u-%u",
|
||||
u[0],u[1],u[2],u[3], u[4],u[5], u[6],u[7], u[8],u[9],
|
||||
u[10],u[11],u[12],u[13],u[14],u[15],
|
||||
(unsigned)((u[16] << 8) | u[17]),
|
||||
(unsigned)((u[18] << 8) | u[19]));
|
||||
out->tx_power = (int8_t)val[24];
|
||||
out->type = BLE_BEACON_IBEACON;
|
||||
return true;
|
||||
}
|
||||
|
||||
/*
|
||||
* AltBeacon: AD type 0xFF, bytes[2..3]=={0xBE,0xAC}
|
||||
* value layout: mfg_id[2] beacon_code[2] beacon_id[20] tx_power[1] reserved[1]
|
||||
*/
|
||||
static bool try_altbeacon(const uint8_t *val, uint8_t vlen, ble_beacon_t *out)
|
||||
{
|
||||
if (vlen < 26 || val[2] != 0xBE || val[3] != 0xAC)
|
||||
return false;
|
||||
|
||||
const uint8_t *id = val + 4;
|
||||
for (int i = 0; i < 20; i++)
|
||||
snprintf(out->id + i * 2, 3, "%02x", id[i]);
|
||||
out->id[40] = '\0';
|
||||
out->tx_power = (int8_t)val[24];
|
||||
out->type = BLE_BEACON_ALTBEACON;
|
||||
return true;
|
||||
}
|
||||
|
||||
/*
|
||||
* Eddystone: AD type 0x16, service UUID {0xAA,0xFE}
|
||||
* value layout: uuid[2] frame_type[1] ...
|
||||
* UID frame (0x00): tx_power[1] namespace[10] instance[6]
|
||||
* URL frame (0x10): tx_power[1] scheme[1] encoded_url[...]
|
||||
*/
|
||||
static bool try_eddystone(const uint8_t *val, uint8_t vlen, ble_beacon_t *out)
|
||||
{
|
||||
if (vlen < 4 || val[0] != 0xAA || val[1] != 0xFE)
|
||||
return false;
|
||||
|
||||
uint8_t frame = val[2];
|
||||
|
||||
if (frame == 0x00) {
|
||||
if (vlen < 20) return false;
|
||||
out->tx_power = (int8_t)val[3];
|
||||
const uint8_t *ns = val + 4;
|
||||
const uint8_t *inst = val + 14;
|
||||
snprintf(out->id, sizeof(out->id),
|
||||
"%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x"
|
||||
".%02x%02x%02x%02x%02x%02x",
|
||||
ns[0],ns[1],ns[2],ns[3],ns[4],ns[5],ns[6],ns[7],ns[8],ns[9],
|
||||
inst[0],inst[1],inst[2],inst[3],inst[4],inst[5]);
|
||||
out->type = BLE_BEACON_EDDYSTONE_UID;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (frame == 0x10) {
|
||||
if (vlen < 5) return false;
|
||||
out->tx_power = (int8_t)val[3];
|
||||
static const char *const schemes[] = {
|
||||
"http://www.", "https://www.", "http://", "https://",
|
||||
};
|
||||
const char *scheme = (val[4] < 4) ? schemes[val[4]] : "";
|
||||
int pos = snprintf(out->id, sizeof(out->id), "%s", scheme);
|
||||
for (uint8_t i = 5; i < vlen; i++) {
|
||||
uint8_t c = val[i];
|
||||
int rem = (int)sizeof(out->id) - pos;
|
||||
if (rem <= 1) break;
|
||||
int n;
|
||||
if (c < (uint8_t)(sizeof(s_url_expansions) / sizeof(s_url_expansions[0]))) {
|
||||
n = snprintf(out->id + pos, rem, "%s", s_url_expansions[c]);
|
||||
} else {
|
||||
ESP_LOGE(TAG, "Scan param set failed: %d", param->scan_param_cmpl.status);
|
||||
out->id[pos] = (char)c;
|
||||
n = 1;
|
||||
}
|
||||
break;
|
||||
|
||||
case ESP_GAP_BLE_SCAN_START_COMPLETE_EVT:
|
||||
if (param->scan_start_cmpl.status != ESP_BT_STATUS_SUCCESS) {
|
||||
ESP_LOGE(TAG, "Scan start failed: %d", param->scan_start_cmpl.status);
|
||||
pos += (n >= rem) ? rem - 1 : n;
|
||||
}
|
||||
break;
|
||||
|
||||
case ESP_GAP_BLE_SCAN_RESULT_EVT: {
|
||||
esp_ble_gap_cb_param_t *p = param;
|
||||
if (p->scan_rst.search_evt == ESP_GAP_SEARCH_INQ_RES_EVT && s_cb) {
|
||||
char tag_id[18];
|
||||
uint8_t *a = p->scan_rst.bda;
|
||||
snprintf(tag_id, sizeof(tag_id),
|
||||
"%02x:%02x:%02x:%02x:%02x:%02x",
|
||||
a[0], a[1], a[2], a[3], a[4], a[5]);
|
||||
s_cb(tag_id, p->scan_rst.rssi);
|
||||
}
|
||||
break;
|
||||
out->id[sizeof(out->id) - 1] = '\0';
|
||||
out->type = BLE_BEACON_EDDYSTONE_URL;
|
||||
return true;
|
||||
}
|
||||
|
||||
case ESP_GAP_BLE_SCAN_STOP_COMPLETE_EVT:
|
||||
ESP_LOGI(TAG, "Scan stopped");
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
return false;
|
||||
}
|
||||
|
||||
/*
|
||||
* Walk BLE advertisement data (sequence of length-type-value records) and
|
||||
* attempt to match iBeacon, AltBeacon, or Eddystone. Returns true on match.
|
||||
*/
|
||||
static bool parse_beacon(const uint8_t *data, uint8_t len, ble_beacon_t *out)
|
||||
{
|
||||
int pos = 0;
|
||||
while (pos < (int)len) {
|
||||
uint8_t ad_len = data[pos];
|
||||
if (ad_len == 0 || pos + ad_len >= (int)len)
|
||||
break;
|
||||
uint8_t ad_type = data[pos + 1];
|
||||
const uint8_t *val = data + pos + 2;
|
||||
uint8_t vlen = ad_len - 1;
|
||||
|
||||
if (ad_type == 0xFF) {
|
||||
if (try_ibeacon(val, vlen, out)) return true;
|
||||
if (try_altbeacon(val, vlen, out)) return true;
|
||||
} else if (ad_type == 0x16) {
|
||||
if (try_eddystone(val, vlen, out)) return true;
|
||||
}
|
||||
pos += 1 + ad_len;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static void do_start_scan(void)
|
||||
{
|
||||
struct ble_gap_disc_params p = {
|
||||
.itvl = MS_TO_BLE_TICKS(CONFIG_BLE_SCAN_INTERVAL_MS),
|
||||
.window = MS_TO_BLE_TICKS(CONFIG_BLE_SCAN_WINDOW_MS),
|
||||
.filter_policy = 0, /* accept all, no whitelist */
|
||||
.limited = 0,
|
||||
.passive = 1,
|
||||
.filter_duplicates = 0, /* report every advertisement for live RSSI */
|
||||
};
|
||||
int rc = ble_gap_disc(s_own_addr_type, BLE_HS_FOREVER, &p, gap_event_cb, NULL);
|
||||
if (rc != 0)
|
||||
ESP_LOGE(TAG, "ble_gap_disc failed: %d", rc);
|
||||
}
|
||||
|
||||
static int gap_event_cb(struct ble_gap_event *event, void *arg)
|
||||
{
|
||||
if (event->type == BLE_GAP_EVENT_DISC && s_cb) {
|
||||
ble_beacon_t beacon = {0};
|
||||
beacon.rssi = event->disc.rssi;
|
||||
if (parse_beacon(event->disc.data, event->disc.length_data, &beacon))
|
||||
s_cb(&beacon);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void on_nimble_sync(void)
|
||||
{
|
||||
ble_hs_id_infer_auto(0, &s_own_addr_type);
|
||||
if (s_scan_active)
|
||||
do_start_scan();
|
||||
}
|
||||
|
||||
static void on_nimble_reset(int reason)
|
||||
{
|
||||
ESP_LOGW(TAG, "NimBLE reset: %d", reason);
|
||||
}
|
||||
|
||||
static void nimble_host_task(void *arg)
|
||||
{
|
||||
nimble_port_run();
|
||||
nimble_port_freertos_deinit();
|
||||
}
|
||||
|
||||
void ble_scanner_init(ble_scanner_cb_t cb)
|
||||
{
|
||||
s_cb = cb;
|
||||
|
||||
ESP_ERROR_CHECK(esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT));
|
||||
|
||||
esp_bt_controller_config_t bt_cfg = BT_CONTROLLER_INIT_CONFIG_DEFAULT();
|
||||
ESP_ERROR_CHECK(esp_bt_controller_init(&bt_cfg));
|
||||
ESP_ERROR_CHECK(esp_bt_controller_enable(ESP_BT_MODE_BLE));
|
||||
ESP_ERROR_CHECK(esp_bluedroid_init());
|
||||
ESP_ERROR_CHECK(esp_bluedroid_enable());
|
||||
|
||||
ESP_ERROR_CHECK(esp_ble_gap_register_callback(gap_event_handler));
|
||||
ESP_LOGI(TAG, "BLE scanner initialised");
|
||||
nimble_port_init();
|
||||
ble_hs_cfg.sync_cb = on_nimble_sync;
|
||||
ble_hs_cfg.reset_cb = on_nimble_reset;
|
||||
nimble_port_freertos_init(nimble_host_task);
|
||||
ESP_LOGI(TAG, "BLE scanner initialised (NimBLE)");
|
||||
}
|
||||
|
||||
void ble_scanner_start(void)
|
||||
{
|
||||
ESP_LOGI(TAG, "Starting BLE scan");
|
||||
ESP_ERROR_CHECK(esp_ble_gap_set_scan_params(&s_scan_params));
|
||||
/* Scanning begins in the param-set callback once confirmed */
|
||||
ESP_LOGI(TAG, "Starting BLE scan (interval=%dms window=%dms)",
|
||||
CONFIG_BLE_SCAN_INTERVAL_MS, CONFIG_BLE_SCAN_WINDOW_MS);
|
||||
s_scan_active = true;
|
||||
if (ble_hs_synced())
|
||||
do_start_scan();
|
||||
/* else: on_nimble_sync() will start scanning once host syncs with controller */
|
||||
}
|
||||
|
||||
void ble_scanner_stop(void)
|
||||
{
|
||||
esp_ble_gap_stop_scanning();
|
||||
s_scan_active = false;
|
||||
ble_gap_disc_cancel();
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
#define KEY_PROV "provisioned"
|
||||
#define KEY_HOST "mqtt_host"
|
||||
#define KEY_PORT "mqtt_port"
|
||||
#define KEY_SCHEMA_VER "schema_ver"
|
||||
#define CURRENT_SCHEMA_VER 1
|
||||
|
||||
static const char *TAG = "config_store";
|
||||
|
||||
@@ -20,7 +22,22 @@ esp_err_t config_store_init(void)
|
||||
ESP_ERROR_CHECK(nvs_flash_erase());
|
||||
err = nvs_flash_init();
|
||||
}
|
||||
return err;
|
||||
if (err != ESP_OK) return err;
|
||||
|
||||
nvs_handle_t h;
|
||||
err = nvs_open(NS, NVS_READWRITE, &h);
|
||||
if (err != ESP_OK) return err;
|
||||
|
||||
uint8_t schema_ver = 0;
|
||||
nvs_get_u8(h, KEY_SCHEMA_VER, &schema_ver);
|
||||
if (schema_ver < CURRENT_SCHEMA_VER) {
|
||||
/* Add migration steps here for future schema changes */
|
||||
nvs_set_u8(h, KEY_SCHEMA_VER, CURRENT_SCHEMA_VER);
|
||||
nvs_commit(h);
|
||||
ESP_LOGI(TAG, "NVS schema migrated %u -> %u", schema_ver, CURRENT_SCHEMA_VER);
|
||||
}
|
||||
nvs_close(h);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
bool config_store_is_provisioned(void)
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#define MQTT_DESELECTED_BIT BIT4
|
||||
#define MQTT_FACTORY_RESET_BIT BIT5
|
||||
#define MQTT_RECONFIGURE_SETTINGS_BIT BIT6
|
||||
#define MQTT_OTA_BIT BIT7
|
||||
|
||||
/* Populated by handle_cmd() before MQTT_RECONFIGURE_SETTINGS_BIT is set.
|
||||
* All fields are optional — empty string means "not provided, leave unchanged". */
|
||||
@@ -27,19 +28,36 @@ typedef struct {
|
||||
* MQTT_RECONFIGURE_SETTINGS_BIT fires; must be read before the next MQTT event. */
|
||||
const mqtt_reconfigure_data_t *mqtt_publisher_get_reconfigure_data(void);
|
||||
|
||||
typedef struct {
|
||||
char url[256];
|
||||
char version[32];
|
||||
} mqtt_ota_data_t;
|
||||
|
||||
/** Returns a pointer to the last parsed OTA payload. Valid only after
|
||||
* MQTT_OTA_BIT fires; must be read before the next MQTT event. */
|
||||
const mqtt_ota_data_t *mqtt_publisher_get_ota_data(void);
|
||||
|
||||
/** Publish OTA status to localiser/sensor/{id}/ota. Non-blocking (QoS 1). */
|
||||
void mqtt_publisher_send_ota_status(const char *status, const char *version);
|
||||
|
||||
/**
|
||||
* Initialise and connect the MQTT client.
|
||||
*
|
||||
* sensor_id — stable anchor ID string (e.g. "anchor_a1b2c3")
|
||||
* broker_uri — e.g. "mqtt://192.168.1.100:1883"
|
||||
* evt_group — FreeRTOS event group; publisher sets bits above on events
|
||||
* version — firmware version string included in announce payload and version query responses
|
||||
*/
|
||||
esp_err_t mqtt_publisher_init(const char *sensor_id,
|
||||
const char *broker_uri,
|
||||
EventGroupHandle_t evt_group);
|
||||
EventGroupHandle_t evt_group,
|
||||
const char *version);
|
||||
|
||||
/** Publish an RSSI reading. Non-blocking (QoS 1). */
|
||||
void mqtt_publisher_send_rssi(const char *tag_id, int8_t rssi);
|
||||
|
||||
/** Publish a parsed beacon reading with type, id, tx_power and rssi. Non-blocking (QoS 1). */
|
||||
void mqtt_publisher_send_beacon(const char *type, const char *id, int8_t tx_power, int8_t rssi);
|
||||
|
||||
/** Publish the announce message (empty payload). */
|
||||
void mqtt_publisher_announce(void);
|
||||
|
||||
@@ -7,21 +7,30 @@
|
||||
|
||||
#define TAG "mqtt_publisher"
|
||||
#define TOPIC_PREFIX "localiser/sensor"
|
||||
#define TOPIC_OTA_BROADCAST "localiser/ota"
|
||||
|
||||
static esp_mqtt_client_handle_t s_client = NULL;
|
||||
static char s_sensor_id[32];
|
||||
static char s_firmware_version[32];
|
||||
static EventGroupHandle_t s_evt = NULL;
|
||||
static mqtt_reconfigure_data_t s_reconfigure_data;
|
||||
static mqtt_ota_data_t s_ota_data;
|
||||
|
||||
const mqtt_reconfigure_data_t *mqtt_publisher_get_reconfigure_data(void)
|
||||
{
|
||||
return &s_reconfigure_data;
|
||||
}
|
||||
|
||||
const mqtt_ota_data_t *mqtt_publisher_get_ota_data(void)
|
||||
{
|
||||
return &s_ota_data;
|
||||
}
|
||||
|
||||
/* Pre-built topic strings */
|
||||
static char s_topic_rssi[96];
|
||||
static char s_topic_announce[96];
|
||||
static char s_topic_cmd[96];
|
||||
static char s_topic_ota[96];
|
||||
|
||||
static void build_topics(void)
|
||||
{
|
||||
@@ -31,6 +40,8 @@ static void build_topics(void)
|
||||
"%s/%s/announce", TOPIC_PREFIX, s_sensor_id);
|
||||
snprintf(s_topic_cmd, sizeof(s_topic_cmd),
|
||||
"%s/%s/cmd", TOPIC_PREFIX, s_sensor_id);
|
||||
snprintf(s_topic_ota, sizeof(s_topic_ota),
|
||||
"%s/%s/ota", TOPIC_PREFIX, s_sensor_id);
|
||||
}
|
||||
|
||||
static void handle_cmd(const char *data, int data_len)
|
||||
@@ -50,6 +61,16 @@ static void handle_cmd(const char *data, int data_len)
|
||||
else if (strcmp(a, "selected") == 0) xEventGroupSetBits(s_evt, MQTT_SELECTED_BIT);
|
||||
else if (strcmp(a, "deselected") == 0) xEventGroupSetBits(s_evt, MQTT_DESELECTED_BIT);
|
||||
else if (strcmp(a, "factory_reset") == 0) xEventGroupSetBits(s_evt, MQTT_FACTORY_RESET_BIT);
|
||||
else if (strcmp(a, "version") == 0) mqtt_publisher_announce();
|
||||
else if (strcmp(a, "ota") == 0) {
|
||||
memset(&s_ota_data, 0, sizeof(s_ota_data));
|
||||
cJSON *url_j = cJSON_GetObjectItemCaseSensitive(root, "url");
|
||||
cJSON *ver_j = cJSON_GetObjectItemCaseSensitive(root, "version");
|
||||
if (cJSON_IsString(url_j)) strncpy(s_ota_data.url, url_j->valuestring, sizeof(s_ota_data.url) - 1);
|
||||
if (cJSON_IsString(ver_j)) strncpy(s_ota_data.version, ver_j->valuestring, sizeof(s_ota_data.version) - 1);
|
||||
if (s_ota_data.url[0] != '\0') xEventGroupSetBits(s_evt, MQTT_OTA_BIT);
|
||||
else ESP_LOGW(TAG, "OTA cmd missing url");
|
||||
}
|
||||
else if (strcmp(a, "reconfigure_settings") == 0) {
|
||||
memset(&s_reconfigure_data, 0, sizeof(s_reconfigure_data));
|
||||
cJSON *ssid_j = cJSON_GetObjectItemCaseSensitive(root, "ssid");
|
||||
@@ -75,6 +96,7 @@ static void mqtt_event_handler(void *arg, esp_event_base_t base,
|
||||
case MQTT_EVENT_CONNECTED:
|
||||
ESP_LOGI(TAG, "Connected to broker");
|
||||
esp_mqtt_client_subscribe(s_client, s_topic_cmd, 1);
|
||||
esp_mqtt_client_subscribe(s_client, TOPIC_OTA_BROADCAST, 1);
|
||||
xEventGroupSetBits(s_evt, MQTT_CONNECTED_BIT);
|
||||
mqtt_publisher_announce();
|
||||
break;
|
||||
@@ -85,7 +107,8 @@ static void mqtt_event_handler(void *arg, esp_event_base_t base,
|
||||
break;
|
||||
|
||||
case MQTT_EVENT_DATA:
|
||||
if (strncmp(event->topic, s_topic_cmd, event->topic_len) == 0) {
|
||||
if (strncmp(event->topic, s_topic_cmd, event->topic_len) == 0 ||
|
||||
strncmp(event->topic, TOPIC_OTA_BROADCAST, event->topic_len) == 0) {
|
||||
handle_cmd(event->data, event->data_len);
|
||||
}
|
||||
break;
|
||||
@@ -101,9 +124,11 @@ static void mqtt_event_handler(void *arg, esp_event_base_t base,
|
||||
|
||||
esp_err_t mqtt_publisher_init(const char *sensor_id,
|
||||
const char *broker_uri,
|
||||
EventGroupHandle_t evt_group)
|
||||
EventGroupHandle_t evt_group,
|
||||
const char *version)
|
||||
{
|
||||
strncpy(s_sensor_id, sensor_id, sizeof(s_sensor_id) - 1);
|
||||
strncpy(s_firmware_version, version ? version : "", sizeof(s_firmware_version) - 1);
|
||||
s_evt = evt_group;
|
||||
build_topics();
|
||||
|
||||
@@ -126,8 +151,10 @@ esp_err_t mqtt_publisher_init(const char *sensor_id,
|
||||
void mqtt_publisher_announce(void)
|
||||
{
|
||||
if (!s_client) return;
|
||||
esp_mqtt_client_publish(s_client, s_topic_announce, "", 0, 1, 0);
|
||||
ESP_LOGI(TAG, "Announced on %s", s_topic_announce);
|
||||
char payload[48];
|
||||
int len = snprintf(payload, sizeof(payload), "{\"version\":\"%s\"}", s_firmware_version);
|
||||
esp_mqtt_client_publish(s_client, s_topic_announce, payload, len, 1, 0);
|
||||
ESP_LOGI(TAG, "Announced on %s (version: %s)", s_topic_announce, s_firmware_version);
|
||||
}
|
||||
|
||||
void mqtt_publisher_send_rssi(const char *tag_id, int8_t rssi)
|
||||
@@ -139,3 +166,24 @@ void mqtt_publisher_send_rssi(const char *tag_id, int8_t rssi)
|
||||
"{\"tag_id\":\"%s\",\"rssi\":%d}", tag_id, (int)rssi);
|
||||
esp_mqtt_client_publish(s_client, s_topic_rssi, payload, len, 1, 0);
|
||||
}
|
||||
|
||||
void mqtt_publisher_send_beacon(const char *type, const char *id, int8_t tx_power, int8_t rssi)
|
||||
{
|
||||
if (!s_client) return;
|
||||
|
||||
char payload[128];
|
||||
int len = snprintf(payload, sizeof(payload),
|
||||
"{\"type\":\"%s\",\"id\":\"%s\",\"tx_power\":%d,\"rssi\":%d}",
|
||||
type, id, (int)tx_power, (int)rssi);
|
||||
esp_mqtt_client_publish(s_client, s_topic_rssi, payload, len, 1, 0);
|
||||
}
|
||||
|
||||
void mqtt_publisher_send_ota_status(const char *status, const char *version)
|
||||
{
|
||||
if (!s_client) return;
|
||||
|
||||
char payload[96];
|
||||
int len = snprintf(payload, sizeof(payload),
|
||||
"{\"status\":\"%s\",\"version\":\"%s\"}", status, version ? version : "");
|
||||
esp_mqtt_client_publish(s_client, s_topic_ota, payload, len, 1, 0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
idf_component_register(
|
||||
SRCS "src/ota_manager.c"
|
||||
INCLUDE_DIRS "include"
|
||||
REQUIRES esp_https_ota app_update mqtt_publisher esp_system
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
/**
|
||||
* Begin an OTA update in a background task.
|
||||
* Applies a MAC-derived jitter delay before downloading so a fleet-wide
|
||||
* trigger staggers reboots instead of dropping all coverage at once.
|
||||
* Reports status via mqtt_publisher_send_ota_status().
|
||||
* On success the device reboots; on failure it stays on the current firmware.
|
||||
*/
|
||||
esp_err_t ota_manager_start(const char *url, const char *version);
|
||||
@@ -0,0 +1,84 @@
|
||||
#include "ota_manager.h"
|
||||
#include "mqtt_publisher.h"
|
||||
#include "esp_https_ota.h"
|
||||
#include "esp_ota_ops.h"
|
||||
#include "esp_mac.h"
|
||||
#include "esp_log.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#define TAG "ota_manager"
|
||||
|
||||
/* Prevent concurrent OTA attempts */
|
||||
static volatile bool s_ota_running = false;
|
||||
|
||||
typedef struct {
|
||||
char url[256];
|
||||
char version[32];
|
||||
} ota_task_arg_t;
|
||||
|
||||
static void ota_task(void *arg)
|
||||
{
|
||||
ota_task_arg_t *params = (ota_task_arg_t *)arg;
|
||||
|
||||
/* Spread reboots across up to 60 s using last 3 MAC bytes as seed */
|
||||
uint8_t mac[6];
|
||||
esp_read_mac(mac, ESP_MAC_WIFI_STA);
|
||||
uint32_t jitter_ms = ((uint32_t)(mac[3] ^ mac[4] ^ mac[5])) % 60000;
|
||||
if (jitter_ms > 0) {
|
||||
ESP_LOGI(TAG, "OTA jitter: %lu ms", jitter_ms);
|
||||
mqtt_publisher_send_ota_status("pending", params->version);
|
||||
vTaskDelay(pdMS_TO_TICKS(jitter_ms));
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Starting OTA from %s", params->url);
|
||||
mqtt_publisher_send_ota_status("downloading", params->version);
|
||||
|
||||
esp_http_client_config_t http_cfg = {
|
||||
.url = params->url,
|
||||
.timeout_ms = 30000,
|
||||
.keep_alive_enable = true,
|
||||
};
|
||||
esp_https_ota_config_t ota_cfg = {
|
||||
.http_config = &http_cfg,
|
||||
};
|
||||
|
||||
esp_err_t err = esp_https_ota(&ota_cfg);
|
||||
if (err == ESP_OK) {
|
||||
ESP_LOGI(TAG, "OTA succeeded, rebooting");
|
||||
mqtt_publisher_send_ota_status("rebooting", params->version);
|
||||
vTaskDelay(pdMS_TO_TICKS(500)); /* let MQTT publish flush */
|
||||
esp_restart();
|
||||
} else {
|
||||
ESP_LOGE(TAG, "OTA failed: %s", esp_err_to_name(err));
|
||||
mqtt_publisher_send_ota_status("failed", params->version);
|
||||
}
|
||||
|
||||
free(params);
|
||||
s_ota_running = false;
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
esp_err_t ota_manager_start(const char *url, const char *version)
|
||||
{
|
||||
if (s_ota_running) {
|
||||
ESP_LOGW(TAG, "OTA already in progress, ignoring");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
ota_task_arg_t *params = malloc(sizeof(ota_task_arg_t));
|
||||
if (!params) return ESP_ERR_NO_MEM;
|
||||
|
||||
strncpy(params->url, url, sizeof(params->url) - 1);
|
||||
strncpy(params->version, version ? version : "", sizeof(params->version) - 1);
|
||||
|
||||
s_ota_running = true;
|
||||
if (xTaskCreate(ota_task, "ota_task", 8192, params, 5, NULL) != pdPASS) {
|
||||
free(params);
|
||||
s_ota_running = false;
|
||||
return ESP_FAIL;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
@@ -103,7 +103,7 @@ esp_err_t provisioning_run(void)
|
||||
|
||||
network_prov_mgr_config_t config = {
|
||||
.scheme = network_prov_scheme_ble,
|
||||
.scheme_event_handler = NETWORK_PROV_SCHEME_BLE_EVENT_HANDLER_FREE_BTDM,
|
||||
.scheme_event_handler = NETWORK_PROV_SCHEME_BLE_EVENT_HANDLER_FREE_BT,
|
||||
.network_prov_wifi_conn_cfg = { .wifi_conn_attempts = 3 },
|
||||
};
|
||||
ESP_ERROR_CHECK(network_prov_mgr_init(config));
|
||||
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# Upload firmware to localiserd, optionally triggering
|
||||
# a fleet-wide OTA push immediately after upload.
|
||||
#
|
||||
# Usage:
|
||||
# ./deploy_firmware.sh <firmware_path> <version> <localiserd_url> <token> [--ota]
|
||||
#
|
||||
# Example:
|
||||
# ./deploy_firmware.sh ./build/esp-anchor.bin 1.2.0 http://192.168.1.10:4000 <jwt> --ota
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
FIRMWARE_PATH="${1:?Usage: $0 <firmware_path> <version> <localiserd_url> <token> [--ota]}"
|
||||
VERSION="${2:?}"
|
||||
SERVER="${3:?}"
|
||||
TOKEN="${4:?}"
|
||||
OTA="${5:-}"
|
||||
|
||||
if [[ ! -f "$FIRMWARE_PATH" ]]; then
|
||||
echo "ERROR: firmware not found at $FIRMWARE_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SIZE=$(wc -c < "$FIRMWARE_PATH")
|
||||
echo "==> Built: $FIRMWARE_PATH ($SIZE bytes)"
|
||||
|
||||
# Upload
|
||||
echo "==> Uploading version $VERSION to $SERVER..."
|
||||
curl -fsS \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-F "version=$VERSION" \
|
||||
-F "firmware=@$FIRMWARE_PATH;type=application/octet-stream" \
|
||||
"$SERVER/api/firmware" \
|
||||
| jq .
|
||||
|
||||
# Fleet OTA
|
||||
if [[ "$OTA" == "--ota" ]]; then
|
||||
echo "==> Pushing fleet OTA for version $VERSION..."
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$SERVER/api/firmware/$VERSION/ota" \
|
||||
| jq .
|
||||
fi
|
||||
|
||||
echo "==> Done."
|
||||
@@ -12,6 +12,9 @@ idf_component_register(
|
||||
ble_scanner
|
||||
mqtt_publisher
|
||||
led_indicator
|
||||
ota_manager
|
||||
app_update
|
||||
driver
|
||||
esp_driver_gpio
|
||||
esp_coex
|
||||
)
|
||||
|
||||
+48
-5
@@ -3,6 +3,9 @@
|
||||
#include "ble_scanner.h"
|
||||
#include "mqtt_publisher.h"
|
||||
#include "led_indicator.h"
|
||||
#include "ota_manager.h"
|
||||
#include "esp_ota_ops.h"
|
||||
#include "esp_coexist.h"
|
||||
|
||||
#include "esp_wifi.h"
|
||||
#include "esp_event.h"
|
||||
@@ -19,7 +22,7 @@
|
||||
|
||||
#define TAG "main"
|
||||
|
||||
#define WIFI_CONNECTED_BIT BIT5 /* offset to avoid clashing with mqtt_publisher bits */
|
||||
#define WIFI_CONNECTED_BIT BIT8 /* must not clash with mqtt_publisher bits (BIT0-BIT7) */
|
||||
|
||||
#define DEFAULT_MQTT_PORT 1883
|
||||
#define MDNS_QUERY_TIMEOUT_MS 3000
|
||||
@@ -73,17 +76,37 @@ static void wifi_event_handler(void *arg, esp_event_base_t base,
|
||||
}
|
||||
}
|
||||
|
||||
static void on_ble_scan_result(const char *tag_id, int8_t rssi)
|
||||
static const char *beacon_type_str(ble_beacon_type_t t)
|
||||
{
|
||||
mqtt_publisher_send_rssi(tag_id, rssi);
|
||||
switch (t) {
|
||||
case BLE_BEACON_IBEACON: return "ibeacon";
|
||||
case BLE_BEACON_ALTBEACON: return "altbeacon";
|
||||
case BLE_BEACON_EDDYSTONE_UID: return "eddystone_uid";
|
||||
case BLE_BEACON_EDDYSTONE_URL: return "eddystone_url";
|
||||
default: return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
static void on_ble_scan_result(const ble_beacon_t *beacon)
|
||||
{
|
||||
mqtt_publisher_send_beacon(beacon_type_str(beacon->type),
|
||||
beacon->id, beacon->tx_power, beacon->rssi);
|
||||
}
|
||||
|
||||
static void wifi_init_sta(void)
|
||||
{
|
||||
esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, wifi_event_handler, NULL);
|
||||
esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, wifi_event_handler, NULL);
|
||||
|
||||
esp_netif_t *netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
|
||||
esp_netif_ip_info_t ip_info = {0};
|
||||
if (netif && esp_netif_get_ip_info(netif, &ip_info) == ESP_OK && ip_info.ip.addr != 0) {
|
||||
ESP_LOGI(TAG, "WiFi already connected (IP: " IPSTR ")", IP2STR(&ip_info.ip));
|
||||
xEventGroupSetBits(s_evt, WIFI_CONNECTED_BIT);
|
||||
} else {
|
||||
ESP_ERROR_CHECK(esp_wifi_connect());
|
||||
}
|
||||
}
|
||||
|
||||
static bool resolve_mqtt_broker_mdns(char *host_out, size_t host_len, uint16_t *port_out)
|
||||
{
|
||||
@@ -199,9 +222,23 @@ void app_main(void)
|
||||
snprintf(broker_uri, sizeof(broker_uri), "mqtt://%s:%u", broker_host, broker_port);
|
||||
|
||||
// init MQTT
|
||||
ESP_ERROR_CHECK(mqtt_publisher_init(sensor_id, broker_uri, s_evt));
|
||||
const esp_partition_t *running = esp_ota_get_running_partition();
|
||||
esp_app_desc_t app_desc;
|
||||
const char *fw_version = (esp_ota_get_partition_description(running, &app_desc) == ESP_OK)
|
||||
? app_desc.version : "unknown";
|
||||
ESP_ERROR_CHECK(mqtt_publisher_init(sensor_id, broker_uri, s_evt, fw_version));
|
||||
xEventGroupWaitBits(s_evt, MQTT_CONNECTED_BIT, pdFALSE, pdTRUE, portMAX_DELAY);
|
||||
|
||||
/* Confirm this firmware is healthy so the bootloader won't roll back */
|
||||
esp_ota_img_states_t ota_state;
|
||||
if (esp_ota_get_state_partition(running, &ota_state) == ESP_OK &&
|
||||
ota_state == ESP_OTA_IMG_PENDING_VERIFY) {
|
||||
esp_ota_mark_app_valid_cancel_rollback();
|
||||
ESP_LOGI(TAG, "OTA rollback cancelled - firmware validated");
|
||||
}
|
||||
|
||||
esp_coex_preference_set(ESP_COEX_PREFER_BALANCE);
|
||||
|
||||
// init BLE scanner
|
||||
ble_scanner_init(on_ble_scan_result);
|
||||
ble_scanner_start();
|
||||
@@ -213,7 +250,8 @@ void app_main(void)
|
||||
const EventBits_t cmd_bits =
|
||||
MQTT_CALIBRATE_START | MQTT_CALIBRATE_STOP |
|
||||
MQTT_SELECTED_BIT | MQTT_DESELECTED_BIT |
|
||||
MQTT_FACTORY_RESET_BIT | MQTT_RECONFIGURE_SETTINGS_BIT;
|
||||
MQTT_FACTORY_RESET_BIT | MQTT_RECONFIGURE_SETTINGS_BIT |
|
||||
MQTT_OTA_BIT;
|
||||
|
||||
bool calibrating = false;
|
||||
bool selected = false;
|
||||
@@ -242,6 +280,11 @@ void app_main(void)
|
||||
selected = false;
|
||||
if (!calibrating) led_indicator_set(LED_SCANNING);
|
||||
}
|
||||
if (bits & MQTT_OTA_BIT) {
|
||||
const mqtt_ota_data_t *ota = mqtt_publisher_get_ota_data();
|
||||
ESP_LOGI(TAG, "OTA requested: url=%s version=%s", ota->url, ota->version);
|
||||
ota_manager_start(ota->url, ota->version);
|
||||
}
|
||||
if (bits & MQTT_FACTORY_RESET_BIT) {
|
||||
// factory reset requested - erase NVS and reboot
|
||||
ESP_LOGI(TAG, "Factory reset requested");
|
||||
|
||||
+18
-2
@@ -1,7 +1,18 @@
|
||||
CONFIG_BT_ENABLED=y
|
||||
CONFIG_BTDM_CTRL_MODE_BLE_ONLY=y
|
||||
CONFIG_BT_BLUEDROID_ENABLED=y
|
||||
CONFIG_BT_BLE_42_FEATURES_SUPPORTED=y
|
||||
CONFIG_BT_NIMBLE_ENABLED=y
|
||||
# NimBLE host task pinned to core 0 alongside the BT controller (WiFi stays core 1)
|
||||
CONFIG_BT_NIMBLE_PINNED_TO_CORE_0=y
|
||||
# Only provisioning needs 1 connection; scanning needs 0
|
||||
CONFIG_BT_NIMBLE_MAX_CONNECTIONS=1
|
||||
# mbuf pool sized for high-throughput advertisement reports
|
||||
CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT=16
|
||||
CONFIG_BT_NIMBLE_MSYS_2_BLOCK_SIZE=320
|
||||
CONFIG_BT_NIMBLE_MSYS_2_BLOCK_COUNT=6
|
||||
CONFIG_BT_NIMBLE_HOST_TASK_STACK_SIZE=4096
|
||||
# Advertisement report flow control
|
||||
CONFIG_BTDM_BLE_ADV_REPORT_FLOW_CTRL_SUPP=y
|
||||
CONFIG_BTDM_BLE_ADV_REPORT_FLOW_CTRL_NUM=100
|
||||
|
||||
# WiFi
|
||||
CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM=10
|
||||
@@ -20,3 +31,8 @@ CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
|
||||
|
||||
# Enable OTA
|
||||
CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y
|
||||
CONFIG_ESP_HTTPS_OTA_ALLOW_HTTP=y
|
||||
|
||||
# Factory reset button (GPIO 0 = BOOT button on ESP32 DevKit)
|
||||
CONFIG_ANCHOR_RESET_GPIO=0
|
||||
CONFIG_ANCHOR_RESET_HOLD_MS=5000
|
||||
|
||||
Reference in New Issue
Block a user