Skip to main content

SATE Pendant firmware

Internal engineering reference for the SATE Pendant — a wearable BLE audio streamer. Everything below is grounded in the firmware source (SATE_Pendant/SATE_Pendant.ino), the hard-won board notes (SATE_Pendant/HARDWARE.md), the BLE integration guide (SATE_Pendant/INTEGRATION.md), the project handbook (doc/09-pendant.md), and the app-side link (src/pendant/PendantLink.ts). It is grounded in the source as of firmware 1.0.0 (FIRMWARE_VERSION in SATE_Pendant.ino, added 2026-07-22 — the first logged pendant version; not yet exposed over BLE).

Firmware 1.0.0XIAO nRF52840 Sense PlusS140 7.3.0 SoftDevicePCM 16 kHz mono

1. Overview

The pendant is a Seeed XIAO nRF52840 Sense Plus running a single Arduino sketch (SATE_Pendant/SATE_Pendant.ino, header comment). It streams the board's onboard PDM mic over BLE as raw 16-bit PCM @ 16 kHz mono (S16LE) — no codec, no framing, nothing to decode on the receiver (SAMPLE_RATE 16000; INTEGRATION.md §4). The phone app forwards those windows to the inference server (AST model) for food-intake detection; the IMU is deliberately not used — audio only (HARDWARE.md note the IMU characteristic 19B10003 and all LSM6DS3 code were deleted).

PropertyValueSource
MCU / boardXIAO nRF52840 Sense Plus (FQBN Seeeduino:nrf52:xiaonRF52840SensePlus)SATE_Pendant.ino
BLE stackNordic S140 7.3.0 SoftDevice + Adafruit Bluefruit core (Seeed fork)SATE_Pendant.ino; HARDWARE.md
Audio formatPCM S16LE, 16 kHz, mono, 244 B/notify = 122 samplesSATE_Pendant.ino; INTEGRATION.md
RoleBLE peripheral; the phone is the centralINTEGRATION.md
Upload pathApp wraps PCM → WAV → api.uploadSession, device_serial = pendant-<bleId>doc/09-pendant.md

Two things the firmware does not do, by design:

  • No serial log. Unlike the ESP32 recorder, there is no Serial.print telemetry anywhere in the sketch — diagnosis is done externally (USB-CDC port presence as a crash test, bleak scans from the Mac; HARDWARE.md).
  • No direct server upload. The pendant never talks to the backend. It only emits BLE notifications; the app is the relay that assembles the WAV and uploads (doc/09-pendant.md; INTEGRATION.md).

2. Hardware

XIAO nRF52840 Sense Plus. Pin map and LED polarity (active-LOW: driving the pin low turns the LED on):

FunctionnRF pinFirmwareNotes
LED RedP0.26LED_RED 26"recording" indicator, active-low (redOn() = OUTCLR)
LED BlueP0.06LED_BLUE 6BLE status: blink=advertising, dim-duty=connected
LED GreenP0.30active-low; unused by this firmware (HARDWARE.md)
PDM mic powerP1.10(driven by PDM lib)drive HIGH to power the onboard mic (HARDWARE.md)
PDM CLKP1.00(driven by PDM lib)HARDWARE.md
PDM DATAP0.16(driven by PDM lib)HARDWARE.md
VBAT senseP0.31 / AIN7PIN_VBAT 32behind a 1 MΩ/510 kΩ divider
VBAT enableP0.14VBAT_ENABLE 14drive LOW to connect the divider
Charge LEDhardware (charger IC)blinks red/orange forever on USB with no/low battery — normal, ignore (HARDWARE.md)

