Troubleshooting#
Common issues, grouped by target. Deeper protocol background is in protocol-research.md (on-air behavior) and protocol-notes.md (firmware time and credential behavior).
| Section | Target |
|---|---|
| DWM3001CDK | the primary board, one nRF52833 with the DW3110 beside it |
| Build and flash, Hardware | nRF5340 DK, the target with NFC |
| ESP32-S3 ports | ESP32, and then the much longer gotchas log |
| Unlock behavior | any target: this is protocol, not board |
| Tests and CI gates | the host sweep, no hardware involved |
Bare make targets mean the DWM3001CDK. The nRF5340 DK is nrf- prefixed and the ESP32 is esp- prefixed; make with no target prints the grouped list.
DWM3001CDK#
Full detail is in ../apps/dwm3001cdk-lock/README.md, and the bring-up traps that cost real time are in dwm3001cdk-surgery.md. This is the triage list.
make build fails at configure, complaining about a signing key. Run make dfu-key once. Every image on this board is signed and the key is gitignored, so a fresh clone or a new git worktree has none. The build refuses rather than falling back to the demo key published in MCUboot's own repository. make nrf-build stops the same way and takes the same fix, because the nRF5340 DK signs with the same key under its default DFU=1.
make monitor attaches cleanly and prints nothing. Usually the ELF: probe-rs reads the RTT control block address out of the ELF you pass it, so attaching with one you built but did not flash reads a stale address, which looks exactly like a dead board. Reflash, or point CDK_RTT_BUILD at the image the board is actually running.
A log line looks wrong, or mentions something the code cannot do. Check it is real. probe-rs prints tails of .rodata strings out of the ELF, so a phantom line can appear with no target involved. A real line has an HH:MM:SS.mmm: prefix, a complete sentence, and its %d and %s substituted; a phantom has none of those. More than one investigation here has gone down that trail.
The probe enumerates but nothing can connect to it. A stale process still holds the USB interface, usually after an interrupted west flash or a SIGTERMed probe-rs attach. Replugging works but needs a human; killing the holders does not:
pkill -f 'jlinkarm_nrf_worker_osx'
pkill -f 'JLinkGUIServerExe'A make flash that reports "Timed out waiting for response from worker" after "Verifying image" has usually programmed the board correctly and only failed on the reset. Check with a read before reflashing.
Apple Home sits on "Adding to Home" and never finishes. If the board was factory-erased, old images could lose the OpenThread SRP client key while the border router retained its name lease. The current image persists the SRP key and name identity together, keeps asynchronous removal objects alive until OpenThread returns them, and retries a duplicate service name. Capture the RTT log before restarting the border router; a restart is now a diagnostic fallback, not the expected repair.
Apple Home works, but Home Assistant loses the lock after sharing it. This is usually two Thread networks, not a Matter fabric problem. In the Home Assistant iOS Companion app, open Settings > Devices & services > Thread > Configure, send the Apple credentials to Home Assistant, refresh, and make that network preferred. Join Home Assistant's OpenThread Border Router to those credentials rather than commissioning the lock onto a second dataset. Then share the already-commissioned Apple accessory again. See the DWM3001CDK multi-admin guide.
A Home Assistant sharing attempt failed. Let the fail-safe expire before retrying. The current five-slot implementation marks new fabrics provisional and rolls back only the failed attempt, so it does not disturb Apple Home or consume a persistent slot. If the new controller already reached CommissioningComplete, remove that fabric from a surviving controller with Home Assistant's Manage fabrics action.
The lock reports a full fabric table. Five committed fabrics are supported. Apple Home commonly accounts for two and Home Assistant one. Remove an unused controller through Manage fabrics; authenticated RemoveFabric tombstones only the target, revokes its sessions, ACL, subscriptions, and SRP service, and keeps the other controllers live. Use SW2 only if no administrator can reach the lock.
The lock works but the phone never approaches it. The reader only advertises its Aliro service with the provisioned advertising parameters once it has an identity, and on the Matter image the identity is minted by commissioning. A make flash-erase takes the fabrics, the identity and the trust anchors with it, so the board has to be added to Home again. To clear one controller, use its authenticated fabric-management UI. Holding SW2 through reset clears every Matter and Home Key identity and is the last-resort recovery.
The board refuses an over-the-air patch. A delta is computed against the exact bytes the board is running, and only the build host keeps that record. A push from a phone is invisible to it, so make fota-done after every phone push is what keeps the record true, and the cheapest way to see a mismatch.
Nothing happens when you press SW2 during an update. D10, the blue LED, blinks at 2 Hz while the update window is open, and it follows the window rather than the button, so it also goes out when the five minutes expire on their own. A dark LED means no window, not a failed transfer.
The board rebooted while I was holding SW2. SW2 now has two meanings, and the difference is how long you hold it:
| Gesture | Effect |
|---|---|
| Short press | Opens the application update window (the 2 Hz D10 blink above) |
| Hold >= 5 s while the application runs | Requests MCUboot serial recovery and warm-reboots into the bootloader |
The boot banner says so: hold SW2 for 5000 ms to enter MCUboot recovery. The hold duration is CONFIG_ULTRAWIDELOCK_MCUBOOT_RECOVERY_HOLD_MS. This replaced the old behaviour where every boot waited 5 s for mcumgr — that wait is gone (CONFIG_BOOT_SERIAL_WAIT_FOR_DFU=n), so a normal boot is faster and there is no longer a window to catch with an SWD reset. scripts/cdk-dfu.sh depends on the hold and no longer resets over SWD.
The image does not fit. The Matter image is at 96.3% of its flash partition (417,684 B of 433,664 B, about 15.6 KB spare) and 90.3% of RAM (118,312 B, about 12.8 KB spare). Flash is the tighter of the two, so a new code path is now a bigger decision than a new static allocation — the reverse of what this document used to say. LTO is on by default and worth 41,084 B; LTO=0 no longer fits this flash map at all and the build says so.
Do not compare against a size baseline taken before the project rename. make cdk-size-check will refuse, correctly: a number measured across a configuration change is worse than no number, and LTO alone moves this image by 41,084 B. Refresh the baseline with make cdk-size-baseline rather than widening a cap.
Build and flash (nRF5340 DK)#
make nrf-build stops before it starts, saying it will not build a bootloader anybody can sign for. Run make dfu-key once per clone. DFU=1 is the default here and MCUboot must be given a key this checkout owns; with none configured it would fall back to the one published in MCUboot's own repository, which every stock MCUboot already trusts. DFU=0 builds the older no-bootloader bench layout and needs no key. Same key and same target as the DWM3001CDK, so if you have built that board you already have one.
make nrf-build can't find the toolchain. make bootstrap installs the NCS v3.3.0 toolchain, so this normally means bootstrap has not been run here (make tools says whether it is installed). All builds go through nrfutil sdk-manager toolchain launch … west; a bare west is not used. If your toolchain is managed some other way, nrfutil sdk-manager config show names the directory bootstrap looks in, and ULTRAWIDELOCK_TOOLCHAIN=env uses whatever is already on PATH instead.
make bootstrap says nrfutil is not on PATH. It is what installs the toolchain, so bootstrap stops in its preflight rather than after the 6.5 GB fetch. On macOS and Linux it offers to install it for you: one 5 MB binary from Nordic into ~/.local/bin, y to accept. SETUP_AUTO=1 make bootstrap accepts without asking (for CI and containers) and SETUP_AUTO=0 never asks. Prefer to do it yourself, or on another platform: it is a download from Nordic. make tools reports what this machine has.
nrfutil is installed but make bootstrap still fails on sdk-manager. nrfutil is a launcher that ships with no commands inside it, so having the binary does not mean having the toolchain manager. Bootstrap now adds it (nrfutil install sdk-manager, a few seconds) when it is missing; run that by hand if you are driving nrfutil directly. make tools lists nrfutil, sdk-manager and toolchain as three separate rows for this reason.
make bootstrap stops in preflight over disk, or a missing git/curl/python3. It reports every gap in one pass with the install command for this host, because the phases after it cost several GB and many minutes. For disk, ULTRAWIDELOCK_WS_STORE=/big/disk/ws make bootstrap moves the whole workspace store to another volume, and in any second checkout make ws-link links the tree the machine already has for ~0 disk.
make bootstrap was interrupted. Re-run it. Every phase resumes: the toolchain install is skipped once installed, a half-finished clone is repaired and re-pinned, the fetch sentinel keeps west update from starting over, and the patches are reset and reapplied.
A config change flashed but did not take effect. A change to net-core configuration needs a full erase: use make nrf-flash-erase, not make nrf-flash. make nrf-flash is app-core only and leaves the net-core image in place.
make nrf-term shows nothing. The console and Zephyr shell are on the DK's VCOM1; VCOM0 is silent. make nrf-term auto-detects VCOM1; override with PORT= if detection picks the wrong port.
Build succeeds but the image does not fit. The default configuration targets a full flash budget (app FLASH is ~89.7%). Extra features may need a config trim; build with PRETTY=1 for readable size output.
Unlock behavior#
This lock does not open the lock it is bound to. That path is off unless the firmware was built with CLIENT=1, and it has never been proven on hardware. docs/matter-binding-bench.md is the bring-up procedure: it says what to prove before blaming the client, which log line to read, and which two failures are known deviations rather than bugs.
The first diagnostic question is always: does tap still work? Tap exercises the BLE transport, provisioning, and credentials. If tap works and only approach fails, the fault is UWB-specific, not in the credential path.
Tap works, approach never ranges. Either no common protocol version was negotiated, or the reader never emitted the 0x98 "URSK ready" trigger, so the phone reports URSK_Unavailable. See protocol-research.md §4 and the field guide in §10.
Approach worked, then stopped after a reboot. This is the time/credential-validity path, not UWB. The RAM wall clock is erased on reset and falls back to a stale Last Known Good Time, so freshly minted Access Documents are rejected as not-yet-valid. See protocol-notes.md; this repo carries the ratchet and persist fixes that address it.
Approach stopped but tap and Matter still work, no reboot involved. If the clock is valid but behind real time by more than the advertisement window (default 900 s), phones silently ignore the BLE advertisement because its dynamic-tag expiry lies in their past. This is the interaction documented in protocol-notes.md; the board overlay disables the dynamic tag until a real time source exists.
Ranging dies after walking out of range, or after about 12 hours. Expected: the URSK has a 12-hour TTL and is dropped when the BLE link drops. A fresh access transaction (cheap, via the fast path) re-arms it. See protocol-research.md §8.
Setup (M1-M4) completes but there are zero distance reports. A radio-path or parameter problem, not a control-stack one: check the antenna and channel (5 or 9), confirm a time sync happened (wrong listen window otherwise), and check for a negotiated parameter mismatch, which yields a different SaltedHash and therefore a different STS with no shared frames. See protocol-research.md §6-§7.
Hardware (nRF5340 DK and DWM3000EVB)#
None of this applies to the DWM3001CDK, where the DW3110 sits on the same module as the MCU and the wiring is internal. Its pin table is in ../apps/dwm3001cdk-lock/README.md.
Pin assignments are defined in apps/nrf5340dk-lock/overlays/dw3000-nfc.overlay, which is the source of truth. If the overlay changes, the wiring must change with it.
The DW3000 is a 3.3 V part. Power the DWM3000EVB from a 3.3 V rail, not 5 V. Share a common ground with the host board.
No SPI response / DW3000 not detected. Confirm the EVB is powered (see above), the SPI lines match the overlay, and the reset and IRQ lines are wired. make nrf-selftest builds a boot self-test that exercises the radio bring-up with no phone present, which isolates a wiring problem from a protocol one.
The DWM3000EVB has its own power-select jumper. Wiring the rails correctly is not enough if that jumper selects the wrong source: SPI then fails silently, with no valid device ID and a responder that never listens. Check it before suspecting software.
ESP32-S3 ports#
Full detail lives in docs/esp32-gotchas.md; this is the short triage list.
ESP-IDF export.sh not found or esp-matter not found. Nothing is installed at the path the build looks in. make esp-bootstrap APP=reader installs ESP-IDF alone (about 5 GB, enough for the reader, satellite and initiator apps); make esp-bootstrap installs esp-matter on top of it as well, which APP=matter-lock needs and which costs about 15 GB more and the better part of an hour. Both stages ask first, SETUP_AUTO=1 accepts without asking, and an install you already have elsewhere is reached with IDF_EXPORT= and ESP_MATTER_PATH= instead. make tools shows which of the two the build can currently see.
make esp-bootstrap stopped partway through esp-matter. Re-run it. The clone is pinned and reused, and the install only repeats when its marker (.ultrawidelock-install-done in the esp-matter tree) does not match the revision being installed. If the tree itself is the problem, rm -rf it and run the command again.
make esp-bootstrap installed a different version than the bench uses. It defaults to ESP-IDF v5.5.4 and esp-matter 93b1680 only when it starts from nothing; an existing checkout is offered a re-pin and keeps whatever you say to keep. IDF_VER= and ESP_MATTER_REV= (a full 40-character SHA) choose others, and the build enforces neither, so a tree at any revision still builds.
dwt_probe failed: -1 the first time a phone reaches M4. The DW3000 was never brought up at boot, so the first SPI touch happens inside a NimBLE host callback, where the shallow stack and missing init make probing fail. Bring the radio up once from a dedicated startup task instead; both ports now do this.
The bolt moves but the Wallet never animates. Driving the lock is not the signal iOS watches. The reader must send the Reader-Status-Changed grant message over the BleSK channel; without it iOS shows only a plain Matter accessory notification. Neither the phone's own computed distance nor the advertisement tag is the gate.
The phone disconnects about 1.8 s after a successful EXCHANGE (reason 531). The reader did not send Reader-Status-Access-Protocol-Completed. It is mandatory, not optional.
GeneralError URSK_Unavailable at M1. The ranging session id is derived from the AUTH0 transaction id, not chosen by the reader. A hardcoded session id names a session the phone has no key for. This is never a wrong-URSK-value problem: M1 carries no URSK-derived material, so a value mismatch would surface later, at M2-M4 STS.
protocol 0 unsupported fed to the ranging engine. Ranging SDUs ride their own GCM channel keyed from BleSK, with fresh per-direction counters, not the credential-auth channel. Seeing the raw envelope type as a protocol number means the channel split was missed.
Ranging setup completes, POLL and Response look clean, but no distance is ever computed. On ESP32 this is usually a real-time fault, not a logic fault: a per-round blocking log in a path with a 2 ms deadline starves the DW3000 ISR task, so the Final callback dispatches too late to catch the phone's Final_Data. Throttle hot-path logging first, then look at SPI transaction cost.
Negative or absurd distances. Suspect cross-round timestamp mixing before antenna calibration. If the Final_Data of one round is decoded after the next round has overwritten the shared timestamps, the arithmetic produces plausible-looking but wrong values that still pass the integrity gate. Snapshot the intervals at Final capture. No antenna calibration was needed on this hardware.
Approach unlock works, then the bolt relocks 5 s later while the phone is still there. A fixed auto-relock timer fights approach unlock. Set AutoRelockTime = 0 and drive relock from proximity with hysteresis.
Tests and CI gates#
make check says a gate COULD NOT RUN and exits nonzero. That is deliberate, not a warning: the gate's tool is not installed, and CI will run it whatever this machine has, so "could not check" reads as "not verified". make tools fills the gap. To accept it for one run instead, scope it out by name: SKIP="cbmc docs" make check.
A gate passed here and failed on the PR. Two usual causes. A version-pinned tool (clang-format, clang-tidy, zizmor, reuse) disagreeing with CI's pin: make tools flags a row that is off the pin. Or a gate that silently ran weaker here: without the markdown python package the flash-HTML drift check skips, which make tools also reports.
make check is slow, or a failure is hard to read. It runs in parallel lanes, so rows arrive out of order and two lanes can fail in one sweep. SERIAL=1 make check runs one gate at a time in table order. To re-run a single gate, scope the rest out with SKIP=.
A host suite reported green over code that does not compile. Only on trees before 396fee04. A parallel stage was invoked as "$fn" || rc=$?, and bash suppresses errexit inside a condition context, so a stage whose compile failed carried on and ran the previous binary. Each stage now runs in its own (set -e; ...) subshell and wait carries its real status. If you are on an older tree and a pass looks too easy, remove the build directory and re-run.
git pr cannot run network, Emscripten, user-local tools, or .venv gates. Its disposable candidate deliberately has no network, real home directory, user-local PATH, or gitignored files. Configure git config git-pr.verify make check; the wrapper runs the hermetic candidate checks and explicitly leaves the unavailable seven to CI. Do not configure make check directly for that sandbox; it is the full developer sweep and correctly treats those missing capabilities as failures.
python3 web/build.py refuses to run. It stops when HEAD is behind origin/main, because regenerating from a stale tree writes stale pages. git fetch origin && git merge origin/main first.
Still stuck#
Open an issue with the firmware commit, target, and console log; see the bug report template. For anything security-sensitive, use private reporting instead (see ../SECURITY.md).