Skip to the content
4 layers71 × 64 mmUWB, the printed circuit board in 3D

ESP-IDF 6.0.2 firmware for the custom UWB Module V1.0 Rev.B, based on an ESP32-S3-WROOM-1-N16R8. The same application image runs on every module; persistent identity and runtime configuration select the role and active…

Size
71 × 64 mm
Copper layers
4
Thickness
1.6 mm
Parts
254
Nets
193
Pads
763
Tracks, vias
2,672, 584
Pours
14
Routed
Yes
KiCad
KiCad 9.0 (file 20241229)
Created
6 Jul 2026
Updated
1 Sep 2026

Overview

UWB is an open-source printed circuit board by victorstoica114, from the victorstoica114/UWB_Module_ESP_IDF repository on GitHub. It is a 4-layer board of 71 × 64 mm with 254 parts on 193 nets, routed with 2,672 tracks and 584 vias.

It is built around the ESP32-S3-WROOM-1-N8, DWM3000 and MAX77958EWV+T, with the TPS630701RNMT, BNO085 and BQ25792RQMR among its other parts. Its parts include 97 resistors, 85 capacitors, 27 diodes, 9 ICs and 5 transistors. It belongs with the radio and wireless boards and the ESP32 boards of this library.

Main components

12

README

README.md

UWB ESP-IDF

ESP-IDF 6.0.2 firmware for the custom UWB Module V1.0 Rev.B, based on an ESP32-S3-WROOM-1-N16R8. The same application image runs on every module; persistent identity and runtime configuration select the role and active workflow on each board.

The current firmware combines DW3000 positioning, PX1105R GNSS/RTK, BNO085 IMU telemetry, BQ25792 charging, MAX77958 USB-C Power Delivery, authenticated HTTP OTA/configuration, a browser dashboard, and boot-loop/OTA recovery. The UWB radio runtimes include Native DS-TWR, FlexTDOA, Passive DS-TWR, calibration, survey, distance-test, and beacon diagnostics.

Current firmware defaults, before an NVS override is loaded:

SettingDefault
Runtimeuwb_ranging (Native DS-TWR)
UWBenabled
BNO085disabled; 2 ms / 500 Hz requested when enabled
PX1105R GNSSdisabled
BQ25792 monitorenabled, nominal 1 Hz refresh
MAX77958 monitorenabled, nominal 1 Hz refresh
Wi-Fi logs / binary telemetryenabled
Storage16 MB flash, dual 4 MB OTA slots, 8 MB octal PSRAM

Wi-Fi credentials, the OTA token, and optional NTRIP credentials are read from the Git-ignored secrets.h. Copy secrets.example.h as the starting template. Non-secret defaults live in components/config; authenticated runtime overrides are stored in ESP32 NVS.

Documentation Map

Hardware

This firmware targets the custom UWB Module V1.0 Rev.B hardware.

AreaDetails
MCUESP32-S3-WROOM-1-N16R8
Flash / PSRAM16 MB flash, 8 MB PSRAM
USBNative USB CDC on boot
UWBDW3000/DWM3000 radio path
IMUBNO085 over shared I2C
GPSPX1105R/PX1125R-class GPS path
USB-C / PDMAX77958
ChargerBQ25792
Status LEDGPIO42, active-high

Active pin mapping lives in components/config/include/board_config.h.

FunctionGPIO
Status LED42
Shared I2C SDA / SCL9 / 10
BNO085 reset / interrupt40 / 15
BQ25792 interrupt / QON command / board PG4 / 38 / 5
UWB reset / IRQ / CS / wakeup8 / 6 / 48 / 7
SPI MOSI / SCK / MISO11 / 12 / 13
GPS enable / RX / TX47 / 18 / 17
GNSS correction UART TX1

DW3000 SPI Clock

The DWM3000 data sheet specifies a maximum SPI clock of 38 MHz in mode 0 after OTP boot has completed. ESP32-S3 SPI2 is sourced from the 80 MHz APB clock and can generate 40 MHz exactly. This project deliberately selects that divider after field validation on all five modules. It is a documented 2 MHz overclock, guarded by an explicit compile-time opt-in and a separate 38 MHz data-sheet-limit constant so the exception cannot be accidental.

The driver therefore uses two phases:

  1. reset, DEV_ID probe, soft reset, OTP reads, and radio configuration at 4 MHz;
  2. operational RX/TX traffic requested and realized at 40 MHz, after eight consecutive DEV_ID read-backs succeed.

If the operational verification fails, the driver automatically restores the 4 MHz device configuration so the module remains reachable. /status exposes the effective value as uwb_spi_clock_hz; this is the frequency reported by the ESP-IDF SPI driver, not only the requested value.

Hardware validation on all five modules, updated 2026-08-05:

MetricResult
Effective SPI clock, every module40 MHz
Pure CI-CR validation window35 s
Selected slot / four-anchor frame4.80 ms / 19.20 ms
Passive observations443-450/s
Telemetry drops0
Responder-index balance per anchortypically 37-39 / 37-39 / 37-39 per second
Rejected tag observationstypically 0-1/s
Incoherent anchor timestamps in stable windows0

The radio test exercises TX-buffer writes, delayed-TX programming, timestamp and status reads, and RX-buffer reads. It is therefore a stronger link check than probing DEV_ID alone.

Firmware Architecture

main/app_main.c only calls app_manager_start(). The application manager initializes identity and runtime configuration, brings up the recovery/control plane first, and then starts hardware services and exactly one UWB runtime.

ComponentResponsibility
app_managerBoot sequencing, status LED, recovery cutoff, and active-runtime selection.
configBoard pins, compile-time defaults, persistent module identity/antenna delay, and NVS runtime configuration.
boot_guardESP-IDF OTA validation plus RTC-memory crash-loop detection and recovery mode.
wifi_serviceSTA scan, connection, reconnect, BSSID/channel diagnostics, and network status.
ota_serviceHTTP server, /status, OTA, GNSS update staging, and authenticated configuration endpoints.
wireless_log_serviceTCP mirror of ESP-IDF text logs.
wireless_telemetry_serviceBatched UWT1 binary sensor, geometry, range, and position streams.
resource_monitor_serviceBounded RAM, PSRAM, flash, temperature, and per-core CPU diagnostics.
i2c_bus_serviceShared BNO085 realtime/BQ25792/MAX77958 background arbitration and optional SCL measurement.
bno085_serviceInterrupt-driven accelerometer and GyroRV acquisition, timestamp reconstruction, recovery, rings, and telemetry.
charger_serviceBQ25792 monitoring, decoded status/ADC data, controlled writes, and NVS-backed charger policy.
max77958_serviceMAX77958 status, AP commands, optional direct-HS I2C, PDO/PPS control, and NVS-backed PD policy.
gps_servicePX1105R power/UART control, NMEA/SkyTraq parsing, 8 Hz setup, moving-base transport, and NTRIP.
gnss_firmware_updaterFixed PX1105R loader/image update sequence using ESP32 PSRAM staging.
uwb_dw3000DW3000 GPIO/SPI/IRQ ownership and all timing-critical UWB runtime loops.
uwb_ranging_serviceNative DS-TWR positioning entry point.
uwb_distance_test_serviceTwo-module five-frame diagnostic DS-TWR entry point.
uwb_calibration_serviceTwo- and three-module antenna-delay calibration entry point.
uwb_anchor_survey_serviceAnchor-to-anchor survey entry point.
flextdoa_protocolFlexTDOA packet codec, collector, and CFO estimator.
flextdoa_algminFlexTDOA frame aggregation and 2D minimization.
flextdoa_solver_serviceAsynchronous embedded FlexTDOA solver and position publication.
passive_ds_solver_servicePassive DS batching, dynamic geometry, correlated solve, and position publication.
uwb_mobile_geometryConverts GNSS anchor positions into a shared local UWB coordinate frame.
uwb_localization_solverGeneric tested range/range-difference solver library; it is not the owner of a current runtime loop.

Repository-level supporting material:

PathContents
tools/Build/OTA helpers, dashboard, listeners, capture, replay, calibration, and analysis tools.
tests/Python host tests and standalone C algorithm/protocol tests.
docs/Focused protocol and implementation notes.
firmware_images/Retained ESP32 release inputs, stored through Git LFS where configured.
PX1105R-firmware/Permanent GNSS firmware index and validated ESP32-assisted update artifacts.
reports/Final PDFs plus one self-contained evidence bundle for each report.
mechanical/uwb-gps-enclosure/Parametric V4 enclosure source, reference geometry, print-ready STL files, STEP exports, and previews.
PCB/V1.REV.B/KiCad source, fabrication, BOM, and placement package.
Schematic/ / datasheets/Exported board schematic and local silicon/reference documents.
reference/Migration notes and the intentionally retained legacy GPIO header.

components/config/include/board_config.h is the active board pin map. Old GPIO naming is kept only under reference/legacy_headers. Old legacy project files and cloned third-party repositories are kept local for inspiration/debugging and are intentionally ignored by Git.

components/config/include/app_config.h holds versioned non-secret application settings: the default runtime mode, persistent identity provisioning, Wi-Fi diagnostics/reconnect behavior, wireless-log defaults, OTA-adjacent service defaults, and optional sensor-test parameters. components/config/include/uwb_config.h holds UWB-only settings: role, source ID override, antenna delay, beacon smoke-test parameters, and runtime defaults. Runtime overrides are stored in NVS through /config/runtime. Keep Wi-Fi credentials and OTA tokens in secrets.h.

Runtime ownership is intentionally narrow:

  • main/app_main.c only boots the firmware and calls app_manager_start().
  • components/app_manager/app_manager.c owns what runs on the board and in what order.
  • Long-lived workflows live in separate services/components, then are selected or sequenced by app_manager.

The current boot sequence is also explicit. app_manager starts the status LED and boot guard, loads identity/runtime configuration, then brings up Wi-Fi, wireless logs, binary telemetry, and the HTTP/OTA server. It schedules stable boot confirmation only after that recovery/control plane is reachable. A normal boot then starts the resource monitor, optional GNSS, the selected UWB runtime, BQ25792, MAX77958, and finally the optional BNO085 service. Recovery mode stops before that second group and holds the DW3000 in reset.

The application task/core split is kept simple so the UWB loop is isolated from most Wi-Fi and TCP work:

Task / serviceCoreNotes
uwb_dw30001Owns DW3000 init, SPI access, RX/TX, ranging, survey, and calibration loops.
status_led1Lightweight GPIO blink task.
boot_guardunpinnedConfirms stable boots after Wi-Fi/OTA are online and holds recovery state across resets.
wifi_service0Owns Wi-Fi STA connect/reconnect management.
ota_service / httpd0Starts authenticated OTA, /status, and runtime-config HTTP handling.
bno0850Interrupt-driven BNO085 accelerometer/GyroRV service; owns realtime priority on the shared I2C bus.
bq257920Low-rate charger monitor; uses background I2C access so BNO085 can win bus arbitration.
max779580Low-rate USB-C PD monitor/config service; also uses background I2C access.
gps0PX1105R power, UART parsing, and moving-base integration when runtime-enabled.
gps_ntripunpinnedOptional single NTRIP caster connection owned by the configured module.
flex_algmin0FlexTDOA solver worker, created only by that runtime.
passive_ds_solver0Passive DS solver worker with its large stack allocated from PSRAM.
resource_monitor0Low-priority bounded resource sampling.
wireless_logunpinnedDrains the log queue and mirrors logs over TCP; FreeRTOS may run it on either core.
wireless_tel1Drains high-rate telemetry into batched TCP writes.
short-lived reboot tasksunpinnedTemporary restart helpers after OTA or runtime-config changes that explicitly require a reboot.

ESP-IDF also creates internal Wi-Fi, TCP/IP, and event-loop tasks. The HTTP server task is pinned to core 0 so /status and runtime config do not share the UWB core. The timing-critical UWB transmit instants are still programmed into the DW3000 with delayed TX, so the radio owns the sub-microsecond timing rather than the FreeRTOS scheduler.

UWB waits are deliberately yielding. RX/TX waits use the DW3000 IRQ line and FreeRTOS task notifications when IRQ is enabled, with vTaskDelay as the fallback path. Calibration slot alignment uses the dedicated 1 MHz ESP GPTimer alarm plus a task notification, so the firmware does not busy-loop while waiting for the guard window or slot end.

Boot Recovery

The firmware has two recovery layers so a bad OTA or a crash loop should not make a module disappear from Wi-Fi permanently.

LayerWhat it handlesMechanism
ESP-IDF OTA rollbackA new OTA image crashes or resets before it proves it can boot.CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE marks a fresh OTA image as pending_verify. The app calls esp_ota_mark_app_valid_cancel_rollback() only after Wi-Fi and OTA HTTP are online for APP_BOOT_GUARD_STABLE_DELAY_MS. If a reset happens first, the bootloader rolls back to the previous valid OTA slot.
Local recovery modeA valid app, serial-flashed app, or bad runtime config keeps rebooting.components/boot_guard stores a small state record in RTC no-init memory. After APP_BOOT_GUARD_FAILURE_THRESHOLD unstable resets, the next boot starts only identity/runtime config, Wi-Fi, wireless log, telemetry, and OTA. UWB, GPS, charger, USB-C PD, resource monitor, and BNO085 are skipped.

Normal boot order is intentionally conservative: Wi-Fi, wireless log, telemetry, and OTA start before UWB or I2C peripherals. Recovery mode uses the same path but stops before the risky services and holds the DW3000 in reset.

Task watchdog panic is enabled on purpose. A task watchdog, interrupt watchdog, panic, generic watchdog, or CPU lockup reset is counted as a recovery failure even if the previous boot had already been marked stable. This catches runtime configuration lockups that happen minutes or hours after boot instead of only early boot loops.

/status exposes the recovery state:

FieldMeaning
boot_recovery_modetrue when only the minimal recovery services are running.
boot_guard_failure_countConsecutive unstable resets counted in RTC memory.
boot_guard_recovery_thresholdFailure count that latches recovery mode.
boot_guard_ota_stateCurrent OTA state, such as valid or pending_verify.
boot_guard_pending_verifyThe running app still needs to be marked valid.
boot_guard_last_reset_reason_nameLast reset reason reported by ESP-IDF.

Recovery can be cleared after uploading a known-good firmware or runtime config:

sh
curl -X POST -H "X-OTA-Token: <APP_OTA_PASSWORD>" \
  "http://<module-ip>/config/recovery?clear=1&reboot=1"

Important: rollback lives in the bootloader. OTA updates replace only the app partition, so each module needs one full serial flash after enabling rollback. After that, future OTA images are protected by the rollback flow.

HTTP Control Surface

