No description
  • Python 42%
  • Shell 35.1%
  • HTML 17.1%
  • mupad 5.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
dosch 7f95716cd8 Drop a stray session note from the top of the README
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 09:21:04 +01:00
reticulum Find the deaths are about load, not the card, and fix what the audit found 2026-10-27 13:48:02 +01:00
.gitignore Initial commit: Busy027S office occupancy radar 2026-06-26 13:26:43 +02:00
calibrate.py Close MAC leak, add calibration loop, plan climate sensor 2026-08-20 15:57:50 +02:00
CARD.md Record the Pondr checker going live, and pick the climate sensor 2026-10-29 09:18:26 +01:00
CLAUDE.md Find the deaths are about load, not the card, and fix what the audit found 2026-10-27 13:48:02 +01:00
details.html Keep the front page to one phrase; put the reading's age on details 2026-10-06 14:11:42 +02:00
gaps.py Clear two outage suspects; enable forensics that survive a reboot 2026-09-03 14:13:12 +02:00
index.html Stop polling for news that cannot arrive 2026-10-06 14:23:26 +02:00
memlog.sh Find the deaths are about load, not the card, and fix what the audit found 2026-10-27 13:48:02 +01:00
officecount-boot-record.service Make the Pi write its own post-mortem; the power tests were never tests 2026-09-08 14:15:25 +02:00
officecount-boot-record.sh Find the deaths are about load, not the card, and fix what the audit found 2026-10-27 13:48:02 +01:00
officecount-hardware-watchdog.conf Catch the watchdog in the act: it mistook a busy systemd for a dead one 2026-09-08 12:33:30 +02:00
officecount-journald-persistent.conf Beat the Raspberry Pi OS override that was discarding all the evidence 2026-09-03 17:22:29 +02:00
officecount-maintenance.sh Add unattended recovery, off-box alerting, and a Reticulum node 2026-09-03 13:54:55 +02:00
officecount-scan-ondemand.sh Close three ways a visitor could cost this Pi more than a scan 2026-10-06 14:23:26 +02:00
officecount-scan.path Let the mesh node ask for a scan too, and stop it crying wolf 2026-10-06 14:23:26 +02:00
officecount-scan.service Scan when somebody asks, not 1,440 times a day 2026-10-06 13:51:57 +02:00
officecount-watchdog.service Find the deaths are about load, not the card, and fix what the audit found 2026-10-27 13:48:02 +01:00
officecount-watchdog.sh Find the deaths are about load, not the card, and fix what the audit found 2026-10-27 13:48:02 +01:00
officecount-watchdog.timer Stop the watchdog rebooting over faults it cannot fix 2026-10-06 13:51:30 +02:00
officecount-web.service Scan when somebody asks, not 1,440 times a day 2026-10-06 13:51:57 +02:00
pondr-check-027s.sh Find the deaths are about load, not the card, and fix what the audit found 2026-10-27 13:48:02 +01:00
pondr-tailnet-repair.sh Check the tailnet path by fetching the file, not by pinging 2026-09-08 09:55:57 +02:00
README.md Drop a stray session note from the top of the README 2026-10-29 09:21:04 +01:00
RESILIENCE.md Record the Pondr checker going live, and pick the climate sensor 2026-10-29 09:18:26 +01:00
RETICULUM.md Record the Pondr checker going live, and pick the climate sensor 2026-10-29 09:18:26 +01:00
robots.txt Initial commit: Busy027S office occupancy radar 2026-06-26 13:26:43 +02:00
scan-devices.py Find the deaths are about load, not the card, and fix what the audit found 2026-10-27 13:48:02 +01:00
serve.py Find the deaths are about load, not the card, and fix what the audit found 2026-10-27 13:48:02 +01:00
TEMPSENSOR.md Record the Pondr checker going live, and pick the climate sensor 2026-10-29 09:18:26 +01:00

