kymomapGitHubconnecting…
Setup manual · the reference rig

From sealed boxes to a live map.

One hub, twelve nodes, an afternoon and an evening. Twelve is the worked example this manual counts in — the reference install runs seven, and every number below scales with however many rooms you are covering. The order below is deliberate: the hub first because its install is the long pole, two bare boards before any box because that is where the radio tells the truth, one finished box before the other eleven because it becomes the template. Every stage ends with a checkpoint. Stop there and look.

Stage 00

Before you open anything

TIME · 30 minutesOPEN · the hub, the USB drive, the laser measure

Three things can happen in parallel on day one, and two of them need no hardware at all. Do them first so the hub install, the slowest step, is never on the critical path.

0.1

Put the Ubuntu installer on the USB drive

The hub ships with Windows and you will wipe it. Download Ubuntu Server 24.04 LTS from ubuntu.com on your laptop (the current point release is ubuntu-24.04.4-live-server-amd64.iso, about 3 GB) and check its SHA-256 against the SHA256SUMS file beside it. Two ways onto the 32 GB stick; the first needs no administrator password.

Copy the files. The G10 boots UEFI, and Ubuntu’s installer boots from a plain FAT32 stick that simply holds the ISO’s files. A new stick is already FAT32, and macOS’s tar (bsdtar) reads an ISO directly, so no mounting and no password:

shasum -a 256 ~/Downloads/ubuntu-24.04.4-live-server-amd64.iso          # compare with SHA256SUMS on releases.ubuntu.com
tar -xf ~/Downloads/ubuntu-24.04.4-live-server-amd64.iso \
    --no-same-owner --no-same-permissions -C "/Volumes/NO NAME"          # the stick's volume name; takes a few minutes
ls "/Volumes/NO NAME"/EFI/boot "/Volumes/NO NAME"/.disk/info             # both must exist
diskutil eject "/Volumes/NO NAME"

Two symlinks in the ISO cannot exist on FAT and are skipped with a warning; neither is needed to boot. If the G10’s boot menu does not offer the stick, check in its BIOS that UEFI boot is enabled and legacy/CSM is not the only mode.

Or write the image. balenaEtcher does it in three clicks and cannot pick the wrong disk by accident. With dd, find the stick in diskutil list first and check its size twice: it writes wherever it is pointed, including at your laptop’s own disk.

diskutil unmountDisk /dev/disk4
sudo dd if=~/Downloads/ubuntu-24.04.4-live-server-amd64.iso of=/dev/rdisk4 bs=4m status=progress
diskutil eject /dev/disk4
Put the repository on the stick too. The hub has no GitHub token, so a git bundle beside the installer saves a step later: git bundle create /Volumes/NO\ NAME/kymomap/kymomap.bundle main, and on the hub git clone /mnt/kymomap/kymomap.bundle kymomap. The key never goes on the stick.
0.2

Generate the installation key and write it on paper

Every node and the hub share one 16-byte key. It authenticates commands to the nodes and seals every frame they send; a node configured without one withholds frames rather than sending in the clear, and a hub without it refuses every sealed frame. Make it once, now, before any node is configured:

openssl rand -hex 16
# → 32 hex characters, e.g. 3f9a1c…  Write it down. It goes on every node and into the hub.

Keep it on paper for the day and in your password manager afterwards. Twelve boxes on walls is not a thing you want to re-key because the only copy was in a terminal scrollback.

0.3

Walk the house and mark twelve positions

Take the laser measure and a roll of masking tape. Choose one ground-floor corner as the site origin and keep it for the life of the install: every node position is metres from that corner, x along one wall, y along the other, z up from the floor. Only the geometry between nodes matters, so you are not surveying a building, you are getting twelve points right relative to each other.

  • Pick positions on link geometry: paths crossing the rooms you care about, at different angles.
  • Each position needs a socket within two metres at a usable height. Expect two or three that do not; compromise the position, run an extension, or leave that volume less covered. Do not pretend a node is where it should be.
  • Do not split evenly across floors. Nine down and three up, or eight and four. In simulation an even six and six split left only 15 of 66 links in the walker’s plane and occupancy fell to 25% correct while position still looked fine. Accuracy on a storey is bought by nodes on that storey.
  • Put a strip of tape at each position with a provisional number, N1 to N12. Antennas will be vertical, boxes at chest height or higher.
0.4

Decide the nodes' Wi-Fi and channel

Nodes sound at each other over ESP-NOW on one 2.4 GHz channel, and they also join an ordinary Wi-Fi network to reach the hub over UDP. The firmware refuses to follow an access point onto a different channel, because that would cost the mesh eleven links to buy telemetry for one node. So the access point the nodes join must sit on the sounding channel.

  • Pick the sounding channel now: 1, 6 or 11 (they do not overlap). The setup flow defaults to 6.
  • Fix one access point’s 2.4 GHz radio to that channel, 20 MHz wide, and give it an SSID the nodes will use (a dedicated one such as kymo-hub is tidiest). On UniFi that is the AP’s radio settings, not the network’s.
  • The hub must be reachable from that network. Same LAN, no guest isolation.
Checkpoint · stop here and look
  • A bootable Ubuntu Server USB drive.
  • A 32-hex-character key on paper. Not in a file yet, not on a node yet.
  • Twelve tape marks with numbers, an origin corner chosen, socket reach confirmed at each.
  • An SSID on a fixed 2.4 GHz channel, and that channel number written next to the key.
Stage 01

The hub

TIME · 45 minutes, mostly waitingOPEN · the GMKtec G10, its power supply, an Ethernet cable, a screen and keyboard for twenty minutes

Ubuntu Server, Docker, the repository, the key, and the two compose files. When this stage ends the hub is answering on the LAN in hardware mode with nothing to hear yet.

1.1

Unbox the G10 and boot the installer

Connect power, Ethernet to your router, a monitor over HDMI, a keyboard, and the USB drive. Power on and press the boot-menu key at the GMKtec splash (F7 on most GMKtec boards; Del opens the BIOS instead). Choose the USB drive.

1.2

Install Ubuntu Server

Accept the defaults except where listed. The install takes about twenty minutes; the rest of Stage 00 fits inside it.

  1. Language and keyboard. Choose Ubuntu Server, not the minimized variant.
  2. Network: leave the Ethernet interface on DHCP and note the IP address it shows. If the installer sees no wired link at all, install over Wi-Fi and read step 1.4: the G10’s Realtek RTL8125 is a revision the stock 24.04 kernel does not drive, and the hardware-enablement kernel fixes it after the first boot.
  3. Storage: use the entire disk. This erases Windows, which is the point. LVM is not needed.
  4. Profile: your name, the server name kymomap, a username, a password. The name is what makes kymomap.local work later.
  5. Install OpenSSH server: yes. Skip every featured snap.
  6. Reboot, remove the USB drive when asked. Unplug the monitor and keyboard; everything else is over SSH.
1.3

Reserve the hub's address on your router

Nodes are given the hub as a literal IPv4 address. A hub that changes address silences the mesh, and nothing on a node will tell you why. In your router’s DHCP settings, reserve the address the installer showed for the G10’s MAC. Write that address next to the key.

1.4

Docker, mDNS, the repository

From your laptop:

ssh [email protected]            # or the IP, until avahi is installed below
sudo apt update && sudo apt install -y avahi-daemon docker.io docker-compose-v2 git
# no wired interface in `ip -br link`? the reference G10 needs the HWE kernel for its RTL8125 revision:
sudo apt install -y linux-generic-hwe-24.04 && sudo reboot
sudo usermod -aG docker $USER && newgrp docker
git clone https://github.com/Marlow-Systems-LLC/kymomap.git && cd kymomap
# the channel survey listens on the G10's own Wi-Fi radio: leave it unblocked, never join a network with it
sudo apt install -y iw && sudo rfkill unblock wifi
sudo install -m 0755 ops/hub/kymomap-survey.sh /usr/local/bin/
sudo install -m 0644 ops/hub/kymomap-survey.service ops/hub/kymomap-survey.timer /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now kymomap-survey.timer

The hub is wired; its Wi-Fi radio is a listening instrument. Setup’s channel step reads what that radio hears across the 2.4 GHz band and proposes the quietest channel, so the radio must stay unblocked (rfkillblocks it by default on some installs) and must not be joined to any network, which would pin it to one channel. If you lock the hub down further later, keep that one exception.

Until the repository is public, git clone from GitHub needs a token. If you put the bundle on the stick in step 0.1, mount it and clone from there instead: sudo mount /dev/sdb1 /mnt (find the stick with lsblk), then git clone /mnt/kymomap/kymomap.bundle kymomap. Otherwise rsync -a --exclude node_modules --exclude .next ./ kymomap.local:kymomap/ from inside your checkout.

1.5

Put the installation key where the hub reads it

The hub runs in hardware mode by default and refuses to start without the key at /data/installation.key inside its data volume. It never falls back to simulation. Write it before the first start:

docker compose -f docker-compose.hub.yml run --rm --no-deps hub \
  sh -c 'umask 077; printf "%s\n" "PASTE-THE-32-HEX-CHARACTERS" > /data/installation.key'

That creates the volume, writes the file as the hub’s own user, and exits. The key is never printed back.

Three more values let the hub configure nodes over the air later (step 4.1): its own reserved address from step 1.3, which it tells every node to send frames to, and the SSID and passphrase of the network from step 0.4 that nodes join to reach it. You can give them on the Enrol step of the setup page instead, where the hub keeps them in its own data directory — that is the easier path and it survives a restart the same way. The .env file below is the equivalent for a hub you would rather configure from the shell; it is git-ignored and compose reads it on every start. With neither, the hub still runs and nodes are configured over the USB cable only.

