No description
  • HTML 67.1%
  • Python 32.9%
Find a file
dosch fc36bb9fe0 Initial commit: Busy027S office occupancy radar
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>
2026-06-26 13:26:43 +02:00
.gitignore Initial commit: Busy027S office occupancy radar 2026-06-26 13:26:43 +02:00
CLAUDE.md Initial commit: Busy027S office occupancy radar 2026-06-26 13:26:43 +02:00
details.html Initial commit: Busy027S office occupancy radar 2026-06-26 13:26:43 +02:00
index.html Initial commit: Busy027S office occupancy radar 2026-06-26 13:26:43 +02:00
officecount-web.service Initial commit: Busy027S office occupancy radar 2026-06-26 13:26:43 +02:00
README.md Initial commit: Busy027S office occupancy radar 2026-06-26 13:26:43 +02:00
robots.txt Initial commit: Busy027S office occupancy radar 2026-06-26 13:26:43 +02:00
scan-devices.py Initial commit: Busy027S office occupancy radar 2026-06-26 13:26:43 +02:00

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
16 Het is stil hier green
714 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:0018: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:0005:00 + weekends) are flagged infra and 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 of scan-devices.py.
  • Phrase thresholds (7 / 15) and colours → top of 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.

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.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.
  • 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.

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.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. 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.

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.