categorie created modified tags
project 2026-10-06 2026-10-06

Busy027S

A tiny public website that answers one question — "Is het druk op 027S?" — so I can tell from home (and so can co-workers) whether the office is busy or quiet.

The goal is a decision, not a dashboard. Someone standing in their hallway should be able to glance at this and know whether to cycle in. That is why the page shows a phrase rather than a number, and it is the test every future addition has to pass: occupancy today, room climate next (see TEMPSENSOR.md), and only readings that would actually change someone's mind about going.

Live at https://027s.testwerf.nl/ (also reachable at https://obs6.tail62b52d.ts.net/ if Tailscale Funnel is left on).

Two companion documents: RESILIENCE.md (why the Pi keeps disappearing and what now brings it back) and RETICULUM.md (the same reading, broadcast onto the Reticulum mesh as a Nomad Network page).

Runs on a Raspberry Pi Zero (obs6) that's just a normal wifi client on a network I don't control: no router access, no port forwarding. Built spiral-style — each iteration is a working step with a go/no-go gate.

How it works

[cron, root, 1×/min]  scan-devices.py  ── arp-scan --localnet ──>  unique recent MACs
                              │ subtract learned 'always-on' infra, then ÷3
                              ▼
              /var/www/officecount/data.json   {people, raw, infra, weekdays, …}
                              │ polled every 30s
                              ▼