PDM mic. Driven exclusively through the Arduino PDM library (#include <PDM.h>), never raw nRF PDM registers — hand-rolled register config sounded noisy and was the main clarity culprit (HARDWARE.md). Config is PDM.setGain(MIC_GAIN) then PDM.begin(1, SAMPLE_RATE) inside micStart(). Exact 16000 Hz avoids the pitch/speed drift of the raw ~16125 Hz clock (HARDWARE.md).

Battery. readBatteryPct(): pull VBAT_ENABLE low, settle 200 µs, switch to the internal 2.4 V reference at 12-bit resolution. The nRF52 SAADC applies a new reference only on the next conversion, so the first read after analogReference() is stale and is discarded, then 16 reads are averaged. Voltage is reconstructed with the divider ratio (1000+510)/510 and mapped to a percentage by a piecewise-linear LiPo curve (3.30 V→0 %, 4.10 V→100 %). The divider is disconnected afterward to save ~4 µA.

Charging detection. There is no charge-status pin, so usbPlugged() reads the SoC's VBUS detect register directly (NRF_POWER->USBREGSTATUS & …VBUSDETECT_Msk) — VBUS present ⇒ plugged in ⇒ charging/topped-off.

DC/DC regulator. sd_power_dcdc_mode_set(NRF_POWER_DCDC_ENABLE) in setup() runs the SoC on the DC/DC converter instead of the default LDO. Under streaming load the radio draws hard current bursts; on the LDO those pull ~2× peak current and sag the rail, which on a low / high-internal-resistance LiPo makes the SoC miss connection events → notify() fails → packets drop — which is why drops only appeared at low battery. It must go through the SoftDevice API (the SoftDevice owns POWER); the XIAO populates the required DC/DC inductors, so it is safe.

:::danger Seeed-core flash trap (0x27000 vs 0x26000) Build only with the Seeed core (FQBN Seeeduino:nrf52:xiaonRF52840SensePlus), never adafruit:nrf52:feather52840sense. The Adafruit Feather variant links the app at 0x26000 (S140 6.1.1 layout), which overwrites the last flash page of the S140 7.3.0 SoftDevice and corrupts the BLE stack. Symptom: the app runs but never advertises and no USB-CDC port enumerates — it hardfaults inside Bluefruit.begin(). Non-BLE sketches still run, which is the giveaway it is the SoftDevice, not the code. The correct core links at 0x27000; the UF2 conversion prints the start address and flash_xiao.sh aborts if it sees 0x26000 (HARDWARE.md; flash_xiao.sh; doc/09-pendant.md).

A factory-fresh board needs the DFU SoftDevice+bootloader restore too — its factory SoftDevice state isn't Bluefruit-compatible until you flash Seeed's own bootloader+SoftDevice (HARDWARE.md; doc/09-pendant.md). :::


3. Connection / BLE protocol

All UUIDs are the vendor 128-bit 19B10000-E8F2-537E-4F6C-D104768A1214 family. The app uses the lowercase form to match ble-plx's normalized UUIDs (PendantLink.ts).

GATT profile

CharacteristicUUIDPropertiesPayloadSource
Audio service19B10000-…containerSATE_Pendant.ino
Audio data19B10001-…NOTIFYfixed 244 B = 122 × int16SATE_Pendant.ino
Control19B10002-…WRITEfixed 1 byte commandSATE_Pendant.ino
Battery Service0x180F / level 0x2A19Read + Notify1 byte (see below)BLEBas batSvc, SATE_Pendant.ino
OTA DFUNordic BLE DFU (BLEDfu)firmware pushSATE_Pendant.ino

The audio characteristic is CHR_PROPS_NOTIFY, open read / no-access write, fixed length PKT_SAMPLES * 2 = 244 B. The control characteristic is CHR_PROPS_WRITE, open, fixed length 1, with onCtrlWrite as its write callback.

Each notify carries 122 little-endian int16 PCM samples back to back — no header, no framing (16 kHz mono):

sample 0int16 LE (2 B)
sample 1int16 LE (2 B)
… samples 2–120 …2 B each
sample 121int16 LE (2 B)
= 244 B122 × int16

Control commands (onCtrlWrite)

ByteActionFirmware effect
0x01Start streamingrecording=true; napping=false; lastLoudMs=millis(); micStart(SAMPLE_RATE/7)
0x00Stop streamingrecording=false; napping=false; PDM.end()
0x02Find-mefindMeUntil = millis() + 5000 — flash LEDs ~5 s

0x01/0x00 are idempotent-guarded (&& !recording / && recording); the pendant does not stream until a 0x01 arrives.

0x01Start streaming
0x00Stop streaming
0x02Find-me (flash 5 s)

The connect handshake and stream lifecycle, from the central's 0x01 to its 0x00 stop (onConnect; onCtrlWrite; drain loop):

Battery byte (publishBattery)

The Battery Service value packs the percentage into the low 7 bits and uses bit 7 (0x80) as the charging flag: v = readBatteryPct() & 0x7F, then if (usbPlugged()) v |= 0x80.

bit 7charging (0x80)
bits 0–6battery percent (0–100)
Consumers must mask: `percent = raw & 0x7F`, `charging = (raw & 0x80) != 0` (app: `PendantLink.ts`; `INTEGRATION.md`). The loop refreshes battery every 60 s and fires instantly on plug/unplug.

Advertising

Bluefruit.Advertising.addFlags(BLE_GAP_ADV_FLAGS_LE_ONLY_GENERAL_DISC_MODE);
Bluefruit.Advertising.addTxPower();
Bluefruit.Advertising.addService(audioSvc); // service UUID in the ADV packet
Bluefruit.ScanResponse.addName(); // NAME only in the SCAN RESPONSE
Bluefruit.Advertising.restartOnDisconnect(true);
Bluefruit.Advertising.setInterval(32, 244); // 20 ms fast → 152.5 ms slow after 30 s
Bluefruit.Advertising.start(0);

The service UUID lives in the ADV packet, but the name (SATE Pendant) lives only in the SCAN RESPONSE (ScanResponse.addName()). Consequence for the central: iOS surfaces the scan-response name as localName, not name, and name may be a stale cached GAP name from an earlier firmware (HARDWARE.md; doc/09-pendant.md app note). So the app scans with no service filter and matches on name OR localName OR the advertised audio service (PendantLink.ts). restartOnDisconnect(true) means the pendant re-advertises automatically after a drop.

Throughput negotiation

16 kHz × 16-bit = 256 kbps, right at BLE's practical ceiling (HARDWARE.md). Three settings together make it fit — any one missing means noisy or choppy audio (HARDWARE.md):

SettingWhereWhy
configPrphBandwidth(BANDWIDTH_MAX)setup() before begin()Raises negotiated MTU from 23→247 and grows the notify queue; without it every notify truncates to 20 B = 92 % data loss (HARDWARE.md)
requestPHY(BLE_GAP_PHY_2MBPS)onConnect2M PHY doubles the raw BLE rate; both ends must agree (INTEGRATION.md)
requestMtuExchange(247)onConnectMTU 247 lets a single notify carry the full 244 B packet
requestConnectionParameter(24)onConnect24 × 1.25 ms = 30 ms interval (was 7.5 ms). At 2M+MTU247 one event still carries the ~4 packets/s-of-audio needed, but the radio wakes ~4× less often — big streaming-power saving; the 0.5 s ring absorbs the added latency

Note the firmware comment (HARDWARE.md) documents a 7.5 ms interval (requestConnectionParameter(6)) as "most packets/sec"; the shipping firmware deliberately trades that down to 30 ms for power. TX power is 0 dBm — plenty for an on-body pendant a couple metres from the phone; +4 dBm would just waste radio power.

OTA DFU

BLEDfu bledfu and bledfu.begin() is called first in setup() — Adafruit requires the DFU service be added first so its attribute handle stays fixed across firmware versions. The board already ships the Adafruit/Seeed DFU bootloader (0.6.2 + S140 7.3.0), so a phone connects to the running pendant, writes the DFU control point, the board reboots into the bootloader, and the app streams new firmware over BLE — the app side must speak the Nordic BLE DFU protocol.


4. Features

  • Streaming. 0x01 starts the PDM mic and drains audio into 122-sample BLE notifies (onCtrlWritemicStart; loop drain).
  • Ring buffer (producer/consumer). A lock-free power-of-two ring (RING_SIZE 8192, ~0.5 s cushion @ 16 kHz) decouples the PDM ISR (producer, writes ringHead) from the BLE loop (consumer, reads ringTail). ringHead/ringTail/ringDropped are volatile.
  • DC-block HPF + tanh soft-clip gain in the ISR (onPDMdata). Per sample: one-pole DC-blocking high-pass y = x - x1 + R*y1 (HPF_R 0.976 ≈ 60 Hz corner) removes the PDM DC bias + rumble (the single biggest clarity win), then makeup gain through a tanh knee: v = 32767 * tanhf(y * DIGITAL_GAIN / 32767). tanh is ~linear near zero (quiet parts get ~DIGITAL_GAIN× louder) but bends loud peaks smoothly toward ±full-scale — soft-clip, not the harsh hard-clip "rè" buzz.
  • Warmup drop. After PDM.begin, the first N samples are the mic's DC-settling thump and are discarded (warmup counter). Start drops SAMPLE_RATE/7 ≈ 140 ms; each nap-listen drops NAP_SETTLE_MS=60 ms.
  • Nap mode. While streaming, if audio stays below threshold for SLEEP_AFTER_MS (30 s) the pendant naps: PDM off, no notifies, radio idle. Every NAP_CHECK_MS (2 s) it re-opens the mic, listens NAP_LISTEN_MS (180 ms), and if the drained mean-abs ≥ WAKE_MEANABS resumes streaming instantly, else PDM.end() back to rest. The BLE link stays up throughout — a notification gap is not a disconnect (INTEGRATION.md; doc/09-pendant.md).
  • Overrun guard. In the ISR, if the consumer has stalled and the ring is full ((uint32_t)(h - t) >= RING_SIZE), the newest sample is dropped (ringDropped++; continue) rather than overwriting unsent audio — one clean gap on recovery instead of mid-buffer corruption; the HPF state keeps advancing so the filter stays time-aligned across the gap.
  • Find-me LED. 0x02 sets findMeUntil; the loop's LED block runs a loud alternating red/blue flash for 5 s that overrides all other LED states.
  • OTA DFU. Nordic BLE DFU over the running link (see §3).
  • Duty-cycled status LEDs to save power (solid LEDs burn ~1 mA each): streaming red at 10 % duty, connected-idle blue blip every 3 s, napping blue "alive" blip every 5 s. Bluefruit.autoConnLed(false) — the firmware drives LEDs itself, saving ~1 mA.

5. Audio pipeline

End-to-end, from the PDM interrupt on the pendant to a recordings row on the server. The firmware half (blue) runs on the nRF52; the app half (green) runs in PendantLink.ts; the backend half reuses the unchanged SATE upload path.

Node detail and citations (moved out of the labels above):

StepDetailSource
PDM ISR onPDMdata()producer, PDM interruptSATE_Pendant.ino
warmup dropfirst N samples discardedSATE_Pendant.ino
DC-block HPFy = x - x1 + R*y1SATE_Pendant.ino
gain + tanh soft-clip32767*tanh(y*DIGITAL_GAIN/32767)SATE_Pendant.ino
ring full?overrun guardSATE_Pendant.ino
drop newestringDropped++SATE_Pendant.ino
ring[] 8192ringHead++SATE_Pendant.ino
drain 122-sample pktsin loop()SATE_Pendant.ino
audioChr.notify() 244 Badvance tail on successSATE_Pendant.ino
monitor + accumulateif capturing: chunks.push()PendantLink.ts
takeWav() + applyGainconcat chunks; peak-norm + LOUDNESS + tanhPendantLink.ts
44-byte WAV header16 kHz mono S16LEPendantLink.ts
api.uploadSessiondevice_serial = pendant-<bleId>doc/09-pendant.md
backend/sessions → CF container → finalize-session → recordings

Key invariant: the firmware loop only advances ringTail when notify() succeeds and otherwise breaks to retry next loop — data is never cleared on a failed notify, which is what previously caused gaps (HARDWARE.md). A measured good run: ~390 packets / ~95 KB for a 3 s clip, near-zero loss (HARDWARE.md).


6. Configuration

Firmware tunables, all #defined at the top of SATE_Pendant.ino:

MacroValueMeaning / guidance
SAMPLE_RATE16000Exact 16 kHz (avoids clock pitch drift)
MIC_GAIN64PDM analog gain 0..80 (stock 20). Keep ≤ ~66 — 70+ clips in the decimator = real "rè" buzz that can't be undone
DIGITAL_GAIN6.0fPost-PDM makeup gain into the tanh knee (the raw mic is quiet)
PKT_SAMPLES122244 B = max notify @ MTU 247
HPF_R0.976fDC-block high-pass pole, fc ≈ 60 Hz @ 16 kHz
SLEEP_AFTER_MS30000Quiet this long → nap
NAP_CHECK_MS2000Nap listen cadence
NAP_LISTEN_MS180Listen window per check (incl. mic settle)
NAP_SETTLE_MS60Mic-settle samples discarded per nap check
WAKE_MEANABS1400mean|sample| that counts as sound (wake)
LOUD_MEANABS1400Same threshold while streaming (scaled with DIGITAL_GAIN)
RING_SIZE8192Power-of-two ring, ~0.5 s cushion

App-side gain (PendantLink.ts) is separate and stacks on top of the firmware: TARGET_PEAK 0.97*32767, MAX_GAIN 40, LOUDNESS 2.6 (PendantLink.ts), combined as gain = min(MAX_GAIN, max(1, TARGET_PEAK/peak)) * LOUDNESS then a tanh soft-clip (PendantLink.ts) — see the gain-stacking issue in §8.

Build & flash with the Seeed core:

# from repo root — compiles (Seeed core), converts to UF2, aborts on 0x26000
./SATE_Pendant/flash_xiao.sh SATE_Pendant

The script sets up the python shim, copies the sketch to a space-free /tmp path, compiles with Seeeduino:nrf52:xiaonRF52840SensePlus, converts the .hex to .uf2 (family 0xADA52840), aborts unless the start address is 0x27000, waits for the XIAO-SENSE volume (double-tap reset), and raw-writes the UF2 (no xattrs — Finder drag-drop fails with error -36) (flash_xiao.sh; HARDWARE.md).


7. Key files

PathRole
SATE_Pendant/SATE_Pendant.inoThe entire firmware: BLE setup, PDM ISR, ring buffer, DSP, nap logic, battery, LEDs
SATE_Pendant/HARDWARE.mdBoard identity, Seeed-core flash trap, SoftDevice-corruption recovery, pin map, clear-audio recipe
SATE_Pendant/INTEGRATION.mdBLE integration guide for any central: GATT profile, control bytes, audio format, battery byte, nap mode
SATE_Pendant/flash_xiao.shOne-shot compile → UF2 → flash; aborts on the 0x26000 trap
doc/09-pendant.mdProject-handbook chapter: where the pendant plugs into the app/backend, flashing trap, nap mode
src/pendant/PendantLink.tsApp side: BLE scan/connect/subscribe, PCM accumulation, applyGain, PCM→WAV, takeWav
src/screens/PendantConnectScreen.tsxConnect/record/upload UI (per doc/09-pendant.md)
src/pendant/PendantStore.tsPersists paired pendants (per CLAUDE.md pendant notes)

8. Known issues & current status

From the 2026-07-22 multi-agent audit (~/.claude/projects/-Users-longcao-Desktop-SATE/memory/stuck-state-red-flags.md). The user's hard priorities: (1) never lose/mismatch patient audio; (2) no feature ever stuck.

8.1 lastLoudMs only advances on notify success — the "audio stuck" bug — OPEN

Symptom: lastLoudMs is updated only inside the successful-notify branch of the loop (if (audioChr.notify(...)) { ringTail = …; if (acc/PKT_SAMPLES >= LOUD_MEANABS) lastLoudMs = now; }). If the BLE consumer stalls, notify() fails and the loop breaks before lastLoudMs is touched — even though the room is loud. now - lastLoudMs then crosses SLEEP_AFTER_MS, the pendant naps, and the nap-wake path resets the ring (ringHead = ringTail = 0) — wiping a stalled-but-loud stream. This is the "audio stuck" symptom. File: SATE_Pendant.ino. The audit notes the overrun-guard diff alone does not fix it. Status: OPEN.

8.2 No onDisconnected handler in the app — a nap looks like a disconnect — OPEN

Symptom: the app subscribes to audio/battery but registers no BLE disconnect handler (connect() sets up monitorCharacteristic… only, PendantLink.ts). Because nap mode also produces a gap in notifications (§4), the app cannot distinguish a real disconnect from a nap — both present as "no audio while connected." File: src/pendant/PendantLink.ts. Status: OPEN (mitigated only by treating gaps as "quiet", per INTEGRATION.md).

8.3 Overrun guard keeps oldest / stale ringTail snapshot — OPEN (by design, but subtle)

Symptom: the ISR snapshots the consumer position once at entry (uint32_t t = ringTail;) and uses that stale snapshot for the whole callback's overrun check. Combined with dropping the newest sample, the guard preserves already-queued (oldest) audio, so a stall yields one clean trailing gap rather than mid-buffer corruption — intended — but the stale snapshot means the guard can trip slightly early (it doesn't see samples the consumer drained mid-callback). File: SATE_Pendant.ino. Status: OPEN / accepted trade-off; interacts with 8.1.