cat > .env <<'EOF'
KYMOMAP_HUB_IP=192.168.1.42          # the address reserved in step 1.3
KYMOMAP_NODE_SSID=kymo-hub           # the 2.4 GHz network on the sounding channel
KYMOMAP_NODE_PSK=the-wifi-passphrase
EOF
chmod 600 .env

The address has to be set by hand because the hub runs in a bridged container: what it can see of itself is a Docker address, not the one on your LAN.

1.6

Start the hub and the map UI

docker compose -f docker-compose.hub.yml up -d --build      # first build takes a few minutes
docker compose -f docker-compose.hub.yml ps                 # hub: healthy, web: running
curl -s 127.0.0.1:8787/api/runtime | head -c 300            # "mode":"hardware", "state":"waiting"

Both containers publish on the hub’s loopback only, by design: there are no accounts, so loopback is the access control. The exceptions are the two UDP ports the hub listens on for the nodes — 5115 for frames and 5116 for telemetry — which are open on the LAN, because that is all a node needs; the hub sends its commands out to a node’s UDP 5117. The "control" block in that runtime answer says whether it is listening on 5116 and whether the uplink credentials from step 1.5 were seen.

1.7

Open the LAN door for your phone

You will place nodes with a phone in your hand, so the pages need to be reachable from the LAN. The second compose file puts Caddy with basic auth in front of both containers on port 8080. Three values, in a .env beside the compose files:

HASH=$(docker run --rm caddy:2 caddy hash-password --plaintext 'a passphrase you will type on a phone')
printf "KYMOMAP_LAN_PASSWORD_HASH='%s'\n" "$HASH" >> .env
echo "KYMOMAP_LAN_ORIGIN=http://192.168.1.42:8080,http://kymomap.local:8080" >> .env   # your hub's reserved IP
docker compose -f docker-compose.hub.yml -f docker-compose.lan.yml up -d

The single quotes around the hash matter: it contains $ signs that Compose would otherwise try to expand. The origin list must be exactly what phones will type, because the hub refuses a POST from any other origin.

The passphrase is the one you typed into that first line, and the moment you choose it is the only time it exists in the clear. The hub stores only its hash and cannot show it back, so write it on the sheet with the key. Forgot it? Run the three lines again with a new one and the door restarts with that. The user name is always kymomap.

The link is port 8080, not 3900. On the phone: http://kymomap.local:8080/setup while you are installing, http://kymomap.local:8080/live once sensing has started, or the hub’s address with :8080. The browser asks for a name and a passphrase: the name is kymomap and the passphrase is the one from the first line above. Ports 3900 and 8787 answer only on the hub’s own loopback and will never load from another machine; that is the door working as designed, not a fault.

No LAN door? From a laptop, ssh -L 3900:127.0.0.1:3900 -L 8787:127.0.0.1:8787 kymomap.local and open http://localhost:3900/live. It works; it just does not fit in a pocket.
Keep those local port numbers. The web container is built with the hub at http://127.0.0.1:8787, so a page served from a loopback address looks for its hub on 8787 of the machine the browser is on. Forward to any other local port and the page loads, the map stays empty, and nothing says why. (Reached through the LAN door instead, the page is not on loopback and asks the origin it came from, which is Caddy — that path needs no forwarding at all.) If you must use another port, append ?api=http://localhost:YOURPORT to the URL and the page will believe you.
Checkpoint · stop here and look
  • The setup page opens from a phone and its badge reads hub · hardware · waiting, not “demo house”.
  • /api/runtime reports waiting with the reason “no building yet”. failed means the key file is missing or not 32 hex characters.
  • The hub's IP is reserved on the router and written next to the key.
Stage 02

Two nodes on the bench

TIME · 1 to 2 hours, including the toolchainOPEN · one 3-pack of XIAO ESP32-C6 and two USB-C cables. No box, no antenna yet.

CSI needs a transmitter and a receiver, so the first real radio takes two boards, bare, next to your laptop. This is where the firmware's assumptions meet silicon for the first time. Nothing downstream is worth building until these three numbers are right.

2.1

Install ESP-IDF v5.5.5 on your laptop

Older IDFs will not do: v5.5 changed the ESP-NOW send callback, and anything before v5.2 lacks the rate-pinning call this firmware depends on. On a Mac, brew install cmake ninja dfu-util first.

git clone -b v5.5.5 --recursive https://github.com/espressif/esp-idf.git ~/esp/esp-idf
~/esp/esp-idf/install.sh esp32c6
. ~/esp/esp-idf/export.sh            # every new shell

cd kymomap/firmware
idf.py set-target esp32c6
idf.py build
shasum -a 256 build/kymomap-node.bin # compare with BUILD.txt in CI's kymomap-node-esp32c6 artifact

CI builds the same sources in Espressif’s own image on every push and publishes the binary with its hash. Matching the hash means two benches are comparing the same bytes.

2.2

Flash the first board and open its console

Take one board from the 3-pack. Plug it into the laptop over USB-C: it enumerates as USB Serial/JTAG with no adapter and no jumper.

ls /dev/cu.usbmodem*                        # Linux: /dev/ttyACM*
idf.py -p /dev/cu.usbmodem101 flash monitor   # Ctrl-] leaves the monitor

If no port appears, or flashing times out: hold B (boot), tap R (reset), release B. Flash, then tap R to run. A fresh board blinks a slow 1 Hz square wave: unconfigured, waiting to be claimed.

2.3

Configure it as node 0

At the kymo> prompt in the monitor. Everything but idx and id is identical on every node in the installation; rate is in tenths of a hertz, so 100 is 10 Hz.

kymo> kymo-show
kymo> kymo-set idx=0 n=12 ch=6 rate=100 id=n01-hall ssid=kymo-hub psk=<the Wi-Fi passphrase>
kymo> kymo-set hub=192.168.1.42 key=<the 32 hex characters>
kymo> kymo-save                        # writes NVS and restarts into the mesh
  • idx is the node’s slot, 0 to 11, unique. n is the total, 12, on every node.
  • ch is the sounding channel from step 0.4, the same one the access point is fixed to.
  • id is up to 23 characters and is exactly the name you will type into setup later. Lowercase, a number and a room: n01-hall, n02-living.
  • hub is the reserved IPv4 address from step 1.3.

The same without typing into a monitor, and without the key ever appearing in a shell history: firmware/tools/kymo_provision.py sends those lines over the USB port and prints what the console answers.

python firmware/tools/kymo_provision.py --port /dev/cu.usbmodem101 --key-file ~/.kymomap/installation.key \
    idx=0 n=12 ch=6 rate=100 id=n01-hall ssid=kymo-hub psk=<the Wi-Fi passphrase> hub=192.168.1.42
python firmware/tools/kymo_provision.py --port /dev/cu.usbmodem101 --show

After the restart, kymo-show should print data auth on, the channel, the slot and an uplink that is up. If the uplink stays down, read the boot log: the firmware names the access point’s channel in one line, access point ‘…’ is on channel 1 but sounding is on 6. That is the number step 0.4 asked for; either move the access point to the sounding channel or set ch= to the access point’s. Then repeat 2.2 and 2.3 on the second board as idx=1, id=n02-….

2.4

Watch frames arrive at the hub

Setup is not finished, so the hub’s own listener is idle and port 5115 is free. Run the adapter by hand inside the hub container; it prints every frame it accepts, the authentication tally, and each node’s telemetry line:

ssh kymomap.local
cd kymomap && docker compose -f docker-compose.hub.yml exec hub \
  python -m kymomap.adapters.esp32c6_espnow --key-file /data/installation.key --quiet-frames

Now read the three numbers that decide whether the design holds. All three are assumptions until you see them.

  1. 52 subcarriers. The firmware pins ESP-NOW to 802.11g at 6 Mbit/s so each sounding carries a 64-bin training field. If the pin was refused, ESP-NOW falls back to 1 Mbit/s DSSS, which has no subcarriers at all. Zero or a handful means nothing downstream can work; the fix is in the firmware, not in placement.
  2. csi_unpaired stays near zero. The firmware assumes the CSI callback fires just before the ESP-NOW receive for the same packet. If that is wrong this counter climbs fast, and the answer is to rethink the pairing, not enlarge a buffer. This is the single most consequential unverified assumption in the firmware.
  3. csi_dropped is zero. Non-zero means the node’s work queue overflowed and it is silently discarding links, which looks like a poor reconstruction rather than an error.

Also confirm auth reads on and frames_withheld is zero on both status lines, and that the adapter counts accepted frames rather than no_key or bad_tag. Stop it with Ctrl-C when done.

Checkpoint · stop here and look
  • Two bare boards, both blinking once every two seconds: sounding, uplink up.
  • 52 subcarriers on every frame. csi_unpaired near zero. csi_dropped exactly zero.
  • Frames accepted with authentication on; nothing withheld, nothing refused.
  • The hashes of the binary you flashed and CI's build match, or you know why not.
Stage 03

The template node

TIME · 45 minutesOPEN · one ABS box, one antenna kit, one charger, one hook, the step drill

Build exactly one complete node before touching the other eleven. Drilling the first box carefully makes it the template for the rest; identical hole positions mean identical antenna geometry, which is the same discipline as identical mounting height.

3.1

Drill the box

Two holes. 6.5 mm in the top face for the RP-SMA bulkhead, centred, so the antenna stands vertical when the box hangs. About 9 mm in the bottom face for the USB-C cable. Use the step drill, slowly, from the outside in: the ABS is thin and cracks under a twist drill, and the RP-SMA cutout tolerance is a tenth of a millimetre.