The ESP32 exposes one HTTP control plane after Wi-Fi starts. GET / and GET /status are intentionally readable without authentication. Every POST endpoint requires the same header used by OTA:

text
X-OTA-Token: <APP_OTA_PASSWORD>

The password comes from the Git-ignored secrets.h. Configuration values are passed as URL query parameters even though the method is POST; an empty body is valid. For example:

sh
curl -X POST -H "X-OTA-Token: <APP_OTA_PASSWORD>" \
  "http://<module-ip>/config/runtime?bno085=1&bno085_sample_hz=500"
Method and pathPurposePersistence / restart behavior
GET /Short endpoint and usage summary.Read-only.
GET /statusJSON snapshot of identity, boot, UWB, GNSS, IMU, charger, PD, resources, logs, and telemetry.Read-only.
POST /otaUpload an ESP-IDF application image to the inactive OTA slot.Reboots into the new image; NVS is preserved.
POST /gnss/loaderStage the validated PX1105R loader artifact in PSRAM.Volatile staging step.
POST /gnss/firmwareStage the validated PX1105R firmware artifact in PSRAM.Volatile staging step.
POST /gnss/firmware/runRun the fixed GNSS loader/update sequence using the staged artifacts.Reprograms the GNSS receiver, not the ESP32.
POST /config/antenna-delayStage a calibrated DW3000 antenna delay.Stored in NVS; applied at radio initialization.
POST /config/runtimeChange runtime, topology, timing, diagnostics, GNSS, UWB, or BNO085 configuration.Stored in NVS; some fields apply live or by UWB hot switch, others require reboot=1.
POST /config/recoveryClear the local boot-guard latch and optionally reboot.Updates boot-guard state.
POST /config/chargerRefresh or configure the BQ25792.High-level policy is stored in NVS; raw writes are not.
POST /config/max77958Refresh, inspect, or configure MAX77958 USB-C/PD behavior.Selected high-level policy is stored in NVS; diagnostic operations and raw writes are not.

Boolean charger/PD parameters accept 1/0, true/false, on/off, and yes/no. Runtime booleans accept 1/0, true/false, and on/off. Invalid, out-of-range, incomplete, or unauthenticated requests are rejected; callers should check both the HTTP status and the JSON response instead of assuming a write succeeded. The exact BNO085, charger, and PD command tables are in their respective sections below.

3D-Printed Enclosure

The complete modular V4 enclosure is versioned under mechanical/uwb-gps-enclosure/. It includes the CadQuery generator, input reference geometry, validation manifest, assembly STEP model, rendered previews, and eight print-oriented STL parts.

For printing, use the files ending in _PRINT.stl from mechanical/uwb-gps-enclosure/exports/. The enclosure README documents the required inserts, magnets, screws, print orientation, and assembly order.

UWB GPS enclosure preview

The binary CAD, mesh, reference-image, and preview assets are stored through Git LFS. The source was imported from the validated V4 enclosure revision 7e0be77 of AlexandruPalacean/uwb-gps-enclosure.

PCB Package

The hardware package is versioned with the firmware so the board definition and software assumptions stay together.

PathPurpose
PCB/V1.REV.B/Kicad project/Editable KiCad project and local symbols/footprints
PCB/V1.REV.B/Gerber/Gerber.zipFabrication archive
PCB/V1.REV.B/BOM/BOM exports
PCB/V1.REV.B/PickAndPlace/Assembly placement files
Schematic/UWB_V1.0_Rev.B.pdfExported schematic PDF

PCB preview:

UWB PCB Preview

Released Firmware Images

ESP32 images are published as one GitHub Release per firmware version, ordered from the oldest retained version to the newest. Each release keeps only the artifacts needed to install that version; intermediate ESP-IDF build trees are not release artifacts. The retained source images under firmware_images/ are tracked with Git LFS.

The release asset names have distinct purposes:

AssetUse
uwb_ota.binOTA through POST /ota or tools/ota_upload.py; preserves NVS.
uwb_full_flash.binComplete serial recovery/provisioning image written at offset 0x0; may replace NVS and therefore require identity, runtime, and antenna-delay provisioning again.
Per-partition .bin files, when presentLow-level ESP-IDF/esptool recovery and diagnosis. Use the offsets documented with that release.

The current retained ESP32 sequence starts at firmware-20260804-222730-flextdoa-accuracy and ends at firmware-20260813-190254-native-uwb-only. Use the GitHub Releases page as the canonical download location rather than copying binaries from an old build directory.

PX1105R firmware is versioned independently so the same GNSS payload is not duplicated in every ESP32 release. The permanent entry point is PX1105R-firmware/README.md, which links the dedicated gps-px1105r-01.07.33 release and records the validated artifacts and ESP32-assisted update command.

App Modes

The expected UWB workflow split is:

  1. APP_RUNTIME_MODE_UWB_BEACON_SMOKE: current random beacon TX/RX smoke test in components/uwb_dw3000.
  2. APP_RUNTIME_MODE_UWB_ANTENNA_DELAY_CALIBRATION: antenna delay calibration entry point in components/uwb_calibration_service.
  3. APP_RUNTIME_MODE_UWB_DISTANCE_TEST: current two-module DS-TWR ruler check entry point in components/uwb_distance_test_service.
  4. APP_RUNTIME_MODE_UWB_RANGING: current 4-anchor plus 1-tag DS-TWR ranging runtime in components/uwb_ranging_service. The first configured anchor coordinates the slots, anchors initiate, and the tag responds/logs tag-anchor distances for the dashboard.
  5. APP_RUNTIME_MODE_UWB_ANCHOR_SURVEY: anchor-to-anchor survey/runtime diagnostics in components/uwb_anchor_survey_service.
  6. APP_RUNTIME_MODE_UWB_FLEX_TDOA: experimental FlexTDOA runtime. Anchors rotate as short-slot initiators, continuously refresh live anchor geometry, and the tag only listens on UWB while sending range-difference observations to the dashboard over Wi-Fi logs.
  7. APP_RUNTIME_MODE_UWB_PASSIVE_DS_TWR: project-specific three-frame anchor-to-anchor DS-TWR with receive-only tags. It provides independent Fast Star and Robust Rotating schedules; see docs/PASSIVE_DS_TWR.md. The surveyed-square calibration data and 1.89 cm offline RMSE result are preserved in reports/bundles/passive_ds_known_geometry_20260727.

The beacon smoke mode, distance-test mode, antenna-delay calibration workflows, anchor survey, multi-anchor ranging, and experimental FlexTDOA runtimes are implemented for the current lab workflow.

APP_RUNTIME_MODE remains the firmware default. The effective runtime mode and common test parameters can be overridden at runtime through NVS using the authenticated /config/runtime HTTP endpoint.

Hot Protocol Switching

The Position tab switches directly between Native DS-TWR, FlexTDOA, and Passive DS-TWR through /config/runtime?...&hot_switch=1. A successful hot switch:

  • persists the selected mode in NVS;
  • lets the active UWB loop leave at a safe protocol boundary;
  • stops the active UWB timers and resets the solver state;
  • resets and reconfigures only the DW3000; and
  • keeps Wi-Fi, HTTP, wireless logs, and binary telemetry running.

The supervisor restores the 4 MHz DW3000 boot SPI rate for the radio reset and returns to the verified datasheet-compliant operational rate after initialization. If radio initialization fails, the firmware falls back to a full ESP32 restart.

Hot switching is deliberately limited to a mode-only transition among these three positioning runtimes. Changes to module topology, tag/anchor IDs, channel, radio settings, timing parameters, diagnostics, calibration, or survey modes still follow the normal reboot path. The /status response exposes uwb_runtime_switching, uwb_runtime_switch_count, and uwb_last_runtime_switch_ms so the dashboard can distinguish a short radio transition from an offline module.

The five-module live test on 2026-07-27 produced:

TransitionHTTP acknowledgementAll modules UWB-readyFirst complete live data
Native DS-TWR -> FlexTDOA0.101 s0.773 s0.784 s
FlexTDOA -> Passive DS-TWR0.105 s0.623 s0.932 s
Passive DS-TWR -> Native DS-TWR0.083 s0.764 s0.814 s tag ranges; 2.115 s all six anchor pairs

The measured DW3000 reinitialization time was 449-481 ms. ESP32 boot counters did not change during any transition.

UWB Ranging Protocol

The active ranging implementation is based on Double-Sided Two-Way Ranging (DS-TWR). Position ranging uses exactly the three native UWB frames: POLL, RESP, and FINAL.

FrameDirectionPurpose
POLLtag -> anchor10-byte protocol header that starts one ranging exchange.
RESPanchor -> tag10-byte protocol header that provides the responder turnaround.
FINALtag -> anchor35 bytes: header plus the tag's POLL TX, RESP RX, and scheduled FINAL TX timestamps.

The anchor combines the timestamps carried in FINAL with its own POLL RX, RESP TX, and FINAL RX hardware timestamps and calculates the distance locally. It reports the result through Wi-Fi, not through an additional UWB frame. The separate distance-test and calibration runtimes retain their five-frame diagnostic exchange for bidirectional verification.

Runtime Shape

The same firmware image runs on every module. At boot, each board reads its persistent module ID and runtime config from NVS.

Module IDNormal ranging behavior
1Tag / initiator; ranges each configured anchor in order
2Anchor / responder
3Anchor / responder
4Anchor / responder
5Anchor / responder

APP_RUNTIME_MODE_UWB_RANGING is tag-initiated. The tag owns the complete frame schedule and starts one native DS-TWR exchange in every anchor slot. There is no coordinator, RANGING_CMD, REPORT, or REPORT2. Each responding anchor calculates the range after FINAL and returns it in a one-shot UWB RESULT packet. The ESP32 tag solves every coherent four-anchor frame and publishes the range and raw independent-frame position over Wi-Fi. The dashboard displays that result and the host tools can independently replay it for RTK comparison; no temporal position filter is applied.

Native DS protocol version 2 protects every packet with CRC-16/CCITT-FALSE. This application-level CRC also detects payload corruption on the DW3000-to-ESP SPI path after the radio has already accepted the over-the-air frame check.

The tag schedules anchors sequentially:

text
round N

tag 1 -> anchor 2
tag 1 -> anchor 3
tag 1 -> anchor 4
tag 1 -> anchor 5

wait ranging_round_gap_ms
round N + 1
...

The important timing parameters are exposed by /status, can be changed with /config/runtime, and are also available in the dashboard:

NameDefault / lab valueMeaning
ranging_slot_ms10 msTime budget for one native four-packet exchange.
ranging_round_gap_ms4 msGap after all anchor slots in one frame.
ranging_rx_slice_ms100 msRX window used by idle anchor responders.
ranging_rx_timeout_ms5 msMaximum wait for the expected RESP, FINAL, or RESULT.
ranging_resp_delay_ms2 msDelayed TX offset from POLL RX to RESP TX; also used from FINAL RX to RESULT TX.
ranging_final_delay_ms2 msDelayed TX offset from RESP RX to FINAL TX.
ranging_auto_rx_delay_uus500 UUSDW3000 hardware delay before auto-RX opens after TX.

These values have their own NVS keys and dashboard profiles. Changing them does not modify FlexTDOA, distance-test, calibration, or anchor-survey timing. The tag uses a high-resolution host timer for slot and frame-gap boundaries, so profiles are not rounded to the 10 ms FreeRTOS tick. Native DS-TWR timing is copied when its UWB task starts. Timing-only changes can request an in-place UWB hot switch, which cleanly restarts the radio runtime while Wi-Fi, GPS and the ESP32 stay online. For four anchors, the default frame is 4 * 10 + 4 = 44 ms.

The selected field profile is a 10 ms slot, 4 ms frame gap, 100 ms idle-anchor RX slice, 5 ms expected-frame timeout, and 2 ms for each delayed transmission. In a 180-second channel-9 run it completed 15,138 of 15,183 exchanges (99.704%), produced 20.82 raw positions/s, and measured 1.80 cm RMSE and 3.07 cm P95 against stabilized RTK, without position filtering. A 42 ms frame raised throughput to 21.45 positions/s but reduced completion to 99.357%; a 43 ms frame reached 21.12 positions/s at 99.491%. The 44 ms profile is the recommended robustness/throughput tradeoff. The 64 ms and 100 ms profiles remain available as intermediate and conservative fallbacks.

FlexTDOA Runtime

APP_RUNTIME_MODE_UWB_FLEX_TDOA is the active pure CI-CR radio protocol with a radio-passive tag. The tag does not call any UWB TX function in this mode. It only receives anchor frames, records its own RX timestamps, feeds the local AlgMin solver, and sends compact binary range-difference and position telemetry to the dashboard.

FlexTDOA roles are derived from the configured anchor list. Every module in that list is an anchor; every participating module outside it is a passive tag and labels telemetry with its own module ID. This permits multiple passive tags without adding radio slots. An ID must never appear in both Anchor IDs and Tag IDs; the dashboard rejects that configuration. For example, with five modules, anchors 2,3,4 can be combined with tags 1,5.

The current firmware is aligned with the FlexTDOA paper at the radio slot level: one anchor is the initiator in a slot, it broadcasts one request, and K responders answer in ordered response subslots. With four configured anchors, K = 3 and a full frame has four initiator slots. The tag remains passive and derives range-difference observations only from the packets it hears.

With anchors 2,3,4,5, the changing-initiator/changing-responder schedule is:

text
round 0
  slot 0: A2 requests, responders A3,A4,A5
  slot 1: A3 requests, responders A4,A5,A2
  slot 2: A4 requests, responders A5,A2,A3
  slot 3: A5 requests, responders A2,A3,A4

round 1
  slot 0: A2 requests, responders A4,A5,A3
  slot 1: A3 requests, responders A5,A2,A4
  slot 2: A4 requests, responders A2,A3,A5
  slot 3: A5 requests, responders A3,A4,A2

The responder list rotates from the absolute 32-bit slot ID, slot_id % K. Consequently both the initiator and the first responder change at every slot, including between adjacent slots in the same frame. This implements the paper's CI-CR scheme without holding one response order for an entire frame.

The active FlexTDOA frame set is:

FrameDirectionPurpose
FLEX_TDOA_REQinitiator -> broadcastPaper request. Carries the 32-bit slot ID, responder count/order, zero processing delay, and one previous TWR result from this initiator.
FLEX_TDOA_RESPresponder -> broadcastPaper response. Carries the same slot ID, measured reply delay, and one previous TWR result from this responder. Initiator and responder index are reconstructed from CI-CR plus the source ID.

Both packet types use the common Figure 3 tail: processing time, previous TWR responder ID, previous TWR distance, and previous measurement slot. The local transport header already carries message type, source ID, and sequence, while the destination list remains variable-length. Every anchor cycles through its own directed TWR cache so a repeatedly refreshed pair cannot starve the other pairs. The parser temporarily accepts the older local request/response layout to keep a parallel OTA rollout operational.