8.4 Gain stacked twice (firmware tanh + app LOUDNESS) — effective app cap ~104× — OPEN

Symptom: the firmware already applies DIGITAL_GAIN 6.0 + tanh in the ISR, and the app applies its own peak-normalize × LOUDNESS + tanh on the assembled take (applyGain, PendantLink.ts). The app's cap is MAX_GAIN 40 × LOUDNESS 2.6 = 104× effective (PendantLink.ts) on top of the firmware gain. Stacking both toward clipping produces the harsh "rè" buzz. Files: SATE_Pendant.ino + src/pendant/PendantLink.ts. Status: OPEN — noted in CLAUDE.md ("don't stack both to the point of clipping"); no code guard couples the two.

8.5 Nap-wake ring reset race — OPEN

Symptom: on wake the loop clears the ring from the consumer side (napping=false; lastLoudMs=now; ringHead=ringTail=0;) while the PDM ISR (producer) may be concurrently writing ringHead. The non-atomic dual reset of ringHead/ringTail against a live ISR write is a race that can momentarily desync head/tail. micStart() has the same pattern but there the mic is (re)starting so the ISR isn't firing yet; the wake path re-uses an already-running mic. File: SATE_Pendant.ino. Status: OPEN.

8.6 connect() double-subscribe — OPEN

