As-built live per-device WiFi bandwidth strip along the bottom of the camwall’s cctv pane on telep-mainframe. Every 5 s it polls telep-router over one persistent SSH connection, diffs per-station byte counters into Mbps, resolves MACs to friendly names, renders an ASS overlay and pushes it into the already-running mpv. Every device on telep1 gets a tile with live ↓/↑; a header tile shows the 5 GHz WAN total.

Design in 2026-07-22-wifi-usage-strip-design, task-by-task plan in 2026-07-22-wifi-usage-strip-plan — this note is the completion record and the hard-won knowledge, not a restatement of either.

For Agents

Service: wifi-usage.service on telep-mainframe (enabled, survives reboot, runs as root). Code: /home/levander/wifi-usage/iwparse.py, rates.py, names.py, render.py, mpvipc.py, collect.py + 44 unit tests (44/44 pass, python3 stdlib only). Sockets: mpv IPC /run/camwall.sock; SSH ControlMaster /run/wifi-usage/router.sock. Refresh: 5 s. Router access: the same LAN-only dropbear + key from Prerequisite router access.

The four things that made this work

1. The mpv IPC connection must be persistent

mpv destroys osd-overlay entries when the IPC client disconnects

A connect → send → close client silently does nothing. mpv even answers {"error":"success"}, then removes the overlay the instant the socket closes. Proven empirically: the naive version produced no strip at all; holding the same socket open with a byte-identical payload made it appear immediately.

Consequences:

  • The daemon holds one long-lived connection to /run/camwall.sock.
  • It must drain mpv’s event stream (non-blocking recv) or the socket buffer fills and the push eventually blocks.
  • Reconnect-on-failure is the self-healing mechanism — when camwall restarts it recreates the socket, the daemon reconnects, and the strip comes back unaided (measured ~23 s).

2. Bytes come from iw, not iwinfo

iw dev <ap> station dump gives per-station rx bytes / tx bytes

iwinfo assoclist (what 2026-07-17-intruder-alarm uses for presence) reports only packet counts and link rates — no byte counters. iw dev <ap> station dump gives per-station byte totals plus signal, and iw is already installed on the router.

So per-device bandwidth needs no extra package, no nftables accounting rules, no conntrack parsing, no nlbwmon. This collapsed the whole “how do we meter per-device traffic” question.

3. Direction is from the AP’s point of view

Station tx bytes is the device's DOWNLOAD — the easiest bug to ship

The counters belong to the access point, so they are inverted relative to intuition:

  • station tx bytes = AP → device = the device’s download (↓)
  • station rx bytes = device → AP = the device’s upload (↑)

The WAN interface is not inverted: wan rx_bytes really is internet download. Verified in production by cross-check: a device reading 35.4 ↑ while the WAN independently read 35.1 ↑ at the same moment.

4. osd-overlay, never camwall-reload

camwall-reload issues loadfile on the birdseye stream, which restarts video playback. Fine for the weekly TOP KÉPEK refresh; catastrophic at 5 s intervals — it would stutter the live cameras continuously. osd-overlay over the existing /run/camwall.sock touches neither the stream nor the lavfi filter chain, which also sidesteps the pad-config-fail incident that once killed the whole composite (see SESSION-HANDOVER).

Untrusted input: LAN device names

Device names are attacker-influenceable — a DHCP hostname is whatever the client claims.

subprocess.run(..., text=True) decodes strictly and can crash a daemon

Non-UTF-8 bytes in router output (most plausibly a device-supplied DHCP hostname) raise UnicodeDecodeError, which is a ValueError and therefore not caught by except (subprocess.SubprocessError, OSError). Use errors="replace". Generic lesson: anything parsing LAN-supplied names is an untrusted input path.

  • Escaping is necessary but not sufficient. ASS escaping (\{, \}, \\) does correctly prevent override-tag injection — validated against the box’s real libass 0.17.3 — but a 400-char DHCP hostname still renders straight across the 4K frame over the camera feeds. Names are truncated to 22 chars.
  • Camera VLAN excluded by construction. Only phy0-ap0 and phy1-ap0 (ESSID telep1) are ever queried; phy1-ap1 (telep-cc) is never referenced, so a camera cannot enrol as a “device”. Same failure mode as Camera-VLAN gotcha — telep-cc leaked an enrollable device, avoided structurally rather than by filtering.
  • Names are reused, not reinvented. Lookup order: the intruder alarm’s ~/alarm/trusted.json (the Telegram /rename names) → the router’s DHCP hostname → eszköz-<last4>. No second naming system was created.

SSH ControlMaster hides credential loss

Removing the router SSH key showed no degradation — because of multiplexing

A test that deleted the key appeared to prove the daemon didn’t need it. It did: the multiplexed ControlPersist connection was already authenticated and the key file is never re-read. To actually test degradation you must also drop the control socket (/run/wifi-usage/router.sock). Operationally: the daemon keeps polling for up to ~60 s after key loss before it notices.

Socket ownership

/run/camwall.sock is srw------- root root and camwall.service runs as root — so anything pushing overlays must also run as root. As levander the connect fails with EACCES. This is why wifi-usage.service is a root unit.

Layout & behaviour

  • Occupies the previously-empty strip at y 1880–2140, below the TOP KÉPEK row (which ends at y 1855). Nothing is ever drawn over the camera image.
  • Fits 7 tiles. Beyond that: the 6 busiest plus a +N további tile — a stated cap, not silent truncation.
  • Router failure keeps the last values with a marker; after 60 s they become . The wall is never blanked.
  • Verified live: camwall NRestarts unchanged by the daemon, 2026-07-17-intruder-alarm unaffected, 44/44 tests pass, self-healing after a camwall restart in ~23 s.

Open / known-minor

Recorded honestly — these are not fixed:

  • push_overlay returns True on “bytes written to socket”. It cannot confirm mpv actually applied the overlay.
  • Worst-case cycle (~24 s: 12 s SSH + 2×3 s IPC + …) is tight against MAX_GAP_SECONDS = 30. A slow cycle blanks rates to rather than crashing, but the margin is thin.
  • Each reconnect momentarily destroys the overlay before resending → sub-frame flicker.
  • _escape does not strip bidi/control characters (e.g. U+202E) — cosmetic scrambling only, no injection path.
  • Continuous 60 s stutter-free playback was judged from periodic framebuffer grabs, not video. A human eye should confirm.