Mark the hole centres on this box with a pencil before drilling, and after drilling trace them onto a card. That card is how the next eleven boxes get the same holes.

3.2

Fit the antenna and the board

  1. Push the pigtail’s RP-SMA jack through the top hole from inside; nut and washer on the outside. Snug, not tight.
  2. Press the pigtail’s U.FL plug straight down onto the board’s U.FL socket until it clicks. Never at an angle, never with a twist; the socket is rated for a handful of insertions.
  3. Fix the board to the box floor with a foam pad or a dab of hot glue, USB-C toward the bottom hole. Route the cable in.
  4. Screw the 3 dBi whip onto the bulkhead outside. Close the lid.
3.3

Prove the RF switch is on the external antenna

The XIAO has a chip antenna, a U.FL socket, and an RF switch between them that defaults to the chip antenna. Plugging in a pigtail changes nothing. The firmware drives the switch at boot with GPIO numbers taken from documentation, not from a board, and the board gives no feedback. If it is wrong, twelve nodes run several dB down on every link and the map looks like a house with thicker walls. Confirm it once, here:

  1. With the box on the bench and its antenna on, run kymo-show in the monitor and note ap_rssi.
  2. Unscrew the antenna. RSSI should fall by several dB: the U.FL side is now an open circuit.
  3. If it does not move, the radio is still on the chip antenna. In idf.py menuconfig, Kymomap node, flip “Antenna select is high for the external antenna”, rebuild, reflash, repeat.
Unverified on hardware. The GPIO numbers (enable 3, select 14) and their polarity are documented values. This step is the measurement that settles them, and whatever it says applies to all twelve boards.
3.4

Label it, then hang it

Tape with the node’s id and idx on the box, before it leaves the bench. Stick the hook at the tape mark, hang the box with the antenna vertical, plug the charger into the socket. The LED settles to one short blink every two seconds.

Then measure it: distance from the origin corner along x, along y, and height of the box centre above the floor. Write all three down now, on the same sheet as the key. A position remembered later is a position guessed.

Checkpoint · stop here and look
  • One boxed node on a wall, one blink every two seconds, position written down to the centimetre.
  • RSSI fell when the antenna came off. You know which antenna the switch is on.
  • A drilling card traced from this box, ready for the next eleven.
Stage 04

The other eleven

TIME · 2 to 3 hoursOPEN · the remaining eleven XIAOs, boxes, antenna kits, chargers and hooks

Now it is repetition against a template. The image is identical on every node; only the slot and the name differ, and both are set on the USB console. Label before you unplug, measure as you hang.

4.1

Flash each board; configure it over USB or over the air

Flash all the remaining boards in a row without rebuilding. Then each one is configured one of two ways. Over USB, exactly as in step 2.3 with the next idx and its own id: the second bench board becomes idx=1 on the wall, the template is idx=0. This path needs nothing from the hub and is the one where physical access is the credential.

for port in /dev/cu.usbmodem*; do idf.py -p "$port" flash; done
idf.py -p /dev/cu.usbmodem101 monitor    # then kymo-set idx=2 id=n03-… and the shared values, kymo-save

Over the air, from the setup page, once the template node (step 2.3) is on the wall and reporting to the hub. A freshly flashed board parks on channel 1 and broadcasts a beacon every three seconds; a configured node that hears it relays the beacon to the hub, and the board appears under Discovered, not yet claimed as its MAC. Identify makes it blink at 8 Hz so you know which box it is; Claim sends it, through that same configured node, a SET_CONFIG carrying its slot, the channel, the rate, the hub address and uplink from step 1.5, and the installation key. It writes that to flash and restarts into the mesh. Its id is its MAC (a4cf129e0311); the name you give it is a label.

  • The relay only works while the new board and a configured node share a channel. A fresh board sits on channel 1; if the mesh sounds on channel 6, park one configured node on channel 1 for the duration (kymo-set ch=1, then back) or use USB.
  • The board accepts an unauthenticated configuration for ten minutes after power-up, and only one that names its exact MAC and installs a key. Past ten minutes, power-cycle it; the hub’s response says so when a blink went nowhere.
  • The page says where each packet went (“relay via n01-hall, unauthenticated”) or why it did not go: no configured node reporting in the last 90 s, no uplink credentials on the hub, no channel known yet.
This path is tested against the firmware’s packet layout (core/tests/test_control_plane.py parses every byte it sends with the same rules kymo_cmd.c applies), not against a board: nobody has watched a real XIAO take a relayed SET_CONFIG yet. If the first one does not, the USB console loses nothing, and the packet the hub sent is in the runtime status for the bug report.
4.2

Build against the template

Drill each box from the card, fit the bulkhead, click the U.FL on straight, mount the board, close the lid, screw on the whip. Write the node’s id and idx on tape on the box the moment the board goes in. By node nine every board looks identical, and a mislabelled node produces a map that is confidently wrong, not obviously broken.

4.3

Hang, power, measure

  • Hook at the tape mark, antenna vertical, boxes in one consistent orientation. Different heights are fine; unrecorded heights are not.
  • Laser x, y and z from the origin corner as each goes up. Write it on the sheet against the node’s id.
  • Watch the LED settle. If you ever doubt which box is which, kymo-identify on that board’s console blinks it hard for ten seconds.
What the LED is saying
Slow 1 Hz square wave: unconfigured, waiting to be claimed.
One short blink every two seconds: sounding, uplink up. Normal.
Two short blinks every two seconds: sounding, but the hub is unreachable. Check ch and hub=.
Fast 8 Hz: answering IDENTIFY.
Checkpoint · stop here and look
  • Twelve boxes on walls, each labelled, each blinking once every two seconds.
  • A sheet with twelve ids, twelve idx values and thirty-six numbers in metres, plus the key, the channel and the hub's IP.
  • Nothing showing two blinks. If one does, fix it now; a node the hub cannot hear is a twelfth of the mesh missing.
Stage 05

Setup on the hub

TIME · 30 minutes, plus a walk outsideOPEN · the phone, the sheet

The setup page is driven by the hub's own state machine: it shows the step the hub says is next, and a restart lands you back on it. Ten steps, two optional, in the rail's order: 1 Welcome, 2 Where is it, 3 Scan, 4 Floors and outlines, 5 Find and name your nodes, 6 Where each node is, 7 Pick a channel, 8 Baseline, 9 Storage, 10 Done. This is where the sheet of numbers becomes a building.

5.1

Welcome (1), Where is it (2), Scan (3)

Open http://kymomap.local:8080/setup on the phone. Welcome is step 1: it states what this is and is not, and Start moves on to step 2. Where is it is optional: type rough width, depth and floors, or install a map of your region once so the hub can look the address up offline; either suggests an outline for the next step. The lookup finds only buildings that OpenStreetMap has tagged with a street and number, which in much of suburban North America is few of them, so “no building at this address” is common and means nothing about the sensing. Type the dimensions instead. Scan is optional too: if you have an iPhone Pro, a free LiDAR app can capture each room and the hub derives the walls — step by step, one room at a time, in 5.1b. Skip both today if you like; drawing the outline by hand is not the lesser path, and the captures in Stage 06 are worth more than a scan. The scans are worth coming back for: they are what give the map named rooms and a measured storey height instead of a typed one.

5.1b

Scanning the rooms, one at a time (optional)

If you do have a LiDAR phone, /scan opens on Scan your house, which is a checklist rather than a file picker: it walks the storeys bottom up and asks for one room at a time. The list is built from what the hub already knows — the storeys you drew, the rooms a previous merge found, and your nodes’ own labels, because a node called “Kitchen” is sitting in a room called Kitchen. On a house with nothing scanned yet it asks you to type the room names first, one per line. It is designed for a phone held in one hand, which is where you will be.

Each room, in order. Open 3D Scanner App (free, no account, nothing leaves the phone), choose LiDAR mode, and start recording in a corner. Hold the phone at chest height and walk the walls slowly, tilting up to take in the ceiling and down to take in the floor, and go into the corners rather than sweeping past them. Furniture is fine and is ignored. Finish where you started: the loop closing is what fixes the scale, and a scan that drifts arrives as two floors a few centimetres apart and is refused with that sentence. Then Share → Export → USDZ, and upload it in the card. The hub parses it in under a minute and answers with three checks in plain words — ceiling found, loop closed, scale confirmed— each a tick or one line saying what to do about it. Nothing marks a room done on your behalf: the hub can say the ceiling was found, it cannot say the file is of the room the list asked for. Press Done, next, or Skip for now if you would rather come back to it.

Between storeys: scan the stairs. One pass, bottom to top, with both floors in frame as you climb. This is its own item because it is the one that ties the two floors together: without it the hub knows the shape of each storey and not how far apart they are, and with it the height of the upstairs floor is measured rather than typed. On the reference install that measurement came out at 2.90 m against the 3.00 m that had been typed, with a 0.515 m slab between the garage ceiling and the floor above it against an assumed 0.3 m.

“Done with this level?” confirms a storey and moves up. A confirmed storey is not re-opened by the wizard, even if a room in it was skipped — it is your statement that the floor is finished, and you can say “not done after all” from the checklist underneath. At the end, Done with the house runs the merge: every scan the hub can place goes into one model, the storeys stacked at the heights the scans measured, the rooms cut apart at the doorways, and the room list appears for you to name. A scan the hub cannot place is listed with the reason rather than put somewhere plausible — see 5.1c.

All of it is remembered on the hub, so closing the phone, reloading the page or restarting the hub picks up where you left off. The rail says how far through you are: “6 of 9 rooms scanned · stairs done”.

