- Python 42%
- Shell 35.1%
- HTML 17.1%
- mupad 5.8%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| reticulum | ||
| .gitignore | ||
| calibrate.py | ||
| CARD.md | ||
| CLAUDE.md | ||
| details.html | ||
| gaps.py | ||
| index.html | ||
| memlog.sh | ||
| officecount-boot-record.service | ||
| officecount-boot-record.sh | ||
| officecount-hardware-watchdog.conf | ||
| officecount-journald-persistent.conf | ||
| officecount-maintenance.sh | ||
| officecount-scan-ondemand.sh | ||
| officecount-scan.path | ||
| officecount-scan.service | ||
| officecount-watchdog.service | ||
| officecount-watchdog.sh | ||
| officecount-watchdog.timer | ||
| officecount-web.service | ||
| pondr-check-027s.sh | ||
| pondr-tailnet-repair.sh | ||
| README.md | ||
| RESILIENCE.md | ||
| RETICULUM.md | ||
| robots.txt | ||
| scan-devices.py | ||
| serve.py | ||
| TEMPSENSOR.md | ||
| 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 flaggedinfraand 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 ofscan-devices.py.- Phrase bands (
<1/<7/<15/ else) and colours → thebandternary in thetick()JS inindex.html. - Trend retention (90 days) and the day/week/month buckets →
trends.jsonrollup inscan-devices.pyand thebucketsFor()JS indetails.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.
nowequalsrawon every scan, so no device is rotating its address inside the 300 s window. A shorterWINDOWwould change nothing, andnow_actis currently just a copy ofactive. -
Every arrival and departure moves the count by exactly one device. Three counts taken across one afternoon:
Time People counted active17 + people14: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 ispeople ≈ 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 --localnetsweeps 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 —
lastonly advances when a scan sees a device, and anything unseen for 30 days is pruned.infrawould 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 sotrends.jsonand the weekday bars describe the office rather than describing when people happened to look.Three other places encode these same hours:
scheduled_hour()inofficecount-watchdog.shand inpondr-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 stuckarp-scanbecomes a stack of them, each holdingwlan0in promiscuous mode, growing by one a minute until the box runs out of memory.-nmeans "give up immediately if one is running", never queue. The scanner's ownSCAN_TIMEOUTis the second net: the lock stops the pile-up, the timeout stops the stuck process existing at all.
cron PATH gotcha.
/etc/cron.djobs run with a minimalPATH(/usr/bin:/bin) that does not include/usr/sbin, wherearp-scanlives. Without thePATH=line above, the scanner throwsFileNotFoundError: 'arp-scan'every minute, crashes before writing, anddata.jsonsilently freezes at its last good timestamp while cron looks healthy in the journal. Belt-and-suspenders:scan-devices.pyalso calls arp-scan by its absolute path (ARPSCANconstant). Symptom to watch for: the page/data.jsonupdatedtime stops advancing butsystemctl is-active cronisactive. 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.jsonstill 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.
- DNS (Greenhost): delete the old
027sA record (→ tailnet100.x, not routable) and any URL-forward; add an A record027s→ Pondr's public IP (91.132.146.29), optional AAAA to its IPv6. - YunoHost (Pondr):
sudo yunohost domain add 027s.testwerf.nl sudo yunohost domain cert install 027s.testwerf.nl - Reverse proxy: create
/etc/nginx/conf.d/027s.testwerf.nl.d/busy.confwith alocation / { proxy_pass http://100.109.95.99:8080; proxy_set_header Host $host; … }, thensudo nginx -t && sudo systemctl reload nginx. - Make it public (YunoHost 12 SSOwat): the old
skipped_urls/unprotected_urlskeys in/etc/ssowat/conf.json.persistentare ignored in YH12. Use the permissions form instead:
then{ "permissions": { "busy027s": { "auth_header": false, "label": "Busy027S", "public": true, "show_tile": false, "uris": ["027s.testwerf.nl/"], "users": [] } } }sudo yunohost app ssowatconf. Verify withcurl -skI https://<pondr-ip> -H "Host: 027s.testwerf.nl"→ expect200(a302to/yunohost/ssomeans SSO is still protecting it). - 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/rebootbelow has been replaced byofficecount-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-upgradesapplies 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.djob 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.
rebootlives in/sbin,unattended-upgradein/usr/bin— neither is on cron.d's defaultPATH. ThePATH=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
SIGHUPand killsaptmid-transaction; a kernel upgrade interrupted that way leftdpkgwedged,sshdresetting 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 issudo dpkg --configure -a. If the dpkg lock is held, something is still running — wait, never delete the lock file. And do notapt autoremoveuntil 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 -hvoor 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
heartbeatzetten (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. ZieCARD.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.jsonlaten 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,iwentailscale 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.jsonand 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
.icsURL, 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;bluetoothdis 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 theinfra-baseline trick is harder), and many phones don't advertise unless actively pairing — likely an undercount of its own. Could feed a separate count intodata.jsonand 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 bluetoothcalls). 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 theinfrarule 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.