There is no separate coordinator command in the active protocol. After two seconds of radio silence, the first configured anchor bootstraps slot 0. Every anchor reconstructs the TDMA phase from valid request packets. Responses carry the full 32-bit slot ID and can recover a follower that missed the request; a directly received request remains the authoritative phase reference for that slot. Each node schedules only its own next initiator slot, and later requests continually correct local clock drift.

The topology is represented by the same N/K/M parameters used in the paper:

FieldMeaningFirmware range
NOrdered anchor ID list.3..10 anchors
KNumber of responders selected for each request.1..N-1
MNumber of initiator slots in the repeating frame.1..10 slots
responder maskAnchors allowed to respond in one slot; CI-CR rotates the selected K through this pool.one N-bit mask per slot

These values are editable under UWB Settings > FlexTDOA Network. Applying a new topology increments a 32-bit configuration generation and stores it in NVS. During the three-second startup configuration phase, anchors, the tag, and a node removed from the new topology can all broadcast their versioned config. Nodes accept only a newer generation, persist it, and restart their local UWB state. While the network is running, reception of a newer config causes the same controlled rebuild. This is the distributed propagation and recovery path; normal slot synchronization still comes from REQ/RESP, not from a separate periodic synchronization packet.

The versioned fixed-geometry transport remains for backward compatibility with older captures and deployments. The current positioning workflow clears that legacy state and does not freeze coordinates: live anchor ranges continuously drive the embedded and dashboard geometry solvers.

FlexTDOA Slot Timing

The CI-CR slot keeps the structure of equation (20) from the paper. Its timing terms are runtime parameters persisted in NVS and propagated over UWB, so each tested combination is kept as a named frame-length profile in the dashboard:

text
t_slot = guard + request_subslot + request_process
       + K * response_subslot
       + K * response_process_per_responder

For four anchors, K = 3, the dashboard exposes the two field-validated profiles plus the exact equation (20) paper reference:

ProfileGuardREQProcess REQ3 x RESP3 x Process RESPSlotFrameFrame rate
32.20 ms Field Fast250 us2000 us250 us2550 us3000 us8.050 ms32.200 ms31.06 Hz
34.00 ms Field Stable250 us2000 us250 us3000 us3000 us8.500 ms34.000 ms29.41 Hz
20.20 ms Paper Reference250 us2000 us250 us750 us1800 us5.050 ms20.200 ms49.50 Hz

32.20 ms Field Fast is the firmware default. A clean 120 s channel-9 run on all five deployed modules produced 30.02 independent positions/s, 2.924% effective slot loss, zero new delayed-TX errors, 2.03 cm RMSE and 3.58 cm P95 against the static RTK reference, with no position filter. The 34.00 ms Field Stable profile remains available as the conservative control. The paper reference is kept exact for protocol comparison, but its 250 us response spacing does not provide enough margin for the complete ESP32-S3 field workload.

For the selected field profile, one slot is:

text
250 + 2000 + 250 + 3*850 + 3*1000 = 8050 us

The selected responder transmits using DW3000 delayed TX from the request RX timestamp. For the selected field profile:

text
RESP[0] = REQ_RX + 2250 us
RESP[1] = REQ_RX + 3100 us
RESP[2] = REQ_RX + 3950 us

four-anchor frame = 4 * 8050 us = 32.20 ms
frame rate        = 31.06 frames/s theoretical
measured rate     = 30.02 complete positions/s

The responder does not read SYS_TIME to turn that relative delay into an absolute radio timestamp. It combines the already-read request RX timestamp with the relative delay, writes that target to DX_TIME, and issues the native DW3000 CMD_DTX fast command. The reply interval carried in FLEX_TDOA_RESP is derived from the same quantized delayed-TX target, including TX antenna delay, so the tag subtracts the interval that the radio actually used rather than an ideal host-side delay.

FlexTDOA RX timestamps also avoid a per-frame SYS_TIME reference. The DW3000 double-buffer diagnostic set exposes the low 32 bits of RX_TIME reliably, and adjacent FlexTDOA packets are much closer than the approximately 67 ms wrap period of those 32 bits. Firmware therefore extends each low word from the previous RX timestamp by signed continuity. A hardware diagnostic run found that the nominal fifth timestamp byte in the buffered metadata was occasionally incoherent (21 mismatches in about 18,975 comparisons on M1), so trusting that byte directly would be less robust than continuity.

One SYS_TIME read seeds the 40-bit epoch when the first FlexTDOA packet is received. A new seed is used only as a recovery action after more than 30 ms without a received packet, where low-word continuity would become ambiguous. There is no periodic or per-frame SYS_TIME read in the continuous FlexTDOA path. Full SYS_TIME reads remain in generic DS-TWR timestamp reconstruction and delayed-TX failure diagnostics; they are not part of the normal FlexTDOA slot.

The request RX timestamp marks the radio timing reference near the beginning of the received frame. The field profile reserves a 2000 us request subslot and a 250 us request-processing term before the first response. Together they cover packet airtime, SPI decode, RX double-buffer release, and delayed-TX arming. K * response_process is the paper's aggregate response-processing budget, not extra spacing between responses. The configured quiet guard ends the slot before the next request.

For a slot where A3 is the initiator:

text
A3 initiator                  A4/A5/A2 responders             passive tag
 t=0.00 ms  REQ ------------------------>|------------------------> RX REQ
 t=2.25 ms  <---------------- RESP[0] A4 |------------------------> RX RESP
 t=3.10 ms  <---------------- RESP[1] A5 |------------------------> RX RESP
 t=3.95 ms  <---------------- RESP[2] A2 |------------------------> RX RESP
 t=8.05 ms  next initiator request ------|------------------------> next slot

The ESP task wakes before its own next request and programs delayed TX. This preparation lead is local only and adds no airtime. A longer 5000 us lead was rejected in hardware tests because it stopped reception before the preceding slot completed. Delayed responses use the processing budget selected by the active profile; a rejected or late command is reported explicitly instead of leaking into the next slot.

The passive tag computes the paper TDOA observation:

text
diff_dtu = (RESP_RX_tag - REQ_RX_tag)
         - responder_reply_dtu_corrected
         - anchor_anchor_tof_dtu

responder_reply_dtu_corrected uses the instantaneous DW3000 carrier-integrator clock ratio from that response. anchor_anchor_tof_dtu comes from the cached anchor-anchor distance learned from the Figure 3 piggyback stream. The latest directed measurement of either orientation is used until the pair has a newer value.

No median, moving average, or position filter is applied to the passive tag's FlexTDOA observations in firmware. Firmware only rejects structurally invalid observations: mismatched slot_id/responder index, missing CIA clock data, or an RX timestamp outside the current slot. Position solving and any future estimator are deliberately kept outside this radio validation.

FlexTDOA Alignment Audit
Paper requirementFirmware state
Passive DL-TDOA tagImplemented. The tag only receives FLEX_TDOA_REQ/RESP.
One request and K responses per slotImplemented. The exact paper profile uses 250 us response spacing; the field default uses the validated 850 us response spacing with 2000 us REQ, 250 us request processing, 1000 us response processing, and 250 us guard terms.
Request carries responder count/list/orderImplemented in FLEX_TDOA_REQ.
Every localization packet carries 32-bit slot IDImplemented in every request and response. This avoids the 256-slot wrap ambiguity of an 8-bit counter.
CI-CR scheduleImplemented by rotating initiator and responder order every slot/round.
CFO-based reply delay correctionImplemented from buffered CIA_DIAG_0, with the Qorvo sign convention and no clock Kalman filter.
Anchor self-localization payloadImplemented. Each request and response carries one previous directed SS-TWR+CFO result using the Figure 3 fields.
Fully distributed slot synchronizationImplemented from request RX timestamps. A response can recover a node that missed the request, but a directly received request is authoritative for that slot.
General N/K/M topologyImplemented for 3..10 anchors, configurable responder count, initiator slots, and per-slot responder masks.
Configuration propagationImplemented as versioned UWB frames. Topology is persisted and rebroadcast during startup; the legacy fixed-geometry generation is cleared for live positioning.
Local solverImplemented on the tag ESP32-S3. It continuously reconstructs relative geometry from fresh anchor TWR ranges. Raw TDOA observations feed a damped 2D least-squares AlgMin service on core 0; UWB remains on core 1.
Radio setupThe field deployment uses CH9 and the validated PHY2 profile. CH5, 6.8 Mb/s, PRF 64 MHz, preamble 128 remains the paper-reference setup.
Firmware-side measurement filtersNone. The radio path emits every structurally valid raw anchor range and tag range difference.

The local solver is the planar specialization needed by the present hardware layout. The paper evaluates 3D AlgMin and AlgEKF as well. Therefore the radio protocol and AlgMin objective are aligned, but the current embedded solver is not a general 3D replacement for every solver variant in the paper.

The paper's node uses an STM32F429ZIT6: Cortex-M4 with FPU at 168 MHz, 2 MB Flash, and 256 KB SRAM. That MCU has excellent deterministic interrupt latency, but it is not more powerful overall than this dual-core ESP32-S3 at 240 MHz with external PSRAM. The practical difference is workload isolation: Wi-Fi and HTTP share the ESP32-S3, so the timing-critical UWB path is pinned to core 1 and the local solver runs on core 0.

The local solver removes the PC/network round trip from the positioning path; it does not shorten radio airtime. With M=4, one solution can be published after every 32.20 ms complete field frame. The validated deployment produced 30.02 independent solutions/s. The dashboard prefers this ESP32 AlgMin result while it is fresh and falls back to its PC solver only when local position telemetry is unavailable.

FlexTDOA Embedded Solver Optimization

The iterative position solve follows the first-order Taylor linearization and least-squares refinement described in the local datasheets/GPS-Compendium_Book_(GPS-X-02007).pdf, sections 6.2.2-6.2.4. The previous valid position is the normal warm start; the anchor centroid is retained as a recovery candidate. The GPS compendium notes that a good initial estimate normally converges in roughly three to five iterations. On the live FlexTDOA stream, the embedded solver needed only 2.09 iterations on average, while retaining the six-iteration ceiling for recovery from a poor initial estimate.

The ESP32-S3 position hot path now builds one compact set of fresh TDOA observations per solution and uses hardware-accelerated single-precision math. It no longer scans the complete observation matrix for every cost evaluation. The least-squares objective, centroid comparison, Levenberg-style damping, step bound, line search, convergence threshold, and reported uncertainty are unchanged. Anchor self-localization remains double precision and is measured separately.

Static hardware measurements on M1, with four live anchors and the same raw FlexTDOA stream, gave:

MetricOriginal solverOptimized solver
Solutions per second68.067.9
Average position solve5.88 ms0.89 ms
Average AlgMin iterationsnot instrumented2.09
Average cost evaluationsnot instrumented9.66
Solver queue depth, sampled each second16.4 average5.9 average
Solver queue maximum in the test9453
Queue drops in the final 90 s runpresent0
M1 core 0 loadapproximately 67%approximately 41.5%

The optimized and original 15-second stationary captures remained comparable: position standard deviation changed from 4.47/3.18 cm on X/Y to 4.23/3.18 cm, and RMS residual changed from 9.65 cm to 10.16 cm. Those differences are within the changing live radio observations and do not indicate added filtering; every structurally valid raw observation still feeds the solver. Measured wall-time maxima include preemption by Wi-Fi and other core-0 work, so they are diagnostic scheduling latency rather than the normal AlgMin compute time.

Live Position Dashboard Throughput

Local position telemetry has a dedicated Server-Sent Events endpoint. The dashboard records every received position immediately and schedules the newest one for the next browser animation frame. The heavier status, geometry, and diagnostic tables remain on the 250 ms snapshot poll. This separates radio and solver throughput from browser repaint rate: an 83.33 Hz position stream can be received completely on a display that can draw only about 60 fps.

A two-minute hardware run with all five modules and the 12.00 ms Frame profile measured:

MetricResult
Nominal local solution rate83.33/s
Positions received by the SSE server9,971
Measured SSE rate83.333/s
Unique position event IDs9,971
Browser rate, headless 60 Hz display83 rx/s, approximately 55 fps
Median server delivery age1.67 ms
Telemetry drops0 on all five modules
Anchor transmissions160,170
Anchor TX errors9 (0.0056%)
M1 telemetry queue high-water mark1,024/1,024, without drops
M1 peak core load during the runcore 0 63.2%, core 1 85.2%

The exact average output rate proves that the optimized solver and dashboard can sustain the 83.33 Hz experiment. Rare delayed-TX rejects, one long delivery interval, and the full M1 telemetry queue also confirm that this is a limit test with little scheduling reserve. The 14.80 ms Frame profile remains the robust default until the shorter timing receives a longer clean run.

HTTP status polling is performed concurrently for all modules. A single failed poll marks the last status as degraded but does not immediately hide a live position; the module becomes offline only after its last successful status has aged out. High-rate accelerometer samples are fetched and decoded by the browser only while the Graphs tab is visible, while the server continues to receive and buffer them.

A coexistence check with the 14.80 ms Frame profile and all five BNO085 sensors at 500 Hz saturated M1's telemetry queue and added 1,522 drops in one minute. After disabling the accelerometers without rebooting, the queue drained and the drop counter remained unchanged for the following 30 s. Maximum-rate FlexTDOA measurements should therefore keep BNO085 disabled; running both at maximum rate remains a separate telemetry coexistence stress case.

FlexTDOA 5.05 ms Baseline Hardware Validation

Before compacting the request and guard budgets, the complete implementation was exercised on five physical modules: four anchors and one radio-passive tag. The final image ran for more than three minutes with all nodes HTTP-online, no watchdog reset, no panic, and no queue drop. One isolated late slot was reported and the affected anchor recovered from subsequent radio traffic without restarting the network.

MetricMeasuredTheoretical / interpretation
Slot duration5.05 msEquation (20) timing used by this baseline test.
Frame duration20.20 msFour initiator slots.
Anchor resultsusually 141-148/s per anchorMaximum 148.5/s from three responses in each own slot.
Passive-tag observationsusually 470-525/sMaximum 594.1/s; the remaining loss is in the very tight 250 us receive train.
RX double-buffer releaseusually 198-216 us maximum per one-second summaryFits inside one response subslot, with limited margin.
Local positionone result per complete frameApproximately 49.5 AlgMin solutions/s.
Tag CPU loadabout 48% on core 0 and 48% on core 1Solver on core 0, UWB receive path on core 1.
Anchor CPU loadabout 5-8% on core 0 and 23-24% on core 1No local tag-position solve.

