- HTML 67.1%
- Python 32.9%
Fix: call arp-scan by absolute path (/usr/sbin/arp-scan) so the cron.d job no longer crashes under its minimal PATH; document the PATH gotcha in README. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|---|---|---|
| .gitignore | ||
| CLAUDE.md | ||
| details.html | ||
| index.html | ||
| officecount-web.service | ||
| README.md | ||
| robots.txt | ||
| scan-devices.py | ||
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.
Live at https://027s.testwerf.nl/ (also reachable at https://obs6.tail62b52d.ts.net/
if Tailscale Funnel is left on).
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] python http.server ──serves──> index.html (phrase + sparkline + weekday bars)
(Pi, 127.0.0.1/tailnet :8080) │
▼
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 hier | dark/grey |
| 1–6 | Het is stil hier | green |
| 7–14 | Gezellig, kom erbij | amber |
| 15+ | Het is hier druk! Je mag komen, maar neem chocola mee! | red |
Plus a 24h sparkline and a per-weekday bar chart (ma di wo do vr, average people during office hours 08:00–18:00).
How the count works
- Devices = unique MACs seen in the last 5 min (
WINDOW) — survives MAC randomization and brief absences. - Baseline (self-learning) = stable (non-randomized) MACs seen during the quiet
window (weekday nights 01:00–05:00 + weekends) are flagged
infraand subtracted, so always-on gear (printers, APs) reads as an empty office. Sharpens over ~a week. - 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 |
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 |
Runtime state written to /var/www/officecount/: seen.json (live window),
ledger.json (per-MAC memory), profile.json (weekday averages),
history.json (24h sparkline), trends.json (hourly rollups, 90d, for the detail
page), history.csv (full append-only log).
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 thresholds (7 / 15) and colours → top of the
tick()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.
Known limits / gotchas
- 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* * * * * root /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
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.
- ✅ 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.
Backlog — future iterations
- 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. 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.
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 index.html details.html robots.txt pi@obs6.local:~/
ssh pi@obs6.local 'sudo cp ~/scan-devices.py /usr/local/bin/ && sudo cp ~/index.html ~/details.html ~/robots.txt /var/www/officecount/ && sudo chmod +x /usr/local/bin/scan-devices.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 index.html details.html robots.txt pi@obs6.tail62b52d.ts.net:~/
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.