5.1c

A scan the hub says it cannot place

Two scans are joined by their walls, and the hub asks the question twice — how well the wall rasters correlate, and what fraction of one scan’s wall points land within 15 cm of the other’s. Both have to be decisive and both have to agree on the same quarter-turn, or the scan is reported not placed and gets no transform at all. That is deliberate: one square-ish room inside a bigger floor plate genuinely has four equally good placements, and a person holding both scans could not say which either.

The fix is two nodes. Press Tie it by hand, which opens the scan on Tap the nodes: pick a node you can see in the room from the list, orbit to its box on the model and tap it; then do the same for a second one. The hub already knows where those two boxes are in the house, so two of them settle which way round the room goes outright — no correlation, no margin, no tie-break. The panel counts them for you (“1 of 2 nodes tapped”) and offers Merge again as soon as there are two. A scan placed this way is labelled nodes rather than walls in the source list, and it beats the drawn outline wherever the two disagree: a rectangle you typed is a drawing, and a node is a measurement.

5.2

Levels

One level per storey. Add the ground floor as a rectangle in metres, width along x and depth along y from the same origin corner you measured from, with its ceiling height. Add the upper floor. Then Levels are complete.

Two numbers per storey, not one. Floor to floor is the pitch — the distance from this floor to the one above. Floor slab above is what sits between this storey’s ceiling and that floor: joists, subfloor and finish, 0.3 m for a typical timber floor. The ceiling is the difference, and the map prints it as you type. The slab belongs to no storey, and that is the point: the reconstruction stops at the ceiling instead of being free to place a body inside the floor, and a reading that lands in the gap is reported as “between storeys” rather than filed under one of them. A storey typed before this existed keeps the geometry it was given — a house whose volume changed underneath a running hub would rebuild its voxel grid and set its recordings aside — so set the slab here if you want it.

If you scanned the rooms (5.1b), this step will offer you the heights the scans measured, beside the ones the document carries, storey by storey: the ceiling from the horizontal planes each scan found, and the floor-to-floor pitch from the scan that walked the stairs. Use measured heights adopts them. It is a button and not something the merge does on its own, because a storey height is not a label: it is the slab the voxel mask is cut from, so adopting it rebuilds the grid and the reconstruction’s weight matrix and moves the recorded frames aside — they were reconstructed against a grid that no longer exists. Sensing restarts on the remembered quiet floor and measures nothing new.

5.3

Enrol: a data cable to the hub, one board at a time

The normal path is the cable. Plug a board into one of the hub’s own USB ports with a data cable — a charge-only cable presents no data lines and the port never appears — and the Enrol step lists it by the stable by-id name, with its MAC read off the board as its factory identity. Flash it if it is blank, then give it a name and a level and press Set up and claim: the hub writes the id, the key, the channel, the uplink and the hub address over the console, saves them on the board, and adds it to the roster in one action. Unplug it and power it where it will live. One board is one identity; the MAC entry is that same board before its set-up, never a second kind of node.

Two other ways in exist. A node configured from your laptop is added with Add a node by id, where the id must be exactly the string you gave kymo-set id= — the hub matches frames to the roster by that name and a frame from an unknown id is logged as an unknown node. A board that announced itself over the air is in Discovered, not yet claimed: pick its level, Identify it to make it blink, Claim it. Then Nodes are enrolled.

Finishing enrollment sends every claimed node its final slot number and the mesh size, the channel and the rate — the over-the-air ones through their relay, the USB-configured ones directly and authenticated, keeping the key they have. Slots follow the roster’s id order, so a hand-set idx may change; that is fine, the hub’s table is the one that matters. Re-send configuration does the same on demand, after moving or re-flashing a box. Each node writes the configuration and restarts, so expect a few seconds of silence from the mesh.

In simulator or idle mode nothing listens for beacons and Identify is recorded on the roster only; the page says so. On hardware, an empty Discovered list with no node reporting means the first node is not reaching the hub yet — step 2.4 — because beacons arrive through a configured node.
5.3b

Update the firmware on a node that is already on the wall

Same button, same cable, and the board keeps everything about itself. Take the node down, plug it into one of the hub’s USB ports with a data cable, and press Flash on the Enrol step. It writes three images — the bootloader at 0x0, the partition table at 0x8000, the application at 0x20000 — and never erases the chip, so the nvs partition at 0x9000, where the board keeps its id, its slot, the channel, the Wi-Fi credentials and the command key, is not touched. The hub checks that rather than assuming it: it reads the partition table it is about to write, and refuses to flash at all if any image would land on a data partition. Then it reads the console back and tells you whether the configuration survived, before you unplug.

  • “kept its configuration” — unplug it and power it where it lives. It rejoins the mesh in about ten seconds under the same name.
  • “came up blank” — give it a name and a level and press Set up and claim while it is still on the cable. The hub still knows its slot, channel, key and uplink; only the board forgot.
  • Do them one at a time. Each board is out of the mesh while it is unplugged, and the links through it carry no traffic, so a baseline recorded during the round of updates is a baseline recorded with nodes missing (see “What can go wrong”). Recalibrate from an empty house once every board is back up.

The image comes from CI, which builds it on every push with the pinned ESP-IDF and publishes it as the kymomap-node-esp32c6 artifact (.github/workflows/ci.yml, job Firmware build): download it and put bootloader.bin, partition-table.bin and kymomap-node.bin in the hub’s /data/firmware. The Enrol step says which of the three are present and whether esptool is installed; Flash is refused with the missing name rather than half-writing a board.

Firmware 0.2.0 is the first that takes the hub’s clock. Until a board runs it, the hub keeps time for that board by restamping its frames on arrival — which is why the Diagnostics finding about it is informational — and does not send it a SET_TIME it would only reject. After the update the board stamps its own frames with real time and its row reads “clock set”.
5.3c

Update the firmware over Wi-Fi, after the first one

Firmware 0.3.0 is the last one that needs a cable. It is the first image that contains the updater, so it has to be carried to each board by hand once (5.3b above); from then on the hub can install a new image on a board that is on a wall, behind a wardrobe or in the attic, without anyone touching it. Do the cable round first, all nine boards, then never again.

On the Enrol step, under Update the firmware over Wi-Fi, each board is listed with the version it reports, the slot it is running from and either an Update this one button or the sentence saying why not. Press it and the hub sends that board a short, authenticated command carrying two things: a URL on this hub and the SHA-256 of the image at it. The board does the rest.

  1. It fetches the image over its own Wi-Fi uplink from the hub’s firmware port (5118, open on the LAN like the two the nodes already use — the hub’s API port is not, and must not be, because it has no accounts).
  2. It writes it to whichever of its two spare app slots is not running. The image it is running is untouched throughout.
  3. It hashes every byte it wrote and compares it with the hash in the command. If they differ it throws the slot away and says so. This, and not the transport, is what makes the image trustworthy: the hash arrived inside a packet signed with your installation key, so somebody who could serve the board a different image still cannot make it boot one.
  4. It reboots into the new image on probation. The new image has two minutes to receive one command from the hub that verifies against the installation key — which proves the radio came up, the uplink associated, the key survived and the hub can reach it. The hub sends that the moment the board reports in.
  5. If the two minutes pass with nothing verified, the bootloader puts the old image back and the board reboots into it. Nothing is lost, and the board never stopped sounding. The page says “rolled back” and names the board.

It takes about a minute and a half a board, most of it the download. The node’s LED blinks at 4 Hz while it fetches.

What the hub refuses, and why

  • One board at a time. A node is off the mesh while it fetches and reboots, and every link through it is measuring nothing meanwhile. “Update all” sends the first and offers the next when that one is back.
  • Nothing below −75 dBm, and nothing being relayed. A megabyte over a weak association takes a board off the mesh for far longer than the update is worth, and a board reporting through a neighbour has no uplink to fetch over at all. Move the box or the access point first — the same fix as for a node that flaps online and silent.
  • Nothing on firmware older than 0.3.0. That image has no updater in it — and every board in this house reports 0.2.0 today, so today that means all of them. It would refuse the command harmlessly — its parser has no valid size for a command type it has never heard of, so the packet is thrown away before anything acts on it and the board goes on sounding — but the refusal is counted on the board, so the hub does not send one it knows will bounce.
  • Nothing while a board is still on probation, and nothing at all if the image does not hash to what BUILD.txt beside it says. Both halves come out of one CI artifact or neither is used; copying a new kymomap-node.bin over an old one and leaving the old BUILD.txt is the mistake that check exists for.

Putting an image on the hub

CI builds it on every push with the pinned ESP-IDF and publishes kymomap-node.bin, bootloader.bin,partition-table.bin and BUILD.txt as the kymomap-node-esp32c6 artifact. Download it and put all four in the hub’s /data/firmware; the Enrol step reads the image, hashes it, checks it against BUILD.txt and shows the version. Nothing is fetched from the internet by the hub or by a board.

Recalibrate from an empty house once every board is back up. Each one was out of the mesh while it updated, and a baseline measured during the round is a baseline measured with a node missing.
5.4

Place: pin it, or let the hub work it out from the laser

Three ways, and they agree because they all end in the same three numbers. Pin it on the plan: pick the floor, click or drag the node where it is, and give its height above that floor — the hub stacks the storeys, so z comes out right without arithmetic. Type the sheet in: x, y and z straight from Stage 00. Or give it the laser readings: two or three perpendicular distances to walls you name, and the hub solves the position from them and shows you the residual, which is the honest measure of whether your readings agree with each other. A single reading to the floor or the ceiling refines the height on its own.