[systemd] serve.py ──serves──>  index.html  (phrase + weekday bars)
       (Pi, 0.0.0.0:8080 — Pondr proxies to the tailnet IP)   │
                              ▼
   Pondr VPS nginx (public IP + Let's Encrypt cert for 027s.testwerf.nl)
   reverse-proxies over the tailnet to obs6:8080
                              ▼
              https://027s.testwerf.nl   (public, HTTPS, no port-forward)

The public path goes Pondr → tailnet → Pi. Tailscale Funnel (https://obs6.tail62b52d.ts.net) is an optional second public URL that hits the Pi directly; it's no longer required once Pondr is the front door.

The page shows a phrase, not a number:

People (est.) Phrase Colour
0 Er is niemand dark/grey
1–6 Het is rustig green
7–14 Het is gezellig amber
15+ Het is best vol! red

Plus a per-weekday bar chart (ma di wo do vr, average people during office hours 08:00–18:00). The page shows no sparkline — it was dropped, and index.html now reads data.json only.

How the count works

  • Devices = unique MACs seen in the last 5 min (WINDOW) — survives MAC randomization and brief absences.
  • Baseline (self-learning) = any MAC seen during the quiet window (weekday nights 01:00–05:00 + weekends) on QUIET_DAYS (=2) distinct days is flagged infra and subtracted, so always-on gear (printers, APs, servers) reads as an empty office. MAC type is irrelevant — persistence across days is the signal, so a one-off weekend or late-night visitor is not permanently subtracted. Sharpens within a weekend.
  • People = (devices − infra) ÷ PER_PERSON (=3: laptop + private + work phone).

Files

File Goes to on the Pi Purpose
scan-devices.py /usr/local/bin/scan-devices.py Scans, learns baseline, writes data.json
serve.py /usr/local/bin/serve.py Web server: allowlisted static files + POST /observe
calibrate.py /usr/local/bin/calibrate.py Compares estimators against manual counts
index.html /var/www/officecount/index.html The public page
details.html /var/www/officecount/details.html Unlisted detail page (numbers + day/week/month trends)
robots.txt /var/www/officecount/robots.txt Asks crawlers not to index the site
officecount-web.service /etc/systemd/system/officecount-web.service Serves the dir on :8080
officecount-watchdog.sh /usr/local/bin/ Recovers wifi/scanner/web unattended (5-min timer)
officecount-maintenance.sh /usr/local/bin/ Weekly patch + guarded reboot
officecount-hardware-watchdog.conf /etc/systemd/system.conf.d/watchdog.conf Pets /dev/watchdog; SoC resets a hung kernel
reticulum/* see RETICULUM.md Nomad Network node serving the same reading
pondr-check-027s.sh Pondr, /usr/local/bin/ Off-box down-alert
pondr-tailnet-repair.sh Pondr, /usr/local/bin/ Restores the tailnet path to the Pi
gaps.py anywhere Dates outages to the minute from history.csv
officecount-journald-persistent.conf /etc/systemd/journald.conf.d/ Makes logs survive a restart

Runtime state is split across two directories, and the split is a privacy boundary:

Directory Contents Public?
Published /var/www/officecount/ data.json, history.json (24h of people points), trends.json (hourly rollups, 90d) + the HTML yes
Private /var/lib/officecount/ seen.json, ledger.json (raw MAC addresses), profile.json, history.csv, observations.csv, token no

Until 2026-08-20 the state files lived in the web root and were downloadable from the open internet — ledger.json exposed the MAC address of every device that had ever been in the office. scan-devices.py moves them on first run (migrate_state), and serve.py serves only an explicit allowlist, so dropping a new state file into the web root cannot re-open the hole.

Detail page (/details.html)

A private-by-obscurity page for inspecting the count: current numbers (estimated people, people-devices, infrastructure, total seen) plus a Dag / Week / Maand trend graph (estimated people, devices, and infra over time). Not linked from the main page and noindex-tagged — reachable only at https://027s.testwerf.nl/details.html if you know the URL. It shows only counts, no MAC addresses. It is unlisted, not access-controlled; to make it genuinely private, drop it from Pondr's nginx proxy (reach it over the tailnet only) or add basic auth on that location.

Tuning

  • PER_PERSON, WINDOW, quiet-window hours, office hours → top of scan-devices.py.
  • Phrase bands (<1 / <7 / <15 / else) and colours → the band ternary in the tick() JS in index.html.
  • Trend retention (90 days) and the day/week/month buckets → trends.json rollup in scan-devices.py and the bucketsFor() JS in details.html. Rollups average over all hours (incl. nights/weekends), so trend curves dip to ~0 off-hours.
  • Inspect the baseline: column -s, -t /var/www/officecount/history.csv | tail.

Calibration — manual counts

The estimate is active / PER_PERSON, and PER_PERSON is only as good as the ground truth behind it. It was 3 (a guess), which over-counted by roughly a third.

Log a real count while you are in the room: open https://027s.testwerf.nl/details.html?k=<token> — the token is in /var/lib/officecount/token, generated on first start of serve.py. The form only appears with ?k=; without it the page stays read-only. Type the number of people you can actually see, hit Opslaan, and it is stored next to whatever every estimator said at that second.

Then, after a few weeks of counts:

sudo /usr/local/bin/calibrate.py

It refuses to recommend anything from fewer than 5 observations, or from counts that all sit at the same occupancy — a divisor fitted to five readings of "6 people" says nothing about a full room. Aim for a spread: quiet mornings and busy afternoons.

history.csv records rival estimators alongside the published one, at no cost:

Column Meaning
active unique MACs over the 5-min WINDOW, minus infra — what the page uses
now_act a single arp-scan sweep, minus infra — immune to MAC churn
now that sweep before infra subtraction
rand in-window MACs that are randomized — a candidate people-signal, since office equipment does not randomize

calibrate.py reports devices-per-person for the quiet half and the busy half of your observations separately. If those drift apart, no single divisor fits both ends — and the active vs now_act gap says why, because MAC churn inflates the 5-minute window but cannot inflate a single sweep.

What the first measurements show (2026-08-20, n=3 — provisional)

  • MAC churn is not happening. now equals raw on every scan, so no device is rotating its address inside the 300 s window. A shorter WINDOW would change nothing, and now_act is currently just a copy of active.

  • Every arrival and departure moves the count by exactly one device. Three counts taken across one afternoon:

    Time People counted active 17 + people
    14:57 6 23 23
    15:36 9 26 26
    ~16:44 7 24 24

    All three land exactly on active = 17 + 1.0 × people. The marginal rate is ~1 device per person while the absolute ratio is ~3 — the signature of an intercept, which no divisor can represent.

  • The population splits by MAC type: of 30 devices, 14 are locally administered (randomized) and 16 are burned-in OUIs, of which only 4 are flagged infra. Phones and modern laptops randomize; printers, displays and VoIP handsets do not.

Leading hypothesis: the ~17 are not ours. The office owner confirms 027S has only a handful of devices (printer, smartscreen, the Pi) — and those are exactly the 4 the infra rule already catches at 03:00. But this Pi sits on a network we do not control, and arp-scan --localnet sweeps the whole subnet rather than the room. Neighbouring tenants' devices would be present through the working day and gone at night, which fits every observation we have. If true, there is a floor on what this method can ever measure, and BLE scanning (see the backlog) becomes the more interesting second signal, because its range is roughly room-sized.

The decisive test is a near-empty room. With 1–2 people present:

  • active ≈ 18–19 → the intercept is real; the model is people ≈ active − 17.
  • active ≈ 3–6 → the intercept is an artefact; a plain divisor was right.

Those outcomes are far apart, so one quiet-morning count settles it.

None of this is settled at n=3, all taken within two hours at 6–9 people. Three points landing on a two-parameter line is not strong evidence with small integers, which is why PER_PERSON stays at 4 and calibrate.py refuses to recommend below 5 observations spread across a real range of headcounts.

Known limits / gotchas

  • The scan sees the subnet, not the room. arp-scan --localnet sweeps the whole local network, and this Pi is a guest on a network we do not control. Devices belonging to neighbouring offices are indistinguishable from ours by MAC alone. This is a ceiling on accuracy that no amount of tuning removes — see the calibration section, and the BLE item in the backlog for the room-sized alternative.
  • Client (AP) isolation would kill this — if the office wifi blocked client-to-client traffic, the scan returns nothing. This network does not.
  • Counts include any wifi device; ÷3 and the baseline make it a people-ish estimate, not an exact headcount.
  • The page is exposed publicly (via Pondr, and optionally Funnel). A busy-or-not phrase is harmless; keep it that way.
  • Tailscale must stay running on the Pi — Pondr reaches it over the tailnet. Funnel is the only optional bit; the tailnet itself is load-bearing.

Runbook (spiral)

Iteration 1 — Probe: does this network allow scanning? (go/no-go) ✅

sudo apt update && sudo apt install -y arp-scan
ip -br addr
sudo arp-scan --localnet --interface=wlan0

Many IP/MAC rows → proceed. Only gateway + self → client isolation; stop.

Iteration 2 — Count + serve locally ✅

sudo mkdir -p /var/www/officecount
sudo cp index.html /var/www/officecount/index.html
sudo cp scan-devices.py /usr/local/bin/scan-devices.py
sudo chmod +x /usr/local/bin/scan-devices.py
sudo /usr/local/bin/scan-devices.py && cat /var/www/officecount/data.json
printf 'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n# baseline: keeps the infra ledger alive. NOT optional — see below.\n7 1-4 * * *    root flock -n /run/officecount.scan.lock /usr/local/bin/scan-devices.py\n# shape: keeps trends.json and the weekday bars honest\n7 8-17 * * 1-5 root flock -n /run/officecount.scan.lock /usr/local/bin/scan-devices.py\n' | sudo tee /etc/cron.d/officecount
sudo cp officecount-web.service /etc/systemd/system/officecount-web.service
sudo chown -R www-data:www-data /var/www/officecount
sudo systemctl enable --now officecount-web.service

Why these hours, and not every minute. The scanner used to run 1,440 times a day. It now runs 14 scheduled times plus whenever somebody opens the page (see On-demand scanning), which is about 97% fewer writes to the SD card and 97% fewer promiscuous-mode cycles on a radio this project has a long history with.

The nightly 01–04 block is not optional. The infra baseline is learned by seeing which MACs are awake during the quiet window, and nobody visits the website at 03:00. Without it the ledger stops learning and expires — last only advances when a scan sees a device, and anything unseen for 30 days is pruned. infra would fall towards zero and every reading would creep up by about 1.5 people, with nothing to warn you. The 08–17 weekday block exists so trends.json and the weekday bars describe the office rather than describing when people happened to look.

Three other places encode these same hours: scheduled_hour() in officecount-watchdog.sh and in pondr-check-027s.sh, which must not treat a legitimately old reading as a fault. Change the schedule, change all three.

Why flock -n. Cron fires every 60 seconds whether or not the previous run finished, and a normal sweep already takes 5–14 seconds of that minute on this board. Without the lock a single stuck arp-scan becomes a stack of them, each holding wlan0 in promiscuous mode, growing by one a minute until the box runs out of memory. -n means "give up immediately if one is running", never queue. The scanner's own SCAN_TIMEOUT is the second net: the lock stops the pile-up, the timeout stops the stuck process existing at all.

cron PATH gotcha. /etc/cron.d jobs run with a minimal PATH (/usr/bin:/bin) that does not include /usr/sbin, where arp-scan lives. Without the PATH= line above, the scanner throws FileNotFoundError: 'arp-scan' every minute, crashes before writing, and data.json silently freezes at its last good timestamp while cron looks healthy in the journal. Belt-and-suspenders: scan-devices.py also calls arp-scan by its absolute path (ARPSCAN constant). Symptom to watch for: the page/data.json updated time stops advancing but systemctl is-active cron is active. Diagnose by reproducing the cron environment: sudo env -i PATH=/usr/bin:/bin /usr/local/bin/scan-devices.py.

To view on the office LAN, bind the service to 0.0.0.0 and open http://obs6.local:8080.

Iteration 3 — Public via Tailscale Funnel ✅

Tailscale admin: enable HTTPS (admin/dns) and add a funnel nodeAttr (admin/acls):

"nodeAttrs": [
  {"target": ["autogroup:member"], "attr": ["funnel"]},
],

Then on the Pi:

sudo tailscale funnel --bg 8080
sudo tailscale funnel status

Off again: sudo tailscale funnel --https=443 off.

Iteration 4 — Polish (ongoing)

  • ✅ Self-learning baseline, ÷3 people estimate, Dutch phrases.
  • ✅ 24h sparkline — built, then dropped from the page (history.json still written).
  • ✅ Per-weekday bar chart (profile.json).
  • Later: nicer history view, baseline calibration after the first full weekend.

Iteration 5 — Custom domain 027s.testwerf.nl via Pondr reverse proxy ✅

Chosen over the quick DNS-redirect (which can't present a valid cert for an HTTPS subdomain). Pondr is public and on the tailnet, so it fronts the Pi cleanly.

  1. DNS (Greenhost): delete the old 027s A record (→ tailnet 100.x, not routable) and any URL-forward; add an A record 027s → Pondr's public IP (91.132.146.29), optional AAAA to its IPv6.
  2. YunoHost (Pondr):
    sudo yunohost domain add 027s.testwerf.nl
    sudo yunohost domain cert install 027s.testwerf.nl
    
  3. Reverse proxy: create /etc/nginx/conf.d/027s.testwerf.nl.d/busy.conf with a location / { proxy_pass http://100.109.95.99:8080; proxy_set_header Host $host; … }, then sudo nginx -t && sudo systemctl reload nginx.
  4. Make it public (YunoHost 12 SSOwat): the old skipped_urls/unprotected_urls keys in /etc/ssowat/conf.json.persistent are ignored in YH12. Use the permissions form instead:
    {
        "permissions": {
            "busy027s": {
                "auth_header": false, "label": "Busy027S", "public": true,
                "show_tile": false, "uris": ["027s.testwerf.nl/"], "users": []
            }
        }
    }
    
    then sudo yunohost app ssowatconf. Verify with curl -skI https://<pondr-ip> -H "Host: 027s.testwerf.nl" → expect 200 (a 302 to /yunohost/sso means SSO is still protecting it).
  5. Test in a fresh incognito window — browsers cache the old cert/redirect hard.

Maintenance — auto-updates + weekly reboot

Superseded — see RESILIENCE.md. The unconditional ; /sbin/reboot below has been replaced by officecount-maintenance.sh, which waits for the dpkg lock and refuses to reboot into a half-applied transaction. One of the five recorded outages starts exactly in this Sunday-03:00 slot, and the box has no hands attached to it. The rest of this section is kept because the cron-PATH warning and the never-upgrade-over-bare-SSH warning still apply verbatim.

The Pi patches itself daily and power-cycles once a week, unattended.

  • unattended-upgrades applies security updates daily on its own systemd timer (apt-daily-upgrade.timer). Automatic mid-week reboots are left off — the weekly cron reboot below is the only scheduled restart, so it never bounces during office hours.
  • Weekly reboot — a cron.d job runs a final upgrade pass and reboots at 03:00 every Sunday. This activates any kernel/security update that needs a restart and clears long-uptime cruft. Everything auto-starts on boot, so no manual recovery is needed.

Install on the Pi:

sudo apt update && sudo apt install -y unattended-upgrades
printf 'APT::Periodic::Update-Package-Lists "1";\nAPT::Periodic::Unattended-Upgrade "1";\n' | sudo tee /etc/apt/apt.conf.d/20auto-upgrades
printf '# weekly patch + reboot (power cycle), Sun 03:00\nPATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n0 3 * * 0 root apt-get update -qq && unattended-upgrade -v ; /sbin/reboot\n' | sudo tee /etc/cron.d/officecount-maintenance

Same cron-PATH gotcha as the scanner. reboot lives in /sbin, unattended-upgrade in /usr/bin — neither is on cron.d's default PATH. The PATH= line above is required, or the job dies before rebooting. The ; before /sbin/reboot (not &&) makes the reboot fire even if the upgrade pass errors — the weekly power cycle is unconditional.

Never run a manual upgrade over a bare SSH session. Losing the connection sends SIGHUP and kills apt mid-transaction; a kernel upgrade interrupted that way left dpkg wedged, sshd resetting connections, and the box needing a power cycle (2026-08-20). Regenerating an initramfs on a Pi Zero pins the machine for minutes. Use a session that survives a disconnect: ssh pi@obs6.local -t 'tmux new -As up "sudo apt full-upgrade -y"' Recovery from an interrupted run is sudo dpkg --configure -a. If the dpkg lock is held, something is still running — wait, never delete the lock file. And do not apt autoremove until after the reboot onto the new kernel.

Dry-run the upgrade logic without waiting for Sunday: sudo unattended-upgrade --dry-run -v. To broaden beyond security-only, uncomment the relevant origin= lines in /etc/apt/apt.conf.d/50unattended-upgrades.

Open taken — nu

  • Nieuwe SD-kaart erin. De enige nog openstaande verdachte voor de storingen; #focus procedure staat in CARD.md, inclusief de back-up die eerst moet (de staatbestanden staan alleen op de Pi). Hoge-duurzaamheidskaart, 32 GB is genoeg.
  • Oude kaart uitlezen voordat hij hergebruikt wordt — dumpe2fs -h voor de foutentellers van het bestandssysteem en een volledige leesronde voor I/O-fouten (CARD.md §6). Een schone kaart is net zo informatief als een kapotte.
  • ACT-led op heartbeat zetten (dtparam=act_led_trigger=heartbeat). Die led staat nu op kaartactiviteit, waardoor "donker" niet te onderscheiden is van "geen stroom" — precies de verwarring die de diagnose een maand heeft gekost.
  • CMA van 256 MB terugpakken op een bord zonder scherm: gpu_mem=16, de KMS-overlay eruit, camera/audio/codec-modules eruit. Zie CARD.md §3.
  • USB-stroommetertje ertussen (~€5), zodat "wel stroom maar dood" en "geen stroom" niet langer hetzelfde beeld geven.
  • Temperatuur en luchtvochtigheid — GY-SHT31-D over I²C, plan en bedrading in TEMPSENSOR.md. Bijgewerkt voor on-demand scannen: drie lezingen met een mediaan in dezelfde beurt als de telling, niet een middeling per minuut.
  • profile.json laten vergeten. De weekdaggemiddelden zijn [som, n] die eeuwig doorlopen, dus na een paar maanden kan een nieuw patroon de balkjes niet meer bewegen. Een afnemend gemiddelde lost het op; het is een gedragsverandering, dus bewust nog niet gedaan.
  • Meten of de watchdog nu de zwaarste klus op het bord is. 288 runs per dag, elk met curl, ping, iw en tailscale status (een Go-binary van ~30 MB op één core) — tegenover ~70 s CPU per dag voor het scannen zelf. Meet eerst: journalctl -u officecount-watchdog.service --since '24 hours ago' | grep Consumed. Boven een seconde per run is de timer op 10 minuten zetten de helft goedkoper, ten koste van 15 → 30 minuten reactietijd.

Backlog — future iterations

  • Broadcast the status over Reticulum. ✅ Stage 1 (TCP, no radio) — see RETICULUM.md. Stage 2 (a real RNode on LoRa, so it survives the office wifi being down) is still open and hinges on hardware, not code.

  • Outside temp + canal water temp. Outside air: reuse an API (e.g. the OpenWeatherMap key already used in the vault's daily-note script). Canal water temp: either a waterproof DS18B20 sensor wired to the Pi's GPIO, or an open data source (Rijkswaterstaat Waterinfo) if the spot is covered. Add the readings to data.json and show them on the page.

  • Reach it on 027s.testwerf.nl. ✅ Done via Pondr reverse proxy — see Iteration 5 above.

  • Meeting-hall booked status via a public Outlook calendar. Publish the hall's calendar from Outlook/M365 as a read-only .ics URL, fetch + parse it in the scanner (or a second cron job), and show "zaal vrij / bezet tot HH:MM" on the page. No write access or OAuth needed if the calendar is published.

  • Bluetooth/BLE scanning as a second presence signal. (2026-09-22: looked at properly and parked — see below. The camera-glasses half became its own project, ../GlassAlert/, on an ESP32 so it never touches this Pi's radio.) Count nearby BLE devices (the Pi Zero W has an onboard radio; bluetoothd is already running) as an alternative/complement to the wifi arp-scan. Upside: catches people in the room who never join the office wifi (visitors, phones with wifi off). Downsides to weigh: BLE range leaks into neighbouring rooms/the street, modern phones randomize BLE MACs aggressively (so the infra-baseline trick is harder), and many phones don't advertise unless actively pairing — likely an undercount of its own. Could feed a separate count into data.json and reconcile the two estimates. Note: the old BLE-scanner project on this Pi (heartbeat.sh) is unrelated leftover, not a starting point.

    Why it was parked. Three reasons, in increasing order of seriousness. The radio: wifi and Bluetooth are one chip behind one antenna on this board, and this project already has a scar there (6,172 rfkill unblock bluetooth calls). No boundary: being on the office wifi is what makes a device "in this office", and BLE at 10-15 m hears the corridor and the pavement. And the infra rule cannot work at all, because it needs a device to keep the same address across days and BLE addresses rotate every quarter of an hour by design. Glasses also tether to a phone that is already counted.

Update workflow

The served file is the Pi's /var/www/officecount/index.html — editing the repo copy (or ~/index.html) does nothing until you copy it there. No service restart is needed for content or scan-devices.py changes (http.server reads from disk per request; nginx just proxies); only a changed systemd unit or nginx config needs a restart/reload. After deploying, hard-refresh the browser (cached page/cert).

From the office LAN:

scp scan-devices.py serve.py calibrate.py index.html details.html robots.txt pi@obs6.local:~/
ssh pi@obs6.local 'sudo cp ~/scan-devices.py ~/serve.py ~/calibrate.py /usr/local/bin/ && sudo cp ~/index.html ~/details.html ~/robots.txt /var/www/officecount/ && sudo chmod +x /usr/local/bin/scan-devices.py /usr/local/bin/serve.py /usr/local/bin/calibrate.py && sudo /usr/local/bin/scan-devices.py'

Remotely (anywhere, over the tailnet) use the MagicDNS name instead of obs6.local:

scp scan-devices.py serve.py calibrate.py index.html details.html robots.txt pi@obs6.tail62b52d.ts.net:~/

One-time, for this change only — the web server is now serve.py, so the unit really does need a reload (content edits still do not):

scp officecount-web.service pi@obs6.local:~/
ssh pi@obs6.local 'sudo cp ~/officecount-web.service /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl restart officecount-web && sudo /usr/local/bin/scan-devices.py && sudo cat /var/lib/officecount/token'

That prints the token for the manual-count form. Verify the leak is closed — both should return 404:

curl -s -o /dev/null -w '%{http_code}\n' https://027s.testwerf.nl/ledger.json
curl -s -o /dev/null -w '%{http_code}\n' https://027s.testwerf.nl/history.csv

Everything is reboot-proof: tailscaled, officecount-web.service, and the cron scan all auto-start on boot, and the learned state lives in files under /var/www/officecount/. Pondr's nginx + cert persist independently.

On-demand scanning

Since 2026-10-06 the page asks for a reading instead of being handed one every minute.

visitor opens index.html
   ├─ data.json younger than 10 min? → show it, done. No scan, no writes.
   └─ older?  POST /scan ──► serve.py (www-data) creates /run/officecount/scan.request
                               │   tmpfs: asking for a scan writes nothing to the card
                               ▼
                   officecount-scan.path (root) sees the flag
                               ▼
                   officecount-scan.service → officecount-scan-ondemand.sh
                               ▼
                   flock -n … scan-devices.py     (5-14s)
                               ▼
                   data.json rewritten, flag removed
   page polls data.json every 2s meanwhile, radar pulsing, old value still on screen

Why a flag file and not sudo. arp-scan needs root; serve.py runs as www-data and is reachable from the open internet. Giving that process a sudo rule or a setuid binary would put root one bug away from a public HTTP handler. A flag file crosses the boundary with systemd on the other side, and the only thing that can be asked for is "run one scan".

Why it is safe to leave /scan unauthenticated. The page is public, so a token in its source would not be a secret. The cache is the real bound: serve.py refuses while the reading is younger than CACHE_MAX (600s) and refuses again while a scan is pending, so a crawler or a crowd can produce at most one scan per 10 minutes — a tenth of the cron job it replaced. O_CREAT|O_EXCL is what makes a burst of visitors collapse into one scan.

What changed in the numbers. With scans 10+ minutes apart, seen.json never holds more than one sweep, so the published active is now the same thing as now_act — the single-sweep estimator. Against the thirteen manual counts that is a wash: both want a divisor within noise of the deployed 4 (3.76 vs 3.92, identical mean error of 1.33 people). See the calibration section in CLAUDE.md. PER_PERSON does not change.

Caveat worth knowing. n in a trends.json bucket used to be ~60 and now is 1–3, so "n < 55 means a short hour" is no longer a way to spot an outage. Use gaps.py against history.csv, which was always the better tool for that question.