From d7caa154dfda09a36da19f75bf001a65db966b75 Mon Sep 17 00:00:00 2001 From: MacNite Date: Mon, 7 Sep 2026 20:58:09 +0200 Subject: [PATCH 1/2] Add authenticated BLE audio streaming --- README.md | 3 + docs/audio-over-ble.md | 105 +++++ firmware-nrf54lm20a/.gitignore | 1 + firmware-nrf54lm20a/CMakeLists.txt | 2 + firmware-nrf54lm20a/prj.conf | 15 +- firmware-nrf54lm20a/src/audio.c | 382 ++++++++++++++++++ firmware-nrf54lm20a/src/audio.h | 6 + .../src/audio_secret.example.h | 10 + firmware-nrf54lm20a/src/hive_config.h | 36 +- firmware-nrf54lm20a/src/link.c | 174 ++++++++ firmware-nrf54lm20a/src/link.h | 19 + firmware-nrf54lm20a/src/main.c | 7 +- firmware-nrf54lm20a/src/mic.c | 96 +++-- firmware-nrf54lm20a/src/mic.h | 7 + firmware-nrf54lm20a/src/ota.c | 64 +-- firmware-nrf54lm20a/src/ota.h | 11 +- firmware-nrf54lm20a/src/throughput.c | 114 +----- 17 files changed, 845 insertions(+), 207 deletions(-) create mode 100644 docs/audio-over-ble.md create mode 100644 firmware-nrf54lm20a/src/audio.c create mode 100644 firmware-nrf54lm20a/src/audio.h create mode 100644 firmware-nrf54lm20a/src/audio_secret.example.h create mode 100644 firmware-nrf54lm20a/src/link.c create mode 100644 firmware-nrf54lm20a/src/link.h diff --git a/README.md b/README.md index 1356229..d569be6 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ value means the same thing across the ecosystem. - **Ultra-low power** — the nRF54 idles with only the ~1 s advertiser running (a few µA); no deep-sleep rendezvous machinery required. The sensor rail (IMU + microphone) is switched off between the five-minute measurements. +- **Authenticated live audio** — on request, streams 16 kHz PCM16 over BLE with a 60-second privacy cap and end-to-end CRC. + See [`docs/audio-over-ble.md`](docs/audio-over-ble.md). - **Firmware-over-BLE (OTA)** — implemented: MCUboot dual-slot with a small GATT service that streams the signed image into the secondary slot, verifies size + CRC-32, and test-swaps with automatic rollback. The device therefore @@ -125,6 +127,7 @@ See [`docs/vscode-build.md`](docs/vscode-build.md) to build it from VS Code or VSCodium, [`docs/flashing.md`](docs/flashing.md) for flashing details and troubleshooting, and [`firmware-nrf54lm20a/README.md`](firmware-nrf54lm20a/README.md) for the readout format, BLE frame layout, and roadmap. +For the authenticated live PCM protocol, see [`docs/audio-over-ble.md`](docs/audio-over-ble.md). For battery deployment, see the audited [`low-power build profile`](docs/low-power.md), including measurement guidance and upstream references. diff --git a/docs/audio-over-ble.md b/docs/audio-over-ble.md new file mode 100644 index 0000000..4dc24d5 --- /dev/null +++ b/docs/audio-over-ble.md @@ -0,0 +1,105 @@ +# Audio over BLE + +HiveInside exposes an authenticated, on-request microphone stream to HiveHub: + +``` +MSM261 PDM mic -> nRF PDM PCM16 -> 32 KiB ring -> BLE notifications -> HiveHub + 32 kB/s measured 137 kB/s +``` + +The stream remains raw 16 kHz, 16-bit little-endian mono PCM. The measured 15 ms, +2M-PHY link carries 137 kB/s, over four times the 32 kB/s capture rate, so a +codec would add failure state without fitting a constrained pipe. Streaming uses +about 32 KiB rather than the 320 KiB or more needed for a useful whole clip. + +## GATT service + +All UUIDs end in `-7a1c-4b9e-9a2f-1d6e0b9c1a01`. + +| Characteristic | UUID prefix | Properties | Value | +|---|---|---|---| +| Service | `8e8b0002` | primary | — | +| CTRL | `8e8b0020` | write | commands below | +| DATA | `8e8b0021` | notify | sequenced PCM packets | +| STATUS | `8e8b0023` | read, notify | 30-byte session record | + +Subscribe to DATA before START. A DATA notification is `seq(2 LE), flags(1), +reserved(1), PCM16`. The PCM portion is `min(MTU-3-4, 240)` bytes, rounded down +to an even length. Sequence numbers are free-running unsigned 16-bit values. +Flag bit 0 marks the final, header-only packet. Bit 1 says whole audio blocks +were dropped immediately before this packet; do not splice across that hole. + +CTRL commands are: + +| Opcode | Exact bytes | Meaning | +|---|---|---| +| `0x01` START | `op, duration_ds(2 LE), rate_code, format, gain_db, hmac(8)` | authenticate and capture | +| `0x02` STOP | `op` | end the current stream | + +`duration_ds=0` means live/open-ended. Every session still stops at 60 seconds. +The only rate code is 0 (16 kHz); the only implemented format is 0 (PCM16 LE +mono). Format 1 is reserved for ADPCM and returns unsupported-format. Gain is a +signed byte clamped to -20..+20 dB, applied before transmission with saturation. + +STATUS is always exactly 30 bytes: + +| Offset | Size | Field | +|---:|---:|---| +| 0 | 1 | state: IDLE `00`, ARMED `01`, STREAMING `02`, DONE `03` | +| 1 | 1 | error | +| 2 | 8 | current authentication nonce | +| 10 | 4 | transmitted PCM bytes, LE | +| 14 | 4 | elapsed milliseconds, LE | +| 18 | 4 | bytes lost to ring overrun, LE | +| 22 | 4 | IEEE CRC-32 of transmitted, post-gain PCM, LE | +| 26 | 2 | sample rate (16000), LE | +| 28 | 1 | format (0) | +| 29 | 1 | clipped samples as integer percent | + +Errors are bad parameters `10`, authentication `11`, busy `12`, microphone +`13`, DATA not subscribed `14`, notification stall `15`, and unsupported format +`16`. STATUS notifies on each state change and at completion. + +## Authentication and client sequence + +Provision the same random 32-byte key on node and HiveHub. Copy +`src/audio_secret.example.h` to the gitignored `src/audio_secret.h` and replace +the zeros. A missing or all-zero key fails closed. + +1. Connect, subscribe to DATA (and optionally STATUS), then read STATUS. +2. Take its fresh eight-byte nonce. +3. Compute `HMAC-SHA256(key, nonce || op || duration_ds_LE || rate_code || + format || gain_db)` and append the first eight digest bytes to START. +4. Write START and consume DATA until its final flag; verify byte count and + CRC against final STATUS. Treat nonzero dropped bytes as an incomplete clip. + +The node compares the tag without early exit. A failed attempt rotates the +nonce, so reread STATUS before retrying. The nonce also rotates on connection +and after every completed session. + +## Bounded states and privacy + +The microphone is off unless an authenticated START owns the exclusive link. +OTA and audio cannot overlap, and periodic sensing pauses for any connection. +A connection that claims no service is disconnected after 10 seconds. Capture +ends at the requested duration, STOP, disconnect, or the absolute 60-second +cap. Five seconds without a notification completion is a stalled session. Each +end path stops PDM, removes sensor-rail power, publishes final STATUS where the +link remains, and releases ownership. Authentication is application-level +access control; audio is not encrypted unless the BLE connection itself is +secured by the client. + +## Troubleshooting + +* **Error 14:** subscribe to DATA before START; enabling STATUS alone is not enough. +* **Error 11:** provision a nonzero key, reread the rotated nonce, and ensure the + signed gain byte and little-endian duration are included exactly once. +* **Error 16:** request format 0; ADPCM is only a reserved wire value. +* **Short packets:** negotiate ATT MTU 247; smaller MTUs are valid and reduce PCM + per notification while preserving sample boundaries. +* **Holes or CRC mismatch:** inspect sequence numbers, DATA flag bit 1, final + `dropped_bytes`, and CRC. Nonzero drops mean the recording is not clean. +* **Error 15:** verify the central negotiated notifications, 15 ms interval, + 251-byte data length and 2M PHY and continues acknowledging controller TX. +* **Disconnect near startup:** complete STATUS/nonce/START within the 10-second + arm window. diff --git a/firmware-nrf54lm20a/.gitignore b/firmware-nrf54lm20a/.gitignore index a176602..991bc34 100644 --- a/firmware-nrf54lm20a/.gitignore +++ b/firmware-nrf54lm20a/.gitignore @@ -5,3 +5,4 @@ .vscode/launch.json .vscode/ipch compile_commands.json +src/audio_secret.h diff --git a/firmware-nrf54lm20a/CMakeLists.txt b/firmware-nrf54lm20a/CMakeLists.txt index 1770964..91a1957 100644 --- a/firmware-nrf54lm20a/CMakeLists.txt +++ b/firmware-nrf54lm20a/CMakeLists.txt @@ -34,7 +34,9 @@ target_sources(app PRIVATE src/mic.c src/battery.c src/beacon.c + src/link.c src/ota.c + src/audio.c # Temporary measurement module (issue #71 phase 0e). Compiles to nothing # unless ENABLE_THROUGHPUT_SPIKE is set — see throughput-spike.conf. src/throughput.c diff --git a/firmware-nrf54lm20a/prj.conf b/firmware-nrf54lm20a/prj.conf index dac76c6..923cc62 100644 --- a/firmware-nrf54lm20a/prj.conf +++ b/firmware-nrf54lm20a/prj.conf @@ -103,7 +103,20 @@ CONFIG_SYSTEM_WORKQUEUE_STACK_SIZE=2048 CONFIG_BT_L2CAP_TX_MTU=247 CONFIG_BT_BUF_ACL_RX_SIZE=251 CONFIG_BT_BUF_ACL_TX_SIZE=251 -CONFIG_BT_BUF_ACL_TX_COUNT=3 +# Six 251-byte TX buffers let notifications fill a connection event; the five +# extra buffers cost roughly CONFIG_BT_BUF_ACL_TX_SIZE bytes each in RAM. +CONFIG_BT_BUF_ACL_TX_COUNT=6 +# Audio explicitly negotiates maximum data length and 2M PHY: the measured link +# loses roughly two thirds of its throughput if left at the central's defaults. +CONFIG_BT_USER_DATA_LEN_UPDATE=y +CONFIG_BT_USER_PHY_UPDATE=y + +# START authentication uses HMAC-SHA256. Keeping this in PSA rather than a +# local primitive gives the provisioned key one audited crypto boundary. +CONFIG_NRF_SECURITY=y +CONFIG_PSA_WANT_ALG_HMAC=y +CONFIG_PSA_WANT_ALG_SHA_256=y +CONFIG_PSA_WANT_KEY_TYPE_HMAC=y # MCUboot test swap and direct streaming into the secondary fixed partition. CONFIG_BOOTLOADER_MCUBOOT=y diff --git a/firmware-nrf54lm20a/src/audio.c b/firmware-nrf54lm20a/src/audio.c new file mode 100644 index 0000000..71f4f76 --- /dev/null +++ b/firmware-nrf54lm20a/src/audio.c @@ -0,0 +1,382 @@ +/* Authenticated PCM16 capture and notification streaming. */ +#include "audio.h" + +#include "hive_config.h" +#include "link.h" +#include "mic.h" +#include "power.h" + +#if ENABLE_AUDIO + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if __has_include("audio_secret.h") +#include "audio_secret.h" +#define AUDIO_KEY_PRESENT 1 +#else +#define AUDIO_KEY_PRESENT 0 +static const uint8_t hive_audio_psk[32]; +#endif + +#define AUDIO_STATUS_LEN 30 +#define AUDIO_HEADER_LEN 4 +#define AUDIO_PCM_MAX 240 +#define AUDIO_CREDITS CONFIG_BT_BUF_ACL_TX_COUNT +#define AUDIO_BLOCK_BYTES (MIC_BLOCK_SAMPLES * sizeof(int16_t)) + +enum { AUDIO_OP_START = 0x01, AUDIO_OP_STOP = 0x02 }; +enum { AUDIO_IDLE, AUDIO_ARMED, AUDIO_STREAMING, AUDIO_DONE }; +enum { + AUDIO_ERR_PARAM = 0x10, AUDIO_ERR_AUTH, AUDIO_ERR_BUSY, + AUDIO_ERR_MIC, AUDIO_ERR_NO_SUB, AUDIO_ERR_STALLED, + AUDIO_ERR_FORMAT, +}; + +static uint8_t state, error, nonce[8], format; +static uint32_t bytes_sent, dropped_bytes, running_crc, clipped, samples; +static uint16_t seq, duration_ds; +static int8_t gain_db; +static float gain_factor; +static int64_t started_ms, deadline_ms; +static bool subscribed; +static atomic_t stopping, capture_done, dropped_pending; +#define AUDIO_RING_BLOCKS (HIVE_AUDIO_RING_BYTES / AUDIO_BLOCK_BYTES) +static uint8_t audio_ring[AUDIO_RING_BLOCKS][AUDIO_BLOCK_BYTES]; +static uint16_t ring_head, ring_tail, ring_count, ring_offset; +K_MUTEX_DEFINE(audio_ring_lock); +K_SEM_DEFINE(audio_capture_start, 0, 1); +K_SEM_DEFINE(audio_tx_start, 0, 1); +K_SEM_DEFINE(audio_data_ready, 0, 1); +K_SEM_DEFINE(audio_credits, AUDIO_CREDITS, AUDIO_CREDITS); + +struct tx_packet { uint8_t data[AUDIO_HEADER_LEN + AUDIO_PCM_MAX]; bool busy; }; +static struct tx_packet packets[AUDIO_CREDITS]; +static struct k_spinlock packet_lock; + +static ssize_t ctrl_write(struct bt_conn *, const struct bt_gatt_attr *, + const void *, uint16_t, uint16_t, uint8_t); +static ssize_t status_read(struct bt_conn *, const struct bt_gatt_attr *, + void *, uint16_t, uint16_t); +static void data_ccc_changed(const struct bt_gatt_attr *, uint16_t); + +#define UUID128(n) BT_UUID_128_ENCODE(n, 0x7a1c, 0x4b9e, 0x9a2f, 0x1d6e0b9c1a01) +static struct bt_uuid_128 service_uuid = BT_UUID_INIT_128(UUID128(0x8e8b0002)); +static struct bt_uuid_128 ctrl_uuid = BT_UUID_INIT_128(UUID128(0x8e8b0020)); +static struct bt_uuid_128 data_uuid = BT_UUID_INIT_128(UUID128(0x8e8b0021)); +static struct bt_uuid_128 status_uuid = BT_UUID_INIT_128(UUID128(0x8e8b0023)); + +BT_GATT_SERVICE_DEFINE(audio_service, + BT_GATT_PRIMARY_SERVICE(&service_uuid.uuid), + BT_GATT_CHARACTERISTIC(&ctrl_uuid.uuid, BT_GATT_CHRC_WRITE, + BT_GATT_PERM_WRITE, NULL, ctrl_write, NULL), + BT_GATT_CHARACTERISTIC(&data_uuid.uuid, BT_GATT_CHRC_NOTIFY, + BT_GATT_PERM_NONE, NULL, NULL, NULL), + BT_GATT_CCC(data_ccc_changed, BT_GATT_PERM_READ | BT_GATT_PERM_WRITE), + BT_GATT_CHARACTERISTIC(&status_uuid.uuid, + BT_GATT_CHRC_READ | BT_GATT_CHRC_NOTIFY, + BT_GATT_PERM_READ, status_read, NULL, NULL), + BT_GATT_CCC(NULL, BT_GATT_PERM_READ | BT_GATT_PERM_WRITE)); +#define AUDIO_ATTR_DATA 4 +#define AUDIO_ATTR_STATUS 7 + +static void rotate_nonce(void) +{ + if (bt_rand(nonce, sizeof(nonce)) != 0) { + /* A predictable challenge must never make a microphone available. */ + memset(nonce, 0, sizeof(nonce)); + } +} + +static void fill_status(uint8_t v[AUDIO_STATUS_LEN]) +{ + v[0] = state; v[1] = error; + memcpy(&v[2], nonce, sizeof(nonce)); + sys_put_le32(bytes_sent, &v[10]); + uint32_t elapsed = started_ms > 0 ? (uint32_t)(k_uptime_get() - started_ms) : 0; + sys_put_le32(elapsed, &v[14]); + sys_put_le32(dropped_bytes, &v[18]); + sys_put_le32(running_crc, &v[22]); + sys_put_le16(MIC_SAMPLE_RATE, &v[26]); + v[28] = format; + v[29] = samples ? (uint8_t)MIN(100U, (clipped * 100U) / samples) : 0; +} + +static void publish_status(void) +{ + uint8_t value[AUDIO_STATUS_LEN]; + fill_status(value); + (void)bt_gatt_notify(link_conn(), &audio_service.attrs[AUDIO_ATTR_STATUS], + value, sizeof(value)); +} + +static void set_state(uint8_t value, uint8_t fault) +{ + state = value; error = fault; publish_status(); +} + +static bool key_usable(void) +{ + uint8_t any = 0; + for (size_t i = 0; i < sizeof(hive_audio_psk); i++) any |= hive_audio_psk[i]; + return AUDIO_KEY_PRESENT && any != 0; +} + +static bool authenticate(const uint8_t *request) +{ + if (!key_usable()) { + printk("[AUDIO] START refused: audio_secret.h is absent or all zero\n"); + return false; + } + uint8_t message[14], mac[32] = { 0 }; + memcpy(message, nonce, 8); + memcpy(&message[8], request, 6); + psa_key_attributes_t attr = PSA_KEY_ATTRIBUTES_INIT; + psa_key_id_t key; + size_t mac_len = 0; + psa_set_key_usage_flags(&attr, PSA_KEY_USAGE_SIGN_MESSAGE); + psa_set_key_algorithm(&attr, PSA_ALG_HMAC(PSA_ALG_SHA_256)); + psa_set_key_type(&attr, PSA_KEY_TYPE_HMAC); + psa_set_key_bits(&attr, 256); + psa_status_t rc = psa_import_key(&attr, hive_audio_psk, + sizeof(hive_audio_psk), &key); + psa_reset_key_attributes(&attr); + if (rc == PSA_SUCCESS) { + rc = psa_mac_compute(key, PSA_ALG_HMAC(PSA_ALG_SHA_256), message, + sizeof(message), mac, sizeof(mac), &mac_len); + (void)psa_destroy_key(key); + } + uint8_t difference = (rc == PSA_SUCCESS && mac_len >= 8) ? 0 : 1; + for (size_t i = 0; i < 8; i++) difference |= mac[i] ^ request[6 + i]; + return difference == 0; +} + +static void data_ccc_changed(const struct bt_gatt_attr *attr, uint16_t value) +{ + ARG_UNUSED(attr); subscribed = value == BT_GATT_CCC_NOTIFY; +} + +static struct tx_packet *packet_take(void) +{ + k_spinlock_key_t key = k_spin_lock(&packet_lock); + struct tx_packet *found = NULL; + for (size_t i = 0; i < ARRAY_SIZE(packets); i++) { + if (!packets[i].busy) { packets[i].busy = true; found = &packets[i]; break; } + } + k_spin_unlock(&packet_lock, key); + return found; +} + +static void sent_cb(struct bt_conn *conn, void *user_data) +{ + ARG_UNUSED(conn); + k_spinlock_key_t key = k_spin_lock(&packet_lock); + ((struct tx_packet *)user_data)->busy = false; + k_spin_unlock(&packet_lock, key); + k_sem_give(&audio_credits); +} + +static int notify_packet(struct tx_packet *packet, size_t pcm_len, uint8_t flags) +{ + sys_put_le16(seq++, packet->data); + packet->data[2] = flags; packet->data[3] = 0; + struct bt_gatt_notify_params params = { + .attr = &audio_service.attrs[AUDIO_ATTR_DATA], .data = packet->data, + .len = pcm_len + AUDIO_HEADER_LEN, .func = sent_cb, + .user_data = packet, + }; + int rc = bt_gatt_notify_cb(link_conn(), ¶ms); + if (rc != 0) { + packet->busy = false; k_sem_give(&audio_credits); + } + return rc; +} + +static void capture_thread_fn(void *a, void *b, void *c) +{ + ARG_UNUSED(a); ARG_UNUSED(b); ARG_UNUSED(c); + for (;;) { + k_sem_take(&audio_capture_start, K_FOREVER); + while (!atomic_get(&stopping) && link_conn() != NULL && + k_uptime_get() < deadline_ms) { + void *block; uint32_t size; + if (mic_stream_read(&block, &size, 250) != 0) continue; + k_mutex_lock(&audio_ring_lock, K_FOREVER); + if (ring_count == AUDIO_RING_BLOCKS) { + /* Discard exactly one capture block. Dropping a byte range + * could manufacture a false sample at the splice. */ + ring_tail = (ring_tail + 1U) % AUDIO_RING_BLOCKS; + ring_count--; + ring_offset = 0; + dropped_bytes += AUDIO_BLOCK_BYTES; + atomic_set(&dropped_pending, 1); + } + memcpy(audio_ring[ring_head], block, MIN(size, AUDIO_BLOCK_BYTES)); + ring_head = (ring_head + 1U) % AUDIO_RING_BLOCKS; + ring_count++; + k_mutex_unlock(&audio_ring_lock); + mic_stream_release(block); + k_sem_give(&audio_data_ready); + } + atomic_set(&stopping, 1); + mic_stream_stop(); + atomic_set(&capture_done, 1); + k_sem_give(&audio_data_ready); + } +} +K_THREAD_DEFINE(audio_capture_thread, 2048, capture_thread_fn, + NULL, NULL, NULL, 6, 0, 0); + +static void tx_thread_fn(void *a, void *b, void *c) +{ + ARG_UNUSED(a); ARG_UNUSED(b); ARG_UNUSED(c); + for (;;) { + k_sem_take(&audio_tx_start, K_FOREVER); + for (;;) { + k_mutex_lock(&audio_ring_lock, K_FOREVER); + uint32_t available = ring_count == 0 ? 0 : + AUDIO_BLOCK_BYTES - ring_offset; + k_mutex_unlock(&audio_ring_lock); + if (available == 0) { + if (atomic_get(&capture_done)) break; + (void)k_sem_take(&audio_data_ready, K_MSEC(100)); + continue; + } + if (k_sem_take(&audio_credits, + K_MSEC(HIVE_AUDIO_STALL_TIMEOUT_MS)) != 0) { + error = AUDIO_ERR_STALLED; atomic_set(&stopping, 1); continue; + } + struct tx_packet *packet = packet_take(); + if (packet == NULL) { k_sem_give(&audio_credits); continue; } + uint16_t mtu = link_conn() ? bt_gatt_get_mtu(link_conn()) : 0; + size_t amount = MIN((size_t)AUDIO_PCM_MAX, + mtu > 7 ? (size_t)(mtu - 7) : 0); + amount &= ~1U; + if (amount == 0) { packet->busy = false; k_sem_give(&audio_credits); atomic_set(&stopping, 1); continue; } + k_mutex_lock(&audio_ring_lock, K_FOREVER); + amount = MIN(amount, available); + memcpy(&packet->data[4], &audio_ring[ring_tail][ring_offset], amount); + ring_offset += amount; + if (ring_offset == AUDIO_BLOCK_BYTES) { + ring_offset = 0; + ring_tail = (ring_tail + 1U) % AUDIO_RING_BLOCKS; + ring_count--; + } + k_mutex_unlock(&audio_ring_lock); + int16_t *pcm = (int16_t *)&packet->data[4]; + for (size_t i = 0; i < amount / 2; i++) { + float scaled = (float)pcm[i] * gain_factor; + if (scaled > INT16_MAX) { pcm[i] = INT16_MAX; clipped++; } + else if (scaled < INT16_MIN) { pcm[i] = INT16_MIN; clipped++; } + else pcm[i] = (int16_t)scaled; + samples++; + } + running_crc = crc32_ieee_update(running_crc, &packet->data[4], amount); + uint8_t flags = atomic_cas(&dropped_pending, 1, 0) ? BIT(1) : 0; + if (notify_packet(packet, amount, flags) == 0) bytes_sent += amount; + else atomic_set(&stopping, 1); + } + if (link_conn() != NULL && subscribed && + k_sem_take(&audio_credits, K_MSEC(HIVE_AUDIO_STALL_TIMEOUT_MS)) == 0) { + struct tx_packet *packet = packet_take(); + if (packet != NULL) (void)notify_packet(packet, 0, BIT(0)); + else k_sem_give(&audio_credits); + } + (void)power_sensor_rail_disable(); + state = AUDIO_DONE; + rotate_nonce(); + publish_status(); + link_release(LINK_OWNER_AUDIO); + } +} +K_THREAD_DEFINE(audio_tx_thread, 3072, tx_thread_fn, NULL, NULL, NULL, 7, 0, 0); + +static ssize_t 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(attr); ARG_UNUSED(flags); + const uint8_t *p = buf; + if (offset != 0 || len == 0) return BT_GATT_ERR(BT_ATT_ERR_INVALID_OFFSET); + if (p[0] == AUDIO_OP_STOP && len == 1) { + if (state == AUDIO_STREAMING) atomic_set(&stopping, 1); + return len; + } + if (p[0] != AUDIO_OP_START || len != 14) { + set_state(state, AUDIO_ERR_PARAM); + return len; + } + if (p[3] != 0) { set_state(state, AUDIO_ERR_PARAM); return len; } + if (p[4] != 0) { set_state(state, AUDIO_ERR_FORMAT); return len; } + if (!authenticate(p)) { + rotate_nonce(); set_state(AUDIO_ARMED, AUDIO_ERR_AUTH); return len; + } + if (!subscribed) { set_state(AUDIO_ARMED, AUDIO_ERR_NO_SUB); return len; } + if (!link_claim(LINK_OWNER_AUDIO)) { set_state(AUDIO_ARMED, AUDIO_ERR_BUSY); return len; } + + duration_ds = sys_get_le16(&p[1]); format = p[4]; + gain_db = CLAMP((int8_t)p[5], -20, 20); + gain_factor = powf(10.0f, (float)gain_db / 20.0f); + bytes_sent = dropped_bytes = running_crc = clipped = samples = 0; + started_ms = k_uptime_get(); + uint32_t requested_ms = duration_ds ? (uint32_t)duration_ds * 100U : + HIVE_AUDIO_MAX_SECONDS * 1000U; + deadline_ms = started_ms + MIN(requested_ms, + HIVE_AUDIO_MAX_SECONDS * 1000U); + atomic_clear(&stopping); atomic_clear(&capture_done); + atomic_clear(&dropped_pending); + ring_head = ring_tail = ring_count = ring_offset = 0; + k_sem_reset(&audio_credits); + for (int i = 0; i < AUDIO_CREDITS; i++) k_sem_give(&audio_credits); + if (power_sensor_rail_enable() != 0 || mic_stream_start() != 0) { + (void)power_sensor_rail_disable(); link_release(LINK_OWNER_AUDIO); + set_state(AUDIO_ARMED, AUDIO_ERR_MIC); return len; + } + struct bt_le_conn_param cp = { .interval_min = HIVE_AUDIO_CONN_INTERVAL_UNITS, + .interval_max = HIVE_AUDIO_CONN_INTERVAL_UNITS, .latency = 0, + .timeout = 200 }; + (void)bt_conn_le_param_update(conn, &cp); + (void)bt_conn_le_data_len_update(conn, BT_LE_DATA_LEN_PARAM_MAX); + (void)bt_conn_le_phy_update(conn, BT_CONN_LE_PHY_PARAM_2M); + set_state(AUDIO_STREAMING, 0); + k_sem_give(&audio_capture_start); k_sem_give(&audio_tx_start); + return len; +} + +static ssize_t status_read(struct bt_conn *conn, const struct bt_gatt_attr *attr, + void *buf, uint16_t len, uint16_t offset) +{ + uint8_t value[AUDIO_STATUS_LEN]; fill_status(value); + return bt_gatt_attr_read(conn, attr, buf, len, offset, value, sizeof(value)); +} + +void audio_init(void) +{ + psa_status_t rc = psa_crypto_init(); + state = AUDIO_IDLE; error = 0; + if (rc != PSA_SUCCESS) printk("[AUDIO] PSA Crypto init failed (%d)\n", (int)rc); +} + +void audio_link_connected(void) +{ + subscribed = false; started_ms = 0; rotate_nonce(); set_state(AUDIO_ARMED, 0); +} + +void audio_link_disconnected(void) +{ + subscribed = false; atomic_set(&stopping, 1); k_sem_give(&audio_data_ready); +} + +#else +void audio_init(void) { } +void audio_link_connected(void) { } +void audio_link_disconnected(void) { } +#endif diff --git a/firmware-nrf54lm20a/src/audio.h b/firmware-nrf54lm20a/src/audio.h new file mode 100644 index 0000000..8c7bfc2 --- /dev/null +++ b/firmware-nrf54lm20a/src/audio.h @@ -0,0 +1,6 @@ +/* Authenticated, on-request PCM audio service. */ +#pragma once + +void audio_init(void); +void audio_link_connected(void); +void audio_link_disconnected(void); diff --git a/firmware-nrf54lm20a/src/audio_secret.example.h b/firmware-nrf54lm20a/src/audio_secret.example.h new file mode 100644 index 0000000..49e1c44 --- /dev/null +++ b/firmware-nrf54lm20a/src/audio_secret.example.h @@ -0,0 +1,10 @@ +/* Copy to audio_secret.h and replace every byte with a securely generated key. + * The real file is gitignored; an all-zero key deliberately disables START. */ +#pragma once +#include +static const uint8_t hive_audio_psk[32] = { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +}; diff --git a/firmware-nrf54lm20a/src/hive_config.h b/firmware-nrf54lm20a/src/hive_config.h index 3f7d3bc..d5be1b5 100644 --- a/firmware-nrf54lm20a/src/hive_config.h +++ b/firmware-nrf54lm20a/src/hive_config.h @@ -42,7 +42,7 @@ * stamped artifact name disagree with the bytes on the air. Bump it here. */ #define HIVEINSIDE_FW_VERSION_MAJOR 0 -#define HIVEINSIDE_FW_VERSION_MINOR 5 +#define HIVEINSIDE_FW_VERSION_MINOR 6 #define HIVEINSIDE_FW_VERSION_PATCH 0 #define HIVEINSIDE_STRINGIFY_(value) #value @@ -136,6 +136,36 @@ #define HIVE_WDT_FEED_INTERVAL_MS 20000U #endif +/* ── Connected sessions and authenticated audio ───────────────────────── */ + +/* Audio ships enabled, but fails closed unless a separately provisioned key is + * present. Sixty seconds bounds privacy exposure and battery drain even for an + * open-ended listening request. */ +#ifndef ENABLE_AUDIO +#define ENABLE_AUDIO 1 +#endif +#ifndef HIVE_AUDIO_MAX_SECONDS +#define HIVE_AUDIO_MAX_SECONDS 60U +#endif +/* One second of PCM slack absorbs ordinary notification jitter without the + * hundreds of KiB that buffering a whole clip would consume. */ +#ifndef HIVE_AUDIO_RING_BYTES +#define HIVE_AUDIO_RING_BYTES 32768U +#endif +#ifndef HIVE_AUDIO_STALL_TIMEOUT_MS +#define HIVE_AUDIO_STALL_TIMEOUT_MS 5000U +#endif +/* BLE units are 1.25 ms; measurement showed that explicitly requesting 15 ms + * avoids losing roughly two thirds of notification throughput. */ +#ifndef HIVE_AUDIO_CONN_INTERVAL_UNITS +#define HIVE_AUDIO_CONN_INTERVAL_UNITS 12U +#endif +/* Audio needs time to read its nonce before START, but an unclaimed connection + * still removes the node from advertising and therefore remains bounded. */ +#ifndef HIVE_LINK_ARM_TIMEOUT_MS +#define HIVE_LINK_ARM_TIMEOUT_MS 10000U +#endif + /* ── OTA stall timeout ───────────────────────────────────────────────────── * * How long a transfer may go without a single DATA write before the node @@ -241,6 +271,10 @@ #define MIC_WARMUP_BLOCKS 2 #endif +#include +BUILD_ASSERT(HIVE_AUDIO_RING_BYTES >= MIC_SAMPLE_RATE, + "audio ring must hold at least 0.5 seconds of PCM16"); + /* Acoustic FFT bands (Hz), identical to HiveScale/HiveHub. */ #define MIC_BAND_SUBBASS_LO 50 /* 50–150 Hz structural / low rumble */ #define MIC_BAND_SUBBASS_HI 150 diff --git a/firmware-nrf54lm20a/src/link.c b/firmware-nrf54lm20a/src/link.c new file mode 100644 index 0000000..0835b8f --- /dev/null +++ b/firmware-nrf54lm20a/src/link.c @@ -0,0 +1,174 @@ +/* The beacon, OTA and audio service share one peripheral connection. Keeping + * its lifetime here prevents two services from restarting advertising twice. */ +#include "link.h" + +#include "audio.h" +#include "beacon.h" +#include "hive_config.h" +#include "ota.h" + +#include +#include +#include +#include + +static struct bt_conn *active_conn; +static enum link_owner owner; +static struct k_spinlock state_lock; + +static void arm_timeout_handler(struct k_work *work); +K_WORK_DELAYABLE_DEFINE(arm_timeout_work, arm_timeout_handler); + +struct bt_conn *link_conn(void) +{ + return active_conn; +} + +bool link_claim(enum link_owner requested) +{ + bool claimed = false; + k_spinlock_key_t key = k_spin_lock(&state_lock); + + if (active_conn != NULL && owner == LINK_OWNER_NONE && + requested != LINK_OWNER_NONE) { + owner = requested; + claimed = true; + } + k_spin_unlock(&state_lock, key); + if (claimed) { + (void)k_work_cancel_delayable(&arm_timeout_work); + } + return claimed; +} + +void link_release(enum link_owner released) +{ + bool arm = false; + k_spinlock_key_t key = k_spin_lock(&state_lock); + if (owner == released) { + owner = LINK_OWNER_NONE; + arm = active_conn != NULL; + } + k_spin_unlock(&state_lock, key); + /* Releasing a completed service does not make an otherwise idle + * connection safe to retain forever. Give the central a fresh arm window + * in which to begin another bounded session. */ + if (arm) { + (void)k_work_reschedule(&arm_timeout_work, + K_MSEC(HIVE_LINK_ARM_TIMEOUT_MS)); + } +} + +bool link_is_owner(enum link_owner queried) +{ + k_spinlock_key_t key = k_spin_lock(&state_lock); + bool result = owner == queried; + k_spin_unlock(&state_lock, key); + return result; +} + +bool link_is_busy(void) +{ + k_spinlock_key_t key = k_spin_lock(&state_lock); + bool result = active_conn != NULL || owner != LINK_OWNER_NONE; + k_spin_unlock(&state_lock, key); + return result; +} + +static void log_current_params(struct bt_conn *conn) +{ + struct bt_conn_info info; + if (bt_conn_get_info(conn, &info) == 0 && info.type == BT_CONN_TYPE_LE) { + /* interval_us avoids Zephyr's deprecated, lossy interval field. */ + printk("[LINK] interval=%u us latency=%u timeout=%u ms\n", + (unsigned)info.le.interval_us, (unsigned)info.le.latency, + (unsigned)info.le.timeout * 10U); + } +} + +static void connected(struct bt_conn *conn, uint8_t err) +{ + struct bt_le_conn_param param = { + .interval_min = 24, .interval_max = 40, .latency = 0, + .timeout = 200, + }; + if (err != 0U) { + return; + } + active_conn = bt_conn_ref(conn); + owner = LINK_OWNER_NONE; + beacon_connected(); + ota_link_connected(); + audio_link_connected(); + /* Preserve OTA's established 30--50 ms baseline. Audio replaces it with + * its measured 15 ms interval only after an authenticated START. */ + (void)bt_conn_le_param_update(conn, ¶m); + log_current_params(conn); + (void)k_work_reschedule(&arm_timeout_work, + K_MSEC(HIVE_LINK_ARM_TIMEOUT_MS)); +} + +static void disconnected(struct bt_conn *conn, uint8_t reason) +{ + ARG_UNUSED(conn); + (void)k_work_cancel_delayable(&arm_timeout_work); + ota_link_disconnected(); + audio_link_disconnected(); + owner = LINK_OWNER_NONE; + if (active_conn != NULL) { + bt_conn_unref(active_conn); + active_conn = NULL; + } + printk("[LINK] disconnected (0x%02x)\n", reason); + beacon_disconnected(); +} + +static void param_updated(struct bt_conn *conn, uint16_t interval, + uint16_t latency, uint16_t timeout) +{ + ARG_UNUSED(conn); + printk("[LINK] interval now %u units latency=%u timeout=%u ms\n", + (unsigned)interval, (unsigned)latency, (unsigned)timeout * 10U); +} + +#if defined(CONFIG_BT_USER_PHY_UPDATE) +static void phy_updated(struct bt_conn *conn, struct bt_conn_le_phy_info *info) +{ + ARG_UNUSED(conn); + printk("[LINK] PHY now tx=%u rx=%u\n", (unsigned)info->tx_phy, + (unsigned)info->rx_phy); +} +#endif + +#if defined(CONFIG_BT_USER_DATA_LEN_UPDATE) +static void data_len_updated(struct bt_conn *conn, + struct bt_conn_le_data_len_info *info) +{ + ARG_UNUSED(conn); + printk("[LINK] data length tx=%u B/%u us rx=%u B/%u us\n", + (unsigned)info->tx_max_len, (unsigned)info->tx_max_time, + (unsigned)info->rx_max_len, (unsigned)info->rx_max_time); +} +#endif + +BT_CONN_CB_DEFINE(link_callbacks) = { + .connected = connected, + .disconnected = disconnected, + .le_param_updated = param_updated, +#if defined(CONFIG_BT_USER_PHY_UPDATE) + .le_phy_updated = phy_updated, +#endif +#if defined(CONFIG_BT_USER_DATA_LEN_UPDATE) + .le_data_len_updated = data_len_updated, +#endif +}; + +static void arm_timeout_handler(struct k_work *work) +{ + ARG_UNUSED(work); + if (active_conn != NULL && owner == LINK_OWNER_NONE) { + printk("[LINK] connection not claimed; disconnecting\n"); + (void)bt_conn_disconnect(active_conn, + BT_HCI_ERR_REMOTE_USER_TERM_CONN); + } +} diff --git a/firmware-nrf54lm20a/src/link.h b/firmware-nrf54lm20a/src/link.h new file mode 100644 index 0000000..7681a3b --- /dev/null +++ b/firmware-nrf54lm20a/src/link.h @@ -0,0 +1,19 @@ +/* Shared ownership of the single peripheral BLE connection. */ +#pragma once + +#include +#include + +enum link_owner { + LINK_OWNER_NONE, + LINK_OWNER_OTA, + LINK_OWNER_AUDIO, + LINK_OWNER_THROUGHPUT, +}; + +struct bt_conn *link_conn(void); +bool link_claim(enum link_owner owner); +void link_release(enum link_owner owner); +bool link_is_owner(enum link_owner owner); +bool link_is_busy(void); + diff --git a/firmware-nrf54lm20a/src/main.c b/firmware-nrf54lm20a/src/main.c index c60f83f..37dc858 100644 --- a/firmware-nrf54lm20a/src/main.c +++ b/firmware-nrf54lm20a/src/main.c @@ -20,6 +20,8 @@ #include "beacon.h" #include "hive_config.h" #include "measurement.h" +#include "audio.h" +#include "link.h" #include "mic.h" #include "ota.h" #include "power.h" @@ -178,6 +180,7 @@ int main(void) measurement_led_init(); beacon_err = beacon_init(); ota_init(); + audio_init(); /* Armed after the one-time initialisation above, which is not covered by * the watchdog: a hang in a driver's init would leave the node dark rather * than reset-looping, and MCUboot's own rollback deadline below is the @@ -204,7 +207,7 @@ int main(void) while (true) { hive_watchdog_feed(); - if (ota_is_active()) { + if (link_is_busy()) { /* A firmware upload legitimately takes minutes. Polling here * means the main thread is healthy, so keep feeding: the * watchdog must not cut a transfer short. */ @@ -227,7 +230,7 @@ int main(void) if (beacon_publish(&m) == 0) { measurement_led_blink(); if (confirmation_pending && !confirmation_attempted && - !ota_is_active()) { + !link_is_busy()) { /* Make at most one trailer-write attempt per boot. A persistent * flash error must not cause periodic writes for the lifetime of * the device; leaving the image unconfirmed preserves rollback. */ diff --git a/firmware-nrf54lm20a/src/mic.c b/firmware-nrf54lm20a/src/mic.c index c151b1d..fd86566 100644 --- a/firmware-nrf54lm20a/src/mic.c +++ b/firmware-nrf54lm20a/src/mic.c @@ -19,6 +19,7 @@ #include "fft.h" #include "hive_config.h" +#include #include #include #include @@ -30,7 +31,7 @@ #define MIC_NODE DT_COMPAT_GET_ANY_STATUS_OKAY(nordic_nrf_pdm) #define BLOCK_BYTES (MIC_BLOCK_SAMPLES * sizeof(int16_t)) -#define BLOCK_COUNT 4 +#define BLOCK_COUNT 6 #define READ_TIMEOUT_MS 1000 #define FULL_SCALE 32768.0f /* 16-bit PCM */ @@ -45,6 +46,41 @@ static float fft_re[MIC_FFT_SAMPLE_COUNT]; static float fft_im[MIC_FFT_SAMPLE_COUNT]; static int16_t fft_pcm[MIC_FFT_SAMPLE_COUNT]; +int mic_stream_start(void) +{ + if (!device_is_ready(dmic_dev)) { + return -ENODEV; + } + struct pcm_stream_cfg stream = { + .pcm_rate = MIC_SAMPLE_RATE, .pcm_width = 16, + .block_size = BLOCK_BYTES, .mem_slab = &mic_slab, + }; + struct dmic_cfg cfg = { + .io = { .min_pdm_clk_freq = 1000000, .max_pdm_clk_freq = 3500000, + .min_pdm_clk_dc = 40, .max_pdm_clk_dc = 60 }, + .streams = &stream, + .channel = { .req_num_streams = 1, .req_num_chan = 1 }, + }; + cfg.channel.req_chan_map_lo = dmic_build_channel_map(0, 0, PDM_CHAN_LEFT); + int err = dmic_configure(dmic_dev, &cfg); + return err != 0 ? err : dmic_trigger(dmic_dev, DMIC_TRIGGER_START); +} + +int mic_stream_read(void **buf, uint32_t *size, int32_t timeout_ms) +{ + return dmic_read(dmic_dev, 0, buf, size, timeout_ms); +} + +void mic_stream_release(void *buf) +{ + k_mem_slab_free(&mic_slab, buf); +} + +void mic_stream_stop(void) +{ + (void)dmic_trigger(dmic_dev, DMIC_TRIGGER_STOP); +} + static float band_energy_dbfs(const float *mag, float lo_hz, float hi_hz, float norm) { @@ -96,41 +132,10 @@ void mic_read(struct measurement *m) { m->mic_ok = false; - if (!device_is_ready(dmic_dev)) { - printk("[MIC] PDM device not ready\n"); - return; - } - - struct pcm_stream_cfg stream = { - .pcm_rate = MIC_SAMPLE_RATE, - .pcm_width = 16, - .block_size = BLOCK_BYTES, - .mem_slab = &mic_slab, - }; - struct dmic_cfg cfg = { - .io = { - /* Clock window covering MSM261DGT006-class mics. */ - .min_pdm_clk_freq = 1000000, - .max_pdm_clk_freq = 3500000, - .min_pdm_clk_dc = 40, - .max_pdm_clk_dc = 60, - }, - .streams = &stream, - .channel = { - .req_num_streams = 1, - .req_num_chan = 1, - }, - }; - - cfg.channel.req_chan_map_lo = dmic_build_channel_map(0, 0, PDM_CHAN_LEFT); - - int err = dmic_configure(dmic_dev, &cfg); - - if (err != 0) { - printk("[MIC] dmic_configure failed (%d)\n", err); - return; - } - err = dmic_trigger(dmic_dev, DMIC_TRIGGER_START); + /* link_is_busy() keeps the periodic sensor path out while audio owns the + * single PDM controller, so a second lock here would only obscure that + * system-level ownership rule. */ + int err = mic_stream_start(); if (err != 0) { printk("[MIC] start failed (%d)\n", err); return; @@ -146,7 +151,7 @@ void mic_read(struct measurement *m) void *buf; uint32_t size; - err = dmic_read(dmic_dev, 0, &buf, &size, READ_TIMEOUT_MS); + err = mic_stream_read(&buf, &size, READ_TIMEOUT_MS); if (err != 0) { printk("[MIC] read failed (%d) after %u frames\n", err, (unsigned)count); @@ -155,7 +160,7 @@ void mic_read(struct measurement *m) if (block++ < MIC_WARMUP_BLOCKS) { /* Discard: PDM filter + mic output still settling. */ - k_mem_slab_free(&mic_slab, buf); + mic_stream_release(buf); continue; } @@ -179,15 +184,12 @@ void mic_read(struct measurement *m) } count++; } - k_mem_slab_free(&mic_slab, buf); + mic_stream_release(buf); } /* Stop the PDM clock between captures — the microphone consumes power * whenever it is clocked. */ - err = dmic_trigger(dmic_dev, DMIC_TRIGGER_STOP); - if (err != 0) { - printk("[MIC] stop failed (%d)\n", err); - } + mic_stream_stop(); if (count == 0) { printk("[MIC] no PCM samples\n"); @@ -223,6 +225,14 @@ void mic_read(struct measurement *m) #else /* !ENABLE_MIC or no nordic,nrf-pdm node enabled in the devicetree */ +int mic_stream_start(void) { return -ENODEV; } +int mic_stream_read(void **buf, uint32_t *size, int32_t timeout_ms) +{ + ARG_UNUSED(buf); ARG_UNUSED(size); ARG_UNUSED(timeout_ms); return -ENODEV; +} +void mic_stream_release(void *buf) { ARG_UNUSED(buf); } +void mic_stream_stop(void) { } + void mic_read(struct measurement *m) { m->mic_ok = false; diff --git a/firmware-nrf54lm20a/src/mic.h b/firmware-nrf54lm20a/src/mic.h index 85d41ed..26df0bb 100644 --- a/firmware-nrf54lm20a/src/mic.h +++ b/firmware-nrf54lm20a/src/mic.h @@ -3,6 +3,13 @@ #include "measurement.h" +#include + +int mic_stream_start(void); +int mic_stream_read(void **buf, uint32_t *size, int32_t timeout_ms); +void mic_stream_release(void *buf); +void mic_stream_stop(void); + /* Capture ~0.5 s of PCM from the PDM microphone and reduce it to RMS/peak dBFS * plus the five shared acoustic bands (sets m->mic_ok). A no-op that leaves * mic_ok false when the devicetree has no enabled nRF PDM node. */ diff --git a/firmware-nrf54lm20a/src/ota.c b/firmware-nrf54lm20a/src/ota.c index 1101741..ad64c28 100644 --- a/firmware-nrf54lm20a/src/ota.c +++ b/firmware-nrf54lm20a/src/ota.c @@ -1,13 +1,12 @@ /* MCUboot firmware-over-BLE target. ATT responses are returned only after each * flash write completes, providing the relay's stream flow control. */ #include "ota.h" -#include "beacon.h" +#include "link.h" #include "hive_config.h" #include #include -#include #include #include #include @@ -20,7 +19,6 @@ #include #include -#define OTA_ARM_TIMEOUT K_SECONDS(6) #define OTA_STALL_TIMEOUT K_MSEC(HIVE_OTA_STALL_TIMEOUT_MS) #define OTA_REBOOT_DELAY K_MSEC(1500) #define OTA_SUPERVISION_TIMEOUT 200U /* 2 seconds, units of 10 ms */ @@ -33,7 +31,6 @@ enum { }; static struct flash_img_context flash_ctx; -static struct bt_conn *active_conn; static uint32_t expected_size, expected_crc, received, running_crc; static uint8_t state, error; /* Errno of the last failed flash operation, reported as the seventh STATUS @@ -51,20 +48,11 @@ static void record_errno(int rc) op_errno = (int8_t)CLAMP(rc, INT8_MIN, INT8_MAX); } -static void arm_timeout_handler(struct k_work *work); static void reboot_handler(struct k_work *work); static void stall_timeout_handler(struct k_work *work); -K_WORK_DELAYABLE_DEFINE(arm_timeout_work, arm_timeout_handler); K_WORK_DELAYABLE_DEFINE(reboot_work, reboot_handler); K_WORK_DELAYABLE_DEFINE(stall_timeout_work, stall_timeout_handler); -#if ENABLE_THROUGHPUT_SPIKE -void ota_release_arm_timeout(void) -{ - (void)k_work_cancel_delayable(&arm_timeout_work); - printk("[OTA] arm timeout released for a throughput measurement\n"); -} -#endif static ssize_t ctrl_write(struct bt_conn *, const struct bt_gatt_attr *, const void *, uint16_t, uint16_t, uint8_t); @@ -161,11 +149,13 @@ static ssize_t ctrl_write(struct bt_conn *conn, const struct bt_gatt_attr *attr, record_errno(rc); fail(OTA_ERR_BEGIN); return len; } + if (!link_claim(LINK_OWNER_OTA)) { + fail(OTA_ERR_BEGIN); return len; + } received = 0; /* crc32_ieee_update uses zlib's pre/post complement internally. */ running_crc = 0; state = OTA_RECEIVING; error = 0; - (void)k_work_cancel_delayable(&arm_timeout_work); /* The arm timeout is done once BEGIN arrives, so from here the stall * timeout is the only thing bounding the session. */ (void)k_work_reschedule(&stall_timeout_work, OTA_STALL_TIMEOUT); @@ -203,6 +193,7 @@ static ssize_t ctrl_write(struct bt_conn *conn, const struct bt_gatt_attr *attr, (void)k_work_cancel_delayable(&stall_timeout_work); expected_size = expected_crc = received = running_crc = 0; set_state(OTA_IDLE); + link_release(LINK_OWNER_OTA); return len; default: fail(OTA_ERR_SEQ); return len; @@ -244,15 +235,6 @@ static ssize_t status_read(struct bt_conn *conn, const struct bt_gatt_attr *attr return bt_gatt_attr_read(conn, attr, buf, len, offset, value, sizeof(value)); } -static void arm_timeout_handler(struct k_work *work) -{ - ARG_UNUSED(work); - if (active_conn && state != OTA_RECEIVING && state != OTA_DONE) { - printk("[OTA] connection not armed; disconnecting\n"); - (void)bt_conn_disconnect(active_conn, BT_HCI_ERR_REMOTE_USER_TERM_CONN); - } -} - static void stall_timeout_handler(struct k_work *work) { ARG_UNUSED(work); @@ -264,10 +246,10 @@ static void stall_timeout_handler(struct k_work *work) /* Publish the error before dropping the link so a central that is merely * slow, rather than gone, can still read why its session ended. Leaving * OTA_RECEIVING is what releases the sensor loop; the disconnect then - * clears active_conn and restarts advertising. */ + * releases shared link ownership and restarts advertising. */ fail(OTA_ERR_SEQ); - if (active_conn) { - (void)bt_conn_disconnect(active_conn, BT_HCI_ERR_REMOTE_USER_TERM_CONN); + if (link_conn()) { + (void)bt_conn_disconnect(link_conn(), BT_HCI_ERR_REMOTE_USER_TERM_CONN); } } @@ -278,32 +260,22 @@ static void reboot_handler(struct k_work *work) sys_reboot(SYS_REBOOT_COLD); } -static void connected(struct bt_conn *conn, uint8_t conn_err) +void ota_link_connected(void) { - struct bt_le_conn_param param = { .interval_min = 24, .interval_max = 40, - .latency = 0, .timeout = OTA_SUPERVISION_TIMEOUT }; - if (conn_err) return; - beacon_connected(); - active_conn = bt_conn_ref(conn); - (void)bt_conn_le_param_update(conn, ¶m); - (void)k_work_reschedule(&arm_timeout_work, OTA_ARM_TIMEOUT); - printk("[OTA] central connected; waiting for BEGIN\n"); + state = OTA_IDLE; + error = 0; + op_errno = 0; } -static void disconnected(struct bt_conn *conn, uint8_t reason) +void ota_link_disconnected(void) { - ARG_UNUSED(conn); - (void)k_work_cancel_delayable(&arm_timeout_work); (void)k_work_cancel_delayable(&stall_timeout_work); - if (state == OTA_RECEIVING) { state = error = OTA_ERR_SEQ; } - if (active_conn) { bt_conn_unref(active_conn); active_conn = NULL; } - printk("[OTA] central disconnected (0x%02x)\n", reason); - beacon_disconnected(); + if (state == OTA_RECEIVING) { + state = error = OTA_ERR_SEQ; + } + link_release(LINK_OWNER_OTA); } -BT_CONN_CB_DEFINE(ota_conn_callbacks) = { .connected = connected, - .disconnected = disconnected }; - void ota_init(void) { state = OTA_IDLE; error = 0; op_errno = 0; @@ -313,5 +285,5 @@ void ota_init(void) bool ota_is_active(void) { - return active_conn || state == OTA_RECEIVING || state == OTA_DONE; + return link_is_owner(LINK_OWNER_OTA); } diff --git a/firmware-nrf54lm20a/src/ota.h b/firmware-nrf54lm20a/src/ota.h index 46eaba5..61ca426 100644 --- a/firmware-nrf54lm20a/src/ota.h +++ b/firmware-nrf54lm20a/src/ota.h @@ -6,12 +6,5 @@ void ota_init(void); bool ota_is_active(void); - -#if ENABLE_THROUGHPUT_SPIKE -/* Stand down the six-second "connected but never sent BEGIN" guard for the - * current link. Only the throughput spike (src/throughput.c) calls this: its - * measurement runs for tens of seconds on a connection that legitimately never - * sends BEGIN, and the guard would drop the link mid-run. Compiled out of every - * normal build, so the guard is unconditional in anything that ships. */ -void ota_release_arm_timeout(void); -#endif +void ota_link_connected(void); +void ota_link_disconnected(void); diff --git a/firmware-nrf54lm20a/src/throughput.c b/firmware-nrf54lm20a/src/throughput.c index 71fe13a..7dcaf95 100644 --- a/firmware-nrf54lm20a/src/throughput.c +++ b/firmware-nrf54lm20a/src/throughput.c @@ -24,7 +24,7 @@ #if ENABLE_THROUGHPUT_SPIKE -#include "ota.h" +#include "link.h" #include #include @@ -58,7 +58,6 @@ enum { * OTA writes. */ #define TP_PAYLOAD_MAX 244 -static struct bt_conn *tp_conn; static uint8_t state; static uint32_t bytes_sent, notifs_sent, elapsed_ms; static uint16_t payload_len; @@ -108,7 +107,7 @@ static void fill_status(uint8_t v[TP_STATUS_LEN]) sys_put_le32(elapsed_ms, &v[5]); sys_put_le32(notifs_sent, &v[9]); v[13] = (uint8_t)MIN(payload_len, 255U); - sys_put_le16(tp_conn ? bt_gatt_get_mtu(tp_conn) : 0, &v[14]); + sys_put_le16(link_conn() ? bt_gatt_get_mtu(link_conn()) : 0, &v[14]); } static void publish_status(void) @@ -168,7 +167,7 @@ static void tp_thread_fn(void *a, void *b, void *c) (unsigned)duration_s, (unsigned)payload_len, (unsigned)TP_CREDITS); - while (state == TP_RUNNING && tp_conn != NULL && + while (state == TP_RUNNING && link_conn() != NULL && k_uptime_get() < deadline) { if (k_sem_take(&tp_credits, K_MSEC(500)) != 0) { /* Half a second without a single completion is not @@ -185,7 +184,7 @@ static void tp_thread_fn(void *a, void *b, void *c) .len = payload_len, .func = sent_cb, }; - int err = bt_gatt_notify_cb(tp_conn, ¶ms); + int err = bt_gatt_notify_cb(link_conn(), ¶ms); if (err != 0) { k_sem_give(&tp_credits); @@ -208,6 +207,7 @@ static void tp_thread_fn(void *a, void *b, void *c) elapsed_ms = (uint32_t)(k_uptime_get() - start); state = TP_DONE; + link_release(LINK_OWNER_THROUGHPUT); /* Integer arithmetic only: this build may or may not have the * cbprintf float formatter, and a throughput figure does not @@ -241,7 +241,7 @@ static ssize_t ctrl_write(struct bt_conn *conn, const struct bt_gatt_attr *attr, publish_status(); return len; } - if (!tp_conn) { + if (!link_conn()) { state = TP_ERR_NO_LINK; publish_status(); return len; @@ -291,11 +291,9 @@ static ssize_t ctrl_write(struct bt_conn *conn, const struct bt_gatt_attr *attr, (void)bt_conn_le_phy_update(conn, BT_CONN_LE_PHY_PARAM_2M); #endif - /* ota.c disconnects any central that has not sent BEGIN within - * six seconds. That guard is right for the OTA service and fatal - * for a twenty-second measurement, so stand it down for this - * link. */ - ota_release_arm_timeout(); + if (!link_claim(LINK_OWNER_THROUGHPUT)) { + state = TP_ERR_NO_LINK; publish_status(); return len; + } state = TP_RUNNING; publish_status(); @@ -323,100 +321,6 @@ static ssize_t status_read(struct bt_conn *conn, const struct bt_gatt_attr *attr return bt_gatt_attr_read(conn, attr, buf, len, offset, v, sizeof(v)); } -/* ── Link parameter logging (phase 0b) ───────────────────────────────────── - * - * A second set of connection callbacks alongside ota.c's. Zephyr calls every - * registered set, and these deliberately do NOT touch the beacon: advertising - * is ota.c's to restart, and calling beacon_disconnected() from two places - * would schedule the restart work twice. - */ -static void tp_connected(struct bt_conn *conn, uint8_t conn_err) -{ - struct bt_conn_info info; - - if (conn_err) { - return; - } - tp_conn = bt_conn_ref(conn); - subscribed = false; - state = TP_IDLE; - - if (bt_conn_get_info(conn, &info) == 0 && info.type == BT_CONN_TYPE_LE) { - /* interval_us, not the 1.25 ms `interval` field: that one is - * deprecated (it cannot represent the shorter intervals - * CONFIG_BT_SHORTER_CONNECTION_INTERVALS allows) and NCS builds - * deprecation warnings as errors. `latency` and `timeout` are - * unaffected. */ - printk("[TP] connected: interval=%u us (%u.%02u ms) latency=%u timeout=%u ms\n", - (unsigned)info.le.interval_us, - (unsigned)(info.le.interval_us / 1000U), - (unsigned)((info.le.interval_us % 1000U) / 10U), - (unsigned)info.le.latency, - (unsigned)info.le.timeout * 10U); - } -} - -static void tp_disconnected(struct bt_conn *conn, uint8_t reason) -{ - ARG_UNUSED(conn); - if (state == TP_RUNNING) { - state = TP_IDLE; - } - subscribed = false; - if (tp_conn) { - bt_conn_unref(tp_conn); - tp_conn = NULL; - } - printk("[TP] disconnected (0x%02x)\n", reason); -} - -static void tp_param_updated(struct bt_conn *conn, uint16_t interval, - uint16_t latency, uint16_t timeout) -{ - ARG_UNUSED(conn); - printk("[TP] interval now %u units (%u.%02u ms) latency=%u timeout=%u ms\n", - (unsigned)interval, (unsigned)(interval * 5U / 4U), - (unsigned)((interval * 500U / 4U) % 100U), - (unsigned)latency, (unsigned)timeout * 10U); -} - -#if defined(CONFIG_BT_USER_PHY_UPDATE) -static void tp_phy_updated(struct bt_conn *conn, - struct bt_conn_le_phy_info *param) -{ - ARG_UNUSED(conn); - /* 1 = 1M, 2 = 2M, 4 = Coded. 2M on both directions is what the 2 Mbit - * symbol rate needs; a run that reports 1M here is answering a different - * question than the one being asked. */ - printk("[TP] PHY now tx=%u rx=%u\n", (unsigned)param->tx_phy, - (unsigned)param->rx_phy); -} -#endif - -#if defined(CONFIG_BT_USER_DATA_LEN_UPDATE) -static void tp_data_len_updated(struct bt_conn *conn, - struct bt_conn_le_data_len_info *info) -{ - ARG_UNUSED(conn); - /* tx_max_len 27 means DLE did not take, whatever the Kconfig says, and - * every notification is being fragmented across link-layer packets. */ - printk("[TP] data length now tx=%u B/%u us rx=%u B/%u us\n", - (unsigned)info->tx_max_len, (unsigned)info->tx_max_time, - (unsigned)info->rx_max_len, (unsigned)info->rx_max_time); -} -#endif - -BT_CONN_CB_DEFINE(tp_conn_callbacks) = { - .connected = tp_connected, - .disconnected = tp_disconnected, - .le_param_updated = tp_param_updated, -#if defined(CONFIG_BT_USER_PHY_UPDATE) - .le_phy_updated = tp_phy_updated, -#endif -#if defined(CONFIG_BT_USER_DATA_LEN_UPDATE) - .le_data_len_updated = tp_data_len_updated, -#endif -}; /* A banner rather than an init call, so main.c needs no #if of its own. It also * answers the first question any confusing result raises: is the board actually From 57c0368d45f10ccf1f9def19dc36ff8c8477880f Mon Sep 17 00:00:00 2001 From: MacNite Date: Mon, 7 Sep 2026 21:11:24 +0200 Subject: [PATCH 2/2] Fix upstream Zephyr PSA configuration --- firmware-nrf54lm20a/prj.conf | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/firmware-nrf54lm20a/prj.conf b/firmware-nrf54lm20a/prj.conf index 923cc62..0a1408b 100644 --- a/firmware-nrf54lm20a/prj.conf +++ b/firmware-nrf54lm20a/prj.conf @@ -112,8 +112,10 @@ CONFIG_BT_USER_DATA_LEN_UPDATE=y CONFIG_BT_USER_PHY_UPDATE=y # START authentication uses HMAC-SHA256. Keeping this in PSA rather than a -# local primitive gives the provisioned key one audited crypto boundary. -CONFIG_NRF_SECURITY=y +# local primitive gives the provisioned key one audited crypto boundary. The +# repository pins upstream Zephyr, where PSA_CRYPTO is the provider switch; +# NRF_SECURITY is an NCS-only symbol and makes Kconfig warnings fatal here. +CONFIG_PSA_CRYPTO=y CONFIG_PSA_WANT_ALG_HMAC=y CONFIG_PSA_WANT_ALG_SHA_256=y CONFIG_PSA_WANT_KEY_TYPE_HMAC=y