Then Place. The hub runs a placement check afterwards: nodes outside the outline, too close together, a storey with too few, a mesh with too few links. Read each finding; acknowledge the ones you chose deliberately in Stage 00. Then Placement is complete.

5.5

Channel

The hub’s radio surveys the 2.4 GHz band every fifteen seconds (step 1.4) and the step shows it live: networks and busyness per channel, with the quietest proposed. The proposal stays within 1–11 and honours the channel the nodes’ own access point is on; take it whole, or pick your own. The choice is sent to every node when you finish enrolment, so it need not match what a board was first configured with.

One refusal matters: a channel that differs from the node network’s access point is rejected unless you confirm it. The nodes follow the hub onto the new channel; the access point does not, so they lose their uplink and the whole mesh goes quiet with nothing to say why. If you confirm it, move the access point to the same channel in the same sitting.

5.6

Baseline: everyone leaves

Leave the building and start adopts the building you drew and starts sensing; the hub’s first forty-five seconds are the baseline. It has to be a genuinely empty house, including whoever pressed the button: start it, walk out, close the door, wait for the Sensing card to read calibrated, come back and press Record this baseline. A baseline recorded with one person standing still in the hall looks valid and sets every threshold wrong in a way nothing downstream can detect.

5.7

Retention, Done

Raw frames are kept for a rolling window so a moment can be replayed; the map needs none of it. Ask for an estimate for 24 hours in the hub’s data directory, read the size against the free space, Keep this. Twelve nodes at 10 Hz is roughly 8 GB a day stored. Then finish, and open the live map.

Checkpoint · stop here and look
  • The Sensing card reads running and calibrated, frames counting up, twelve nodes seen.
  • The live map shows the empty house as empty, on both floors, with 66 links fresh.
  • Walk in. One person appears, on the right storey, as a soft disc sized to the calibrated error.
Stage 06

Reading the live map

TIME · ten minutes, onceOPEN · a phone or a laptop, and somebody to walk around

The map is deliberately hard to over-read. Every mark on it is either a measurement or a statement that there is no measurement, and the difference is drawn, not written. Learn the four rules once and the map stops being a picture and starts being a reading.

6.1

Plan and 3D, and how to move around both

The stage has a Plan / 3D toggle; your choice is remembered. The plan opens fitted to the buildings, not to the plot — a house seventeen metres across on a parcel forty-three metres deep is a smudge when the view fits the parcel. House and Land switch between the two framings, + and − step the zoom, the wheel or a pinch zooms about the pointer, dragging pans, and a double-click goes back to the house. The 3D view orbits by dragging, zooms on the wheel, a pinch, the buttons or + and −, and H returns to the three-quarter view of the storey you are on. Scrolling with the pointer anywhere but over the map scrolls the page as usual.

The 3D view draws every storey of every building and the land they stand on, always — the storey you have selected brighter than the rest. A house that dissolved when you pulled back to see the plot would not answer the question you pulled back to ask. The arrow on the ground at the top of the plot is north.

6.2

Colour is magnitude; how solid it is, is support

Colour along the ramp is how much attenuation the hub reconstructed. How solid a cell is drawn is how much the radio supports it — how many links cross that voxel, from how many directions. The two are independent and the map never mixes them: a bright cell nobody can see is drawn faint, and it should be.

Support is scaled to the frame. A sparse mesh supports nothing strongly in absolute terms, and drawing the picture in strict proportion to a number that never exceeds a tenth made a real person walking upstairs invisible. So the best-supported cell in each frame is drawn at full strength and anything carrying at least half of it is drawn solidly enough to see. The ranking is untouched. It is not a probability, and the panel on the right says which it is.

6.3

Unpainted is not empty, and a dead node is loud

A cell the hub had no reading for is left unpainted — the ground shows through. That is not a confident empty room; it is outside the building you drew, or the reconstruction did not reach it. Nothing on this map draws “nobody there”, because nothing measured it.

A node that stopped reporting is drawn hollow in the critical colour with a ring and a cross, and its links go dashed; a node never heard from at all is drawn the same way in the warning colour, because a commissioning mistake and an incident are different problems. Both are equally absent from the reconstruction. A map that quietly dropped a failed sensor would look exactly like a map of a house with nobody in that corner.

6.4

The person: a dot, and a shell that is the error

An occupant is a dot at the estimated position. Around it is a translucent shell whose horizontal radius is the calibrated error ring — the radius that contained the person on about 85 % of held-out frames, which is what it measures rather than the ninety per cent it is fitted to reach — and the legend names the basis that number came from. Until a calibration walk has been done in your building the basis reads simulator, which means the radius is honest arithmetic on somebody else’s house. When the hub has no calibrated radius at all the shell is a wireframe at a fixed size: “we do not know how wrong this is” must not be able to read as “this small”.

The shell is the whole storey tall. The nodes sit in a nearly flat plane, so the reconstruction separates floors well and does not resolve metres of height inside one. The dot is drawn at the height the field peaked at because that is the measurement; the shell is what says how much of that height is known.

“at 2.9 m, between storeys”. The reconstruction solves a volume — thirty-four voxels of height on the reference house — and the map draws one plan plane per storey, so a reading that sits near the line between two floors used to be filed under whichever floor the arithmetic picked. It is now published as the column it came from, and a height within half a metre of a storey boundary (or in no storey at all) is labelled as such rather than assigned. Read it as a statement about the mesh: the downstairs nodes sit around a metre up and the upstairs ones around four, so nothing measures the metre and a half between them, and a reading there is the two floors disagreeing rather than somebody in the joists.

“small movement”. A mark that has not lasted three seconds is drawn as a small dashed circle labelled small movement with its height, never as a person. This is not a filter — it is still drawn, and it still carries where and how high — it is the difference between showing you something and telling you it was somebody. Mined out of the reference house’s own recording over thirty-two hours with nobody in it, seven hundred such flashes had a median length of six tenths of a second, almost no support, and sat half a metre from the midpoint of one of the mesh’s own links: one link going loud for a moment with nothing to cross it against, which the solve has to put somewhere and puts in the middle of that link.

Sensing outside the plot is always drawn. If somebody walks up the drive and the mesh sees it, you see it — that approach is real information, and hiding it because it is off the parcel would be editing the reading.
6.5

Heart rate and breathing: what this rig can and cannot do

It cannot measure a heartbeat, and it is not going to. The reasons and the numbers are in docs/vital-signs.md; the short form is that the nodes report per-subcarrier amplitude and no usable phase, the links are room-scale rather than the half-metre chest-facing pair the published work uses, and a heartbeat moves the chest wall by micrometres. Nothing about sampling faster fixes any of those. There is no heart-rate setting on this hub because there is no honest one to offer.

Breathing is a different question with a different answer: chest motion is millimetres, not micrometres, and it is what the still-presence path already leans on to keep a motionless person on the map — presence, not a rate. Whether a rate in breaths per minute is reachable on one strong link is written up honestly in the same document, with what it would take to find out.

Checkpoint · stop here and look
  • Plan and 3D both draw the same house, the same nodes and the same links.
  • Walking around: the dot follows you, on the right storey, inside its shell.
  • The right-hand panel shows a support figure for your mesh. If it is under 10 %, read stage 08 — that is the number that decides whether anybody can be detected at all.
Stage 07

The first captures

TIME · the rest of the day, and the next morningOPEN · masking tape, a metronome app, two people for the last one

Every threshold in the pipeline was fitted against a simulator whose quiet room is thermal noise and slow drift. A real quiet room has a fridge compressor and a curtain in a draught. These four recordings are what turn every number on this site from a simulation into a measurement, in either direction.

7.1

How a capture is made

A capture is a labelled session: frames, marks, telemetry and a manifest that bakes in the building, the node positions and your notes, so it still explains itself years later. The recorder binds the same port the hub’s runtime uses, so stop sensing from the Sensing card first, then run the recorder inside the hub container. Typing a word and Enter while it runs drops a mark at that frame.

docker compose -f docker-compose.hub.yml exec hub python -m kymomap.capture record \
  --out /data/recordings --name empty-evening --activity empty-house \
  --site /data/site.json --key-file /data/installation.key --seconds 600 \
  --env hvac=off --env fridge=on --env windows=closed --env pets=none --operator you

Score it the moment it ends: python -m kymomap.score /data/recordings/<session> replays it through the same pipeline the hub runs and prints only what the ground truth supports, labelled real. Restart sensing from the Sensing card afterwards.

7.2

A · Ten minutes of a genuinely empty house, twice

The single most valuable file this project can produce. It sets the constant that stops an empty house reporting phantom occupants, which is currently fitted to simulated noise and will move. Ten minutes minimum, nobody in the building, and again at a different time of day: empty at 3 a.m. and empty at 6 p.m. are different rooms electrically. Note the weather, HVAC, appliances, windows, pets.

7.3

B · A metronome-paced walk of a taped rectangle

Tape a 4 by 3 metre rectangle on the floor, laser its corners in the site frame, record them as ground truth. Walk the perimeter at one corner every four seconds, at least three circuits, dropping a mark named corner 1 to corner 4 as you pass each. Then once more at half speed; slow movement is the hard case. Everything about localization accuracy is scored against this file.

7.4

C · One person standing still

Five minutes in one place, then five in another at a different distance from the nearest link, seated and standing noted. This is the honesty check on still presence: in simulation it works only at low per-link drift, and this recording is what decides whether a real room is in that regime.

7.5

D · Two people

Three minutes each: apart in different rooms; converging and separating in one room; crossing paths; one moving and one still; one per storey. In simulation a crossing does not swap identities, it destroys them, 36 of 40 times. If two people do not work on real radio, that is a finding for the capability matrix, not a bug to hide.