The DW3000 does not support RXAUTR together with receive double buffering. The fast path therefore issues CMD_RX immediately after a good frame, before copying the occupied buffer. It releases that buffer by clearing only its RDB status and RX-good SYS_STATUS events, then issuing CMD_DB_TOGGLE. It does not use global CMD_CLR_IRQS, which can erase unrelated TX state. Expected PHY errors are accumulated into the one-second summary instead of synchronously formatting UART warnings from the timing-critical UWB task.

Recovery was also tested by rebooting only M4. Its boot counter advanced once, it restored generation 3 (N=4, K=3, M=4) from NVS, learned the current TDMA phase from live FlexTDOA traffic, and resumed increasing TX/RX counters. The other four nodes were not restarted.

FlexTDOA 14.80 ms Compact Profile Trial

On 2026-07-22, the compact profile was applied to all five physical modules and persisted as runtime configuration generation 6. Every module restored the same five timing values after the requested reboot:

text
guard=250 us, REQ=250 us, process REQ=1250 us,
RESP=250 us, process RESP=400 us per responder

For N=4, K=3, and M=4, this produces a 3.700 ms slot, a 14.800 ms frame, 67.57 frames/s, and 810.8 scheduled responses/s. A monotonic-counter window taken after boot stabilization produced:

Metric60 s result
Anchor TX count16,343-16,352 per module
TX errors0 on all five modules
RX error ratio0.13-0.26%
Protocol queue drops0
Incoherent anchor observations0
Measured REQ processing path maximumbelow 460 us

This is a successful compact-profile trial, not yet a replacement for the longer 150 s validation of the conservative profile. Longer runs and dynamic tag tests should be added under the same frame-length profile name so timing results remain directly comparable.

FlexTDOA Timing-Limit Search

The profiles below were exercised consecutively on all five physical modules on 2026-07-22. Each short profile was observed for 30 s; the restored 14.80 ms profile received a separate clean 60 s steady-state window after the simultaneous reboot and network resynchronization had settled.

FrameSlotFrame rateResponse rateHardware result
14.80 ms3.70 ms67.57 Hz810.8/sRecommended: 0 TX errors in the clean 60 s window.
14.20 ms3.55 ms70.42 Hz845.1/sBorderline: 1 delayed-TX reject and resynchronization in 30 s.
13.60 ms3.40 ms73.53 Hz882.4/sBorderline: 1 delayed-TX reject and resynchronization in 30 s.
12.60 ms3.15 ms79.37 Hz952.4/sFailed: 2 delayed-TX rejects and resynchronizations in 30 s.
12.00 ms3.00 ms83.33 Hz1000.0/sFailed: 3 TX failures in 30 s.

The instrumentation separates delayed-TX register programming from the full radio path. Request arming normally peaked at approximately 90-153 us, while the observed response hot path remained below approximately 480 us. Those averages and ordinary maxima do not define the safe timing margin: the failed profiles expose rare millisecond-scale task and radio-state stalls.

An ESP timer ISR dispatch experiment reduced some late request scheduling, but it could wake the same UWB task while that task was waiting for a response TX event and introduced occasional response-path failures. It was rejected. The firmware was restored to the original task-dispatched timer and fixed 1750 us request preparation lead before the final clean 14.80 ms run.

FlexTDOA Speed Notes

The runtime is limited by worst-case host/radio turnaround rather than the 6.8 Mbps UWB PHY. DW3000 SPI requests and runs at the field-validated 40 MHz ESP32-S3 divider. This intentionally exceeds the data-sheet limit of 38 MHz, as documented in the hardware section above. The hot path uses polling SPI, compact payload/metadata reads, targeted RX status clears plus DB_TOGGLE, cached TX_FCTRL, early request-buffer release, and DW3000 delayed TX.

text
validated ESP32-S3 slot, K=3 = 4.80 ms
four-anchor frame            = 19.20 ms
theoretical frame rate       = 52.08/s
theoretical response rate    = 625.00/s

The measured REQ-to-CMD_DTX software path is normally about 230-250 us and stayed below 500 us in the final run. That is not itself a safe protocol budget: the DW3000 also needs a valid radio state and enough future lead when the delayed command is accepted. Hardware tests established the following margin boundary:

REQ processing budgetRX release / delayed-TX orderHardware result
500 usrelease before CMD_DTXRepeated delayed-TX rejects and timeouts.
500 usCMD_DTX before releaseFaster measured host path, but DB_TOGGLE could cancel the pending delayed TX and responder counts became unbalanced. Rejected.
600-650 usrelease before CMD_DTXIntermittent failures remained.
750 usrelease before CMD_DTXRare missed TX/state errors remained during longer runs.
1500 usrelease before CMD_DTXValidated: 150 s steady operation with no delayed-TX timeout/reject/state error, no missed slot, no response failure, and balanced 52/52/52 responder counts per anchor summary.

The safe order is therefore fixed: release the processed RX double buffer before arming delayed TX. A one-time cluster can still appear about 31 s after a newly installed image boots, when the boot guard validates the OTA image and writes flash. The subsequent steady-state radio test was clean; that flash-write disturbance is separate from the FlexTDOA timing margin.

The following table is a historical optimization record from 2026-07-18. Its rows precede the final exact-paper timing and used the moved module layout. It must not be read as the current configuration or as a geometry/accuracy test.

request processresponse subslottag valid/sanchor results/s/moduleresponder-index balanceresult
1000 us250 us407-446128-131nearly balancedAbout 70-105/s tag timestamps were outside the slot.
1000 us300 us491-497111-117middle index lowTag improved, but initiators still lost middle-buffer timestamps.
1500 us300 us452-458106-112middle index lowMore TX margin did not repair the double-buffer boundary.
1500 us350 us443-450113-114typically 37-39 per indexInitial selected run before removing synchronous UART work from the radio task.
1500 us350 us451-456, avg 454.1avg 113.8-114.0normally 38,38,38Final relative-radio path over 41 s: zero drops, delayed-TX misses, response failures, and TX-interval mismatches.

The selected historical 1500/350 us rate was within 0.1% of its 454.55/s theoretical tag rate. Across that 41 s window, the tag rejected 14 of roughly 18,600 observations (0.08%) and anchors marked 5 of roughly 18,700 results incoherent (0.03%); all telemetry drop counters remained zero. The one-second diagnostic summaries are submitted directly to the wireless log queue. They do not pass through synchronous UART formatting in the timing-critical UWB task, which previously created a delayed-TX miss approximately once per second.

Historical slot-order scenarios:

ScenarioDirected slot orderPair reverse separationFull directed cycleRead
Previous round-reverse2->3, 2->4, 2->5, 3->4, 3->5, 4->5, then all reversed next round6 * slot + gap12 * slot + 2 * gapGood for sweeping all pairs, but each paired TDOA observation mixes directions separated by most of a round.
DS-TWR adjacent pair round-trip2->3, 3->2, 2->4, 4->2, ...1 * slot12 * slot + gapPrevious practical hybrid: stable anchor geometry, slower tag updates.
Paper-style FlexTDOAone request, K responders in subslotswithin one frameN * slot + gapCurrent FlexTDOA runtime. Faster and matches the paper's CI-CR slot model.

Legacy hybrid static speed test, 2026-07-16:

The modules were in the temporary charging-room geometry, so this test should be read as a timing/robustness run, not as an absolute-location accuracy run. The tag was left static and the dashboard log stream was sampled after each profile change. This table belongs to the previous DS-TWR-based hybrid, not to the paper-aligned FlexTDOA runtime above. obs Hz counts passive tag observations, observed cycle is inferred from those observations for a 12-directed-slot four-anchor cycle, fresh pairs is the number of paired TDOA observations available to the solver at the end of the run, and rev p90 is the 90th percentile of abs(Ai->Aj + Aj->Ai).

ProfileslotcommandRESPFINALREPORTtimeoutrunfailfresh pairsobs Hzobserved cyclerev p90anchor std medRead
Stable baseline100 ms10 ms20 ms20 ms10 ms90 ms30 s05/68.061.49 s96.0 cm0.55 cmRobust, but slower and missed one fresh pair in this geometry.
Safe Fast preset60 ms5 ms15 ms15 ms5 ms35 ms30 s361/68.361.44 s32.2 cm0.46 cmToo short; mostly RESP wait failed.
Balanced preset50 ms5 ms10 ms10 ms5 ms25 ms30 s631/69.791.23 s35.2 cm0.83 cmFaster observations, but not robust.
Aggressive preset40 ms3 ms7 ms7 ms3 ms18 ms30 s1422/610.891.10 s45.2 cm0.54 cmBeyond the current reliable DS-TWR limit.
Reduced-delay 9090 ms5 ms15 ms15 ms5 ms80 ms60 s06/69.101.32 s64.9 cm0.68 cmBest fast candidate in this run.
Reduced-delay 100100 ms5 ms15 ms15 ms5 ms90 ms60 s06/68.871.35 s102.3 cm0.71 cmSafest current operational profile; similar speed to 90 ms with more slot margin.

The main conclusion from that older branch was that full DS-TWR slots were robust but expensive. That pushed the current branch toward the paper-style FlexTDOA slot above: one request, ordered response subslots, and a passive tag that does not need the full POLL/RESP/FINAL/REPORT/REPORT2 exchange.

FlexTDOA Robustness Improvements

Every localization packet carries the full 32-bit slot counter. The counter is part of the deterministic initiator/responder ordering used by every anchor. An earlier experiment carried only the low byte; after 256 slots that could make a recovering follower infer a different response order. Keeping all 32 bits is cheap and removes that long-run ambiguity.

The dashboard consumes tag observations coherently by slot ID instead of mixing fresh-looking rows from unrelated slots. Anchor ranges also carry the full 32-bit slot ID. The dashboard groups all pair ranges from one complete TDMA frame and performs one multi-range EKF update, matching the paper's anchor self-localization procedure.

The anchor EKF uses the paper's published covariance values:

text
anchor model variance      sigma_Q^2 = 1 cm^2
TWR measurement variance  sigma_R^2 = 10 cm^2

The dashboard treats the resulting covariance and the geometric fit as two different quantities. A small EKF covariance does not prove that the nonlinear solver selected the correct geometric minimum. The RMS error between the EKF coordinates and the current complete TWR frame is compared with three measurement standard deviations:

text
fit reference = 3 * sqrt(10 cm^2) = 9.49 cm RMS

This is a solver diagnostic, not a filter applied to the raw FlexTDOA or TWR observations. Positioning no longer waits for an operator to freeze geometry. The EKF keeps updating while the protocol runs. Three newly updated geometry batches above 25 cm RMS trigger an automatic geometric reinitialization, so a physically relocated anchor is acquired instead of being treated as a permanent residual. Reset Live Geometry Estimate remains available for a manual recovery and also clears any legacy fixed generation from the modules.

For the current planar setup, the paper's coordinate convention is adapted to 2D: the first selected anchor is fixed at (0,0), the second is on the positive Y axis, and the third has positive X. This removes the unobservable global translation, rotation, and mirror ambiguity while allowing every relative anchor coordinate to change continuously. Geometry sessions are kept separate per radio protocol so protocol-specific range bias is not mixed when the operator switches solvers. There is no median window or automatic freeze in this path.

Each new least-squares tag solve is seeded from the previous valid tag position, as described for AlgMin in the paper. This selects the same physical solution branch but does not average or smooth tag measurements.

The initiator also refreshes live anchor geometry from the request/response exchange in the same slot. This is the short TWR measurement described by the paper as anchor self-localization payload, not the older full DS-TWR POLL/RESP/FINAL/REPORT/REPORT2 sequence:

text
round = RESP_RX_initiator - REQ_TX_initiator
reply = RESP_TX_responder - REQ_RX_responder

tof = (round - reply_corrected) / 2

anchor_distance = c * tof

That distance is cached as an unordered anchor pair and emitted to the dashboard with its 32-bit slot ID. No rolling median is applied. The anchor EKF only updates when a complete coherent frame contains every selected pair.

Lab stability comparison, 2026-07-15:

This test compares anchor-anchor range stability only, not absolute placement accuracy. The modules were left in the current room/charging layout, so the important number is short-term spread, not whether the measured edge length is a known physical value. The FlexTDOA run used the short REQ/RESP anchor measurement used by the earlier clean FlexTDOA prototype. sample is the unfiltered corrected single-shot value from that short-anchor prototype. The median9 rows are historical offline/firmware comparisons only; the current FlexTDOA runtime no longer applies them. The DS-TWR run used APP_RUNTIME_MODE_UWB_ANCHOR_SURVEY, which performs the full POLL/RESP/FINAL/REPORT/REPORT2 exchange. This result explains why the legacy hybrid geometry looked so stable, while the current FlexTDOA runtime intentionally keeps the shorter paper-style request/response geometry path and shows raw behavior.

Protocol/valuePairnavg cmmedian cmmin cmmax cmstd cmspan cm
FlexTDOA sample raw2-3221547.3548.5486.1593.514.9107.4
FlexTDOA sample raw2-4236563.9563.9503.4668.717.1165.3
FlexTDOA sample raw2-5243862.6864.9747.2957.118.2209.9
FlexTDOA sample raw3-4257827.9829.2763.0881.014.5118.0
FlexTDOA sample raw3-5274733.4735.7650.0834.521.1184.5
FlexTDOA sample raw4-5275552.1552.5477.0664.621.1187.6
FlexTDOA median92-3221548.3548.5546.3552.20.85.9
FlexTDOA median92-4236566.0566.0562.2568.30.46.1
FlexTDOA median92-5243865.5865.5853.5873.41.019.9
FlexTDOA median93-4257829.4829.4827.8832.50.34.7
FlexTDOA median93-5274732.5732.5713.0745.12.732.1
FlexTDOA median94-5275551.2551.1528.5559.52.231.0
DS-TWR survey raw2-398549.4549.4547.5551.10.83.6
DS-TWR survey raw2-498564.2564.2560.3567.11.56.8
DS-TWR survey raw2-599867.5867.2862.9872.32.19.4
DS-TWR survey raw3-498830.7830.8829.1832.70.73.6
DS-TWR survey raw3-599737.1737.3732.8740.11.57.3
DS-TWR survey raw4-578551.6551.7548.1554.61.56.5
DS-TWR median92-398549.4549.4548.3550.40.42.1
DS-TWR median92-498564.2564.1562.3565.80.73.5
DS-TWR median92-599867.3867.4864.8868.80.94.0
DS-TWR median93-498830.7830.8830.0831.20.31.2
DS-TWR median93-599737.3737.4735.3738.70.53.4
DS-TWR median94-578551.7551.7550.4552.80.52.4

