Implementation
Four workstreams and on-device spell gestures.
Implementation — four workstreams
Each card is the concrete next-step checklist. Dependency order: C (network, can start today, in parallel) → D (prerequisite gate) → A → B. A and B both build on a working real fix from D; D gates all arena spend.
D · Close the UWB loop
UART parsing + fake-data toggle implemented in firmware. Software ready — needs first real fix in the room.
- Done: real UART path in
dwm3001c_protocol.c— issuesrangeto the DWM3001C, parsesRANGE 0x…: <m>andTIMEOUTlines, populatesuwb_ranging_result_t(distance, rssi, quality). - Done:
WAND_UWB_USE_FAKE_DATA = falseis the default inwand_config.h; fake mode stays available at runtime via the NVS keyuwb_fakefor Playground/demo. Caveat: real-mode discovery does no UWB scan yet — the anchor set comes from config / the gateway's assignment; local top-4-by-RSSI selection remains the fallback when no assignment arrives. - Seed 4 anchor positions in the DB
Anchortable soPositioningEngine.loadAnchors()picks them up. - Bring-up rig: 1 wand + 4 anchors, static positions; confirm a
stable
PositionDTOarrives on the/gamenamespace.
A · Gateway-driven anchor-set assignment
AnchorAssignment.ts + WS messages implemented. Validate reassignment with a moving wand in the arena.
- Done:
AnchorAssignment.ts— inputs: anchor positions (PositioningEngine.getAnchors()), wand last position (getLastPosition(wandId)), anchor health/status. - Algorithm: candidate = anchors in range of last position (all,
on cold start) → greedily pick 4 that minimize GDOP (nearest first,
then maximize angular spread; reject non-
ONLINE). - Hysteresis: only reassign when the wand moves > ~3 m or an assigned anchor goes stale — prevents thrashing.
- Done:
UwbAnchorAssignmentDTO {anchors[], epoch}incontracts/v1.ts;sendAnchorAssignment(wandId, dto)(type:'uwb_assign_anchors') inWandWebSocketServer.ts. - Done (firmware):
uwb_manager_set_active_anchors(ids, count)handlesuwb_assign_anchorson/wand. Fallback: local top-4-by-RSSI when no assignment / link lost.
B · UWB + TDMA slotting
TdmaScheduler.ts + firmware TDMA hooks implemented.
Backpressure telemetry (uwb_report) still pending in firmware.
Gate B: measure 16 wands @ ≥8 Hz on real hardware.
- Superframe model:
T_sf = 100 ms÷ 16 slots. Each wand owns a slot; in it, one TWR cycle to its 3–4 anchors (≈6 ms). Budget: 16 × 6 ms = 96 ms < 100 ms. ✓ - Sync, phase 1 — WiFi/gateway time: gateway pushes a periodic
time reference (
uwb_sync_ref); wand aligns slots with drift correction, ~3–5 ms guards (≈12–14 usable slots). Validates scheduler logic. - Sync, phase 2 — UWB beacon (only if WiFi jitter forces it): a sync-anchor broadcasts a superframe beacon; ns-level, tight enough for a full 16 slots.
- Done:
TdmaScheduler.ts— recomputes slot map on wand join/leave, pushesUwbTdmaConfigDTOviasendTdmaConfig(type:'uwb_tdma_config'). - Adaptive backpressure pending: gateway handler for
uwb_report(successRate, timeouts) exists, but firmware does not send it yet; on congestion drop some wands to ½-rate or 4→3 anchors. Log the wand-rate-vs-count curve. - Done (firmware):
uwb_manager_set_tdma(slotIndex, slotCount, slotMs, superframeMs)fires a one-shot range atsyncEpoch + slotIndex×slotMseach superframe (replaces fixedinterval).
C · VLAN + network boundary
Isolated VLAN for wands + anchors + gateway; traffic stays app-layer-unsecured without exposing ports — and without a real perf hit. No code dependency on D, so it can run in parallel from day one.
- VLAN: dedicated VLAN (e.g. 20) on a managed switch + AP; SSID → that VLAN; only wands/anchors/gateway NIC on it; no internet uplink (or firewalled).
- WiFi (2.4 GHz only): clean channel (1/6/11), 20 MHz, minimize competing SSIDs; disable power-save on wands; enable WMM/QoS, mark wand UDP as voice (AC_VO); wired backbone AP↔gateway. Dual-AP on different channels if airtime saturates near 16 wands.
- Keep WPA2 on (AES is hardware/near-free; an open SSID invites airtime theft). Skip app-layer TLS on the firehose — that's the real cost.
- Boundary hardening: no public ports (WireGuard if remote needed);
lock device HTTP APIs (
/api/uart/command,/api/ota,/api/reboot,/api/config) behind gateway-IP allowlist + shared secret; close wildcard CORS. Done in firmware + gateway. - Creds: remove default admin creds; move WiFi creds out of
committed headers into NVS via
config_manager/ anchor NVS. Device creds done. Change admin password before venue. - Field provisioning (wand + anchor): shared setup web UI at
GET /on device :80. If STA fails (or after factory reset), device startsLaserWands-Setup-XXXX→ laptop joins setup AP →http://192.168.4.1→ WiFi form (no secret in setup mode). Done. Seedocs/DEVICE_MANAGEMENT.md.
Device provisioning flow
No chicken-and-egg: you never need arena WiFi to reach a device for first-time setup.
Wand and anchor share one web UI (common_components/device_web_ui/).
Path A — Flash-time
idf.py menuconfig→ WiFi SSID/password +DEVICE_API_SECRET- Flash firmware — device joins arena WiFi on first boot
Path B — Same LAN
- Device already on arena WiFi — note IP (serial / DHCP / admin UI)
- Open
http://<device-ip>/— enterX-Device-Secretonce - Save WiFi or gateway URL in the shared web form
- Power device — if STA fails (or after Factory Reset), it starts
LaserWands-Setup-XXXX(passwordlaserwands-setupby default) - Laptop joins that WiFi (not arena WiFi)
- Open
http://192.168.4.1— WiFi form works without secret in setup mode - Save → device reconnects → reboots on success → joins arena WiFi
[Factory reset] ──► clear NVS WiFi ──► reboot ──► Setup AP again Arena AP (hidden SSID OK) ◄── STA ◄── wand / anchor ESP32 :80 ──► gateway .209:4000 Game plane (open): UDP :12346 firehose + WS /wand · Admin plane: X-Device-Secret on POST
Source-agnostic pose pipeline (player look stays identical)
UWB multilateration ─┐
IMU dead-reckoning ─┼─► PoseProvider ─► PoseFusion ─► POSITION event ─► SpellEngine · Unity · HUD · lighting
(retro optical, v2) ─┘ {x,y,z,heading,conf,source} (unchanged contract — nothing above changes)
On-device spell gestures software
Each wand runs a tiny neural net (TFLite Micro) that recognizes the
cast gesture locally from 6-DOF motion (accelerometer + gyroscope), then sends only the
recognized gesture to the gateway. The gateway — not the wand — decides legality (mana, cooldown,
bans, tier, ownership) before the spell fires. Gestures are trained off-device in wand_tinyml,
compiled into wand_sw, and named/assigned to spells in the admin panel.
The pipeline is fully implemented in firmware, but the repository ships no pre-trained model
(GESTURE_MODEL_AVAILABLE 0) — gestures are recorded and trained per deployment, and
casting stays disabled until a model is exported and flashed.
Pipeline — train once, assign freely
- Collect on a wand running
wand_tinyml: name a gesture on the PC and record ~50–80 reps (plus one shared idle reject class). Up to ~15 gestures per model is comfortable. - Train + export → an int8 model compiled into
wand_sw. - Flash
wand_sw(USB or OTA) — needed only when the model changes. - Import the manifest into the gateway, then rename + assign each gesture to a spell in the admin panel.
No reflash to rename a gesture or point it at a different spell — that is a server-side change.
Representative gesture traces
Training emits a 2-D trajectory per class so admins can see each learned gesture (start ● → end ●) and assign it confidently.
This is the differentiator — player accounts with levels, avatars and unlocks already exist as sold features of competing laser-tag platforms (Laserforce memberships, Zone/O-Zone). What they don't have: an unlock that is a physically trained motor skill — casting a spell is something you learn to do and the wand recognizes it, not a cosmetic or a point balance.