7.6

Replaying what the hub already recorded

You do not have to make a capture to have one. The hub has been writing raw frames, per-link features, reconstructions and events to its store since the day it was set up, and the recordings page is how you look at them: a strip of mesh activity over the last day, drag to zoom, click a moment to replay it on the map at real speed with every link’s activity beside it. Everything on that page is labelled recorded and carries its timestamp, and nobody is drawn on it — the store keeps the reconstruction’s values and one confidence per frame, and the tracker’s output was never written to disk.

Export on a replay downloads that window’s raw frames as a .kymo capture — the same container the recorder writes and the replay adapter reads — so a real walk through your own house can be run against new code without touching the hub:

curl -o walk.kymo 'http://kymomap.local:8080/api/recordings/export?t0=<epoch>&t1=<epoch>'

KYMOMAP_MODE=replay KYMOMAP_REPLAY=$PWD/walk.kymo \
KYMOMAP_DATA_DIR=/tmp/kymomap-replay KYMOMAP_PORT=8788 \
  python -m kymomap.api.app

The data directory has to hold the same site.json the walk was recorded under, or the geometry will not match the frames. Export more than you want to watch: a replay hub spends the first forty-five seconds of the file measuring its quiet level, so a one-minute export teaches it that walking is normal and then has fifteen seconds left. docs/recordings.md has the whole workflow and what the store can and cannot answer.

7.7

Recording a walk

A walk you did not mark has to be found again afterwards, and the first clean walk through the reference house had to be dug back out of the store minute by minute because the recorder that was watching it had failed. Two ways to avoid that, and they answer different questions.

From the page. Press Record a walk on the recordings page, walk the rooms you want to test, press Stop the walk. The window is read back and summarised: how long it ran, how many bins the hub was not writing in, which storey held the loudest reconstruction and in how many bins — the number to check against where you actually were — and the loudest links by name. The mark survives a reload, so locking the phone halfway round the house costs nothing. It still draws nobody, for the same reason as above.

Say which room you are in. The window says when and nothing about the house, and where you were is the one thing the radio cannot supply. While the walk runs the page shows I am in: and a row of rooms — tap one as you enter it, or type a name in another room… and it becomes a button on every later walk. The hub stamps the time, not the phone. Afterwards the summary lists where you said you were and how long each room stood before the next mark, which is the only span a mark supports. A walk with no marks is still a recording; it just has nothing to score the map against.

Today the buttons are your storeys plus whatever you have typed before. Named rooms from a phone scan will appear here when the scan importer names them; it stores their shapes and nothing names them yet.

From the hub. The page can only see what was stored, and two of the three things worth knowing about a walk exist only while it happens: the tracks the pipeline published, which are never written to disk, and where the detection floor was standing at the time. scripts/walk-record.py writes one line every two seconds with both, and ends with a plain verdict on whether the map was ever allowed to see you:

scp scripts/walk-record.py HUB:~/
ssh HUB 'python3 ~/walk-record.py --seconds 300 --out ~/walk.jsonl'

Standard library only, nothing to install, nothing written to the hub. Start it before you start walking. It picks up the room marks too, prints each one as you tap it, and ends with the field peak measured between one mark and the next.

Checkpoint · stop here and look
  • Two empty-house sessions, one rectangle walk at two speeds, one standing-still, five two-person clips: all scored, all with manifests.
  • The full sessions live on the hub and are backed up; only their manifests and 30-second excerpts go into git.
  • Every number you publish from them says “real”, next to the simulated one it replaces, and never without the label.
Stage 08

A still session

TIME · fifteen minutes, most of it out of the houseOPEN · a pulse oximeter, a phone with a clock, pen and paper

The breathing estimator has never seen a chest, and it will not show a rate to anybody until this has been recorded and has passed its gate. Nothing on the hub starts a still session on its own: it happens because you press the button, and it stops when you press Stop. The full protocol, and what each instrument can and cannot establish, is in docs/reference-sensors.md.

8.1

Record an empty-house control first

With nobody and no pet indoors: on the Recordings page press Mark empty — nobody indoors from now as you leave, and I am back — stop the control when you return. Ten minutes is plenty.

This is the step that is tempting to skip and impossible to fake afterwards. Measured on this installation, the estimator with no control published a plausible breathing rate 86 times an hour in an empty house — six to fifteen breaths a minute, printed beside an empty room. A session with no control cannot pass the gate at all: a false-output rate that was never measured is not a rate of zero.

8.2

Sit down, get settled, then press Start still session

Somewhere with a name you will recognise later — “the armchair”, “the left side of the bed”. Type the spot, choose sitting, standing or lying, and press the button. Get settled before you start: the seconds spent arranging yourself are movement, not stillness.

8.3

Take the first readings and write them on paper

Clip the oximeter on and let it settle. Write down the heart rate, the blood-oxygen percentage and the clock time. Then count your own breaths for a full sixty seconds against the phone’s clock and write the count and the time down. One in-and-out is one breath.

Paper, not the phone. Typing is movement, and the form is built to take a past time precisely so that you do not have to do it now.

8.4

Stay still for two minutes

Breathe normally. Do not scroll, do not shift in the chair, do not talk. A walk moves a link by orders of magnitude more than a chest does, so a window with movement in it is thrown away rather than corrupted — fidgeting does not make the answer wrong, it deletes the evidence, and a session that deleted most of itself fails the coverage threshold.

8.5

Second readings, then Stop

The same three numbers again with their clock time, then press Stop still session.

8.6

Type the readings in, at the times you wrote

In the Readings box on the Recordings page: set taken at to the time beside each reading. A past time is the normal case here. Enter the breath count with the number of seconds you counted over — fourteen breaths is not a rate until you say fourteen in how long — and the oximeter’s two numbers.

8.7

Score it, and read the five criteria

Press Score the newest session. The hub compares its own windows with your counts and shows all five criteria with what this session got: error at most 2.0 breaths a minute, at least 10 windows matched to a reading, at least 60 % coverage of the still time, an empty-house control on file, and at most 1 false reading an hour in the empty house.

The four beside the error are there because an error can always be made small by refusing to answer: a session that published one window in a hundred can have a beautiful mean error and mean nothing. If it passes, press Record it and turn breathing on. If it does not, the page names which threshold missed and by how much — do it again on another day rather than tuning anything, because sessions pool and the worst session’s false-output rate is the one that counts.

8.8

What the readings are, and where they stay

The counted breaths are the reference the gate uses: coarse, but directly the quantity the estimator claims. The oximeter numbers gate nothing — this rig publishes no heart rate — and an oximeter cannot certify chest-motion beat timing, because its timing includes the delay while the pulse travels to your finger. An Apple Watch respiratory rate is a sleep-derived estimate from Apple’s own model, not a count of breaths; it can be imported to look at and is deliberately excluded from the reference set.

These are health data about a person. They are written to a file in the hub’s data directory and go nowhere else: nothing here uploads, syncs or forwards them, the browser keeps no copy, and the Apple Health import reads a file you put on the hub yourself.

Stage 09

What can go wrong

TIME · as neededOPEN · the sheet, the console

Most first-day failures are one of these. The first thing to run on a node that looks wrong is kymo-show; the first thing to check on a hub that hears nothing is the key.