The conclusion is useful: the full DS-TWR anchor-anchor exchange is already stable raw, roughly 0.7-2.1 cm standard deviation in this run. The short FlexTDOA anchor measurement is much noisier raw, roughly 14.5-21.1 cm standard deviation. The current paper-aligned runtime uses the short FlexTDOA measurement because it matches the article and is much faster. The full DS-TWR geometry path remains a documented fallback if the lab later decides to trade paper purity for extra geometry stability. The uncorrected FlexTDOA raw field was much wider than sample in this run, so the per-frame clock-ratio correction is still required.

Legacy Hybrid Experiments

Before the paper-aligned FlexTDOA runtime, the firmware used a DS-TWR-based hybrid. The tag was still radio-passive, but each anchor pair ran a full DS-TWR slot and the tag derived two passive range-difference estimates from the frames it heard. This section is kept as lab history and comparison data; it is not the current FlexTDOA slot implementation.

The tag hears:

text
Ai POLL      ---- unicast/broadcast on air ----> tag RX timestamp R_poll
Aj RESP      ---- unicast/broadcast on air ----> tag RX timestamp R_resp
Ai FINAL     ---- unicast/broadcast on air ----> tag RX timestamp R_final
Aj REPORT2   ---- unicast/broadcast on air ----> tag receives anchor timestamps

REPORT2 carries the responder-side timestamps, the initiator timestamps received earlier in REPORT, and the current DS-TWR anchor-anchor distance. The tag combines those values with its own receive timestamps.

Primary estimate, from POLL -> RESP:

text
reply_aj     = RESP_TX_Aj - POLL_RX_Aj         // Aj clock
tof_ij       = distance(Ai, Aj) / c
rx_delta_tag = R_resp - R_poll                 // tag clock

range_diff_ij_primary = c * (rx_delta_tag - reply_aj_corrected - tof_ij)
                      = distance(tag, Aj) - distance(tag, Ai)

Alternate estimate, from RESP -> FINAL:

text
reply_ai      = FINAL_TX_Ai - RESP_RX_Ai        // Ai clock
rx_delta_tag  = R_final - R_resp                // tag clock

range_diff_ij_alt = c * (tof_ij + reply_ai_corrected - rx_delta_tag)
                  = distance(tag, Aj) - distance(tag, Ai)

If both estimates were present and agreed within the legacy 0.75 m guard, the firmware used a guarded dual-leg average:

text
blend = 0.5
diff  = primary + blend * (alt - primary)

So the normal fused result was the 50/50 average of the POLL -> RESP and RESP -> FINAL estimates. If the two estimates disagreed beyond the reject limit, the firmware fell back to the classic POLL -> RESP estimate and marked the row with suspect=1; the dashboard kept it visible while excluding it from the live least-squares fit when enough other observations were available.

The legacy firmware log line included both estimates:

text
UWB_FLEX_TDOA obs tag=<tag> initiator=<Ai> responder=<Aj> seq=<seq>
  diff=<m> m raw=<m> m anchor=<m> m primary=<m> m alt=<m> m
  agree=<m> m blend=<0..0.5> fused=<0|1> suspect=<0|1> ...

Initial dual-leg passive sanity test, 2026-07-15:

The test below used the same room setup that was active during development. It does not claim absolute tag accuracy, because the tag position was not measured with a tape. It only checks whether the passive solver is internally stable when the tag is left fixed.

MetricResult
Duration75 s
Passive observations673
Fused observations670 / 673 (99.6%)
Suspect observations3 / 673 (0.4%)
primary vs alt agreement, median13.0 cm
Paired reverse sum, median0.9 cm
Paired reverse sum, std3.9 cm
Reconstructed anchor edge std, median1.05 cm
Solved tag X std / span1.35 cm / 6.17 cm
Solved tag Y std / span0.97 cm / 5.49 cm
Solver residual RMS, median7.59 cm

This was a useful improvement over the earlier visibly wandering passive view: the DS-TWR anchor geometry remains stable, the reverse directed observations are near antisymmetric, and the extra RESP -> FINAL estimate gives a cheap multipath/consistency check without making the tag transmit. One outlier class still exists: individual primary vs alt agreement can spike hard, so the dashboard keeps suspect rows visible and excludes them from the live fit when there are enough healthy pairs.

Back-to-back classic vs dual-leg comparison, 2026-07-15:

This run was made to compare the old passive FlexTDOA observation against the dual-leg version as fairly as possible. The same anchor/tag layout was kept, the same dashboard parser/solver was used, and each firmware ran for 75 s:

MetricClassic FlexTDOA 78967acDual-leg FlexTDOA 56d0033Read
Passive observations617670Similar throughput
Fused observationsn/a665 / 670 (99.3%)Dual leg usually has both estimates
Suspect observationsn/a5 / 670 (0.7%)Few hard disagreements
primary vs alt agreement, mediann/a31.2 cmUseful as a quality signal
Paired reverse sum, median-30.75 cm0.8 cmDual-leg removes a strong directional bias
Paired reverse sum, std11.63 cm3.68 cmDual-leg is much more antisymmetric
Anchor edge std, median1.03 cm1.18 cmSame DS-TWR geometry path; effectively similar
Solved tag X std / span1.36 cm / 6.16 cm1.25 cm / 7.10 cmSimilar
Solved tag Y std / span1.03 cm / 5.31 cm0.99 cm / 4.94 cmSimilar
Solver residual RMS, median5.35 cm6.64 cmClassic was lower in this run

The practical verdict is not "dual-leg is better at every metric". It is more specific: dual-leg greatly improves pair symmetry (reverse_sum) and gives a new internal consistency check (agree, suspect) while preserving roughly the same fixed-tag position stability. The cost in this run was a slightly higher least-squares residual. For now the dual-leg path is worth keeping because it exposes bad/multipath-like observations directly instead of letting a directional bias hide inside the solver.

Guarded dual-leg validation, 2026-07-15:

After the comparison above, the firmware was changed to use a guarded 50/50 dual-leg average when primary and alt agree within 0.75 m, and to fall back to the classic primary estimate when they do not. The table below uses the same 715fee2 log capture and recomputes both variants offline, so the numbers compare math only, not different room layouts.

Metricprimary_classic from same logsguarded_dual_50 715fee2Read
Passive observations681681Same capture
Fused observationsn/a678 / 681 (99.6%)Almost all slots had both legs
Suspect observationsn/a3 / 681 (0.4%)Hard disagreements are rare
primary vs alt agreement, mediann/a21.8 cmQuality signal remains useful
Paired reverse sum, median-23.0 cm0.45 cmDual-leg removes the directional bias
Paired reverse sum, p90 abs35.9 cm9.21 cmWorst normal rows are much tighter
Solved tag X std / span1.72 cm / 9.98 cm1.60 cm / 9.57 cmSlightly better
Solved tag Y std / span1.21 cm / 8.52 cm1.04 cm / 5.98 cmBetter
Solver residual RMS, median14.91 cm7.77 cmRoughly half in this geometry

This was the practical hybrid before the paper-aligned rewrite: full DS-TWR was kept for anchor-anchor geometry, while the radio-passive tag used both DS-TWR legs as a consistency check and as a fused range-difference measurement.

The passive tag uses the instantaneous DW3000 carrier-integrator clock ratio from the received response frame to convert reply_aj from the responder clock domain into the tag clock domain. There is no running clock-ratio filter in the current FlexTDOA runtime. The log still includes both uncorrected raw and clock-corrected diff so the correction can be audited.

The dashboard Position tab reconstructs relative anchor geometry through the paper-style TWR-EKF phase. Raw pair ranges are grouped by TDMA frame and all available edges update the anchor state simultaneously. Tag positioning starts automatically once a complete live geometry exists and keeps following physical anchor movement.

The browser-side geometry estimator has a temporal range gate in front of the EKF. A one-shot pair jump is rejected and the last accepted edge is held for up to 15 seconds, so a missed maintenance range or a brief radio outlier cannot reshape the canvas or hide the live position. A physical relocation is accepted automatically only after at least three persistent samples on two or more changed edges that share the same anchor, and only when the proposed complete geometry passes a 12 cm RMS consistency check. Published coordinates are slew-limited to 5 cm per update. The geometry panel preserves a cumulative rejected-spike counter and the last rejected pair/delta, making sub-second failures inspectable after they disappear from the raw stream. It reports the raw and accepted distances side by side and uses median-absolute-deviation robust sigma rather than the outlier-sensitive standard deviation.

Native DS-TWR tag positioning additionally uses a robust 3-of-4 fit. A single bad tag-anchor range is omitted from the position and circle drawing, recorded in a persistent session counter, and shown as a skipped range in the measurement table. FlexTDOA and Passive DS-TWR tag observations remain raw for protocol analysis; the guard described above applies to their shared anchor-geometry stage.

Native DS-TWR now inserts one rotating anchor-pair maintenance exchange every four tag-position frames. With four anchors, all six geometry edges are refreshed over 24 position frames; this costs about one quarter of one slot per frame on average. Passive DS-TWR Fast Star keeps its reference for three frames and rotates the fourth frame through the remaining anchors, so it obtains a complete geometry without extra slots. Robust Rotating and FlexTDOA already observe the required anchor pairs in their normal schedules.

The Position Setup solver selector is intentionally protocol-level, so field tests can compare the same geometry and tag path without changing dashboard code:

SolverFirmware runtimeTag radio behaviorRange value used by dashboardBest use
DS-TWR rangesuwb_rangingtag actively responds to each anchorabsolute tag-anchor DS-TWR distanceBaseline comparison with direct ranges.
FlexTDOAuwb_flex_tdoatag only listenspaper-style request/response passive range differenceCurrent default. Matches the FlexTDOA paper slot model.
Passive DS-TWRuwb_passive_ds_twrtag only listenspassive difference from native anchor DS-TWRScalable passive tags with native three-packet anchor ranging.
Legacy hybrid logsuwb_flex_tdoa legacy capturestag only listensguarded dual-leg diff, fused from primary and alt when they agreeHistorical comparison only. Useful for replaying older captures.

The passive FlexTDOA path now has two consumers of the same raw measurements. The dashboard keeps its PC least-squares range-difference fit for inspection, while the tag ESP32-S3 also runs the planar AlgMin solver locally and publishes the resulting position through binary telemetry. Both use the latest fresh directed observations directly. Neither applies reverse-sum gates, residual outlier pruning, medians, dynamic weights, or Kalman prediction. This exposes the raw protocol behavior so radio changes can be evaluated without smoothing hiding the result. DS-TWR ranges can use the same measured anchor geometry for absolute tag-anchor ranges, but the blue distance circles only make sense for absolute ranges, not TDOA range differences.

Historical solver replay, 2026-07-15:

Earlier dashboard experiments compared static/dynamic median windows and an Auto-Kalman display mode. Those filters were useful for exploratory walking tests, but they were removed on 2026-07-17. Current FlexTDOA development uses raw observations only.

Walking-test note, 2026-07-16:

During a handheld walk with the tag, the fixed-point tail after the walk settled near x ~= 3.43 m, y ~= 3.11 m. The last stationary segment had all 6/6 paired observations fresh, reverse_sum usually below 11 cm, residual RMS around 4-10 cm, and a short-window position span around 6.5-7.6 cm. During motion, the raw least-squares solution still produced large jumps when only 3/6 or 4/6 usable observations remained, or when reverse_sum climbed above roughly 1 m. This supports two follow-up changes: stricter observation gating before the solver, and adjacent pair round-trip scheduling so paired directions are observed while the tag is closer to the same physical position.

Time Units

The DW3000 uses several time units:

UnitMeaning
msNormal milliseconds used by FreeRTOS and runtime config.
UUSUWB microseconds; 1 UUS = 512 / 499.2 MHz = 1.025641 us.
DTUDW3000 device time unit; about 15.650040064 ps.

Useful conversions:

ValueApproximate real time
500 UUS512.82 us, or 0.513 ms
20 ms1,277,952,000 DTU
10 ms638,976,000 DTU

UUS is used for DW3000 automatic TX-to-RX wait. Native ranging turnaround delays (for example 5 ms) are converted to DTU and programmed as delayed TX timestamps.

Message Sequence

One ranging attempt between tag 1 and one anchor looks like this:

text
Tag / initiator, module 1                         Anchor / responder, module N

T1: POLL TX  ------------------------------------>
                                                   T2: POLL RX

                                                   schedule RESP at:
                                                   T3 = T2 + ranging_resp_delay_ms

                                                   T3: RESP TX
     auto RX opens after 500 UUS  <---------------
T4: RESP RX

schedule FINAL at:
T5 = T4 + ranging_final_delay_ms

T5: FINAL TX  ----------------------------------->
                                                   T6: FINAL RX
     FINAL carries T1, T4, T5
                                                   calculate distance
                                                   log UWB_RANGING result

The critical RESP and FINAL instants are owned by the DW3000 radio through delayed TX. The firmware computes the target timestamp, writes DX_TIME, and issues DTX or DTX_W4R. That keeps the timing stable even if FreeRTOS has scheduler jitter.

Calibration Slot Timing

The three-module antenna-delay calibration uses deterministic slots so only one ordered pair is expected to talk at a time. For 1,2,3, one complete round is:

text
slot 0: 1 -> 2
slot 1: 1 -> 3
slot 2: 2 -> 1
slot 3: 2 -> 3
slot 4: 3 -> 1
slot 5: 3 -> 2
then wait calibration_round_gap_ms
round N + 1

The slot length is calibration_min_interval_ms. In the lab dashboard this is normally 100 ms. The round gap is calibration_max_interval_ms; the dashboard labels it Round gap ms and defaults it to 10 ms.

If the coordinator is also the initiator for the current pair, for example 1 -> 2, the slot looks like this:

text
t = 0 us
M1 coordinator/init     M2 responder          M3 listener
     CAL_SYNC  ------->      receive               receive
     start timer             start timer           start timer
          |                       |                     |
          |<------ guard 500 us ------>|                |

t = 500 us
     POLL  ------------>      RX POLL
                              schedule RESP delayed TX
                              RESP at +20 ms
     <------------- RESP

     schedule FINAL delayed TX
     FINAL at +20 ms
     FINAL ------------>
                              RX FINAL

     wait 10 ms
     REPORT ----------->
                              RX REPORT
                              calculate distance
     <------------ REPORT2
     verify anchor result

If the initiator is a follower, for example 2 -> 3, the coordinator assigns the slot with CAL_CMD:

text
t = 0 us
M1 coordinator          M2 initiator          M3 responder
     CAL_SYNC  ------->      receive               receive
     start timer             start timer           start timer

t = 500 us
     CAL_CMD ---------> M2
     "this slot is 2 -> 3"