Symptom: connect() assigns this.audioSub/this.batterySub without first removing any existing subscription (PendantLink.ts). A reconnect without an intervening disconnect()/teardown() (which are the only places subs are removed, PendantLink.ts) leaks the prior subscription and can double-count incoming packets into chunks. File: src/pendant/PendantLink.ts. Status: OPEN.

Symptom (was): stale PCM from a previous take could prefix a new recording and inflate capturedMs (cross-take contamination). Fix: start() now clears this.chunks = []; this.capturedBytes = 0 before every take (PendantLink.ts), and disconnect() drops any un-taken PCM (PendantLink.ts). Called out as FIXED in the audit memory. File: src/pendant/PendantLink.ts. Status: FIXED.

takeWav() clears the buffer as it returns the take (this.chunks = [], PendantLink.ts), so a failed upload after takeWav() loses the take; the buffer is memory-only, so an app kill loses an in-progress take. Tracked in the audit memory as OPEN. File: src/pendant/PendantLink.ts.


Grounding: every claim above traces to SATE_Pendant/SATE_Pendant.ino, SATE_Pendant/HARDWARE.md, SATE_Pendant/INTEGRATION.md, SATE_Pendant/flash_xiao.sh, doc/09-pendant.md, src/pendant/PendantLink.ts, or the audit memory stuck-state-red-flags.md. Re-check against git for the current source.