docs(bf-14wx5): document BLE identity fixture + verifiable run-sim-ble-fixture.sh

The bf-14wx5 deliverables were written but uncommitted (stuck-open-but-done —
auto-split fired at failure-count:3 only because the templated br close --body
had been failing). Commit the focused set so the bead is genuinely closable.

Adds:
- scripts/run-sim-ble-fixture.sh — canonical, self-verifying fixture: starts a
  fresh-data-dir mothership, registers a person (POST /api/people), binds the
  sim's advertised AA:BB:CC:DD:EE:00 (walker 0) via preregister + PUT, and gates
  on GET /api/ble/devices?registered=true showing person_id/person_name in BOTH
  phases.
- notes/ble-identity-fixture.md — reuse-note recording the exact REST sequence,
  the observed sim BLE address convention (AA:BB:CC:DD:EE:%02X per walker, ids
  from 0), and the answer to "must the device be seen-live first?": NO —
  PreregisterDevice sets last_seen_at=now so it falls inside the
  GetDevicesSeenInHours window with no live sighting, and the binding survives
  live sim --ble advertisements (ProcessRelayMessage upserts never touch
  person_id).

Verified 2026-07-08 at HEAD 36b9729: ./scripts/run-sim-ble-fixture.sh PASSes
phaseA (bind with NO live adv) and phaseB (binding survives live adv), exit 0.
All four acceptance criteria met; deliberately excludes scripts/run-sim-
identity.sh (parent bf-2m534 capstone) and notes/hardware-free-runtime.md
(bf-40hc re-verify).

Bead-Id: bf-14wx5
This commit is contained in:
jedarden 2026-07-08 09:17:28 -04:00
parent 09bd6e372d
commit 83c761a5e8
2 changed files with 423 additions and 0 deletions

View file