M2 waits APP_UWB_CALIBRATION_COMMAND_DELAY_MS
currently 20 ms

     POLL -------------------------------> M3
                                            RESP delayed +20 ms
     <------------------------------- RESP

     FINAL delayed +20 ms ---------------->
     REPORT after 10 ms ------------------>
                                            calculate distance
     <------------------------------ REPORT2

t = slot end, normally 100 ms
all modules exit the slot and reset the calibration timer

CAL_SYNC is sent before every slot. The coordinator starts a dedicated 1 MHz ESP GPTimer when the SYNC transmission completes. Followers arm the same timer before RX and start it from the DW3000 IRQ when the SYNC frame arrives. The first cal_guard_us microseconds of the slot are a guard window. The firmware default is APP_UWB_CALIBRATION_SLOT_GUARD_US = 500 us, and the dashboard can change it at runtime without OTA.

The guard and slot-end waits are implemented with a GPTimer alarm and task notification. This preserves microsecond-level slot alignment without spinning the UWB task in a CPU busy-wait.

This ESP-side timer does not enter the distance formula. It only aligns the slot windows so the lab workflow is deterministic. Distance is still calculated from DW3000 hardware timestamps with DTU resolution.

SYNC is intentionally not retried inside the same slot. If a follower misses SYNC, if the SYNC sequence number jumps, or if the coordinator cannot transmit SYNC, firmware logs:

text
UWB CAL slot skipped due to sync fail

Dashboard-driven auto calibration treats any sync miss during the collection window as invalid and blocks antenna-delay writes.

Timestamp Ownership

The easiest way to reason about DS-TWR is to track who knows each timestamp. Every timestamp below is a hardware timestamp captured or scheduled by DW3000.

After POLL:

SideWhat it knows
Initiator/tagT1, timestamp when it transmitted POLL.
Responder/anchorT2, timestamp when it received POLL.

T1 is in the tag clock domain. T2 is in the anchor clock domain. The two absolute values cannot be compared directly.

After RESP:

text
T3 = T2 + dt_resp_delay_ms
SideWhat it knows
Initiator/tagT1, T4
Responder/anchorT2, T3

The tag can compute:

text
round_a = T4 - T1

The anchor can compute:

text
reply_b = T3 - T2

The tag still cannot calculate a correct distance from only those two values, because reply_b was measured in the anchor clock domain.

After FINAL:

text
T5 = T4 + dt_final_delay_ms
SideWhat it knows
Initiator/tagT1, T4, T5
Responder/anchorT2, T3, T6

The tag-side intervals are:

text
round_a = T4 - T1
reply_a = T5 - T4

The anchor-side intervals are:

text
reply_b = T3 - T2
round_b = T6 - T3

At this point all pieces needed for DS-TWR exist, but they are split between the two modules. Native ranging places T1, T4, and the precomputed delayed TX timestamp T5 directly in FINAL. The anchor already owns T2, T3, and T6, so it can calculate distance immediately after receiving the third frame. No fourth UWB message is required.

Distance Formula

The uncorrected DS-TWR calculation uses these intervals:

text
round_a = T4 - T1   tag sees POLL TX -> RESP RX
reply_a = T5 - T4   tag waits RESP RX -> FINAL TX

reply_b = T3 - T2   anchor waits POLL RX -> RESP TX
round_b = T6 - T3   anchor sees RESP TX -> FINAL RX

Then:

text
tof_dtu = (round_a * round_b - reply_a * reply_b)
          / (round_a + round_b + reply_a + reply_b)

distance_m = tof_dtu * 15.650040064 ps * 299702547 m/s

In the ideal model, tof is the real one-way flight time. If both sides shared one perfect clock:

text
round_a = 2 * tof + reply_b
round_b = 2 * tof + reply_a

The DS-TWR expression is chosen so the internal reply delays cancel out:

text
round_a * round_b - reply_a * reply_b

Substitute the ideal expressions:

text
(2 * tof + reply_b) * (2 * tof + reply_a) - reply_a * reply_b

Expand and cancel the reply_a * reply_b terms:

text
4 * tof^2 + 2 * tof * reply_a + 2 * tof * reply_b

Factor:

text
2 * tof * (2 * tof + reply_a + reply_b)

The denominator is:

text
round_a + round_b + reply_a + reply_b

Substitute again:

text
(2 * tof + reply_b) + (2 * tof + reply_a) + reply_a + reply_b

which factors to:

text
2 * (2 * tof + reply_a + reply_b)

The full fraction therefore simplifies to tof:

text
2 * tof * (2 * tof + reply_a + reply_b)
----------------------------------------
2       * (2 * tof + reply_a + reply_b)

This is why the formula has this shape:

text
tof = (round_a * round_b - reply_a * reply_b)
      / (round_a + round_b + reply_a + reply_b)

A simpler expression such as this is tempting:

text
tof = (round_a - reply_b) / 2

but round_a is measured by the tag clock while reply_b is measured by the anchor clock. Those clocks are close, but not identical. The DS-TWR formula uses both perspectives symmetrically and reduces clock mismatch error. With APP_UWB_DISTANCE_TEST_CLOCK_OFFSET_CORRECTION = 1, the firmware also uses the DW3000 clock-offset measurement from the received frame before converting time of flight to meters.

TX-to-RX and RX-to-TX Timing

The two most common timing questions are:

QuestionCurrent answer
How long after TX until RX opens?500 UUS, about 0.513 ms
How long after RX until the next native ranging TX?5 ms for RESP, 5 ms for FINAL by default

The TX-to-RX delay is a DW3000 auto-receive setting. It applies after:

text
POLL TX  -> tag opens RX for RESP
RESP TX  -> anchor opens RX for FINAL

The five-frame distance-test and calibration workflows also open RX after REPORT while waiting for their diagnostic REPORT2.

The RX-to-TX delays are delayed-TX timestamps. They apply after:

text
POLL RX  -> anchor schedules RESP TX at +5 ms
RESP RX  -> tag schedules FINAL TX at +5 ms

So the system is not trying to turn around instantly. The radio has about half a millisecond before RX opens after a TX, and the default native profile leaves about 5 ms between receiving one DS-TWR frame and transmitting the next scheduled frame. Distance-test and calibration retain their independent, longer timing defaults.

Antenna Delay

Antenna delay is separate from protocol delays. It is not a sleep, slot, or turnaround time. It is a calibration value written to the DW3000 RX and TX antenna-delay registers during radio init.

The firmware reads the active antenna delay from persistent identity NVS. If no calibrated value exists, it falls back to APP_UWB_ANTENNA_DELAY_DEFAULT.

Wrong antenna delay shifts absolute distance even if packet success is perfect. Use /status to inspect the live values:

text
uwb_active_antenna_delay
uwb_active_antenna_delay_hex
uwb_antenna_delay_from_nvs

Common Log Patterns

Log patternMeaning
DS-TWR RESP wait failedInitiator sent POLL but did not receive matching RESP before timeout.
DS-TWR FINAL wait failedResponder sent RESP but did not receive FINAL before timeout.
DS-TWR REPORT wait failedResponder received FINAL but did not receive the timestamp report.
DS-TWR REPORT2 wait failedResponder calculated distance, but initiator did not receive verification report.
UWB distance RX errorDW3000 reported PHY/RX error instead of a valid frame.
UWB_RANGING resultNative four-packet exchange completed; the tag received the anchor's one-shot RESULT distance.
DS-TWR tag verifyInitiator recalculated the same exchange and compared against anchor result.
UWB CAL slot skipped due to sync failCalibration slot synchronization failed; dashboard marks the run invalid.

Useful code entry points:

AreaFile / function
Runtime mode selectioncomponents/app_manager/app_manager.c
Runtime config defaults/NVScomponents/config/app_runtime_config.c
Ranging service entry pointcomponents/uwb_ranging_service/uwb_ranging_service.c
Tag/anchor loopscomponents/uwb_dw3000/uwb_dw3000.c
Native ranging initiatoruwb_ranging_native_initiate_once()
Native ranging responderuwb_ranging_native_respond_to_poll()
Five-frame diagnostic initiatoruwb_distance_initiate_once()
Five-frame diagnostic responderuwb_distance_respond_to_poll()
Delayed TX helpersuwb_dw3000_send_payload_delayed*()
Antenna-delay calibrationcomponents/uwb_dw3000/uwb_dw3000_calibration.inc

Identity and Wi-Fi

Persistent Module Identity

The board identity is stored in NVS, which plays the role of persistent EEPROM storage on ESP32. Normal firmware reads module_id from NVS and builds the hostname from APP_IDENTITY_HOSTNAME_PREFIX, for example uwb-module-2. To change a board identity or UWB role, set these values in components/config/include/app_config.h for one provisioning boot:

c
#define APP_IDENTITY_PROVISION_ENABLED 1
#define APP_IDENTITY_PROVISION_MODULE_ID 2
#define APP_IDENTITY_PROVISION_UWB_ROLE_ENABLED 1
#define APP_IDENTITY_PROVISION_UWB_ROLE APP_UWB_ROLE_ANCHOR

For a mobile tag, use APP_UWB_ROLE_TAG; for fixed points, use APP_UWB_ROLE_ANCHOR. After that boot, set the provisioning flags back to 0 and flash the common firmware again. /status exposes hostname, module_id, module_id_from_nvs, module_id_provisioned_this_boot, uwb_role_name, uwb_role_from_nvs, and uwb_role_provisioned_this_boot.

Wi-Fi Selection and Reconnect

The Wi-Fi service scans before connecting and logs each matching AP with BSSID, channel, RSSI, auth mode, and cipher. If a network broadcasts the same SSID from multiple radios and one behaves better, lock the board to that AP in components/config/include/app_config.h:

c
#define APP_WIFI_LOCK_BSSID 1
#define APP_WIFI_BSSID "aa:bb:cc:dd:ee:ff"
#define APP_WIFI_LOCK_CHANNEL 6

The firmware also keeps reconnecting after disconnects and exposes the last disconnect reason plus scan/connected AP details in /status.

UWB Bring-up and Calibration

UWB bring-up follows the same first-step pattern used by the Zephyr DW3000 decadriver reference: initialize hardware, reset the chip, then read DEV_ID. The valid IDs currently accepted are 0xDECA0302 and 0xDECA0312.

After bring-up, the firmware runs a simple same-image UWB smoke test. Each board sends a UWBT beacon at a random interval and spends the rest of the time in RX. With the same firmware on two boards, wireless logs should show UWB TX beacon ... and UWB RX beacon ... lines. The HTTP /status response also exposes uwb_tx_count, uwb_rx_count, and the last received source/seq.

In APP_RUNTIME_MODE_UWB_DISTANCE_TEST, the same firmware assigns roles from the source ID: source 1 is the initiator and source 2 is the responder by default. The UWB source ID is derived from the persistent module ID unless APP_UWB_SOURCE_ID is explicitly overridden. The initiator sends POLL, receives RESP, schedules FINAL, then sends a REPORT with its hardware timestamps. The responder schedules RESP, receives FINAL and REPORT, calculates DS-TWR distance, and logs lines like DS-TWR distance seq=... distance=....

RESP and FINAL use DW3000 delayed TX by default (APP_UWB_DISTANCE_TEST_USE_DELAYED_TX). The delay windows are configured with APP_UWB_DISTANCE_TEST_RESP_DELAY_MS and APP_UWB_DISTANCE_TEST_FINAL_DELAY_MS; the firmware converts them to DW3000 device time units and programs DX_TIME, so the critical transmit instant is owned by the radio instead of the FreeRTOS scheduler.

APP_RUNTIME_MODE_UWB_ANTENNA_DELAY_CALIBRATION reuses the same DS-TWR exchange and supports two workflows selected in components/config/include/uwb_config.h:

c
#define APP_UWB_CALIBRATION_METHOD APP_UWB_CALIBRATION_METHOD_TWO_MODULE
#define APP_UWB_CALIBRATION_KNOWN_DISTANCE_MM 2000
#define APP_UWB_CALIBRATION_REFERENCE_ID 1
#define APP_UWB_CALIBRATION_DUT_ID 2

For the two-module method, the reference module initiates and the DUT responds. Each measurement slot starts with a short UWB CAL_SYNC frame from the reference. The reference starts a dedicated 1 MHz ESP GPTimer when that SYNC transmission completes; the DUT arms the same timer before RX and starts it from the DW3000 IRQ when the SYNC frame arrives. Both sides then wait the fixed cal_guard_us guard window before POLL/RESP/FINAL begins. The firmware default comes from APP_UWB_CALIBRATION_SLOT_GUARD_US, currently 500 us. The DUT logs sample distance, mean, standard deviation, error against the known distance, and a first-pass suggested antenna delay. Swap REFERENCE_ID and DUT_ID to calibrate the other board.

c
#define APP_UWB_CALIBRATION_METHOD APP_UWB_CALIBRATION_METHOD_THREE_MODULE
#define APP_UWB_CALIBRATION_THREE_ID_0 1
#define APP_UWB_CALIBRATION_THREE_ID_1 2
#define APP_UWB_CALIBRATION_THREE_ID_2 3
#define APP_UWB_CALIBRATION_THREE_DISTANCE_0_1_MM 2000
#define APP_UWB_CALIBRATION_THREE_DISTANCE_0_2_MM 2000
#define APP_UWB_CALIBRATION_THREE_DISTANCE_1_2_MM 2000

For the three-module method, the first ID in the configured set is the calibration coordinator. It walks a deterministic six-slot schedule: id0->id1, id0->id2, id1->id0, id1->id2, id2->id0, and id2->id1. Each slot is APP_UWB_CALIBRATION_MIN_INTERVAL_MS long; in the lab this is 100 ms, which should fit the complete DS-TWR exchange when dt_rx_timeout_ms is kept short enough for the experiment. Before every slot, the coordinator broadcasts a short CAL_SYNC frame and all participants restart the dedicated 1 MHz calibration timer. The first cal_guard_us microseconds of the slot are a guard window (500 us by default), then the assigned ordered pair performs DS-TWR. Because SYNC is repeated before every slot, ESP timer drift can only accumulate inside one slot. The firmware still attempts the full POLL, RESP, FINAL, REPORT, and REPORT2 sequence, so the logs show if the exchange does not fit.

The coordinator waits APP_UWB_CALIBRATION_SYNC_PREPARE_MS before each SYNC frame (20 ms by default). This is not part of the measured slot; it simply gives the followers time to finish the previous slot, clear DW3000 state, and re-arm RX before the next SYNC is transmitted.

