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.
Before you open anything
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.
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
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.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.
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.
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-hubis 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.
- 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.
The hub
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.
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.
Install Ubuntu Server
Accept the defaults except where listed. The install takes about twenty minutes; the rest of Stage 00 fits inside it.
- Language and keyboard. Choose Ubuntu Server, not the minimized variant.
- 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.
- Storage: use the entire disk. This erases Windows, which is the point. LVM is not needed.
- Profile: your name, the server name
kymomap, a username, a password. The name is what makeskymomap.localwork later. - Install OpenSSH server: yes. Skip every featured snap.
- Reboot, remove the USB drive when asked. Unplug the monitor and keyboard; everything else is over SSH.
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.
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.
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.
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.
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.
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.- The setup page opens from a phone and its badge reads
hub · hardware · waiting, not “demo house”. /api/runtimereportswaitingwith the reason “no building yet”.failedmeans 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.
Two nodes on the bench
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.
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.
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.
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
idxis the node’s slot, 0 to 11, unique.nis the total, 12, on every node.chis the sounding channel from step 0.4, the same one the access point is fixed to.idis 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.hubis 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 --showAfter 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-….
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.
- 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.
csi_unpairedstays 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.csi_droppedis 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.
- Two bare boards, both blinking once every two seconds: sounding, uplink up.
- 52 subcarriers on every frame.
csi_unpairednear zero.csi_droppedexactly 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.
The template node
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.
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.
Fit the antenna and the board
- Push the pigtail’s RP-SMA jack through the top hole from inside; nut and washer on the outside. Snug, not tight.
- 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.
- 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.
- Screw the 3 dBi whip onto the bulkhead outside. Close the lid.
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:
- With the box on the bench and its antenna on, run
kymo-showin the monitor and noteap_rssi. - Unscrew the antenna. RSSI should fall by several dB: the U.FL side is now an open circuit.
- 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.
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.
- 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.
The other eleven
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.
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.
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.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.
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-identifyon that board’s console blinks it hard for ten seconds.
- 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.
Setup on the hub
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.
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.
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”.
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.
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.
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.
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.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.
SET_TIME it would only reject. After the update the board stamps its own frames with real time and its row reads “clock set”.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.
- 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).
- It writes it to whichever of its two spare app slots is not running. The image it is running is untouched throughout.
- 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.
- 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.
- 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.binover an old one and leaving the oldBUILD.txtis 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.
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.
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.
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.
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.
- The Sensing card reads
runningandcalibrated, 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.
Reading the live map
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.
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.
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.
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.
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.
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.
- 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
supportfigure for your mesh. If it is under 10 %, read stage 08 — that is the number that decides whether anybody can be detected at all.
The first captures
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.
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.
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.
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.
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.
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.
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.
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.
- 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.
A still session
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.
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.
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.
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.
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.
Second readings, then Stop
The same three numbers again with their clock time, then press Stop still session.
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.
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.
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.
What can go wrong
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.
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.