@ -0,0 +1,192 @@
# BLE Identity Fixture — Registered Person + Sim Device (bf-14wx5)
**Canonical script:** `scripts/run-sim-ble-fixture.sh`
**Verified:** 2026-07-08 — HEAD `36b9729` (post bf-21v71 Stage 2b write-back).
**Resolves:** bf-14wx5 — the FIRST link of the bf-2m534 identity chain. Establishes the
BLE fixture (a registered person + the sim's advertised device bound to that person) that
the identity matcher needs to produce ANY match. Without it the matcher has nothing to
match and identity stays empty regardless of the wire fix.
This note records the exact REST sequence, the observed sim BLE address convention, and
the answer to "must the device be seen-live first?" so that later beads (the matcher /
`/api/blobs` identity capstone under bf-2m534, and `scripts/run-sim-identity.sh`) can
**reuse, not re-derive.**
---
## TL;DR
```bash
./scripts/run-sim-ble-fixture.sh
# expects:
# [fixture] phaseA (preregister+PUT, NO live adv): PASS
# [fixture] phaseB (live sim --ble adv, binding survives): PASS
# [fixture] PASS: GET /api/ble/devices confirms AA:BB:CC:DD:EE:00 <-> "Alice" in both phases
```
The sim advertises each walker as device addr `AA:BB:CC:DD:EE:%02X` (walker.ID, from 0),
name `sim-person-%d`. With `--walkers 1` the advertised addr is exactly
`AA:BB:CC:DD:EE:00`. Bind it to a person with three REST calls, then `GET /api/ble/devices`
confirms `person_id` / `person_name` are non-empty.
---
## The repeatable REST sequence (the fixture)
Against a running mothership (start one with `scripts/run-sim-local.sh`'s env, or any
fresh-data-dir instance — `SPAXEL_DATA_DIR=$(mktemp -d)`):
```bash
PORT=8088
MAC="AA:BB:CC:DD:EE:00" # sim walker 0 (see convention below)
NAME="Alice"
COLOR="#4488ff"
# 1. Create the device row for the known MAC. Sets last_seen_at=now, so the device
# appears in GET /api/ble/devices even with NO live advertisement (see "seen-live").
curl -s -X POST http://localhost:$PORT/api/ble/devices/preregister \
-H 'Content-Type: application/json' -d "{\"mac\":\"$MAC\",\"label\":\"$NAME\"}"
# 2. Create the person; capture its id.
PERSON_ID=$(curl -s -X POST http://localhost:$PORT/api/people \
-H 'Content-Type: application/json' \
-d "{\"name\":\"$NAME\",\"color\":\"$COLOR\"}" | jq -r .id)
# 3. Assign the device to the person (sets ble_devices.person_id).
curl -s -X PUT http://localhost:$PORT/api/ble/devices/$MAC \
-H 'Content-Type: application/json' \
-d "{\"person_id\":\"$PERSON_ID\",\"label\":\"$NAME\"}"
# 4. Verify: device is associated to the seeded person.
curl -s "http://localhost:$PORT/api/ble/devices?registered=true" \
| jq '.devices[]|select(.mac=="AA:BB:CC:DD:EE:00")|{mac,label,person_id,person_name}'
```
Expected (verified 2026-07-08):
```json
{ "mac": "AA:BB:CC:DD:EE:00", "label": "Alice",
"person_id": "<uuid>", "person_name": "Alice" }
```
Endpoint reference: `mothership/internal/ble/handler.go`
(`POST /api/ble/devices/preregister`, `POST /api/people`, `PUT /api/ble/devices/{mac}`,
`GET /api/ble/devices`).
---
## Sim BLE address convention (observed)
Source: `mothership/cmd/sim/main.go`, `sendBLEMessages` (line ~1125). For each walker:
```go
"addr": fmt.Sprintf("AA:BB:CC:DD:EE:%02X", walker.ID),
"rssi_dbm": int(rssi), // -50 - 20*log10(dist), floored at -90
"name": fmt.Sprintf("sim-person-%d", walker.ID),
```
`walker.ID = i` (the loop index, from 0 — `mothership/cmd/sim/main.go` walker constructors
`ID: i`). So:
| `--walkers N` | Advertised addrs (one per walker) |
|---------------|-----------------------------------|
| 1 | `AA:BB:CC:DD:EE:00` |
| 2 | `AA:BB:CC:DD:EE:00`, `AA:BB:CC:DD:EE:01` |
| k | `AA:BB:CC:DD:EE:00``AA:BB:CC:DD:EE:%02X` (k1) |
BLE cadence: the sim relays one `{type:"ble",...}` message per node every 5 s
(`if *flagBLE && time.Since(lastBLETime) > 5*time.Second`). Each node reports every
walker, so a 4-node fleet yields 4 RSSI samples per walker per 5 s window — enough for
the matcher's triangulation.
> **Field-name note (bf-21v71):** the sim previously sent `"rssi"`; the mothership's BLE
> parser reads `rssi_dbm`, so RSSI was read as 0 and triangulation collapsed. The sim now
> sends `"rssi_dbm"` (uncommitted working-tree change at `mothership/cmd/sim/main.go`).
> This is the third precondition the diagnosis `notes/blob-identity-diagnosis.md` named.
---
## "Must the device be seen-live first?" — NO (with evidence)
**Answer: `preregister` + `PUT` binds the sim device addr standalone; the device does NOT
need to be seen live first.** Evidence (bf-14wx5 Phase A, `scripts/run-sim-ble-fixture.sh`):
The fixture was seeded with the sim **not running** (no live advertisement), and
`GET /api/ble/devices?registered=true` still returned the device fully bound:
```
[fixture] Phase A: seed fixture BEFORE any sim BLE advertisement (no live adv)...
[fixture] [phaseA] OK: AA:BB:CC:DD:EE:00 -> person_id=345dcfeb-… person_name="Alice"
```
**Why it works** (code, `mothership/internal/ble/registry.go`):
- `PreregisterDevice` (`INSERT … VALUES (… last_seen_at=now …)`) sets `last_seen_at` to
the current time on insert.
- `GetDevicesSeenInHours` (the query behind `GET /api/ble/devices`) filters
`WHERE d.last_seen_at >= <now hours>`. Because preregister sets `last_seen_at=now`,
the device falls inside the default 24 h window and is served — no live sighting needed.
**The binding also survives live advertisements.** Phase B of the same run started
`sim --ble` after the fixture was seeded, let advertisements flow, and re-verified:
```
[fixture] [phaseB] OK: AA:BB:CC:DD:EE:00 -> person_id=345dcfeb-… person_name="Alice"
{ "mac": "AA:BB:CC:DD:EE:00", "label": "Alice", "person_id": "345dcfeb-…",
"person_name": "Alice", "rssi_avg": -60, "last_seen_node": "AA:BB:CC:00:00:02" }
```
`ProcessRelayMessage` (the live-adv path) upserts with `ON CONFLICT(mac) DO UPDATE` that
touches only `device_type`, `device_name`, `manufacturer`, mfr fields, rssi stats,
`last_seen_at`, `last_seen_node`, `is_wearable`**it never writes `person_id`**, so a
live sighting cannot clobber the assignment. (Conversely, registering a device that is
already seen-live works identically — that is the realistic onboarding order exercised by
`scripts/run-sim-identity.sh`.)
> **Caveat on re-running `preregister`:** the `ON CONFLICT(mac) DO UPDATE` clause updates
> only `name`/`label`, **not** `last_seen_at`. So a second `preregister` on an already-seen
> device will not refresh its visibility window. For the fixture this is irrelevant (one
> preregister on a fresh data dir), but if you ever need to re-surface a stale device, send
> a live advertisement or bump `last_seen_at` directly — don't rely on re-preregistering.
---
## What this fixture enables (for later beads)
The identity matcher (`mothership/internal/ble`) only considers devices with a non-null
`person_id` (e.g. `GetAllPersonDevices` / `GetAllPersonDevicesWithAliases` filter
`WHERE person_id IS NOT NULL`). So this fixture — a person + a bound device — is the
**precondition** for any identity match to fire at all. Downstream:
- **bf-2m534 / `scripts/run-sim-identity.sh`** (the parent capstone): pairs this fixture
with `sim --ble` and the Stage 2b write-back (bf-21v71) to assert a tracked blob served
at `/api/blobs` carries non-empty canonical identity (`personName`/`assignedColor`).
- **bf-m1ynp diagnosis** (`notes/blob-identity-diagnosis.md`): named "no person
registered" as a required fix; this fixture is that fix, factored out as the first link.
This script deliberately does NOT gate on `/api/blobs` identity — that is the parent
bead's concern. It gates only on `GET /api/ble/devices`, keeping the fixture link
independently verifiable.
---
## Reuse, don't re-derive
- **Start from `scripts/run-sim-ble-fixture.sh`.** It builds both binaries from
`mothership/cmd/{mothership,sim}` (the canonical sim with `--ble`; never the repo-root
`cmd/sim/` — see `notes/hardware-free-runtime.md` gotcha #1), starts a fresh-data-dir
mothership, and runs both phases.
- **All params overridable:** `SIM_PORT`, `SIM_WALKER_MAC`, `SIM_PERSON_NAME`,
`SIM_PERSON_COLOR`, `SIM_NODES`, `SIM_WALKERS`, `SIM_RATE`, `SIM_SEED`, `SIM_SPACE`.
- **Determinism:** `--seed` pins walker paths; walker 0 always advertises
`AA:BB:CC:DD:EE:00`. The only residual nondeterminism is the ~5 s BLE relay cadence,
absorbed by the 7 s settle before the Phase B re-verify.
## Keeping this honest
If `scripts/run-sim-ble-fixture.sh` ever fails Phase A: the `PreregisterDevice`
`last_seen_at=now` invariant (or the `GetDevicesSeenInHours` window) changed; re-read
`registry.go` before assuming the fixture is wrong. If it fails Phase B only:
`ProcessRelayMessage`'s upsert started touching `person_id`, or the sim stopped emitting
`rssi_dbm` — check `mothership/cmd/sim/main.go` `sendBLEMessages` first.

231
scripts/run-sim-ble-fixture.sh Executable file
View file

@ -0,0 +1,231 @@
#!/usr/bin/env bash
#
# run-sim-ble-fixture.sh — the BLE identity FIXTURE (bf-14wx5).
#
# This is the FIRST link of the bf-2m534 identity chain: it establishes the BLE
# fixture a registered person + a sim device bound to that person that the
# identity matcher needs to produce ANY match. It does NOT assert anything about
# /api/blobs identity that is the parent bead's capstone (scripts/run-sim-identity.sh,
# bf-2m534/bf-5h1t). This script's acceptance is narrower and directly verifiable:
#
# GET /api/ble/devices on a running mothership confirms the sim's advertised
# device addr is associated to the seeded person (person_id / person_name non-empty).
#
# It also answers the "must the device be seen-live first?" question empirically:
#
# Phase A registers + binds the device with NO live BLE advertisement flowing
# (sim not started yet), proving preregister+PUT binds standalone.
# Phase B then starts sim --ble and re-verifies, proving the binding survives
# live advertisements (ProcessRelayMessage upserts never touch person_id).
#
# Sim BLE addr convention (mothership/cmd/sim/main.go: sendBLEMessages):
# each walker advertises addr = fmt.Sprintf("AA:BB:CC:DD:EE:%02X", walker.ID),
# name = fmt.Sprintf("sim-person-%d", walker.ID), and walker.ID = i (from 0).
# So --walkers 1 advertises exactly AA:BB:CC:DD:EE:00.
#
# No hardware, no Docker, no manual IP, no manual token. Reuses the build + health
# path proven by run-sim-local.sh. See notes/ble-identity-fixture.md for the full
# reasoning, exact REST calls, and evidence.
#
# Usage: ./scripts/run-sim-ble-fixture.sh
# Env: SIM_NODES, SIM_WALKERS, SIM_RATE, SIM_DURATION, SIM_SEED, SIM_PORT,
# SIM_SPACE, SIM_WALKER_MAC, SIM_PERSON_NAME, SIM_PERSON_COLOR,
# SPAXEL_MOTHERSHIP_BIN, SPAXEL_SIM_BIN
# Exit: 0 if GET /api/ble/devices confirms the device<->person binding in both
# phases; 1 otherwise.
#
# Requires: go, curl, jq.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
MS="${SPAXEL_MOTHERSHIP_BIN:-/tmp/spaxel-mothership}"
SIM="${SPAXEL_SIM_BIN:-/tmp/spaxel-sim}"
PORT="${SIM_PORT:-8088}"
# walkers=1: one sim device (AA:BB:CC:DD:EE:00) -> one registered person. rate=30
# mirrors run-sim-local.sh's verified default (REQUIRED for deterministic blob
# emission, though blobs are not this script's gate).
SIM_NODES="${SIM_NODES:-4}"
SIM_WALKERS="${SIM_WALKERS:-1}"
SIM_RATE="${SIM_RATE:-30}"
SIM_DURATION="${SIM_DURATION:-15}"
SIM_SEED="${SIM_SEED:-42}"
SIM_SPACE="${SIM_SPACE:-5x5x2.5}"
# Walker 0 -> BLE addr AA:BB:CC:DD:EE:00 (cmd/sim/main.go: AA:BB:CC:DD:EE:%02X, IDs from 0).
WALKER_MAC="${SIM_WALKER_MAC:-AA:BB:CC:DD:EE:00}"
PERSON_NAME="${SIM_PERSON_NAME:-Alice}"
PERSON_COLOR="${SIM_PERSON_COLOR:-#4488ff}"
log() { printf '[fixture] %s\n' "$*"; }
die() { printf '[fixture] ERROR: %s\n' "$*" >&2; exit 1; }
for dep in go curl jq; do
command -v "$dep" >/dev/null 2>&1 || die "missing required dependency: $dep"
done
# --- Build both binaries from source (reuse if present; force sim has --ble) ------
# Canonical-source rule (see notes/hardware-free-runtime.md gotcha #1): build ONLY
# mothership/cmd/sim (it has --ble/--verify); never the repo-root cmd/sim/ (older).
log "building canonical binaries from source (reuse if present)..."
need_build=0
[ -x "$MS" ] || need_build=1
[ -x "$SIM" ] || need_build=1
if [ -x "$SIM" ] && ! "$SIM" --help 2>&1 | grep -q -- '--ble'; then
log "existing $SIM lacks --ble (stale/standalone build); rebuilding..."
rm -f "$SIM"; need_build=1
fi
if [ "$need_build" -eq 1 ]; then
( cd "$ROOT/mothership" || exit 1
go build -o "$MS" ./cmd/mothership || die "building mothership"
go build -o "$SIM" ./cmd/sim || die "building sim"
) || exit 1
fi
DATA_DIR=$(mktemp -d -t spaxel-fixture-data-XXXXXX)
MS_PID=""
SIM_PID=""
cleanup() {
[ -n "$SIM_PID" ] && kill "$SIM_PID" 2>/dev/null || true
[ -n "$MS_PID" ] && kill -INT "$MS_PID" 2>/dev/null || true
sleep 1
[ -n "$MS_PID" ] && kill -9 "$MS_PID" 2>/dev/null || true
wait 2>/dev/null || true
rm -rf "$DATA_DIR"
}
trap cleanup EXIT INT TERM
log "mothership: $MS"
log "sim: $SIM"
log "fixture: $WALKER_MAC -> person \"$PERSON_NAME\" ($PERSON_COLOR) [walkers=$SIM_WALKERS]"
# --- Start mothership on an ephemeral data dir (identical env to run-sim-local) --
SPAXEL_BIND_ADDR="127.0.0.1:$PORT" \
SPAXEL_DATA_DIR="$DATA_DIR" \
SPAXEL_LOG_LEVEL=warn \
SPAXEL_MDNS_ENABLED=false \
TZ=UTC \
"$MS" > "$DATA_DIR/mothership.log" 2>&1 &
MS_PID=$!
ok=""
for _ in $(seq 1 50); do
if resp=$(curl -s --max-time 2 "http://localhost:$PORT/healthz" 2>/dev/null) && \
[ "$(printf '%s' "$resp" | jq -r '.status // empty' 2>/dev/null)" = "ok" ]; then
ok=1; break
fi
sleep 0.3
done
[ -n "$ok" ] || { log "mothership never became healthy"; tail -30 "$DATA_DIR/mothership.log"; exit 1; }
log "mothership healthy (port $PORT)"
# Bind a helper that asserts GET /api/ble/devices shows the device<->person binding.
# Echoes "OK <json>" on success (person_id and person_name non-empty for $WALKER_MAC).
verify_binding() {
local label="$1"
local rec
rec=$(curl -s --max-time 2 "http://localhost:$PORT/api/ble/devices?registered=true" \
| jq -c --arg mac "$WALKER_MAC" '[.devices[]|select(.mac==$mac)][0]' 2>/dev/null)
if [ "$rec" = "null" ] || [ -z "$rec" ]; then
log " [$label] $WALKER_MAC NOT in /api/ble/devices?registered=true"
return 1
fi
local pid pname
pid=$(printf '%s' "$rec" | jq -r '.person_id // empty')
pname=$(printf '%s' "$rec" | jq -r '.person_name // empty')
if [ -z "$pid" ] || [ -z "$pname" ]; then
log " [$label] $WALKER_MAC present but unbound (person_id=\"$pid\" person_name=\"$pname\")"
return 1
fi
log " [$label] OK: $WALKER_MAC -> person_id=$pid person_name=\"$pname\""
printf '%s\n' "$rec" > "$DATA_DIR/${label}.json"
return 0
}
# --- The repeatable REST fixture sequence (register person + bind device) --------
# POST /api/ble/devices/preregister -> create the device row (sets last_seen_at=now,
# so the device shows in GET /api/ble/devices even with NO live advertisement).
# POST /api/people -> create the person, capture its id.
# PUT /api/ble/devices/{mac} -> assign device -> person (sets person_id).
seed_fixture() {
curl -s -X POST "http://localhost:$PORT/api/ble/devices/preregister" \
-H 'Content-Type: application/json' \
-d "{\"mac\":\"$WALKER_MAC\",\"label\":\"$PERSON_NAME\"}" >/dev/null
person_id=$(curl -s -X POST "http://localhost:$PORT/api/people" \
-H 'Content-Type: application/json' \
-d "{\"name\":\"$PERSON_NAME\",\"color\":\"$PERSON_COLOR\"}" \
| jq -r '.id // empty' 2>/dev/null)
[ -n "$person_id" ] || die "could not create person \"$PERSON_NAME\" via /api/people"
curl -s -X PUT "http://localhost:$PORT/api/ble/devices/$WALKER_MAC" \
-H 'Content-Type: application/json' \
-d "{\"person_id\":\"$person_id\",\"label\":\"$PERSON_NAME\"}" >/dev/null
SEED_PERSON_ID="$person_id"
}
# ================= Phase A: bind with NO live advertisement ======================
# Proves preregister+PUT binds standalone — the device does NOT need to be seen
# live first. This directly answers bf-14wx5 acceptance criterion #4.
log "Phase A: seed fixture BEFORE any sim BLE advertisement (no live adv)..."
SEED_PERSON_ID=""
seed_fixture
log "registered: $WALKER_MAC -> person \"$PERSON_NAME\" (id=$SEED_PERSON_ID)"
phaseA=1
verify_binding "phaseA" || phaseA=0
# ================= Phase B: start sim --ble, re-verify ===========================
# Proves the binding survives live advertisements (ProcessRelayMessage upserts
# rssi/last_seen but never person_id) and the device is now seen live (rssi set).
log "Phase B: starting sim --ble (walkers=$SIM_WALKERS, seed=$SIM_SEED)..."
"$SIM" \
--mothership "ws://localhost:$PORT/ws/node" \
--nodes "$SIM_NODES" --walkers "$SIM_WALKERS" --rate "$SIM_RATE" \
--space "$SIM_SPACE" \
--duration "$SIM_DURATION" --seed "$SIM_SEED" --ble \
> "$DATA_DIR/sim.log" 2>&1 &
SIM_PID=$!
# Let at least one BLE adv relay land (~5s BLE cadence in the sim).
log "waiting 7s for sim BLE advertisements to land..."
sleep 7
phaseB=1
verify_binding "phaseB" || phaseB=0
# Capture the full live device record (incl. rssi, now populated by live advs) and
# the people view as corroboration for the note.
curl -s --max-time 2 "http://localhost:$PORT/api/ble/devices" \
| jq -c --arg mac "$WALKER_MAC" '[.devices[]|select(.mac==$mac)][0]' > "$DATA_DIR/phaseB_full.json" 2>/dev/null
curl -s --max-time 2 "http://localhost:$PORT/api/people" > "$DATA_DIR/people.json" 2>/dev/null
# Stop the sim so the run is bounded.
kill "$SIM_PID" 2>/dev/null || true
wait "$SIM_PID" 2>/dev/null || true
SIM_PID=""
reject_count=$(grep -ciE 'reject|invalid_token|\b401\b|\b403\b' "$DATA_DIR/sim.log" 2>/dev/null)
echo ""
log "---- RESULT ----"
log "phaseA (preregister+PUT, NO live adv): $([ "$phaseA" = 1 ] && echo PASS || echo FAIL)"
log "phaseB (live sim --ble adv, binding survives): $([ "$phaseB" = 1 ] && echo PASS || echo FAIL)"
log "seeded person: \"$PERSON_NAME\" (id=$SEED_PERSON_ID) device: $WALKER_MAC"
log "reject_in_sim_log=${reject_count:-0}"
echo ""
log "evidence: /api/ble/devices?registered=true record after live adv (phaseB_full):"
if [ -s "$DATA_DIR/phaseB_full.json" ] && [ "$(cat "$DATA_DIR/phaseB_full.json")" != "null" ]; then
jq '{mac,label,person_id,person_name,rssi_avg,last_seen_node,last_seen_at}' "$DATA_DIR/phaseB_full.json" 2>/dev/null \
|| cat "$DATA_DIR/phaseB_full.json"
else
log " (no record)"
fi
echo ""
# --- Acceptance gate: GET /api/ble/devices confirms the binding in BOTH phases ----
if [ "$phaseA" != 1 ] || [ "$phaseB" != 1 ]; then
log "FAIL: binding not confirmed via GET /api/ble/devices (phaseA=$phaseA phaseB=$phaseB)"
log " (mothership.log tail for diagnostics:)"
tail -20 "$DATA_DIR/mothership.log" >&2 || true
exit 1
fi
log "PASS: GET /api/ble/devices confirms $WALKER_MAC <-> \"$PERSON_NAME\" in both phases"
log " (preregister+PUT binds with NO live adv; binding survives live sim --ble advs)"
exit 0