You seeIt means, and what to do
Phone cannot load kymomap.local:3900That port is the hub's loopback only. Phones use the LAN door on 8080 (step 1.7): http://kymomap.local:8080/setup, user kymomap, the passphrase you chose there.
No building at this addressOpenStreetMap has no street-and-number tag on that building; most suburban houses lack one. Type width, depth and floors instead; nothing downstream needs the address.
Ubuntu installer sees no wired linkThe G10's RTL8125 revision has no driver in the stock 24.04 kernel. Install over Wi-Fi, then linux-generic-hwe-24.04 and reboot (step 1.4).
The page shows the demo houseYou opened kymomap.com, or the hub is down. Open the hub's own address (kymomap.local:8080) and check the containers are up.
hub · hardware · failedThe key file is missing or not 32 hex characters. Step 1.5, then restart the hub container.
Node blinks twice every two secondsIt is sounding but cannot reach the hub. Wrong hub= address, wrong Wi-Fi passphrase, or the access point is on a different channel than ch= (kymo-show prints chmis).
auth: no-key on a status lineThe node was configured without a key and is withholding every frame. kymo-set key=…, kymo-save.
Hub refuses frames as bad_tagKey mismatch between that node and the hub. One of them has a typo; the sheet is the reference.
Hub refuses frames as replayThe node's boot counter repeated, which the hub reads as a replayed capture. It clears itself once a newer counter arrives; persistent, and it is an NVS finding worth reporting.
0 or a handful of subcarriersThe rate pin to 6 Mbit/s did not take and ESP-NOW is on DSSS. There is no CSI. Look for the rate-config error in the monitor log; this is a firmware finding, not a placement one.
csi_unpaired climbingThe CSI and ESP-NOW callbacks are not pairing as assumed. Report it; do not enlarge the ring.
Map looks like thick walls everywhereProbably the chip antenna, not the external one. Step 3.3 on one box settles it for all twelve.
One room confidently wrongA mislabelled or misplaced node: the geometry the hub was given and the geometry the radio measured disagree. kymo-identify each box on that storey and check the sheet.
Frame from unknown node in the hub logA node's id= does not match any id in the setup roster. Fix the roster or the node; they must be identical strings.
Discovered list stays empty on hardwareBeacons reach the hub through a configured node on the same channel. Check the runtime status says listening on 5116 and at least one node is reporting; a fresh board sits on channel 1.
Identify or Claim answers 'no route'No configured node with an uplink has reported in the last 90 s to relay through. Fix the first node's uplink (kymo-show), or configure the board over USB.
Claimed over the air, never joined the meshThe board's enrollment window (ten minutes from power-up) had closed, or the relay was on another channel. Power-cycle it, Release it, wait for its beacon, Claim again.
Empty house reads occupiedExpected until capture A has been used to refit the noise floor margin. Record it before tuning anything.
A node flips online / silent every few secondsIt is on the wrong access point. Check the controller: a node associated at -81 dBm with five transmit retries, where the others sit at -50 to -56, loses 85-96 % of its frames and all of its status beacons, and the live page's two-second silence rule then flaps it. This is not a software setting. Move the box a metre or two, off metal and away from the wall, or nearer an access point on the chosen channel, and re-record the baseline afterwards - every link through it was in the reference.
A node was never seen by the router at allIt has no Wi-Fi credentials, or the wrong ones. Over-the-air configuration cannot reach a board that never joined; take the data cable to the hub and do Flash and 'Set up and claim' again on the Enrol step, then unplug it and power it where it lives. Firmware from 2026-09-08 also gives up on an association that the Wi-Fi driver never reports the outcome of, after fifteen seconds, and tries again — before that a wedged attempt latched and the board never tried to join for as long as it was powered.
The whole mesh went quiet after changing the channelThe nodes' access point is on a different channel than the one you chose. The nodes follow the hub; the access point does not. Setup refuses a channel that differs from the node network's unless you confirm it - if you confirmed it, either move the access point to the chosen channel or set the channel back.
Everything says running and calibrated, and nobody is ever detectedLook at the Sensing card for the word deaf, and at the two numbers beside it. The absolute floor is measured from the quiet house during calibration; if the loudest thing the building has produced since has never reached it, no arrangement of people can be reported. The banner now says by how much: 'the loudest thing the house has produced is 0.19x the floor' is a hub that cannot hear anybody, not a house nobody is in. A person walking a covered room produces roughly six to seven times the quiet level on the reference mesh, so a floor much above that can never report one. Causes, in order: the baseline was recorded while somebody was still inside, or while nodes were missing, or seconds after the mesh came up before the per-link baselines had settled. Leave the building, wait for every node to be online, and recalibrate.
How the detection floor is worked out, and why two numbersTwo rules, and the smaller of them is in force. The margin rule is the old one: a constant (8 by default) times the loudest single probe of one 45-second calibration window. It is easy to explain and it is a property of that window rather than of the house - on the reference install the same empty building produced 0.037, 0.0019 and 0.897 for it in one morning, as the observable and the tone map changed underneath, and at 0.897 the hub was deaf. The distribution rule is the second: the hub keeps the loudest field value of every second it accepted as quiet, takes the 99th percentile of each rolling five minutes, and uses the median of those, times 1.75. That number is a property of the building - 0.128 to 0.149 across all three of those rulers on the same house. The Sensing card shows both and says which one bound. The distribution can only lower the floor, never raise it, which is what makes it safe to keep computing while you are at home; and it needs a few minutes of quiet before it has an answer at all, so a hub that has just restarted runs on the margin's number until then.
Two calibrations in the history are wildly different and neither looks wrongCheck the ruler column. A floor is a number in the units of whatever the feature layer and the tone map were doing when it was measured, and a firmware update or an observable change rescales those. Rows measured in units the hub no longer reads in are shown in italics and marked 'different ruler'; a row whose run measured a conversion is shown converted, with an asterisk. Only rows in the same ruler are compared with each other, so a node firmware update can no longer raise a 'the floor rose 24x, the house was not quiet' warning that nothing in the house caused.
The cat never appears, or somebody lying on the floor disappearsNot a filter: there is nothing to filter. Every node sits at about the same height, so every link is a near-horizontal path at that height, and a body low enough to be under all of them is off every link path - the same reason a person outside a wall cannot be placed. On the reference geometry, with the nodes at 1.2 m, the mesh's own weights score exactly zero below half a metre and best at about 1.1 m. The live map's coverage panel now says the height for your own mesh. Nothing in the tracker discards a low reading; if you want floor-height sensing, the answer is a node placed low, not a setting.
support under 10 % on the live pageThe mesh's geometry and link coverage cannot support a confident reading anywhere, on any frame - a sparse mesh, a node down, or most links losing their frames. The confidence gates scale to it so detection still happens, and the error shell grows to say how little is known. The fix is links: get the failed node back, or add nodes.
Baseline recorded with somebody in the houseIt looks valid and sets every threshold wrong in a way nothing downstream can detect. Recalibrate from the live page's Recalibrate button, from outside the building.
The live page says N of 21 links have no empty-house baselineThe empty-house window ran while some links were carrying no traffic - a node offline, or a node so weakly associated that almost none of its frames arrived - so those links never got a quiet level to measure against. They are not switched off: they report activity against whatever they happened to see first, the reconstruction weights them as though that meant something, and the peak can settle in a room nobody is in. The reference install ran a whole day at ten of twenty-one, with every page reading calibrated, and the person upstairs was drawn at the east end of the floor wherever they actually walked. Nothing clears it by itself. Wait until every node reads online on the live page, leave the building, and press Recalibrate; the Links panel then reads 21 of 21. Dashed lines on the map are the links that still have none.
Sensing stopped and nothing restarted itThe setup page's Stop button stops the runtime. Finishing setup starts it again, and the live page's Sensing card has a Start. A hub in hardware mode otherwise starts sensing by itself.
The hub cannot see the board on USB, or flashing hangsModemManager grabbed the serial port - install the udev rule from step 1.4 - or the cable is charge-only. A charging cable presents no data lines and the port simply never appears; try another cable before anything else.
The hub's Wi-Fi will not come uprfkill has it soft-blocked from the installer. rfkill unblock wifi, then bring the interface up.
The parcel or footprint lookup refusesRecords lookups are consented per source and per address; nothing is fetched until you say so on the Location step, and no imagery is stored. Decline it and type the dimensions instead - nothing downstream needs the address.
The map updates once or twice a second instead of ten timesLook at the live page for "The hub is losing work" and at Diagnostics for a mesh that does not share a timeline. The nodes stamp their frames with uptime, so the hub estimates each one's offset from when its frames arrive; a board whose uplink queue backs up for seconds - which on this rig means a weak association to an access point - drags its own timeline away from the rest, and no other node's measurement window overlaps it. The reconstruction then finds no usable link and the tick is lost. The hub now corrects an offset that far out in one step and counts it, so the symptom should clear itself within a minute; if it does not, the named node's radio link is the thing to fix, not the software.
Diagnostics says the hub keeps time for N nodesExpected, and not a fault. The boards have no real-time clock and no route to a time server - the sounding plane never touches the internet - so out of the box they stamp each frame with their own uptime instead of the date. The hub puts every such frame on its own clock as it arrives, keeping the board's reading beside it, so the reconstruction, the recording and this page all run on real time either way; the finding says how far apart the hub's estimate and the board's own evidence are, and that number is the one to watch. It becomes a warning at a quarter of a second, and at that point the cause is a board whose uplink queue is backing up rather than its crystal. To give a board its own clock, plug it into the hub with a data cable and press Flash on the Enrol step (5.3b); from then on the hub sends it the time when it first reports, after every configuration change, whenever it reports having lost the clock (a reflash or a power cut), and every ten minutes, and its row reads "clock set".
The boards' clocks are N ms apartEach board takes the hub's time once and keeps the offset, so a board that was slow to answer bakes that moment's delay into it. One board being 200 ms behind its own arrivals costs nothing on its own - the whole mesh can be shifted together and no window notices - but the boards *disagreeing* is what drops links out of the fit, and that is the number Diagnostics warns on, at a quarter of a second. The hub re-sends the time every ten minutes, so a spread that opened during a restart closes by itself. One that does not is the same weak uplink that shows up as lost frames: the board furthest out is the one to move.
A node I just reflashed says "clock not set" for a whileIt should now be one status line, about fifteen seconds. A board holds the hub's time as an offset in RAM, so a reflash, a power cut or a watchdog reset throws it away and the board comes back saying so. The hub used to answer only on its ten-minute round, which meant a freshly updated board could spend ten minutes stamping frames with uptime - all of them restamped by the hub on arrival, so nothing was lost, but every restart of the board's counter read as another reboot and filled the log with them. The hub now hears "not synced" in a status line and sends the clock on the spot, at most one command every twenty seconds per board so a node whose uplink is eating the command does not get a flood of them. A row that stays unsynced for minutes after a reboot is that weak uplink, not the clock.
A node's row says N commands rejected just after an updateThe hub sent it something its firmware does not understand. Only firmware 0.2.0 and newer takes the clock, and the hub checks the version a board reports before sending one, so this should not happen from that; if it does, the board is running an older image than it reports. Rejections are counted and rate-limited on the node and cost nothing else - it keeps sounding - but the count is worth explaining rather than living with.
The map looks emptier than the radio soundsTurn on Raw, next to Links above the map. The default picture draws each cell in proportion to how much the mesh's geometry supports it, which is the honest rule and is also why a sparse mesh looks quiet; Raw draws it in proportion to what the reconstruction actually put there, and rings every blob the tracker considered with the support number the gates judged it on. If Raw shows blobs where you are and the default shows nothing, the mesh heard you and could not support the reading - the answer is more nodes, or a node moved off a wall, not a threshold. Raw is a weaker claim than the default and is labelled so; leave it off for normal use.
I stood just outside the wall, next to a node, and nothing movedThat is the geometry, not a fault. Every link runs from one node to another through the inside of the house, so a body outside a wall is off every link path and the reconstruction has no voxel to put it in - and inventing one would be worse than saying nothing. What the mesh can still see is that the links touching the nearest node lifted, and the live page now draws that as a pulsing ring on the node with the words 'movement near <node>'. It names a node, never a place. Turn on Coverage, next to Links above the map, to see the region a person can actually be placed in; everything greyed is outside it. On the reference nine-node fixture that is 57 % of the ground floor and none of the land.
How far outside the house can it see?As far as the reconstruction grid reaches and no further, and the map prints the number on its own edge: 'the map ends 2.0 m past the walls'. The grid stops there because reconstructing the whole parcel at 25 cm cost the hub two thirds of its frames. Beyond that line the map is not quiet, it is absent - the only reading that reaches out there is the node ring above.
Every link goes loud after a restart, on an empty houseFixed from 2026-09-10, and worth knowing what it was. A restart brought back the remembered quiet floor and not the per-link baselines, so every link began again from the first variance it happened to see - a few seconds of whatever the house was doing while somebody deployed to it. The banner read '17 of 36 links had an empty-house baseline' after every deploy and the ratios read five to sixty-five times on an empty building. The quiet-floor file now keeps the baselines beside the probes and a restart restores both; the Sensing card reports how many came back. A link that did not exist when the floor was measured starts unseeded and says so.
A board I just fixed still reads x35 to x140 on an empty houseIts baseline, not its antenna. A per-link baseline is the quiet level that link measured, and a link that was starved when the level was taken measured almost nothing - so once the board is fixed and carrying real traffic, every reading is a huge multiple of that near-zero. On the reference house one board's baseline read 1.56 where every healthy link read 13 to 24, and its ratios came back x140. It walks back to normal on its own over an hour or two as the baseline relearns; recalibrating with the house empty fixes it in one window.
A link with two samples reports a ratio of thirteenA variance over two packets is a variance of two packets, not of the room. Links with fewer than four samples in their window, and links measuring under a quarter of the mesh's own median rate, are now left out of the fit and out of the quiet-floor window entirely - absent, not weighted down to zero, because zero would claim that path measured no attenuation. The Sensing card counts them.
I pressed Recalibrate and nothing happenedFixed from 2026-09-10. The request was reaching the hub and being answered; the page simply said nothing until its next five-second poll. Every button that changes the hub now shows a pending state from the moment you press it, disables itself while the request is in flight, and prints the result or the hub's own refusal beside the button. If the hub refuses because of the address the page was loaded from, it now says so and names the setting to add it to.
The threshold went up every time I recalibratedThen the house was not quiet when you measured it. The Sensing card keeps a calibration history: time, floor, nodes online, links seeded, and whether that row was measured or reused. A new floor far above the last one is flagged, because a floor measured while somebody was moving next to a node sets the bar above every person who will ever walk past afterwards, and every page still reads calibrated. Leave the building and do it again.
The threshold went up every time I updated the hubIt used to. A hub update restarts the pipeline, and a restart used to measure the empty-house floor again - with whoever was home at the time in it. On the reference install one day of otherwise correct deploys walked the floor from 0.146 to 0.452 and the hub went deaf, and twice the remembered floor had to be deleted by hand before a quiet window could bring it back. A restart now reuses the floor this mesh already agreed on: the Sensing card's history marks those rows reused, the source line reads remembered, and sensing is back within seconds instead of spending forty-five of them measuring a house that is not empty. Only the Recalibrate button measures a new one.
The Sensing card says the floor is frozenThat is the normal state of a house somebody lives in, and it is what you want. A frozen floor is kept exactly as it is: the hub takes no measurement of stillness at all while it runs, so a deploy, a restart, or you walking past a node cannot move it. Before this, the hub refused a probe that was loud relative to the floor - which is the right rule for a building nobody has said anything about, and not enough for one in use, because a reading just under the limit is still a person and it is taken a hundred times an hour. Recalibrate is how you say the house is empty: it lifts the freeze for exactly one window and freezes again on the floor it measures. Unfreeze, on the same card, is there for a building that is genuinely empty for a while.
The floor says 'inherited'It was measured on a different mesh - a node was moved, added or removed since. The hub keeps it rather than measuring a new one, because measuring a new one means measuring your house while you are in it, and that is the failure this whole card exists to record. Inherited is a weaker claim than a floor measured on the mesh that is running now, and the page says so rather than quietly presenting it as one. Recalibrate from outside, with every node online, whenever you next get the chance.
It says 'which floor?' and offers me two numbersA recalibration measured a floor far above the one this building had already agreed on. That is almost always a house that was not empty while it was measured, so neither is thrown away and neither is adopted behind your back: keep the old one to go back to the floor that was working, or keep the new one if the building really has changed - new appliance, new furniture, a node re-sited. Either choice restarts sensing onto the floor you picked. If you are not sure, keep the old one, leave the house, and recalibrate again.
The floor is still being measured while I am at homeOnly in the sense that every reconstruction the hub already does is offered to the remembered set, and only a quiet one is kept: a probe more than 1.5x the floor is a person and is refused, with the reason recorded on /api/runtime as rejected_probes. That is the mechanism that stops an occupied evening slowly raising the bar on the next morning. Nothing you do at home can push the floor up; only Recalibrate can.
One link is the loudest thing in the house, minute after minuteThe recordings strip says so out loud when one pair is the loudest link in more than four bins out of five. It is not a person: somebody walking a house lights different links as they move, and one pair staying loudest for twenty minutes is a board with a baseline measured against the wrong thing, a placement where its two ends see each other through nothing, or a radio in trouble. Look at that pair on the live page's Links panel, check its seeded flag, recalibrate from outside, and move the box if it persists.
The recordings page says the hub is not recordingNo retention plan has been accepted, so no store was ever opened. Finish the Storage step in setup (5.7); the live page's Sensing card shows the same thing as an empty recording block.
A minute in the middle of the recording strip is hatchedThe hub was not writing then: it restarted, sensing was stopped, or a deploy replaced the container. It is drawn as a hole rather than as a flat bar, because a flat bar at zero would read as a still house. The newest minute or two is normally hatched for a different reason - the tiers are buffered and pushed to disk about once a minute.
Bars on the recording strip are tall while the house was emptyThe bar is the loudest link as a multiple of its own stored baseline, and a link whose empty-house baseline was measured badly is a multiple of the wrong thing. Check the Links panel on the live page for the seeded count, recalibrate from outside, and compare the strip after. A tall bar is the feature layer, not a claim that anybody was home.
A board came back on its old firmware after an updateIt rolled back, and that is the update path working rather than failing. A freshly installed image gets two minutes to receive one command from the hub that verifies against the installation key; if it does not - the radio did not come up, the uplink did not associate, the key did not survive - the bootloader puts the old image back and the board reboots into it. Nothing was lost and the board never stopped sounding. The usual cause is the same weak association that stops it being offered an update in the first place: look at its dBm on the Enrol step, move the box, and try again.
An update says "does not match the hash it sent"The bytes that arrived are not the bytes the hub signed for, so the board threw the half-written slot away and kept running what it was running. Either the download was corrupted, or something on the network answered the hub's firmware port instead of the hub. Nothing was installed either way - that refusal is the design, not a fault - so try it again, and if it repeats, check that /data/firmware/kymomap-node.bin and the BUILD.txt beside it came out of the same CI artifact.
The Update over Wi-Fi section says the firmware door is not runningPort 5118 is taken, or it is not published out of the hub's container. It is in docker-compose.hub.yml beside 5115 and 5116, and it exists because the boards have to reach it and the API port must stay on loopback - the hub has no accounts. Until it is free, boards are updated over the USB cable as before; nothing else on the hub is affected.
Every node is online, every link is fresh, and a walk through the house is invisibleLook at the links count above the map: it now reads how many links are actually carrying, with the starved ones counted beside it, and a board whose links are all starved gets its own banner. A node can beacon on time - so its age is fine and the page calls it online - while transmitting a fiftieth of what the other boards transmit, and then every link through it is 'fresh' at two or three measurements a second while the rest of the mesh runs at twenty to fifty. That is what happened on the reference house during the owner's first clean ground-floor walk: one of the four ground-floor boards had sent 3,166 frames where the others had sent 122,000 to 197,000, all eight of its links sat at 68-89 % loss, and the ground floor was being watched by three nodes instead of four. Nothing in software fixes it. Power-cycle the board, check its power supply and its antenna, and if it persists move it - and re-record the baseline afterwards, because every link through it was in the reference.
Can it read a heartbeat?No, and it is not a setting that is missing. The nodes report per-subcarrier amplitude and no usable phase, the links cross rooms rather than facing a chest at half a metre, and a heartbeat moves the chest wall by micrometres. Breathing is used only to keep a motionless person on the map, as presence and never as a rate. docs/vital-signs.md has the measurements and what a heartbeat would actually require.
Say the unknowns out loud before you start, so nobody mistakes an expected unknown for a bug. Slot alignment across a full mesh is still unmeasured and shows up as sequence gaps rather than errors; the calibrated error radius still reads simulator until a calibration walk has been done in your own building, which means the shell around a person is honest arithmetic on somebody else’s house; and and still-presence — the breathing path — has never run on hardware, because until today the calibration window was shorter than the fifteen seconds a breathing estimate needs and the hub made no breathing claim at all. The window is forty-five seconds now, which means the claim becomes possible, not that it has been checked. None of these block a first day of data. All of them block claiming the system works.