SYNC is intentionally not retried inside the same slot. Followers wait long enough to cover the normal round gap, then use the CAL_SYNC sequence number to detect skipped slots. If a sequence gap is detected, if the long SYNC wait expires, or if the coordinator/reference cannot transmit SYNC, firmware logs UWB CAL slot skipped due to sync fail and discards that slot. The same log line includes aggregate sync health counters (sync_ok, sync_timeout, sync_invalid, sync_rx_error, sync_tx_fail, sync_sequence_gap, and slot_skipped). During dashboard-driven auto calibration, any such sync miss in the collection window marks the run invalid and blocks antenna-delay writes, even if enough diagnostic samples are later collected.

After all six directed pairs are measured, the firmware waits APP_UWB_CALIBRATION_MAX_INTERVAL_MS before the next round. The firmware default is 10 ms; increase it only if short-gap testing shows sync misses or radio cleanup problems between rounds. The dashboard labels these as Slot ms and Round gap ms.

Calibration timing controls in the dashboard:

ControlMeaning
Summary everyLog diagnostic calibration statistics every N accepted samples. It does not change the measurement sequence.
Slot msFixed time budget for one directed pair, for example M4 -> M2.
Guard usQuiet time at the beginning of each synchronized slot before the assigned pair starts talking.
DS-TWR timeout msMaximum wait for expected ranging frames inside the DS-TWR exchange. For the current short-slot test it is intentionally 100 ms, the same as the slot width.
Round gap msDelay after all six directed pairs finish, before the next calibration round starts.
RX slice msShort listen window used by passive calibration nodes while they monitor the rest of the slot. Smaller slices wake more often; larger slices reduce loop churn.

The logs contain directed pair statistics such as 1->2, 2->1, 1->3, etc. The dashboard keeps these directions separate and solves the antenna-delay corrections by minimizing the full directed EDM residual, matching the spirit of Qorvo APS014: collect all nChips * (nChips - 1) TWR measurements, compare them against the measured physical EDM, and choose the delays that minimize the matrix error. The three distance macros can all be equal for an equilateral setup or can hold the measured distance of each triangle edge. Use the final radio configuration and a known distance such as 2-5 m; 20 cm is useful for smoke testing, not for calibration.

The dashboard uses a median center for calibration samples. The default collection count is 39 samples per directed pair, so the center sample is unambiguous after sorting. Mean, standard deviation, min, and max are still reported as diagnostics, but antenna-delay corrections are based on medians to make an occasional outlier less likely to move the result. The dashboard uses exactly that many samples per directed pair for the solve: once a direction reaches the requested count, later samples for that direction are ignored so all six directed edges have equal weight.

For 39 samples over all six directed pairs, use at least a 240 s dashboard auto-calibration timeout. A 180 s timeout is close enough to the real run time that a clean calibration can finish one or two directed samples short.

When dashboard auto calibration finishes, it stops UWB on the calibration participants by writing uwb=0 and rebooting them. Antenna-delay writes made by the same job are staged in NVS first, so this final reboot also applies the new delay while leaving the DW3000 held in reset. If setup fails before collection starts, the dashboard sends the same UWB stop command to all configured modules so a partial calibration start cannot leave a transmitter running. The dashboard Cancel Calibration button is an emergency stop: it marks the running job as cancelled when one exists and always sends uwb=0 plus reboot to all configured modules. The Settings tab keeps the last auto-calibration result in a table next to the calibration controls, including summary, antenna-delay writes, pair errors, directed samples, fit residuals, and UWB stop/cleanup status.

Lab calibration note, 2026-07-11:

  • Modules 1, 2, and 3 were placed in printed alignment fixtures as a 2.00 m equilateral triangle.
  • Starting delays were M1=0x3fd6, M2=0x4019, M3=0x3fdc.
  • The first pass showed symmetric pair errors of about 1-2=-0.05 dtu, 1-3=+44.65 dtu, and 2-3=-23.90 dtu.
  • Solving the three pair equations produced corrections of M1=+34 dtu, M2=-34 dtu, and M3=+10 dtu.
  • The delays stored in NVS are now M1=0x3ff8, M2=0x3ff7, M3=0x3fe6.
  • Verification after reboot measured 1-2=2.001 m, 1-3=1.996 m, and 2-3=2.000 m, with per-link standard deviation around 1.0-1.4 cm.

For additional modules, keep two already calibrated modules fixed as references and place the uncalibrated module in the third fixture position. Run the three-module calibration with all three sides set to the measured triangle edges, but apply only the correction for the new module. If K1 and K2 are known-good references and U is the new module, the dashboard fits the new-module correction from all directed errors that include U, while using the reference-only directions as a guard. Intuitively this is close to:

text
err_K1_U = center(err_dtu K1->U, err_dtu U->K1)
err_K2_U = center(err_dtu K2->U, err_dtu U->K2)
correction_U = round(center(err_K1_U, err_K2_U))
new_delay_U = old_delay_U + correction_U

The reference-reference pair should remain close to zero. If it does not, check the physical placement or RF stability before writing a new delay.

The local dashboard can automate this workflow from Settings -> Antenna Delay Calibration with Auto Calibrate + Apply. The button:

  1. writes the runtime calibration setup and reboots the selected modules,
  2. collects the requested number of UWB CAL sample log entries for each required directed pair,
  3. takes the median distance for each directed pair, computes the directed EDM fit and least-squares antenna-delay corrections, including residuals that show how well one delay per module explains the measurements, and
  4. writes the corrected antenna delay values to NVS for the selected target modules.

For a new module, keep two calibrated references in the fixtures, select only the new module in Targets, and use the three-module set that includes both references. For example, select module 4 and run 1,2,4 to adjust only M4. The dashboard configures all three calibration participants automatically and holds excluded live UWB modules in reset while the calibration runs. Min apply DTU prevents rewriting NVS for tiny noise-level corrections; the lab default is 2. Reference guard cm protects the calibrated references: when a reference-only edge, such as M1-M2 while calibrating M4, exceeds this error threshold, the dashboard refuses NVS writes even when Auto apply is enabled. The lab default is 2.00 cm.

APP_RUNTIME_MODE_UWB_RANGING is the current absolute-range 1-tag/4-anchor runtime. APP_RUNTIME_MODE_UWB_FLEX_TDOA is the experimental radio-passive tag runtime that uses FlexTDOA request/response slots and solves from range-difference observations in the dashboard. The same firmware image runs on all modules; the runtime config selects tag ID and anchor IDs, the first anchor ID acts as slot coordinator, and persistent NVS identity keeps each module's hostname, module ID, UWB role, and calibrated antenna delay.

If APP_UWB_DW_LEDS_ENABLED is set, the DW3000 configures GPIO0 as RXOKLED, GPIO1 as SFDLED, GPIO2 as RXLED, and GPIO3 as TXLED once during radio init. The chip then drives these LED indications in hardware, so the firmware does not add SPI traffic for LED toggling. Each function can be disabled separately with APP_UWB_DW_RXOK_LED_ENABLED, APP_UWB_DW_SFD_LED_ENABLED, APP_UWB_DW_RX_LED_ENABLED, or APP_UWB_DW_TX_LED_ENABLED.

The configured antenna delay is applied to both DW3000 RX and TX antenna delay registers during radio init.

BNO085 IMU

The BNO085 is a live runtime service, not only a factory test. It uses address 0x4A at 400 kHz on the shared I2C bus, GPIO40 for reset, and the active-low level interrupt on GPIO15. Firmware enables calibrated acceleration report 0x01 at the selected interval and GyroRV report 0x2A at a fixed 50 Hz. The compile-time defaults in components/config/include/app_config.h are:

c
#define APP_BNO085_ACCEL_TEST_ENABLED 0
#define APP_BNO085_I2C_ADDRESS 0x4A
#define APP_BNO085_I2C_CLOCK_HZ 400000
#define APP_BNO085_ACCEL_INTERVAL_MS 2
#define APP_BNO085_LOG_INTERVAL_MS 1000
#define APP_BNO085_INT_WAIT_TIMEOUT_MS 250

APP_BNO085_ACCEL_TEST_ENABLED supplies the default runtime-enabled state and remains 0, so normal images boot with the IMU held in reset. It can be enabled live and persisted through the authenticated runtime endpoint. When enabled, the task runs on core 0, resets the sensor, initializes SHTP/SH2, then drains pending packets from the GPIO interrupt. The GPIO is deliberately configured as active-level, not edge-only: on I2C the BNO08X deasserts H_INTN when its I2C address is recognized, so the ISR masks the GPIO interrupt and the task rearms it after draining the packet. The timeout is a missed-interrupt fallback.

The service has bounded startup retries, soft/hard recovery, and a stall reset when reports stop for five seconds. Samples are kept in bounded local rings (using PSRAM where configured) before they are exported; /status exposes report, packet, IRQ, timeout, read/parse error, ring-overwrite, telemetry, recovery, and current-value counters.

BNO085 Runtime Commands

All BNO085 controls use POST /config/runtime and require X-OTA-Token. Changes are saved in NVS and applied immediately by starting, stopping, or reconfiguring the service; no ESP32 reboot is required.

ParameterAccepted valueEffect
bno085Runtime booleanEnables or disables the service. bno085_accel is an alias.
bno085_sample_hzInteger 1..500 HzConverts the requested frequency to the nearest whole-millisecond interval, clamped to at least 2 ms. This is the dashboard control.
bno085_sample_msInteger 2..60000 msSets the accelerometer interval directly. Alias for the interval field.
bno085_accel_interval_msInteger 2..60000 msSets the accelerometer interval directly.
bno085_log_interval_msInteger 2..60000 msSets the human-readable summary-log interval; it does not change the binary sample rate.

Send only one of the three sample-rate forms in a request. If both bno085_sample_hz and bno085_sample_ms are present, handler order makes the millisecond value win, which is valid but unnecessarily ambiguous.

sh
# Enable the validated maximum-rate profile.
curl -X POST -H "X-OTA-Token: <APP_OTA_PASSWORD>" \
  "http://<module-ip>/config/runtime?bno085=1&bno085_sample_hz=500"

# Reduce acceleration to 100 Hz while keeping 1 Hz summary logs.
curl -X POST -H "X-OTA-Token: <APP_OTA_PASSWORD>" \
  "http://<module-ip>/config/runtime?bno085=1&bno085_sample_ms=10&bno085_log_interval_ms=1000"

# Stop the service and hold the sensor in reset.
curl -X POST -H "X-OTA-Token: <APP_OTA_PASSWORD>" \
  "http://<module-ip>/config/runtime?bno085=0"

Runtime enable/disable is applied live through /config/runtime and the dashboard Settings tab. If BNO085 is disabled, firmware does not keep an accelerometer task polling a variable; it holds GPIO40 reset active. When BNO085 is enabled, the runtime endpoint starts the task directly. While the task is running, GPIO15 interrupts, rate changes, and stop requests all wake it with FreeRTOS task notifications (INT, CONFIG, STOP).

The dashboard Graphs tab plots accelerometer samples as soon as their wireless telemetry arrives. Its Timebase control only changes the visible time window, oscilloscope-style. Samples/s is the actual BNO085/report export rate; the dashboard applies it as bno085_sample_hz, which updates the runtime accelerometer interval. The running BNO085 task picks up rate changes live by sending a new Set Feature command, so no reboot is needed. The BNO08X datasheet lists Accelerometer at a maximum configurable rate of 500 Hz, although I2C bandwidth and wireless throughput still need to be considered in practice.

Shared I2C Arbitration

When BNO085 is enabled, the shared I2C service treats its configured sample period as a realtime reservation. BNO085 packet reads and Set Feature writes take the realtime lock. BQ25792 and MAX77958 use the background lock, which will not start a new transaction when BNO085 is waiting or when the next expected BNO sample is within APP_I2C_BACKGROUND_GUARD_US (500 us by default). If BNO stops producing interrupts for several sample periods, the reservation is considered stale so charger/PD status can still be read and recovery logic can run. Background clients may wait for a free window, but each low-level BQ/MAX I2C transaction has a short timeout (10 ms) so a stuck background transfer cannot hold the bus for dozens or hundreds of milliseconds. The one-time startup probe keeps a longer timeout (200 ms) because some devices need more slack before they acknowledge reliably; it is not used for the regular status-transfer path. Normal BQ/MAX reads retry twice before reporting an error. BQ register writes are retried because they are single-register, idempotent updates. MAX77958 single-register writes are retried too. Longer MAX77958 AP command writes are attempted through the direct high-speed path in bounded chunks and then fall back to the standard 1 MHz transaction if the HS path fails, so a configuration command still has one clean recovery route.

The current board uses mixed I2C speeds on the same physical bus. BNO085 stays at 400 kHz, because the BNO08X datasheet only specifies standard/fast mode up to 400 kHz. BQ25792 is configured at 1 MHz. MAX77958 keeps a normal 1 MHz ESP-IDF device handle for fallback and startup, then enables HS_EXT_EN and uses a local direct-HS helper at about 2 MHz for validated MAX register traffic. ESP-IDF keeps scl_speed_hz in each i2c_device_config_t, so the firmware does not reinitialize the bus between ordinary device transactions; the MAX direct-HS helper temporarily programs the I2C peripheral only while it holds the shared background lock. BQ25792 has one descriptive I2C section that mentions fast mode, but its electrical table specifies fSCL = 1000 kHz; the 1 MHz setting was therefore validated empirically on the module.

The MAX77958 high-speed path is a measured ESP32-S3 board result, not a claim that ESP-IDF exposes a turnkey high-speed switch for this target. The firmware enters an HS-master-style transfer by sending the I2C HS master code (0x08) at the entry clock, then reprogramming the peripheral timing for the direct transaction. On M1 with 1k pull-ups, the validated timing profile is low/high/wait = 10/0/0, which produced clean MAX77958 direct-HS traffic at about 2 MHz. That gives us a practical HS master mode for this board while keeping the normal ESP-IDF 1 MHz handle as a safe fallback path.

At the maximum BNO085 accelerometer rate, the sample period is 2 ms. A normal accelerometer input report is small: the firmware reads the 4-byte SHTP header and then the 14-byte SHTP accelerometer packet, so the I2C wire time is on the order of 0.5-0.8 ms at 400 kHz after protocol overhead. Long status maps are read in adaptive chunks: at 500 Hz the chunk size shrinks to fit the tight window, while at lower BNO sample rates the same code uses larger chunks and refreshes BQ/MAX faster.

Worst-case planning at BNO085 500 Hz:

TransferWire estimate
BNO header + normal accel packet at 400 kHzabout 0.8 ms
BQ25792 16-byte read chunk at 1 MHzabout 0.27 ms
BQ25792 single-register write at 1 MHzabout 0.13 ms
MAX77958 single-byte direct HS read at about 2 MHzabout 0.04-0.05 ms measured on M1
MAX77958 AP-command write, direct HS 30 + 3 bytesabout 0.20 ms wire time plus two HS entries
MAX77958 33-byte AP response read at 1 MHzabout 0.42 ms
MAX77958 fallback 34-byte AP-command write at 1 MHzabout 0.42 ms

