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 tc throttle is untouched).
  • Interactivity. This is a glanceable wall display.

Key decisions

DecisionChoiceWhy
Usage metricLive MbpsUser requirement
Device scopeAll stations on telep1User requirement
Camera VLANExcludedphy1-ap1/telep-cc is never queried
OrderingAlphabetical by name, stableTiles must not jump on a wall display
Refresh5sSmooths bursty WiFi, trivial router load
Displaympv osd-overlay (ASS) via existing IPCNo 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, ESSID telep1)
  • phy1-ap0 (2.4GHz, ESSID telep1)

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

  1. ~/alarm/trusted.json — MAC → user-assigned name (from the alarm’s Telegram /rename). Reused, not duplicated.
  2. Router /tmp/dhcp.leases — MAC → DHCP hostname, if not *.
  3. 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 , not 0.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 0 and 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ÉPEK title 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_y set to 3840×2160 gives a 1:1 pixel coordinate space matching the layout above.

Why not the lavfi filter or camwall-reload

camwall-reload issues loadfile on 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 a movie= source to --vf also requires a full camwall restart and touches the filter chain that has already killed this wall once (the pad config-fail incident). osd-overlay touches 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ábbi for 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_alarm and ~/alarm/trusted.json).
  • Requires write access to /run/camwall.sock.
  • SSH uses ControlMaster=auto + ControlPersist=60s so the 5s poll does not pay a TLS/handshake cost each cycle.

Testing / verification

  1. Parser unit check — feed a captured station dump fixture, assert MAC/rx/tx extraction and that tx bytes maps to download.
  2. Rate math — synthetic before/after samples with a known elapsed time assert exact Mbps; include a counter-reset case asserting 0, not negative.
  3. Name resolution — assert trusted.json wins over DHCP, and the eszköz-<last4> fallback.
  4. Camera exclusion — assert no telep-cc station can appear (only the two APs are queried).
  5. 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.
  6. Non-interference — confirm the birdseye video does not stutter and camwall is never restarted during a 5-minute run.

Risks

RiskMitigation
Inverting rx/txExplicit fixture test asserting direction
Overlay disturbs videoosd-overlay never touches the filter or stream; verified by framebuffer grab
5s SSH polling loadControlPersist; single combined command per cycle
Wall taken down by this serviceCollector only writes to a socket; all failures are caught and retried
MAC case mismatchNormalise to lowercase everywhere