WiFi device usage strip on the camwall — design
Design for showing WiFi-connected devices, their live throughput, and the 5G WAN uplink speed on the cctv pane of the TV wall.
Related: SESSION-HANDOVER, telep-mainframe, telep-router, 2026-07-17-intruder-alarm
Goal
On the cctv pane of the camwall (not a separate pane), render a strip showing every device currently associated to the telep1 WiFi, with a friendly name and its current down/up speed, plus a header showing total 5G WAN throughput.
Non-goals
- Cumulative volume (GB today/month). Live speed only.
- Historical graphs or retention.
- Wired/LAN devices.
- Managing bandwidth caps (the existing
tcthrottle is untouched). - Interactivity. This is a glanceable wall display.
Key decisions
| Decision | Choice | Why |
|---|---|---|
| Usage metric | Live Mbps | User requirement |
| Device scope | All stations on telep1 | User requirement |
| Camera VLAN | Excluded | phy1-ap1/telep-cc is never queried |
| Ordering | Alphabetical by name, stable | Tiles must not jump on a wall display |
| Refresh | 5s | Smooths bursty WiFi, trivial router load |
| Display | mpv osd-overlay (ASS) via existing IPC | No deps, no restart, no video interruption |
Architecture
Single collector daemon on telep-mainframe; the router stays untouched (no new packages, no firewall rules, no scripts installed).
wifi-usage.service (mainframe, python3 stdlib)
every 5s:
ssh -i ~/.ssh/router_alarm root@192.168.1.1 (ControlMaster-persistent)
iw dev phy0-ap0 station dump
iw dev phy1-ap0 station dump
cat /sys/class/net/wan/statistics/rx_bytes
cat /sys/class/net/wan/statistics/tx_bytes
cat /tmp/dhcp.leases
-> parse, diff vs previous sample, compute Mbps
-> resolve MAC -> friendly name
-> render ASS text
-> push to /run/camwall.sock {"command":["osd-overlay", ...]}
Why the collector lives on the mainframe
The mpv IPC socket is on the mainframe, device names live in ~/alarm/trusted.json on the mainframe, and the router is a busybox box we want to keep clean. The router is treated as a read-only data source.
Data sources
Per-device bytes — iw dev <ap> station dump
iw is already installed. Only the two telep1 APs are queried:
phy0-ap0(5GHz, ESSIDtelep1)phy1-ap0(2.4GHz, ESSIDtelep1)
phy1-ap1 (telep-cc, cameras) is never queried, so a camera can never appear as a “device”. This is the same class of bug documented for the intruder alarm, avoided by construction rather than by filtering.
Relevant fields per station: rx bytes, tx bytes, signal, inactive time.
Direction semantics are inverted
Counters are from the access point’s point of view:
- station
tx bytes= AP → device = the device’s download (↓)- station
rx bytes= device → AP = the device’s upload (↑)Getting this backwards is the single easiest bug to ship here.
WAN uplink — interface counters
WAN is the wan interface (192.168.254.2, default route via the 5G antenna at 192.168.254.1).
/sys/class/net/wan/statistics/rx_bytes= internet download/sys/class/net/wan/statistics/tx_bytes= internet upload
Name resolution chain
First hit wins:
~/alarm/trusted.json— MAC → user-assigned name (from the alarm’s Telegram/rename). Reused, not duplicated.- Router
/tmp/dhcp.leases— MAC → DHCP hostname, if not*. - Fallback
eszköz-<last4-of-mac>.
Names are matched case-insensitively; iw emits lowercase MACs, trusted.json may hold either.
Metric computation
Rate is computed from real elapsed time, never the nominal 5s:
mbps = (bytes_now - bytes_prev) * 8 / elapsed_seconds / 1_000_000
Edge cases that MUST be handled:
- First sample for a device — no previous value. Display
—, not0.0. A device that just appeared has no measurable rate yet. - Counter reset on re-association — a device that roams or reconnects restarts its counters, producing a negative delta. Clamp negatives to
0and reset the baseline. Never render a negative or absurd spike. - Device disappeared — no longer in any station dump → tile removed, stored baseline dropped.
- Elapsed time — measured with
time.monotonic(). - Sample gap — if elapsed is implausible (> 30s, e.g. after a suspend or SSH stall), discard the delta and re-baseline rather than showing a huge fake spike.
Display
Placement
The cctv composite is 3840×2160:
- birdseye video occupies y 0–1080
TOP KÉPEKtitle at y≈1170- the four 880×495 images occupy y 1360–1855
The strip therefore uses the unused region y 1880–2140, full width. Nothing is drawn over the camera image or the top4 images.
Mechanism
mpv osd-overlay over the existing /run/camwall.sock:
{"command":["osd-overlay", 42, "ass-events", "<ass>", 3840, 2160, 0, false, false]}- Fixed overlay id
42, so each push replaces the previous — no accumulation. res_x/res_yset to 3840×2160 gives a 1:1 pixel coordinate space matching the layout above.
Why not the lavfi filter or camwall-reload
camwall-reloadissuesloadfileon the birdseye stream, which restarts video playback. That is acceptable for the weekly TOP KÉPEK refresh but would stutter the live cameras every 5 seconds. Adding amovie=source to--vfalso requires a fullcamwallrestart and touches the filter chain that has already killed this wall once (thepadconfig-fail incident).osd-overlaytouches neither.
Visual style
Match the existing wall: DejaVu Sans Bold, white text, semi-transparent black boxes. Tile backgrounds are drawn as ASS rectangles on a lower layer; text sits above.
WIFI ESZKÖZÖK INTERNET: 28.6 ↓ / 3.1 ↑ Mbps
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Mark-iPhone │ │ iPhone │ │ F_M │ │ eszköz-be18 │
│ 12.4 ↓ │ │ 0.2 ↓ │ │ 15.0 ↓ │ │ 0.0 ↓ │
│ 0.8 ↑ │ │ 0.1 ↑ │ │ 1.2 ↑ │ │ 0.0 ↑ │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
Overflow
Tiles are fixed width; the strip fits 7. If more devices are associated:
- show the 6 busiest (by current down+up), still rendered in alphabetical order,
- plus a final tile
+N továbbifor the remainder.
This keeps ordering stable while guaranteeing the heaviest users are always visible. The cap is a deliberate, stated limit — not a silent truncation.
Idle devices
Shown, with 0.0. The user asked for all connected devices, so presence itself is information. Devices below 0.1 Mbps render dimmed (lower alpha) so active ones stand out.
Failure handling
The 5G WAN is documented as intermittently flaky, so transient failures are the norm and must not blank the wall.
- SSH/router failure — keep the last rendered strip, mark it stale (dimmed header +
⚠marker). Do not clear the overlay, do not crash. - Stale beyond 60s — replace values with
—so nobody reads minutes-old numbers as current. - mpv socket missing or refused — camwall may be restarting. Log once, retry next cycle. Never exit.
- camwall restart — the overlay is lost; the next 5s push restores it automatically. This makes the design self-healing against
camwall-watchdog.service, which restarts the wall on stalls. - The service must never be able to take down the wall. It only writes to a socket.
Service
wifi-usage.service,Restart=always,After=network-online.target.- Runs as
levander(needs~/.ssh/router_alarmand~/alarm/trusted.json). - Requires write access to
/run/camwall.sock. - SSH uses
ControlMaster=auto+ControlPersist=60sso the 5s poll does not pay a TLS/handshake cost each cycle.
Testing / verification
- Parser unit check — feed a captured
station dumpfixture, assert MAC/rx/tx extraction and thattx bytesmaps to download. - Rate math — synthetic before/after samples with a known elapsed time assert exact Mbps; include a counter-reset case asserting
0, not negative. - Name resolution — assert trusted.json wins over DHCP, and the
eszköz-<last4>fallback. - Camera exclusion — assert no
telep-ccstation can appear (only the two APs are queried). - End-to-end — run the collector, generate load on a known device, confirm its tile moves and the WAN header rises. Verify by grabbing the framebuffer (
ffmpeg -f x11grab) rather than trusting logs. - Non-interference — confirm the birdseye video does not stutter and
camwallis never restarted during a 5-minute run.
Risks
| Risk | Mitigation |
|---|---|
| Inverting rx/tx | Explicit fixture test asserting direction |
| Overlay disturbs video | osd-overlay never touches the filter or stream; verified by framebuffer grab |
| 5s SSH polling load | ControlPersist; single combined command per cycle |
| Wall taken down by this service | Collector only writes to a socket; all failures are caught and retried |
| MAC case mismatch | Normalise to lowercase everywhere |