With a 2 ms BNO period and 500 us guard, a normal BNO packet leaves roughly 700 us for background work. That fits one MAX77958 full AP-data chunk or one BQ25792 chunk. If BNO coalesces a larger input packet, the adaptive chunk logic shrinks BQ/MAX transfers rather than delaying the next BNO read. The schematic currently shows 10k I2C pull-ups, but the live module tested cleanly at 1 MHz; if a board revision is unstable, verify the actual pull-up values and reduce them for Fast-Mode Plus before blaming firmware scheduling.

Validated M1 coexistence test:

ConditionResult
Firmware / board3646a76 on ESP32-S3 M1, 1k I2C pull-ups
Duration180.96 s
BNO085 accelerometer500 Hz requested, 91,614 reports, 506.3 samples/s observed
BNO085 errors0 read errors, 0 parse errors
BQ25792 load180 full refreshes, about 1 Hz, 0 errors
MAX77958 load180 refreshes, about 1 Hz, 0 public read errors, 0 operation errors
MAX77958 direct-HS recovery2 direct-HS attempts fell back cleanly; the higher-level MAX operation still completed
Control-path errors0 HTTP refresh errors, 0 /status errors

That run proves the shared bus can carry the BNO085 at its maximum accelerometer rate while still fitting charger and USB-C/PD refreshes at about 1 Hz. The internal BQ25792 and MAX77958 monitor defaults are therefore set to APP_BQ25792_READ_INTERVAL_MS = 1000 and APP_MAX77958_READ_INTERVAL_MS = 1000.

Validated five-module coexistence test after fitting 1k I2C pull-ups to every module:

ModuleBNO085 reports / rateBNO read / parse errorsBQ refresh rate / errorsMAX refresh rate / errorsDirect-HS fallbacksTelemetry drops / reboot
M190,764 / 507.2 Hz0 / 00.89 Hz / 00.72 Hz / 000 / no
M291,458 / 511.1 Hz0 / 00.93 Hz / 00.79 Hz / 000 / no
M392,391 / 516.3 Hz0 / 00.83 Hz / 00.63 Hz / 010 / no
M490,767 / 507.2 Hz0 / 00.91 Hz / 00.73 Hz / 000 / no
M591,501 / 511.4 Hz0 / 00.88 Hz / 00.72 Hz / 010 / no

The run used firmware 4ffed0e for 178.94 s, with BNO085 at 400 kHz, BQ25792 at 1 MHz, MAX77958 direct HS at about 2 MHz, and wireless binary telemetry connected. The two direct-HS failures represent about 0.01% of the corresponding M3/M5 direct reads; both fell back cleanly, while MAX public-read and operation error counters remained at zero. BQ25792 writes were also tested concurrently by changing IINDPM from 480 mA to 490 mA, confirming the readback, and restoring 480 mA on every module. MAX77958 AP commands completed on every module with no operation errors while BNO085 continued producing reports.

The BQ/MAX monitor timeout is configured as 1 s after each completed refresh, so the effective completion rate is lower than exactly 1 Hz when background transactions repeatedly yield to the 500 Hz BNO reservation. In this test it was 0.83-0.93 Hz for BQ and 0.63-0.79 Hz for MAX; recent status remained available while the accelerometer path retained zero read, parse, and telemetry losses.

Post-merge validation repeated the same workload with the resource monitor and the rest of main active. The final efd5fc6 run lasted 180.6 s:

ModuleBNO085 reports / rateBNO errorsBQ refresh rate / errorsMAX refresh rate / errorsDirect-HS fallbacksTelemetry drops / reboot
M191,378 / 506.0 Hz0 / 00.94 Hz / 00.81 Hz / 000 / no
M291,502 / 506.7 Hz0 / 00.92 Hz / 00.80 Hz / 000 / no
M392,999 / 514.9 Hz0 / 00.91 Hz / 00.78 Hz / 010 / no
M491,355 / 505.8 Hz0 / 00.93 Hz / 00.81 Hz / 000 / no
M591,742 / 508.0 Hz0 / 00.93 Hz / 00.80 Hz / 010 / no

With BNO still at 500 Hz, every BQ25792 accepted an IINDPM change from 480 mA to 490 mA, confirmed it by readback, and restored 480 mA. Confirmation took 0.22-0.33 s; the slowest restore was 0.86 s. Every MAX77958 also completed a sink-PDO AP read with ESP_OK, including eight direct-HS writes per module and no new HS or operation errors.

The first merged run exposed a separate resource-monitor defect rather than an I2C failure. Generic per-task CPU sampling suspended the FreeRTOS scheduler to walk internal task lists once per second; under the 500 Hz BNO workload it eventually starved an idle task and triggered the task watchdog. The iterator also returned duplicate bno085 entries, so its breakdown was not trustworthy. That global task enumeration is now disabled. RAM, PSRAM, flash, temperature, and per-core CPU meters remain active because they use bounded queries and the FreeRTOS idle runtime counters. /status reports resource_task_load_valid=false instead of risking real-time sensor traffic for a diagnostic task list.

/status exposes i2c_realtime_period_us, i2c_realtime_time_to_next_us, i2c_background_window_us, and the realtime/background lock counters.

The firmware also has optional SCL pulse-count diagnostics for BNO085, BQ25792, and the MAX77958 direct-HS path. They are disabled by default:

c
#define APP_BNO085_I2C_MEASURE_SCL_ENABLED 0
#define APP_BQ25792_I2C_MEASURE_SCL_ENABLED 0
#define APP_MAX77958_I2C_HS_DIRECT_MEASURE_SCL_ENABLED 0

When enable

Parts

254
QtyValueFootprintReferences
2910kResistor_SMD:R_0603_1608MetricR1, R2, R26, R27, R30, R31, R32, R34, R35, R36, R37, R38, R39, R40, R41, R42 +13
2710uFCapacitor_SMD:C_0603_1608MetricC19, C26, C29, C31, C32, C33, C39, C41, C43, C44, C45, C47, C48, C49, C57, C67 +11
26100nFCapacitor_SMD:C_0603_1608MetricC3, C4, C15, C16, C17, C18, C23, C27, C34, C35, C36, C38, C46, C51, C53, C59 +10
25100Resistor_SMD:R_0603_1608MetricR10, R11, R12, R13, R14, R15, R16, R17, R18, R19, R20, R21, R24, R25, R28, R29 +9
161uFCapacitor_SMD:C_0603_1608MetricC1, C2, C5, C13, C14, C22, C25, C37, C60, C61, C81, C83, C84, C85, C86, C87
91kResistor_SMD:R_0603_1608MetricR56, R57, R58, R61, R68, R75, R81, R87, R93
9SP4020Diode_SMD:D_SOD-323D1, D2, D3, D4, D5, D6, D12, D15, D19
8100kResistor_SMD:R_0603_1608MetricR47, R50, R51, R66, R71, R74, R80, R84
8LED_GREENLED_SMD:LED_0603_1608MetricD7, D8, D10, D11, D16, D17, D18, D22
60Resistor_SMD:R_0603_1608MetricR22, R23, R67, R69, R95, R97
610nFCapacitor_SMD:C_0603_1608MetricC6, C12, C20, C21, C24, C62
5680Resistor_SMD:R_0603_1608MetricR33, R43, R44, R45, R46
5FerriteBeadInductor_SMD:L_0805_2012MetricFB1, FB2, FB3, FB4, FB5
422uFCapacitor_SMD:C_1206_3216MetricC50, C52, C54, C58
45.1kResistor_SMD:R_0603_1608MetricR70, R72, R77, R78
4BSS138Package_TO_SOT_SMD:SOT-23Q3, Q6, Q7, Q8
3PMEG10020Diode_SMD:D_SOD-123FD25, D26, D28
21AFuse:Fuse_1206_3216MetricF1, F2
222pFCapacitor_SMD:C_0603_1608MetricC10, C11
2330Resistor_SMD:R_0603_1608MetricR48, R85
247nFCapacitor_SMD:C_0603_1608MetricC55, C56
2LED_REDLED_SMD:LED_0603_1608MetricD9, D24
2SMAJ5.0CADiode_SMD:D_SMAD21, D23
11.5uHInductor_SMD:L_Taiyo-Yuden_NR-60xxL2
110.nFCapacitor_SMD:C_1812_4532MetricC30
110kConnector_JST:JST_PH_B2B-PH-K_1x02_P2.00mm_VerticalTH1
110vDiode_SMD:D_SOD-123FD27
1133kResistor_SMD:R_0603_1608MetricR88
1150kResistor_SMD:R_0603_1608MetricR83
11uHInductor_SMD:L_Taiyo-Yuden_NR-60xxL3
127nHInductor_SMD:L_0603_1608MetricL1
12AFuse:Fuse_1206_3216MetricF3
130.1kResistor_SMD:R_0603_1608MetricR94
132.768KHzCrystal:Crystal_SMD_2012-2Pin_2.0x1.2mmY1
1365kResistor_SMD:R_0603_1608MetricR76
1390Resistor_SMD:R_0603_1608MetricR62
13kResistor_SMD:R_0603_1608MetricR92
13v3TestPoint:TestPoint_Pad_D2.0mmTP3
13v9Diode_SMD:D_SMAD20
1470kResistor_SMD:R_0603_1608MetricR82
147pFCapacitor_SMD:C_0603_1608MetricC28
150kResistor_SMD:R_0603_1608MetricR79
151kResistor_SMD:R_0603_1608MetricR90
1B39162B3520U410B39162B3520U410:B39162B3520U410FL1
1BATConnector_JST:JST_PH_B2B-PH-K_1x02_P2.00mm_VerticalBT2
1BNO085Package_LGA:LGA-28_5.2x3.8mm_P0.5mmU2
1BQ25792RQMRBQ25792RQMR:BQ25792RQMRIC2
1Battery_CellBattery:BatteryHolder_Seiko_MS621FBT1
1DMG2307LPackage_TO_SOT_SMD:SOT-23Q1
1DWM RESETButton_Switch_SMD:SW_Push_1P1T_NO_CK_KMR2SW5
1DWM3000RF_Module:DWM1000DWM1
1ESP32 BOOTButton_Switch_SMD:SW_Push_1P1T_NO_CK_KMR2SW1
1ESP32 RESETButton_Switch_SMD:SW_Push_1P1T_NO_CK_KMR2SW2
1ESP32-S3-WROOM-1RF_Module:ESP32-S3-WROOM-1U1
1FT232Connector_PinSocket_2.54mm:PinSocket_1x06_P2.54mm_VerticalJ1
1GNDTestPoint:TestPoint_Pad_D2.0mmTP4
1GPS RESETButton_Switch_SMD:SW_Push_1P1T_NO_CK_KMR2SW3
1GPS antennaConnector_Coaxial:SMA_Samtec_SMA-J-P-H-ST-EM1_EdgeMountJ2
1LDS3985M33RPackage_TO_SOT_SMD:SOT-23-5U3
1LOGOSymbol:CE-Logo_8.5x6mm_SilkScreenG1
1LOGOSymbol:WEEE-Logo_4.2x6mm_SilkScreenG2
1MAX77958EWVMAX77958EWV:BGA30C50P6X5_307X262X69IC3
1MPM3612GLQ-PMPM3612GLQ-33-P:MPM3612GLQ33PIC4
1PX1125RPX1125R:PX1105RU4
1SMAJ20CADiode_SMD:D_SMAD14
1SYM_ESD_SmallSymbol:ESD-Logo_6.6x6mm_SilkScreenSYM1
1SolderJumper_2_OpenJumper:SolderJumper-2_P1.3mm_Open_RoundedPad1.0x1.5mmJP1
1TPD2S017Package_TO_SOT_SMD:SOT-23-6U6
1TPS630701RNMTTPS630702RNMT:TPS630702RNMTIC1
1USB_C_Receptacle_USB2.0_14PConnector_USB:USB_C_Receptacle_GCT_USB4105-xx-A_16P_TopMnt_HorizontalJ3
1on/offButton_Switch_SMD:SW_SPDT_PCM12SW4

Similar boards

  • 4 layers103 × 126 mmSenzori iradiere, the printed circuit board in 3D

    Senzori iradiere

    cs-pub-ro/smart-solar-irradiance-sensor

    Open-source hardware & software for a smart wireless sensor that measures solar irradiance.

    351 parts
  • 2 layersUsb, the printed circuit board in 3D

    Usb

    wozniak-works/X1-palmrest-to-keyboard

    A custom replacement board for the Lenovo ThinkPad X1 Carbon palmrest to transform it to keyboard with integrated powerbank and usb hub.

    186 parts
  • 4 layers100 × 60 mmMicro stratux, the printed circuit board in 3D

    Micro stratux

    ciborg971/micro_stratux

    compact and self powered project stratux fork

    145 parts1
  • 4 layers72.5 × 85.4 mmSbcb, the printed circuit board in 3D

    Sbcb

    jaguilar/sbcb

    112 parts
  • 2 layers160 × 100 mmMarine gps, the printed circuit board in 3D

    Marine gps

    mbouyer/marine_chartplotter

    Hardware and software for an opencpn-based chartplotter

    115 parts6BSD-2-Clause
  • 8 layers50 × 50 mmINT R1.0.1, the printed circuit board in 3D

    INT R1.0.1

    manyetox/Autonomous-Photonics-Carrier

    Source-available modular mobile carrier for autonomous photonics experiments using ESP32 control, optical flow, IMU sensing and magnetic wheel encoding.

    182 parts1
  • 2 layersGrad project, the printed circuit board in 3D

    Grad project

    Eng-Youssef-Yassin/sos-wearable-device

    A wearable safety device for industrial environments. A worker wears a UWB tag; fixed anchor stations track its position in real time and a web dashboard shows where every worker is.

    44 parts

Questions

How big is the UWB PCB?

It measures 71 × 64 mm and has 4 copper layers, 1.6 mm thick.

What parts does the UWB use?

254 parts, built around the ESP32-S3-WROOM-1-N8, DWM3000, MAX77958EWV+T and TPS630701RNMT. The full list of its parts is on this page, with links to each part's symbol and footprint where PCBKit's library has them.

Where can I download the UWB KiCad files?

From the victorstoica114/UWB_Module_ESP_IDF repository on GitHub, at the commit this page was made from: the board is PCB/V1.REV.B/Kicad project/UWB.kicad_pcb and its schematic PCB/V1.REV.B/Kicad project/Battery_Charger.kicad_sch. PCBKit opens the board in the browser, with nothing to install.

Can I use the UWB design in my own project?

Its repository states no licence, so its author keeps all rights: ask victorstoica114 before reusing the design.