mirror of
https://github.com/liquidraver/ZephCore.git
synced 2026-09-01 23:38:18 +00:00
1527 lines
48 KiB
C++
1527 lines
48 KiB
C++
/*
|
|
* SPDX-License-Identifier: MIT
|
|
* ZephCore BLE Adapter — NUS service, advertising, security, TX/RX
|
|
*
|
|
* Security: SMP pairing with SC + MITM + Bonding, DisplayOnly IO (app_passkey).
|
|
* Pairing is triggered reactively by ATT_ERR_AUTHENTICATION on secured GATT
|
|
* attributes (Apple §55 compliant — no proactive Security Request on connect).
|
|
*
|
|
* Advertising: always uses the identity address (BT_LE_ADV_OPT_USE_IDENTITY in
|
|
* start_adv). On ESP32, CONFIG_BT_PRIVACY=y is load-bearing (the Espressif
|
|
* controller's privacy-OFF Secure-Connections path MIC-fails against iOS — see
|
|
* findings.md Issue #34), but USE_IDENTITY keeps us off the RPA so the Android
|
|
* companion's "connect from app" still works. nRF/MG24 keep privacy OFF and
|
|
* already advertise identity, so USE_IDENTITY is a no-op there.
|
|
*/
|
|
|
|
#include <stdio.h>
|
|
#include <string.h>
|
|
#include <zephyr/kernel.h>
|
|
#include <zephyr/sys/util.h>
|
|
|
|
#include <zephyr/logging/log.h>
|
|
LOG_MODULE_REGISTER(zephcore_ble, CONFIG_ZEPHCORE_BLE_LOG_LEVEL);
|
|
|
|
#include <zephyr/bluetooth/bluetooth.h>
|
|
#include <zephyr/bluetooth/conn.h>
|
|
#include <zephyr/bluetooth/gatt.h>
|
|
#include <zephyr/bluetooth/hci.h>
|
|
#include <zephyr/bluetooth/uuid.h>
|
|
#include <zephyr/bluetooth/services/nus.h>
|
|
#include <zephyr/settings/settings.h>
|
|
#include <zephyr/sys/byteorder.h>
|
|
|
|
#include "ZephyrBLE.h"
|
|
|
|
/* ========== Constants ========== */
|
|
|
|
#define DEVICE_NAME_MAX 29
|
|
#define FRAME_QUEUE_SIZE CONFIG_ZEPHCORE_BLE_QUEUE_SIZE
|
|
#define BLE_TX_POWER 8
|
|
#define BLE_TX_RETRY_MS 20
|
|
|
|
/* BLE connection parameters */
|
|
#define BLE_DEFAULT_MIN_INTERVAL CONFIG_ZEPHCORE_BLE_CONN_MIN_INTERVAL
|
|
#define BLE_DEFAULT_MAX_INTERVAL CONFIG_ZEPHCORE_BLE_CONN_MAX_INTERVAL
|
|
#define BLE_DEFAULT_LATENCY CONFIG_ZEPHCORE_BLE_CONN_LATENCY
|
|
#define BLE_DEFAULT_TIMEOUT CONFIG_ZEPHCORE_BLE_CONN_TIMEOUT
|
|
|
|
/* TX timeout watchdog - reset ble_tx_in_progress if callback never fires */
|
|
#define BLE_TX_TIMEOUT_MS 2000
|
|
|
|
/* Congestion overflow retry interval — when the TX queue is full, the
|
|
* stuck frame retries at this cadence. Slow enough to not hammer the
|
|
* BLE stack when the link is marginal, fast enough to recover quickly. */
|
|
#define BLE_TX_OVERFLOW_RETRY_MS 250
|
|
|
|
/* App push notifications are 0x80+; protocol response packets are < 0x80. */
|
|
#define PUSH_CODE_BASE 0x80
|
|
|
|
/* Advertising intervals (Apple Accessory Design Guidelines §5.5) */
|
|
#define BT_ADV_FAST_INTERVAL 32 /* 20ms in 0.625ms units */
|
|
#define BT_ADV_FAST_DURATION_MS (60 * 1000) /* fast window after boot/disconnect */
|
|
#define BT_ADV_INTERVAL CONFIG_ZEPHCORE_BLE_ADV_SLOW_INTERVAL
|
|
|
|
/* ========== Frame type for queues ========== */
|
|
|
|
struct frame {
|
|
uint16_t len;
|
|
uint8_t buf[MAX_FRAME_SIZE];
|
|
};
|
|
|
|
/* ========== Static state ========== */
|
|
|
|
/* Deferred connection parameter update — applied after initial sync
|
|
* completes (NO_MORE_MSGS) rather than immediately on security_changed,
|
|
* because the negotiation disrupts BLE throughput during channel/contact sync. */
|
|
static bool conn_params_pending;
|
|
|
|
/* Callbacks to main */
|
|
static const struct ble_callbacks *ble_cbs;
|
|
|
|
/* Advertising data */
|
|
static char device_name[DEVICE_NAME_MAX];
|
|
static const uint8_t ad_flags = BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR;
|
|
static const int8_t ad_tx_power = BLE_TX_POWER;
|
|
static const uint8_t nus_uuid[] = { BT_UUID_NUS_SRV_VAL };
|
|
static struct bt_data ad[3];
|
|
static struct bt_data sd[1];
|
|
static size_t ad_len;
|
|
static size_t sd_len;
|
|
|
|
/* Queues — ISR-safe, no mutex needed */
|
|
K_MSGQ_DEFINE(ble_send_queue, sizeof(struct frame), FRAME_QUEUE_SIZE, 4);
|
|
K_MSGQ_DEFINE(ble_recv_queue, sizeof(struct frame), FRAME_QUEUE_SIZE, 4);
|
|
|
|
/* TX retry buffer - used when BLE returns -ENOMEM/-EAGAIN */
|
|
static struct frame tx_retry_frame;
|
|
static bool tx_retry_pending = false;
|
|
|
|
/* TX congestion control — flow-control mechanism for queue-full conditions.
|
|
*
|
|
* When the TX queue is full, instead of blocking or dropping:
|
|
* 1. Set ble_tx_congested flag → callers (contact iteration, etc.) stop sending
|
|
* 2. Save the stuck frame to overflow_frame → retried every 250ms
|
|
* 3. When queue drains below low water mark (1/3) → clear congestion
|
|
* 4. On disconnect → clear everything
|
|
*
|
|
* Water marks (with default FRAME_QUEUE_SIZE=12):
|
|
* - Contact iteration pauses: 2/3 = 8 frames (high water)
|
|
* - Congestion mode enters: 12/12 = full
|
|
* - Congestion mode clears: 1/3 = 4 frames (low water)
|
|
*/
|
|
static bool ble_tx_congested;
|
|
static struct frame overflow_frame;
|
|
static bool overflow_pending;
|
|
|
|
/* Connection state */
|
|
static struct bt_conn *current_conn;
|
|
static bool nus_notif_enabled;
|
|
static bool ble_tx_ready = false;
|
|
static bool ble_tx_in_progress = false;
|
|
static int64_t ble_tx_start_time = 0;
|
|
|
|
#if IS_ENABLED(CONFIG_BT_GATT_SERVICE_CHANGED)
|
|
/* Bump when static BT_GATT_SERVICE_DEFINE layout or registration order changes.
|
|
* 1 = pre-2cf4b97 (dfu_svc before secure_nus_svc)
|
|
* 2 = secure_nus_svc_dfu after secure_nus_svc (+ packet/revision chars) */
|
|
#define ZEPHCORE_GATT_LAYOUT_VERSION 2
|
|
|
|
/* Delay before sending Service Changed after L2 security is established.
|
|
* On a fresh pairing, pairing_complete() fires within this window and marks the
|
|
* peer current, so only genuine bonded reconnects with a stale layout get an SC.
|
|
* The delay also gives the peer time to subscribe to the Service Changed CCC. */
|
|
#define GATT_SC_INDICATE_DELAY_MS 1000
|
|
|
|
static bool gatt_sc_ind_in_flight;
|
|
static const struct bt_gatt_attr *gatt_sc_value_attr;
|
|
static struct bt_gatt_indicate_params gatt_sc_ind_params;
|
|
static uint16_t gatt_sc_ind_range[2];
|
|
|
|
static void gatt_peer_settings_key(char *key, size_t key_len, const bt_addr_le_t *addr)
|
|
{
|
|
char addr_str[BT_ADDR_LE_STR_LEN];
|
|
|
|
bt_addr_le_to_str(addr, addr_str, sizeof(addr_str));
|
|
snprintk(key, key_len, "ble/gatt_peer/%s", addr_str);
|
|
}
|
|
|
|
static int gatt_peer_layout_load(const bt_addr_le_t *addr, uint8_t *ver_out)
|
|
{
|
|
#if IS_ENABLED(CONFIG_SETTINGS)
|
|
char key[64];
|
|
ssize_t len;
|
|
|
|
gatt_peer_settings_key(key, sizeof(key), addr);
|
|
len = settings_load_one(key, ver_out, sizeof(*ver_out));
|
|
if (len == (ssize_t)sizeof(*ver_out)) {
|
|
return 0;
|
|
}
|
|
#endif
|
|
return -ENOENT;
|
|
}
|
|
|
|
static int gatt_peer_layout_save(const bt_addr_le_t *addr, uint8_t ver)
|
|
{
|
|
#if IS_ENABLED(CONFIG_SETTINGS)
|
|
char key[64];
|
|
int err;
|
|
|
|
gatt_peer_settings_key(key, sizeof(key), addr);
|
|
err = settings_save_one(key, &ver, sizeof(ver));
|
|
return err;
|
|
#else
|
|
ARG_UNUSED(addr);
|
|
ARG_UNUSED(ver);
|
|
return -ENOTSUP;
|
|
#endif
|
|
}
|
|
|
|
static void gatt_peer_layout_delete(const bt_addr_le_t *addr)
|
|
{
|
|
#if IS_ENABLED(CONFIG_SETTINGS)
|
|
char key[64];
|
|
|
|
gatt_peer_settings_key(key, sizeof(key), addr);
|
|
settings_delete(key);
|
|
#else
|
|
ARG_UNUSED(addr);
|
|
#endif
|
|
}
|
|
|
|
static int gatt_global_layout_save(uint8_t ver)
|
|
{
|
|
#if IS_ENABLED(CONFIG_SETTINGS)
|
|
return settings_save_one("ble/gatt_layout", &ver, sizeof(ver));
|
|
#else
|
|
ARG_UNUSED(ver);
|
|
return -ENOTSUP;
|
|
#endif
|
|
}
|
|
|
|
struct gatt_layout_bond_ctx {
|
|
int count;
|
|
};
|
|
|
|
static void gatt_layout_count_bonds(const struct bt_bond_info *info, void *user_data)
|
|
{
|
|
struct gatt_layout_bond_ctx *ctx =
|
|
static_cast<struct gatt_layout_bond_ctx *>(user_data);
|
|
|
|
ARG_UNUSED(info);
|
|
ctx->count++;
|
|
}
|
|
|
|
static void gatt_layout_check_after_settings_load(void)
|
|
{
|
|
#if IS_ENABLED(CONFIG_SETTINGS)
|
|
struct gatt_layout_bond_ctx bond_ctx = { 0 };
|
|
uint8_t global;
|
|
uint8_t prev;
|
|
ssize_t len;
|
|
|
|
bt_foreach_bond(BT_ID_DEFAULT, gatt_layout_count_bonds, &bond_ctx);
|
|
|
|
len = settings_load_one("ble/gatt_layout", &global, sizeof(global));
|
|
if (len != (ssize_t)sizeof(global)) {
|
|
if (bond_ctx.count == 0) {
|
|
/* Fresh device — no stale phone caches to fix. */
|
|
if (gatt_global_layout_save(ZEPHCORE_GATT_LAYOUT_VERSION) == 0) {
|
|
LOG_DBG("GATT layout v%u seeded (no bonds)",
|
|
ZEPHCORE_GATT_LAYOUT_VERSION);
|
|
}
|
|
return;
|
|
}
|
|
|
|
prev = 1; /* bonded before layout tracking existed */
|
|
} else {
|
|
prev = global;
|
|
}
|
|
|
|
if (prev != ZEPHCORE_GATT_LAYOUT_VERSION) {
|
|
LOG_INF("GATT layout v%u -> v%u (per-peer SC on connect)",
|
|
prev, ZEPHCORE_GATT_LAYOUT_VERSION);
|
|
if (gatt_global_layout_save(ZEPHCORE_GATT_LAYOUT_VERSION) != 0) {
|
|
LOG_WRN("GATT layout global version save failed");
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
static uint8_t gatt_sc_find_value_attr(const struct bt_gatt_attr *attr, uint16_t handle,
|
|
void *user_data)
|
|
{
|
|
ARG_UNUSED(handle);
|
|
ARG_UNUSED(user_data);
|
|
|
|
if (!bt_uuid_cmp(attr->uuid, BT_UUID_GATT_CHRC)) {
|
|
const struct bt_gatt_chrc *chrc =
|
|
static_cast<const struct bt_gatt_chrc *>(attr->user_data);
|
|
|
|
if (!bt_uuid_cmp(chrc->uuid, BT_UUID_GATT_SC)) {
|
|
gatt_sc_value_attr = bt_gatt_attr_next(attr);
|
|
return BT_GATT_ITER_STOP;
|
|
}
|
|
}
|
|
|
|
return BT_GATT_ITER_CONTINUE;
|
|
}
|
|
|
|
static void gatt_sc_indicate_cb(struct bt_conn *conn, struct bt_gatt_indicate_params *params,
|
|
uint8_t err)
|
|
{
|
|
const bt_addr_le_t *addr = bt_conn_get_dst(conn);
|
|
|
|
ARG_UNUSED(params);
|
|
|
|
gatt_sc_ind_in_flight = false;
|
|
|
|
if (err) {
|
|
LOG_WRN("Service Changed indicate failed: 0x%02x (will retry)", err);
|
|
return;
|
|
}
|
|
|
|
if (gatt_peer_layout_save(addr, ZEPHCORE_GATT_LAYOUT_VERSION) != 0) {
|
|
LOG_WRN("GATT peer layout save failed (will retry on reconnect)");
|
|
return;
|
|
}
|
|
|
|
LOG_INF("GATT layout v%u: Service Changed confirmed", ZEPHCORE_GATT_LAYOUT_VERSION);
|
|
}
|
|
|
|
static void gatt_peer_mark_current_no_sc(const bt_addr_le_t *addr)
|
|
{
|
|
if (gatt_peer_layout_save(addr, ZEPHCORE_GATT_LAYOUT_VERSION) != 0) {
|
|
LOG_WRN("GATT peer layout save failed");
|
|
return;
|
|
}
|
|
|
|
LOG_DBG("GATT layout v%u: peer marked current (no SC needed)", ZEPHCORE_GATT_LAYOUT_VERSION);
|
|
}
|
|
|
|
static void maybe_indicate_service_changed(struct bt_conn *conn)
|
|
{
|
|
const bt_addr_le_t *addr = bt_conn_get_dst(conn);
|
|
uint8_t peer_ver;
|
|
int err;
|
|
|
|
if (gatt_sc_ind_in_flight) {
|
|
return;
|
|
}
|
|
|
|
if (gatt_peer_layout_load(addr, &peer_ver) == 0 &&
|
|
peer_ver == ZEPHCORE_GATT_LAYOUT_VERSION) {
|
|
return;
|
|
}
|
|
|
|
if (!gatt_sc_value_attr) {
|
|
bt_gatt_foreach_attr(0x0001, 0xffff, gatt_sc_find_value_attr, NULL);
|
|
if (!gatt_sc_value_attr) {
|
|
LOG_ERR("Service Changed characteristic not found");
|
|
return;
|
|
}
|
|
}
|
|
|
|
gatt_sc_ind_range[0] = sys_cpu_to_le16(0x0001);
|
|
gatt_sc_ind_range[1] = sys_cpu_to_le16(0xffff);
|
|
|
|
memset(&gatt_sc_ind_params, 0, sizeof(gatt_sc_ind_params));
|
|
gatt_sc_ind_params.attr = gatt_sc_value_attr;
|
|
gatt_sc_ind_params.func = gatt_sc_indicate_cb;
|
|
gatt_sc_ind_params.data = gatt_sc_ind_range;
|
|
gatt_sc_ind_params.len = sizeof(gatt_sc_ind_range);
|
|
|
|
err = bt_gatt_indicate(conn, &gatt_sc_ind_params);
|
|
if (err) {
|
|
LOG_WRN("Service Changed indicate err %d (will retry)", err);
|
|
return;
|
|
}
|
|
|
|
gatt_sc_ind_in_flight = true;
|
|
LOG_INF("GATT layout migration: Service Changed indicated");
|
|
}
|
|
|
|
/* Deferred from security_changed() — see GATT_SC_INDICATE_DELAY_MS. Runs on the
|
|
* single active connection (BT_MAX_CONN=1); cancelled on disconnect. */
|
|
static void gatt_sc_work_fn(struct k_work *work)
|
|
{
|
|
ARG_UNUSED(work);
|
|
|
|
if (current_conn) {
|
|
maybe_indicate_service_changed(current_conn);
|
|
}
|
|
}
|
|
static K_WORK_DELAYABLE_DEFINE(gatt_sc_work, gatt_sc_work_fn);
|
|
#endif /* CONFIG_BT_GATT_SERVICE_CHANGED */
|
|
|
|
/* Active interface tracking */
|
|
static enum zephcore_iface active_iface = ZEPHCORE_IFACE_NONE;
|
|
|
|
/* active_iface is mutated from two threads — the Bluetooth callback thread
|
|
* (connect / security / pairing / disconnect) and the USB workqueue
|
|
* (CMD_APP_START / DTR drop). This mutex makes the check-then-act claim and
|
|
* release sequences atomic so the two transports can never both believe they
|
|
* own the interface (or lose a write to it). */
|
|
K_MUTEX_DEFINE(ble_iface_lock);
|
|
|
|
/* Atomically claim the interface for `who` iff it is currently idle.
|
|
* Returns true only if this call performed the NONE -> who transition. */
|
|
static bool iface_claim_if_idle(enum zephcore_iface who)
|
|
{
|
|
k_mutex_lock(&ble_iface_lock, K_FOREVER);
|
|
bool claimed = (active_iface == ZEPHCORE_IFACE_NONE);
|
|
if (claimed) {
|
|
active_iface = who;
|
|
}
|
|
k_mutex_unlock(&ble_iface_lock);
|
|
return claimed;
|
|
}
|
|
|
|
/* Atomically release the interface if `who` currently owns it. */
|
|
static void iface_release(enum zephcore_iface who)
|
|
{
|
|
k_mutex_lock(&ble_iface_lock, K_FOREVER);
|
|
if (active_iface == who) {
|
|
active_iface = ZEPHCORE_IFACE_NONE;
|
|
}
|
|
k_mutex_unlock(&ble_iface_lock);
|
|
}
|
|
|
|
/* Claim the active interface for BLE, but only if nothing else owns it.
|
|
* First-come-first-served: a live USB session must not be evicted by BLE
|
|
* connecting/pairing in the background. Returns true if BLE now owns it. */
|
|
static bool ble_claim_iface_if_idle(void)
|
|
{
|
|
return iface_claim_if_idle(ZEPHCORE_IFACE_BLE);
|
|
}
|
|
|
|
/* DLE tracking — set after successful DLE request to avoid double-request */
|
|
static bool dle_requested;
|
|
|
|
/* Fast advertising — true for BT_ADV_FAST_DURATION_MS after boot or disconnect */
|
|
static bool fast_adv_active;
|
|
|
|
/* Set before bt_le_adv_stop() in adv_slow_work_fn to suppress recycled() from
|
|
* restarting fast advertising. Zephyr fires recycled() when the pre-allocated
|
|
* connection slot is freed after any adv stop — not just on disconnect.
|
|
* Cleared by recycled() itself (both run on the cooperative system work queue). */
|
|
static bool adv_stop_for_interval_change;
|
|
|
|
/* Ground truth for "controller is currently broadcasting adv PDUs":
|
|
* set TRUE : bt_le_adv_start() returned success
|
|
* set FALSE : bt_le_adv_stop() called explicitly (set_enabled(false),
|
|
* adv_slow_work interval change, update_name restart)
|
|
* set FALSE : a phone connected — Zephyr stops adv internally to consume
|
|
* the BT_MAX_CONN=1 slot (no slot left to advertise from).
|
|
* Re-set TRUE later when recycled() → start_adv() runs.
|
|
* Exposed via zephcore_ble_is_advertising() for the companion advertising
|
|
* watchdog (main_companion.cpp housekeeping) that catches transient
|
|
* bt_le_adv_start failures. Arduino nrf52 has an equivalent 10s watchdog
|
|
* (SerialBLEInterface.cpp:343). */
|
|
static bool adv_running;
|
|
|
|
/* Administrative BLE state */
|
|
static bool ble_enabled = true;
|
|
|
|
|
|
/* Runtime BLE passkey */
|
|
static uint32_t ble_passkey = CONFIG_ZEPHCORE_BLE_PASSKEY;
|
|
|
|
/* NUS TX characteristic attribute — resolved at init, avoids hard-coded offset */
|
|
static const struct bt_gatt_attr *nus_tx_attr;
|
|
|
|
static bool is_lossless_protocol_frame(const uint8_t *data, uint16_t len)
|
|
{
|
|
if (!data || len == 0) {
|
|
return false;
|
|
}
|
|
return data[0] < PUSH_CODE_BASE;
|
|
}
|
|
|
|
/* ========== Forward declarations ========== */
|
|
|
|
static void ble_tx_complete_cb(struct bt_conn *conn, void *user_data);
|
|
static int secure_nus_send(struct bt_conn *conn, const void *data, uint16_t len);
|
|
static void secure_nus_ccc_changed(const struct bt_gatt_attr *attr, uint16_t value);
|
|
#if defined(CONFIG_BT_USER_DATA_LEN_UPDATE)
|
|
static void request_dle(struct bt_conn *conn);
|
|
#endif
|
|
static ssize_t secure_nus_rx_write(struct bt_conn *conn, const struct bt_gatt_attr *attr,
|
|
const void *buf, uint16_t len, uint16_t offset, uint8_t flags);
|
|
static void start_adv(void);
|
|
static void start_fast_adv(void);
|
|
static void kick_tx_drain(void);
|
|
|
|
/* ========== GATT Service ========== */
|
|
|
|
/*
|
|
* NUS service — secured with AUTHEN permissions.
|
|
* Matches Arduino's SECMODE_ENC_WITH_MITM on bleuart.
|
|
*
|
|
* When the phone tries to subscribe (CCC write) or send data (RX write),
|
|
* Zephyr returns ATT_ERR_AUTHENTICATION. The phone's BLE stack then
|
|
* initiates pairing (PIN dialog). After pairing succeeds,
|
|
* security_changed() fires at L3+ and the phone retries the operation.
|
|
*/
|
|
BT_GATT_SERVICE_DEFINE(secure_nus_svc,
|
|
BT_GATT_PRIMARY_SERVICE(BT_UUID_NUS_SERVICE),
|
|
BT_GATT_CHARACTERISTIC(BT_UUID_NUS_TX_CHAR,
|
|
BT_GATT_CHRC_NOTIFY,
|
|
BT_GATT_PERM_NONE,
|
|
NULL, NULL, NULL),
|
|
BT_GATT_CCC(secure_nus_ccc_changed,
|
|
BT_GATT_PERM_READ_AUTHEN | BT_GATT_PERM_WRITE_AUTHEN),
|
|
BT_GATT_CHARACTERISTIC(BT_UUID_NUS_RX_CHAR,
|
|
BT_GATT_CHRC_WRITE | BT_GATT_CHRC_WRITE_WITHOUT_RESP,
|
|
BT_GATT_PERM_WRITE_AUTHEN,
|
|
NULL, secure_nus_rx_write, NULL),
|
|
);
|
|
|
|
#if IS_ENABLED(CONFIG_ZEPHCORE_BLE_DFU)
|
|
/* ========== Legacy Nordic/Adafruit buttonless DFU service ==========
|
|
*
|
|
* Mirrors Adafruit BLEDfu (Arduino MeshCore >=1.15.0) so the same DFU tools
|
|
* interoperate: a paired phone writes 0x01 to the control point and the device
|
|
* resets into the bootloader's BLE OTA mode. We use the *unbonded* OTA reset
|
|
* (GPREGRET 0xA8, same as `start ota`): the bootloader comes up as a fresh DFU
|
|
* target and the tool re-scans for it (the legacy buttonless flow). Adafruit's
|
|
* 0xB1 bonded-resume path needs SoftDevice peer-data enrollment we can't
|
|
* replicate on Zephyr, so the phone makes a fresh connection to the bootloader.
|
|
*
|
|
* The service exposes the FULL legacy DFU shape Adafruit ships (not just the
|
|
* control point): iOS's LegacyDFUService treats the DFU Packet (1532) char as
|
|
* a *required* characteristic — discovery fails (DFU "instantly fails") without
|
|
* it — and reads the DFU Revision (1534 = 0x0001 "app mode") to classify the
|
|
* device as an application that supports the buttonless jump (missing → the app
|
|
* can't identify the device → shows it nameless). Packet is a no-op here: the
|
|
* real image upload happens in the bootloader, not the running app. All three
|
|
* chars sit behind AUTHEN, matching the Arduino companion's service-wide MITM
|
|
* floor (`bledfu.setPermission(SECMODE_ENC_WITH_MITM, ...)`); a bonded phone's
|
|
* DFU app reuses the OS-level bond, an unpaired stranger is rejected.
|
|
*
|
|
* NOTE on the service symbol name: Zephyr registers static GATT services in
|
|
* the order ld's SORT_BY_NAME emits them — i.e. alphabetically by the symbol
|
|
* passed to BT_GATT_SERVICE_DEFINE. This service MUST sort *after*
|
|
* `secure_nus_svc` so the NUS attribute handles stay fixed; otherwise every
|
|
* NUS handle shifts and bonded phones with cached handles get ATT 0x03
|
|
* (Write Not Permitted) on the NUS RX write. Hence the `secure_nus_svc_dfu`
|
|
* name (a string sorts before its own extensions, so NUS keeps the low range).
|
|
*/
|
|
static struct bt_uuid_128 dfu_svc_uuid = BT_UUID_INIT_128(
|
|
BT_UUID_128_ENCODE(0x00001530, 0x1212, 0xefde, 0x1523, 0x785feabcd123));
|
|
static struct bt_uuid_128 dfu_ctrl_uuid = BT_UUID_INIT_128(
|
|
BT_UUID_128_ENCODE(0x00001531, 0x1212, 0xefde, 0x1523, 0x785feabcd123));
|
|
static struct bt_uuid_128 dfu_packet_uuid = BT_UUID_INIT_128(
|
|
BT_UUID_128_ENCODE(0x00001532, 0x1212, 0xefde, 0x1523, 0x785feabcd123));
|
|
static struct bt_uuid_128 dfu_revision_uuid = BT_UUID_INIT_128(
|
|
BT_UUID_128_ENCODE(0x00001534, 0x1212, 0xefde, 0x1523, 0x785feabcd123));
|
|
|
|
/* DFU Revision = 0x0001 (DFU_REV_APPMODE), little-endian — tells the DFU app
|
|
* "this is an application that supports the buttonless jump to bootloader". */
|
|
static const uint8_t dfu_revision[2] = { 0x01, 0x00 };
|
|
|
|
static void dfu_jump_work_fn(struct k_work *work);
|
|
static K_WORK_DELAYABLE_DEFINE(dfu_jump_work, dfu_jump_work_fn);
|
|
|
|
static void dfu_jump_work_fn(struct k_work *work)
|
|
{
|
|
ARG_UNUSED(work);
|
|
if (ble_cbs && ble_cbs->on_dfu_request) {
|
|
ble_cbs->on_dfu_request(); /* sets GPREGRET + resets; never returns */
|
|
}
|
|
}
|
|
|
|
static ssize_t dfu_ctrl_write(struct bt_conn *conn, const struct bt_gatt_attr *attr,
|
|
const void *buf, uint16_t len, uint16_t offset, uint8_t flags)
|
|
{
|
|
ARG_UNUSED(conn);
|
|
ARG_UNUSED(attr);
|
|
ARG_UNUSED(offset);
|
|
ARG_UNUSED(flags);
|
|
/* Adafruit BLEDfu jump command: first byte 0x01 (1-2 byte write). */
|
|
if (len >= 1 && ((const uint8_t *)buf)[0] == 0x01) {
|
|
LOG_INF("buttonless DFU requested - rebooting to BLE OTA");
|
|
/* Defer so the ATT write response flushes and the DFU tool can
|
|
* arm its disconnect/rescan before we reset. */
|
|
k_work_schedule(&dfu_jump_work, K_MSEC(250));
|
|
}
|
|
return len;
|
|
}
|
|
|
|
static void dfu_ctrl_ccc_changed(const struct bt_gatt_attr *attr, uint16_t value)
|
|
{
|
|
ARG_UNUSED(attr);
|
|
ARG_UNUSED(value);
|
|
}
|
|
|
|
/* DFU Packet — present only so the DFU library's characteristic discovery
|
|
* succeeds; the actual image transfer happens in the bootloader, not here. */
|
|
static ssize_t dfu_packet_write(struct bt_conn *conn, const struct bt_gatt_attr *attr,
|
|
const void *buf, uint16_t len, uint16_t offset, uint8_t flags)
|
|
{
|
|
ARG_UNUSED(conn);
|
|
ARG_UNUSED(attr);
|
|
ARG_UNUSED(buf);
|
|
ARG_UNUSED(offset);
|
|
ARG_UNUSED(flags);
|
|
return len; /* no-op in app mode */
|
|
}
|
|
|
|
static ssize_t dfu_revision_read(struct bt_conn *conn, const struct bt_gatt_attr *attr,
|
|
void *buf, uint16_t len, uint16_t offset)
|
|
{
|
|
return bt_gatt_attr_read(conn, attr, buf, len, offset,
|
|
dfu_revision, sizeof(dfu_revision));
|
|
}
|
|
|
|
BT_GATT_SERVICE_DEFINE(secure_nus_svc_dfu,
|
|
BT_GATT_PRIMARY_SERVICE(&dfu_svc_uuid),
|
|
BT_GATT_CHARACTERISTIC(&dfu_ctrl_uuid.uuid,
|
|
BT_GATT_CHRC_WRITE | BT_GATT_CHRC_NOTIFY,
|
|
BT_GATT_PERM_WRITE_AUTHEN,
|
|
NULL, dfu_ctrl_write, NULL),
|
|
BT_GATT_CCC(dfu_ctrl_ccc_changed,
|
|
BT_GATT_PERM_READ_AUTHEN | BT_GATT_PERM_WRITE_AUTHEN),
|
|
BT_GATT_CHARACTERISTIC(&dfu_packet_uuid.uuid,
|
|
BT_GATT_CHRC_WRITE_WITHOUT_RESP,
|
|
BT_GATT_PERM_WRITE_AUTHEN,
|
|
NULL, dfu_packet_write, NULL),
|
|
BT_GATT_CHARACTERISTIC(&dfu_revision_uuid.uuid,
|
|
BT_GATT_CHRC_READ,
|
|
BT_GATT_PERM_READ_AUTHEN,
|
|
dfu_revision_read, NULL, NULL),
|
|
);
|
|
#endif /* CONFIG_ZEPHCORE_BLE_DFU */
|
|
|
|
/* ========== Work items ========== */
|
|
|
|
static void tx_drain_work_fn(struct k_work *work);
|
|
static void overflow_retry_work_fn(struct k_work *work);
|
|
static void adv_slow_work_fn(struct k_work *work);
|
|
|
|
K_WORK_DELAYABLE_DEFINE(tx_drain_work, tx_drain_work_fn);
|
|
K_WORK_DELAYABLE_DEFINE(overflow_retry_work, overflow_retry_work_fn);
|
|
K_WORK_DELAYABLE_DEFINE(adv_slow_work, adv_slow_work_fn);
|
|
|
|
/* ========== TX completion callback ========== */
|
|
|
|
/*
|
|
* TX completion callback - chains to next frame (event-driven like Arduino)
|
|
* This is called by bt_gatt_notify_cb when the notification is sent over the air.
|
|
*/
|
|
static void ble_tx_complete_cb(struct bt_conn *conn, void *user_data)
|
|
{
|
|
ARG_UNUSED(conn);
|
|
ARG_UNUSED(user_data);
|
|
|
|
ble_tx_in_progress = false;
|
|
|
|
/* Chain to next frame immediately via work queue */
|
|
k_work_schedule(&tx_drain_work, K_NO_WAIT);
|
|
}
|
|
|
|
/* Helper function to send via our secure NUS TX characteristic with callback */
|
|
static int secure_nus_send(struct bt_conn *conn, const void *data, uint16_t len)
|
|
{
|
|
struct bt_gatt_notify_params params = {
|
|
.attr = nus_tx_attr,
|
|
.data = data,
|
|
.len = len,
|
|
.func = ble_tx_complete_cb,
|
|
.user_data = NULL,
|
|
};
|
|
|
|
return bt_gatt_notify_cb(conn, ¶ms);
|
|
}
|
|
|
|
/* ========== Device name and advertising data ========== */
|
|
|
|
static void build_device_name_and_adv(const char *name_from_prefs)
|
|
{
|
|
if (name_from_prefs && name_from_prefs[0]) {
|
|
/* Prepend "MeshCore-" prefix so apps that filter on it can find us */
|
|
snprintf(device_name, sizeof(device_name), "MeshCore-%s", name_from_prefs);
|
|
|
|
/* Sanitize the BLE-advertised name to printable ASCII, compacting
|
|
* in place (write index w never outpaces read index r):
|
|
* - ':' / ';' -> '-' (Apple Accessory Design Guidelines)
|
|
* - non-ASCII bytes -> dropped (e.g. emoji in the node name).
|
|
* iOS's BLE scanner blanks the WHOLE advertised name if it
|
|
* contains any non-ASCII byte, showing the device nameless.
|
|
* This only sanitizes the BLE/GAP copy; the mesh node name (emoji
|
|
* and all) is untouched and still shown by the companion app.
|
|
*/
|
|
size_t w = 0;
|
|
for (size_t r = 0; device_name[r]; r++) {
|
|
unsigned char c = (unsigned char)device_name[r];
|
|
if (c == ':' || c == ';') {
|
|
device_name[w++] = '-';
|
|
} else if (c >= 0x20 && c < 0x7F) {
|
|
device_name[w++] = (char)c;
|
|
}
|
|
/* else: drop control / non-ASCII (multi-byte UTF-8) bytes */
|
|
}
|
|
device_name[w] = '\0';
|
|
} else {
|
|
/* Fallback - should never happen since prefs.node_name has default */
|
|
snprintf(device_name, sizeof(device_name), "MeshCore");
|
|
}
|
|
|
|
bt_set_name(device_name);
|
|
|
|
ad[0].type = BT_DATA_FLAGS;
|
|
ad[0].data_len = 1;
|
|
ad[0].data = &ad_flags;
|
|
ad[1].type = BT_DATA_TX_POWER;
|
|
ad[1].data_len = 1;
|
|
ad[1].data = (const uint8_t *)&ad_tx_power;
|
|
ad[2].type = BT_DATA_UUID128_ALL;
|
|
ad[2].data_len = sizeof(nus_uuid);
|
|
ad[2].data = nus_uuid;
|
|
ad_len = 3;
|
|
|
|
sd[0].type = BT_DATA_NAME_COMPLETE;
|
|
sd[0].data_len = (uint8_t)strlen(device_name);
|
|
sd[0].data = (const uint8_t *)device_name;
|
|
sd_len = 1;
|
|
|
|
LOG_DBG("%s", device_name);
|
|
}
|
|
|
|
/* ========== Connection callbacks ========== */
|
|
|
|
static void connected(struct bt_conn *conn, uint8_t err)
|
|
{
|
|
char addr[BT_ADDR_LE_STR_LEN];
|
|
bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr));
|
|
if (err) {
|
|
LOG_WRN("connection failed: %s err 0x%02x", addr, err);
|
|
return;
|
|
}
|
|
LOG_INF("connected: %s", addr);
|
|
|
|
/* Reject BLE connections while USB is the active transport.
|
|
* The user connected via BLE expecting to exchange messages, but
|
|
* USB owns the interface — they would get nothing and be confused. */
|
|
if (active_iface == ZEPHCORE_IFACE_USB) {
|
|
LOG_INF("connected: USB active — rejecting BLE connection from %s", addr);
|
|
bt_conn_disconnect(conn, BT_HCI_ERR_REMOTE_USER_TERM_CONN);
|
|
return;
|
|
}
|
|
|
|
current_conn = bt_conn_ref(conn);
|
|
|
|
/* Zephyr stops advertising internally when the conn slot is consumed
|
|
* (BT_MAX_CONN=1 — there's no slot left to advertise from). Sync our
|
|
* adv_running flag so zephcore_ble_is_advertising() reflects ground
|
|
* truth, not just "we last called bt_le_adv_start()". */
|
|
adv_running = false;
|
|
|
|
/* Cancel fast→slow transition — already connected, no need to switch */
|
|
k_work_cancel_delayable(&adv_slow_work);
|
|
|
|
/* DLE is NOT requested here — the phone may start a PHY update LL
|
|
* procedure immediately, and BLE allows only one at a time.
|
|
* DLE is deferred to le_phy_updated() (after PHY negotiation completes)
|
|
* with a fallback in security_changed() if PHY update never fires. */
|
|
dle_requested = false;
|
|
|
|
/* Do NOT proactively request security here.
|
|
*
|
|
* Apple Accessory Design Guidelines §55 (Pairing): the accessory should
|
|
* not request pairing until an ATT request is rejected with "Insufficient
|
|
* Authentication." Pairing is triggered reactively when the phone tries
|
|
* to access our AUTHEN-secured GATT attributes (CCC write / RX write).
|
|
*
|
|
* For bonded reconnects, Zephyr auto-encrypts with stored keys when
|
|
* CONFIG_BT_SMP and CONFIG_BT_BONDABLE are enabled. */
|
|
|
|
/* Notify main of BLE connection */
|
|
if (ble_cbs && ble_cbs->on_connected) {
|
|
ble_cbs->on_connected();
|
|
}
|
|
}
|
|
|
|
static void disconnected(struct bt_conn *conn, uint8_t reason)
|
|
{
|
|
char addr[BT_ADDR_LE_STR_LEN];
|
|
bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr));
|
|
LOG_INF("disconnected: %s reason 0x%02x", addr, reason);
|
|
|
|
if (conn == current_conn) {
|
|
bt_conn_unref(current_conn);
|
|
current_conn = NULL;
|
|
}
|
|
nus_notif_enabled = false;
|
|
|
|
/* Reset BLE TX state */
|
|
ble_tx_in_progress = false;
|
|
ble_tx_ready = false;
|
|
conn_params_pending = false;
|
|
|
|
/* Clear interface state if BLE was active */
|
|
iface_release(ZEPHCORE_IFACE_BLE);
|
|
|
|
/* Clear queues, retry state, and congestion */
|
|
k_msgq_purge(&ble_send_queue);
|
|
k_msgq_purge(&ble_recv_queue);
|
|
tx_retry_pending = false;
|
|
overflow_pending = false;
|
|
ble_tx_congested = false;
|
|
|
|
k_work_cancel_delayable(&tx_drain_work);
|
|
k_work_cancel_delayable(&overflow_retry_work);
|
|
|
|
#if IS_ENABLED(CONFIG_BT_GATT_SERVICE_CHANGED)
|
|
k_work_cancel_delayable(&gatt_sc_work);
|
|
gatt_sc_ind_in_flight = false;
|
|
#endif
|
|
|
|
/* Notify main of BLE disconnection */
|
|
if (ble_cbs && ble_cbs->on_disconnected) {
|
|
ble_cbs->on_disconnected();
|
|
}
|
|
}
|
|
|
|
static void recycled(void)
|
|
{
|
|
if (adv_stop_for_interval_change) {
|
|
adv_stop_for_interval_change = false;
|
|
return;
|
|
}
|
|
LOG_DBG("restart advertising");
|
|
start_fast_adv();
|
|
}
|
|
|
|
static void security_changed(struct bt_conn *conn, bt_security_t level, enum bt_security_err err)
|
|
{
|
|
char addr[BT_ADDR_LE_STR_LEN];
|
|
bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr));
|
|
if (err) {
|
|
LOG_WRN("security failed: %s level %u err %d", addr, level, err);
|
|
return;
|
|
}
|
|
LOG_INF("%s level %u", addr, level);
|
|
|
|
/* Enable TX when we have sufficient security (level 2+ = encrypted).
|
|
* This is the ONLY place that sets ble_tx_ready — security_changed is
|
|
* the authority. CCC subscription (secure_nus_ccc_changed) only kicks
|
|
* the TX drain; it never sets ble_tx_ready.
|
|
*/
|
|
if (level >= BT_SECURITY_L2 && !ble_tx_ready) {
|
|
LOG_INF("security established, enabling TX");
|
|
ble_tx_ready = true;
|
|
ble_claim_iface_if_idle();
|
|
|
|
/* If CCC was already subscribed (bonded reconnect — phone writes
|
|
* CCC before security_changed fires), kick TX now. On fresh
|
|
* pairing CCC hasn't been written yet, so this is a no-op and
|
|
* TX starts when CCC fires later. */
|
|
if (nus_notif_enabled) {
|
|
kick_tx_drain();
|
|
}
|
|
}
|
|
|
|
if (level >= BT_SECURITY_L2) {
|
|
#if IS_ENABLED(CONFIG_BT_GATT_SERVICE_CHANGED)
|
|
/* After a GATT layout change, bonded phones may hold stale handles.
|
|
* Service Changed forces rediscovery without breaking the bond.
|
|
* Deferred so a fresh pairing (pairing_complete marks the peer
|
|
* current) doesn't trigger a needless SC. */
|
|
k_work_reschedule(&gatt_sc_work, K_MSEC(GATT_SC_INDICATE_DELAY_MS));
|
|
#endif
|
|
#if defined(CONFIG_BT_USER_DATA_LEN_UPDATE)
|
|
/* Fallback DLE request — if le_phy_updated() already sent it,
|
|
* request_dle() returns immediately (dle_requested flag). */
|
|
request_dle(conn);
|
|
#endif
|
|
/* Defer connection parameter update until after the initial
|
|
* app sync finishes (channels + contacts + offline messages).
|
|
* Requesting a param change now would disrupt BLE throughput
|
|
* during the sync burst. CompanionMesh calls
|
|
* zephcore_ble_conn_params_ready() when sync is done. */
|
|
conn_params_pending = true;
|
|
LOG_INF("conn param update deferred until post-sync");
|
|
}
|
|
}
|
|
|
|
static void le_param_updated(struct bt_conn *conn, uint16_t interval,
|
|
uint16_t latency, uint16_t timeout)
|
|
{
|
|
ARG_UNUSED(conn);
|
|
LOG_INF("BLE conn params updated: interval=%dms latency=%d timeout=%dms",
|
|
interval * 5 / 4, latency, timeout * 10);
|
|
}
|
|
|
|
#if defined(CONFIG_BT_USER_DATA_LEN_UPDATE)
|
|
/* Request max DLE (251 bytes). Called from le_phy_updated() after PHY
|
|
* negotiation completes, and from security_changed() as a fallback if
|
|
* PHY update never fires. The dle_requested flag prevents double-request. */
|
|
static void request_dle(struct bt_conn *conn)
|
|
{
|
|
if (dle_requested) {
|
|
return;
|
|
}
|
|
struct bt_conn_le_data_len_param data_len_param = {
|
|
.tx_max_len = BT_GAP_DATA_LEN_MAX,
|
|
.tx_max_time = BT_GAP_DATA_TIME_MAX,
|
|
};
|
|
int err = bt_conn_le_data_len_update(conn, &data_len_param);
|
|
if (err) {
|
|
LOG_WRN("Failed to request data length update: %d", err);
|
|
} else {
|
|
LOG_INF("Requested max data length (251 bytes)");
|
|
dle_requested = true;
|
|
}
|
|
}
|
|
|
|
static void le_data_len_updated(struct bt_conn *conn, struct bt_conn_le_data_len_info *info)
|
|
{
|
|
ARG_UNUSED(conn);
|
|
LOG_INF("BLE data length updated: TX=%u/%uus RX=%u/%uus",
|
|
info->tx_max_len, info->tx_max_time,
|
|
info->rx_max_len, info->rx_max_time);
|
|
}
|
|
#endif
|
|
|
|
#if defined(CONFIG_BT_USER_PHY_UPDATE)
|
|
static const char *phy_name(uint8_t phy)
|
|
{
|
|
switch (phy) {
|
|
case BT_GAP_LE_PHY_1M: return "1M";
|
|
case BT_GAP_LE_PHY_2M: return "2M";
|
|
case BT_GAP_LE_PHY_CODED: return "Coded";
|
|
default: return "Unknown";
|
|
}
|
|
}
|
|
|
|
static void le_phy_updated(struct bt_conn *conn, struct bt_conn_le_phy_info *param)
|
|
{
|
|
LOG_INF("BLE PHY updated: TX=%s RX=%s", phy_name(param->tx_phy),
|
|
phy_name(param->rx_phy));
|
|
|
|
/* Accept whatever PHY the phone chose — overriding 2M with Coded|1M
|
|
* caused iPhone to freeze the connection (and the whole node).
|
|
* PHY is settled — request DLE. Deferred here from connected()
|
|
* because the phone starts a PHY procedure on connect and BLE
|
|
* only allows one LL procedure at a time. */
|
|
request_dle(conn);
|
|
}
|
|
#endif
|
|
|
|
BT_CONN_CB_DEFINE(conn_callbacks) = {
|
|
.connected = connected,
|
|
.disconnected = disconnected,
|
|
.recycled = recycled,
|
|
.le_param_updated = le_param_updated,
|
|
.security_changed = security_changed,
|
|
#if defined(CONFIG_BT_USER_PHY_UPDATE)
|
|
.le_phy_updated = le_phy_updated,
|
|
#endif
|
|
#if defined(CONFIG_BT_USER_DATA_LEN_UPDATE)
|
|
.le_data_len_updated = le_data_len_updated,
|
|
#endif
|
|
};
|
|
|
|
/* ========== Authentication callbacks ========== */
|
|
|
|
static uint32_t auth_app_passkey(struct bt_conn *conn)
|
|
{
|
|
return ble_passkey;
|
|
}
|
|
|
|
static void auth_passkey_display(struct bt_conn *conn, unsigned int passkey)
|
|
{
|
|
ARG_UNUSED(conn);
|
|
ARG_UNUSED(passkey);
|
|
}
|
|
|
|
static void auth_cancel(struct bt_conn *conn)
|
|
{
|
|
ARG_UNUSED(conn);
|
|
LOG_WRN("pairing cancelled");
|
|
}
|
|
|
|
static struct bt_conn_auth_cb auth_cb = {
|
|
.passkey_display = auth_passkey_display,
|
|
.cancel = auth_cancel,
|
|
.app_passkey = auth_app_passkey,
|
|
};
|
|
|
|
static void pairing_complete(struct bt_conn *conn, bool bonded)
|
|
{
|
|
LOG_INF("pairing complete: bonded=%d", bonded);
|
|
|
|
#if IS_ENABLED(CONFIG_BT_GATT_SERVICE_CHANGED)
|
|
/* Fresh pairing always does full GATT discovery — no Service Changed needed. */
|
|
if (bonded) {
|
|
gatt_peer_mark_current_no_sc(bt_conn_get_dst(conn));
|
|
}
|
|
#endif
|
|
|
|
if (ble_claim_iface_if_idle()) {
|
|
LOG_INF("pairing complete, activating BLE interface");
|
|
} else {
|
|
LOG_INF("pairing complete, %s already active — BLE paired but not promoted",
|
|
active_iface == ZEPHCORE_IFACE_USB ? "USB" : "BLE");
|
|
}
|
|
}
|
|
|
|
static void pairing_failed(struct bt_conn *conn, enum bt_security_err reason)
|
|
{
|
|
LOG_WRN("pairing failed: reason %d", reason);
|
|
bt_conn_disconnect(conn, BT_HCI_ERR_AUTH_FAIL);
|
|
}
|
|
|
|
#if IS_ENABLED(CONFIG_BT_GATT_SERVICE_CHANGED)
|
|
/* Fires on explicit unpair and on BT_MAX_PAIRED overwrite-oldest eviction (both
|
|
* route through bt_unpair). Drop the per-peer layout key so /lfs/settings does
|
|
* not accumulate orphaned ble/gatt_peer entries over the device lifetime. */
|
|
static void bond_deleted(uint8_t id, const bt_addr_le_t *peer)
|
|
{
|
|
ARG_UNUSED(id);
|
|
gatt_peer_layout_delete(peer);
|
|
}
|
|
#endif
|
|
|
|
static struct bt_conn_auth_info_cb auth_info_cb = {
|
|
.pairing_complete = pairing_complete,
|
|
.pairing_failed = pairing_failed,
|
|
#if IS_ENABLED(CONFIG_BT_GATT_SERVICE_CHANGED)
|
|
.bond_deleted = bond_deleted,
|
|
#endif
|
|
};
|
|
|
|
/* ========== TX congestion overflow retry ========== */
|
|
|
|
static void overflow_retry_work_fn(struct k_work *work)
|
|
{
|
|
ARG_UNUSED(work);
|
|
|
|
if (!overflow_pending) {
|
|
return;
|
|
}
|
|
|
|
/* Abandon overflow if connection is gone */
|
|
if (!current_conn || active_iface == ZEPHCORE_IFACE_NONE) {
|
|
overflow_pending = false;
|
|
ble_tx_congested = false;
|
|
LOG_INF("overflow cleared (disconnected)");
|
|
return;
|
|
}
|
|
|
|
if (k_msgq_put(&ble_send_queue, &overflow_frame, K_NO_WAIT) == 0) {
|
|
overflow_pending = false;
|
|
LOG_DBG("overflow frame queued hdr=0x%02x, kicking drain",
|
|
overflow_frame.buf[0]);
|
|
kick_tx_drain();
|
|
/* Congestion flag cleared by tx_drain at low water mark */
|
|
} else {
|
|
/* Still full — retry at reduced rate */
|
|
LOG_DBG("overflow retry: queue still full, retry in 250ms");
|
|
k_work_schedule(&overflow_retry_work, K_MSEC(BLE_TX_OVERFLOW_RETRY_MS));
|
|
}
|
|
}
|
|
|
|
/* ========== TX drain work ========== */
|
|
|
|
static void kick_tx_drain(void)
|
|
{
|
|
k_work_schedule(&tx_drain_work, K_NO_WAIT);
|
|
}
|
|
|
|
static void tx_drain_work_fn(struct k_work *work)
|
|
{
|
|
ARG_UNUSED(work);
|
|
struct frame f;
|
|
int err;
|
|
|
|
/* USB TX path is handled in main — only BLE TX here */
|
|
if (active_iface == ZEPHCORE_IFACE_USB) {
|
|
/* Let main handle USB TX — just signal tx_idle if queue empty */
|
|
if (k_msgq_num_used_get(&ble_send_queue) == 0) {
|
|
if (ble_cbs && ble_cbs->on_tx_idle) {
|
|
ble_cbs->on_tx_idle();
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
/*
|
|
* BLE TX path - Event-driven (like Arduino's HVN_TX_COMPLETE)
|
|
* Uses bt_gatt_notify_cb() callback to chain TX without polling.
|
|
* Re-entrancy guard prevents concurrent notify calls.
|
|
*
|
|
* IMPORTANT: Must wait for ble_tx_ready before sending. This is set in
|
|
* security_changed() after encryption is established. Sending before the
|
|
* connection is fully secured causes "No ATT channel for MTU" errors.
|
|
*/
|
|
if (!current_conn || !nus_notif_enabled || !ble_tx_ready) {
|
|
LOG_DBG("tx_drain[BLE]: not ready (conn=%p notif=%d ready=%d)",
|
|
current_conn, nus_notif_enabled, ble_tx_ready);
|
|
return;
|
|
}
|
|
|
|
/* Take a reference snapshot — prevents use-after-free if
|
|
* disconnected() fires from another context between our check
|
|
* and use of the connection pointer. */
|
|
struct bt_conn *conn = bt_conn_ref(current_conn);
|
|
if (!conn) {
|
|
return;
|
|
}
|
|
|
|
/* Re-entrancy guard - only one TX in flight at a time */
|
|
if (ble_tx_in_progress) {
|
|
/* TX timeout watchdog - if callback never fired, reset state */
|
|
if ((k_uptime_get() - ble_tx_start_time) > BLE_TX_TIMEOUT_MS) {
|
|
LOG_WRN("tx_drain[BLE]: TX timeout, resetting state");
|
|
ble_tx_in_progress = false;
|
|
/* Fall through to try next TX */
|
|
} else {
|
|
LOG_DBG("tx_drain[BLE]: TX in progress, callback will chain");
|
|
bt_conn_unref(conn);
|
|
return;
|
|
}
|
|
}
|
|
|
|
/* Check retry buffer first */
|
|
if (tx_retry_pending) {
|
|
LOG_DBG("tx_drain[BLE]: retrying len=%u hdr=0x%02x", (unsigned)tx_retry_frame.len, tx_retry_frame.buf[0]);
|
|
ble_tx_in_progress = true;
|
|
ble_tx_start_time = k_uptime_get();
|
|
err = secure_nus_send(conn, tx_retry_frame.buf, tx_retry_frame.len);
|
|
if (err == 0) {
|
|
tx_retry_pending = false;
|
|
LOG_DBG("tx_drain[BLE]: retry success");
|
|
bt_conn_unref(conn);
|
|
return; /* Callback will chain to next */
|
|
} else if (err == -EAGAIN || err == -ENOMEM) {
|
|
ble_tx_in_progress = false;
|
|
LOG_DBG("tx_drain[BLE]: retry still busy, wait %dms", BLE_TX_RETRY_MS);
|
|
k_work_schedule(&tx_drain_work, K_MSEC(BLE_TX_RETRY_MS));
|
|
bt_conn_unref(conn);
|
|
return;
|
|
} else {
|
|
ble_tx_in_progress = false;
|
|
tx_retry_pending = false;
|
|
LOG_WRN("tx_drain[BLE]: retry failed err=%d, dropped", err);
|
|
/* Fall through to try next frame */
|
|
}
|
|
}
|
|
|
|
/* Get next frame from queue */
|
|
if (k_msgq_get(&ble_send_queue, &f, K_NO_WAIT) != 0) {
|
|
/* TX queue empty — clear congestion and signal idle */
|
|
if (ble_tx_congested) {
|
|
ble_tx_congested = false;
|
|
LOG_INF("tx_drain: congestion cleared (queue empty)");
|
|
}
|
|
if (ble_cbs && ble_cbs->on_tx_idle) {
|
|
ble_cbs->on_tx_idle();
|
|
}
|
|
bt_conn_unref(conn);
|
|
return;
|
|
}
|
|
|
|
/* Clear congestion at low water mark (1/3 of queue) — gives headroom
|
|
* before hitting full again. Hysteresis: ON at full, OFF at 1/3. */
|
|
if (ble_tx_congested) {
|
|
uint32_t used = k_msgq_num_used_get(&ble_send_queue);
|
|
if (used <= FRAME_QUEUE_SIZE / 3) {
|
|
ble_tx_congested = false;
|
|
LOG_INF("tx_drain: congestion cleared (queue=%u/%u)",
|
|
used, (unsigned)FRAME_QUEUE_SIZE);
|
|
}
|
|
}
|
|
|
|
LOG_DBG("tx_drain[BLE]: sending len=%u hdr=0x%02x queue=%u",
|
|
(unsigned)f.len, f.buf[0], k_msgq_num_used_get(&ble_send_queue));
|
|
|
|
/* Mark TX in progress before calling notify_cb */
|
|
ble_tx_in_progress = true;
|
|
ble_tx_start_time = k_uptime_get();
|
|
err = secure_nus_send(conn, f.buf, f.len);
|
|
|
|
if (err == 0) {
|
|
/* Success - callback will chain to next */
|
|
bt_conn_unref(conn);
|
|
return;
|
|
} else if (err == -EAGAIN || err == -ENOMEM) {
|
|
/* BLE buffer full - save for retry */
|
|
ble_tx_in_progress = false;
|
|
tx_retry_frame = f;
|
|
tx_retry_pending = true;
|
|
LOG_DBG("tx_drain[BLE]: BLE busy (err=%d), saved for retry", err);
|
|
k_work_schedule(&tx_drain_work, K_MSEC(BLE_TX_RETRY_MS));
|
|
bt_conn_unref(conn);
|
|
return;
|
|
} else {
|
|
/* Other error - drop frame */
|
|
ble_tx_in_progress = false;
|
|
LOG_WRN("tx_drain[BLE]: send failed err=%d, dropped frame", err);
|
|
bt_conn_unref(conn);
|
|
return;
|
|
}
|
|
}
|
|
|
|
/* ========== NUS service callbacks ========== */
|
|
|
|
static void secure_nus_ccc_changed(const struct bt_gatt_attr *attr, uint16_t value)
|
|
{
|
|
ARG_UNUSED(attr);
|
|
bool enabled = (value == BT_GATT_CCC_NOTIFY);
|
|
LOG_INF("CCCD notif %s (value=0x%04x)", enabled ? "enabled" : "disabled", value);
|
|
nus_notif_enabled = enabled;
|
|
if (enabled) {
|
|
/* Kick TX drain — if security_changed already set ble_tx_ready,
|
|
* data starts flowing. If security hasn't fired yet (bonded
|
|
* reconnect race), kick_tx_drain bails harmlessly and
|
|
* security_changed will kick again once ble_tx_ready is set. */
|
|
kick_tx_drain();
|
|
}
|
|
}
|
|
|
|
static ssize_t secure_nus_rx_write(struct bt_conn *conn, const struct bt_gatt_attr *attr,
|
|
const void *buf, uint16_t len, uint16_t offset, uint8_t flags)
|
|
{
|
|
ARG_UNUSED(conn);
|
|
ARG_UNUSED(attr);
|
|
ARG_UNUSED(offset);
|
|
ARG_UNUSED(flags);
|
|
|
|
if (len == 0 || len > MAX_FRAME_SIZE) {
|
|
return BT_GATT_ERR(BT_ATT_ERR_INVALID_ATTRIBUTE_LEN);
|
|
}
|
|
|
|
const uint8_t *data = (const uint8_t *)buf;
|
|
uint8_t cmd = data[0];
|
|
|
|
LOG_DBG("NUS RX: len=%u cmd=0x%02x", len, cmd);
|
|
|
|
/* Notify main via callback */
|
|
if (ble_cbs && ble_cbs->on_rx_frame) {
|
|
ble_cbs->on_rx_frame(data, len);
|
|
}
|
|
|
|
return len;
|
|
}
|
|
|
|
/* ========== Advertising ========== */
|
|
|
|
static void adv_slow_work_fn(struct k_work *work)
|
|
{
|
|
ARG_UNUSED(work);
|
|
LOG_INF("fast adv window expired, switching to slow interval");
|
|
fast_adv_active = false;
|
|
adv_stop_for_interval_change = true; /* suppress recycled() restart */
|
|
bt_le_adv_stop();
|
|
adv_running = false;
|
|
start_adv();
|
|
/* adv_stop_for_interval_change cleared by recycled() on the work queue */
|
|
}
|
|
|
|
static void start_adv(void)
|
|
{
|
|
if (!ble_enabled) {
|
|
return;
|
|
}
|
|
|
|
uint16_t interval = fast_adv_active ? BT_ADV_FAST_INTERVAL : BT_ADV_INTERVAL;
|
|
|
|
/* USE_IDENTITY: advertise the stable identity address instead of an RPA,
|
|
* even when CONFIG_BT_PRIVACY=y. Keeps the controller in its (working)
|
|
* privacy-enabled SC encryption path for iOS while exposing a fixed address
|
|
* so the Android companion app's "connect from app" flow works. See
|
|
* findings.md Issue #34 iOS-privacy capture (controller MIC failure 0x3d on
|
|
* the privacy-off SC path). */
|
|
struct bt_le_adv_param adv_param = {
|
|
.id = BT_ID_DEFAULT,
|
|
.options = BT_LE_ADV_OPT_CONN | BT_LE_ADV_OPT_USE_IDENTITY,
|
|
.interval_min = interval,
|
|
.interval_max = interval,
|
|
};
|
|
|
|
int err = bt_le_adv_start(&adv_param, ad, ad_len, sd, sd_len);
|
|
if (err && err != -EALREADY) {
|
|
LOG_ERR("adv start failed: %d", err);
|
|
adv_running = false;
|
|
} else {
|
|
LOG_INF("BLE advertising: %s",
|
|
fast_adv_active ? "20ms fast (60s)" : "211ms slow");
|
|
adv_running = true;
|
|
}
|
|
}
|
|
|
|
/* Enter the post-boot/disconnect fast-advertising window, then start adv.
|
|
* The adv_slow_work timer flips back to the slow interval after
|
|
* BT_ADV_FAST_DURATION_MS. */
|
|
static void start_fast_adv(void)
|
|
{
|
|
fast_adv_active = true;
|
|
k_work_reschedule(&adv_slow_work, K_MSEC(BT_ADV_FAST_DURATION_MS));
|
|
start_adv();
|
|
}
|
|
|
|
/* ========== Public API ========== */
|
|
|
|
void zephcore_ble_init(const struct ble_callbacks *cbs)
|
|
{
|
|
ble_cbs = cbs;
|
|
|
|
/* Resolve NUS TX characteristic attribute once — avoids hard-coded
|
|
* array offset in secure_nus_send(). attrs[2] = TX char value
|
|
* (attrs[0]=service, attrs[1]=TX char decl, attrs[2]=TX char value,
|
|
* attrs[3]=CCC, attrs[4]=RX char decl, attrs[5]=RX char value). */
|
|
nus_tx_attr = &secure_nus_svc.attrs[2];
|
|
|
|
bt_conn_auth_cb_register(&auth_cb);
|
|
bt_conn_auth_info_cb_register(&auth_info_cb);
|
|
}
|
|
|
|
void zephcore_ble_start(const char *name)
|
|
{
|
|
if (IS_ENABLED(CONFIG_SETTINGS)) {
|
|
/* NVS self-initializes the storage_partition on first mount: a
|
|
* region carved from the old app slot reads as "all sectors closed",
|
|
* which nvs_startup() reformats (erase-all) before settings load.
|
|
* No app-side seeding needed. */
|
|
settings_load();
|
|
#if IS_ENABLED(CONFIG_BT_GATT_SERVICE_CHANGED)
|
|
gatt_layout_check_after_settings_load();
|
|
#endif
|
|
}
|
|
|
|
build_device_name_and_adv(name);
|
|
LOG_DBG("init complete, starting adv");
|
|
start_fast_adv();
|
|
}
|
|
|
|
size_t zephcore_ble_send(const uint8_t *data, uint16_t len)
|
|
{
|
|
if (len == 0 || len > MAX_FRAME_SIZE) {
|
|
LOG_WRN("invalid len=%u", (unsigned)len);
|
|
return 0;
|
|
}
|
|
|
|
/* Don't queue frames if no active transport */
|
|
if (active_iface == ZEPHCORE_IFACE_BLE && !current_conn) {
|
|
LOG_DBG("no BLE conn, dropping len=%u hdr=0x%02x",
|
|
(unsigned)len, data[0]);
|
|
return 0;
|
|
}
|
|
if (active_iface == ZEPHCORE_IFACE_NONE) {
|
|
LOG_DBG("no active iface, dropping len=%u hdr=0x%02x",
|
|
(unsigned)len, data[0]);
|
|
return 0;
|
|
}
|
|
|
|
struct frame f;
|
|
f.len = len;
|
|
memcpy(f.buf, data, len);
|
|
|
|
if (k_msgq_put(&ble_send_queue, &f, K_NO_WAIT) != 0) {
|
|
/* Queue full — enter congestion mode.
|
|
*
|
|
* Instead of blocking (would stall LoRa) or dropping (loses
|
|
* frames), we signal congestion so all senders stop, then
|
|
* save this frame and retry at a reduced 250ms cadence until
|
|
* the queue drains or the connection drops.
|
|
*
|
|
* Callers check zephcore_ble_is_congested() and hold off:
|
|
* - contact iteration (run_contact_iteration): pauses iteration
|
|
* - main thread (sendPush): pushes are best-effort signals,
|
|
* actual message data is safe in the offline queue
|
|
*/
|
|
if (!ble_tx_congested) {
|
|
LOG_WRN("TX queue full (%u/%u), entering congestion",
|
|
k_msgq_num_used_get(&ble_send_queue),
|
|
(unsigned)FRAME_QUEUE_SIZE);
|
|
ble_tx_congested = true;
|
|
}
|
|
|
|
/* Protocol response frames are lossless. If queue is full, report
|
|
* failure so the caller can retry instead of overflow replacement. */
|
|
if (is_lossless_protocol_frame(data, len)) {
|
|
LOG_DBG("TX queue full for lossless protocol frame hdr=0x%02x, retry later", data[0]);
|
|
return 0;
|
|
}
|
|
|
|
/* Save to overflow — retried at 250ms intervals.
|
|
* If overflow is already occupied, drop the new frame rather
|
|
* than clobber the buffered one. Only MSG_WAITING / PATH_UPDATED
|
|
* / CONTACTS_FULL are truly idempotent; most other push codes
|
|
* (ADVERT, SEND_CONFIRMED, RAW_DATA, telemetry/status responses,
|
|
* etc.) carry per-event data, so silent replacement = data loss.
|
|
* Chat messages are unaffected — message bytes live in the
|
|
* CompanionMesh offline queue and ride the lossless response
|
|
* path (data[0] < 0x80) on sync. */
|
|
if (overflow_pending) {
|
|
LOG_WRN("overflow full, dropping push hdr=0x%02x", data[0]);
|
|
return 0;
|
|
}
|
|
overflow_frame = f;
|
|
overflow_pending = true;
|
|
k_work_schedule(&overflow_retry_work, K_MSEC(BLE_TX_OVERFLOW_RETRY_MS));
|
|
return len; /* Accepted into overflow — will be retried */
|
|
}
|
|
|
|
LOG_DBG("queued len=%u hdr=0x%02x queue=%u",
|
|
(unsigned)len, data[0], k_msgq_num_used_get(&ble_send_queue));
|
|
|
|
kick_tx_drain();
|
|
return len;
|
|
}
|
|
|
|
void zephcore_ble_set_enabled(bool enable)
|
|
{
|
|
ble_enabled = enable;
|
|
if (!enable) {
|
|
/* Disconnect current connection if any */
|
|
if (current_conn) {
|
|
bt_conn_disconnect(current_conn,
|
|
BT_HCI_ERR_REMOTE_USER_TERM_CONN);
|
|
}
|
|
/* Stop advertising */
|
|
bt_le_adv_stop();
|
|
adv_running = false;
|
|
|
|
/* No future fast->slow transition needed */
|
|
k_work_cancel_delayable(&adv_slow_work);
|
|
|
|
LOG_INF("BLE disabled");
|
|
} else {
|
|
/* Re-enable advertising — start fast window */
|
|
start_fast_adv();
|
|
LOG_INF("BLE enabled");
|
|
}
|
|
}
|
|
bool zephcore_ble_is_enabled(void)
|
|
{
|
|
return ble_enabled;
|
|
}
|
|
|
|
bool zephcore_ble_is_active(void)
|
|
{
|
|
return active_iface == ZEPHCORE_IFACE_BLE && current_conn != NULL && ble_tx_ready;
|
|
}
|
|
|
|
bool zephcore_ble_is_connected(void)
|
|
{
|
|
return current_conn != NULL;
|
|
}
|
|
|
|
bool zephcore_ble_is_congested(void)
|
|
{
|
|
return ble_tx_congested;
|
|
}
|
|
|
|
bool zephcore_ble_is_advertising(void)
|
|
{
|
|
return adv_running;
|
|
}
|
|
|
|
void zephcore_ble_set_passkey(uint32_t passkey)
|
|
{
|
|
if (passkey >= 100000 && passkey <= 999999) {
|
|
ble_passkey = passkey;
|
|
} else {
|
|
ble_passkey = CONFIG_ZEPHCORE_BLE_PASSKEY;
|
|
}
|
|
LOG_INF("BLE passkey updated to %06u (effective on next pairing)", ble_passkey);
|
|
}
|
|
|
|
uint32_t zephcore_ble_get_passkey(void)
|
|
{
|
|
return ble_passkey;
|
|
}
|
|
|
|
enum zephcore_iface zephcore_ble_get_active_iface(void)
|
|
{
|
|
k_mutex_lock(&ble_iface_lock, K_FOREVER);
|
|
enum zephcore_iface iface = active_iface;
|
|
k_mutex_unlock(&ble_iface_lock);
|
|
return iface;
|
|
}
|
|
|
|
void zephcore_ble_set_active_iface(enum zephcore_iface iface)
|
|
{
|
|
k_mutex_lock(&ble_iface_lock, K_FOREVER);
|
|
active_iface = iface;
|
|
k_mutex_unlock(&ble_iface_lock);
|
|
}
|
|
|
|
bool zephcore_ble_iface_try_claim(enum zephcore_iface who)
|
|
{
|
|
k_mutex_lock(&ble_iface_lock, K_FOREVER);
|
|
bool ok = (active_iface == ZEPHCORE_IFACE_NONE || active_iface == who);
|
|
if (ok) {
|
|
active_iface = who;
|
|
}
|
|
k_mutex_unlock(&ble_iface_lock);
|
|
return ok;
|
|
}
|
|
|
|
struct k_msgq *zephcore_ble_get_recv_queue(void)
|
|
{
|
|
return &ble_recv_queue;
|
|
}
|
|
|
|
struct k_msgq *zephcore_ble_get_send_queue(void)
|
|
{
|
|
return &ble_send_queue;
|
|
}
|
|
|
|
void zephcore_ble_kick_tx(void)
|
|
{
|
|
kick_tx_drain();
|
|
}
|
|
|
|
void zephcore_ble_disconnect(void)
|
|
{
|
|
if (current_conn) {
|
|
bt_conn_disconnect(current_conn, BT_HCI_ERR_REMOTE_USER_TERM_CONN);
|
|
}
|
|
}
|
|
|
|
void zephcore_ble_update_name(const char *new_name)
|
|
{
|
|
build_device_name_and_adv(new_name);
|
|
|
|
/* If currently advertising (not connected), restart so the new name
|
|
* is published immediately. Restart at fast interval so anyone
|
|
* scanning sees the new name quickly. */
|
|
if (!current_conn) {
|
|
LOG_INF("name updated, restarting adv");
|
|
adv_stop_for_interval_change = true; /* suppress recycled() restart */
|
|
bt_le_adv_stop();
|
|
adv_running = false;
|
|
start_fast_adv();
|
|
}
|
|
/* If connected: GATT device name (via bt_set_name in build_device_name_and_adv)
|
|
* is live now; advertising payload updates on next adv cycle after disconnect. */
|
|
}
|
|
|
|
void zephcore_ble_conn_params_ready(void)
|
|
{
|
|
if (!conn_params_pending || !current_conn) {
|
|
return;
|
|
}
|
|
conn_params_pending = false;
|
|
|
|
struct bt_le_conn_param conn_param = {
|
|
.interval_min = BLE_DEFAULT_MIN_INTERVAL,
|
|
.interval_max = BLE_DEFAULT_MAX_INTERVAL,
|
|
.latency = BLE_DEFAULT_LATENCY,
|
|
.timeout = BLE_DEFAULT_TIMEOUT,
|
|
};
|
|
int err = bt_conn_le_param_update(current_conn, &conn_param);
|
|
if (err) {
|
|
LOG_WRN("Post-sync conn param update failed: %d", err);
|
|
} else {
|
|
LOG_INF("Post-sync conn params: %d-%dms interval, latency=%d",
|
|
BLE_DEFAULT_MIN_INTERVAL * 5 / 4,
|
|
BLE_DEFAULT_MAX_INTERVAL * 5 / 4,
|
|
BLE_DEFAULT_LATENCY);
|
|
}
|
|
}
|