Rebuilt the TV presence daemon on telep-mainframe as a stdlib-only python3 service that unions the router WiFi association table with arp-scan. The previous arp-only attempt was removed: it is provably broken here, and this session captured the proof live. The TV it drives is now paired and working (2026-09-02-lg-tv-network-control-presence).
For Agents — Quick Reference
Daemon:
/opt/tv-control/presence(python3, stdlib only, no venv needed) Unit:/etc/systemd/system/tv-presence.service— enabled and running Cadence: poll every 60 s, presence window 900 s Actuates:/opt/tv-control/tv on|off|camwall— on transitions only (⚠️ the subcommand was renamedhdmi1→camwall; there is nohdmi1any more) Sources:iwinfo phy0-ap0 assoclist+iwinfo phy1-ap0 assoclistover SSH to telep-router (192.168.1.1), unionarp-scan --interface=enp5s0 --localnetSSH key:/home/levander/.ssh/router_alarm— shared with 2026-07-17-intruder-alarm TV: paired and working since 2026-09-02 16:13 — the actuation target is live. The camwall is on the TV’sHDMI_2, notHDMI_1(Key finding 1 — TV input number ≠ GPU output name). Caveat: the absent→present path has still never fired for real — see Validation status.
The core lesson — arp-scan alone is broken, use the union
Never build LAN presence on arp-scan alone
A sleeping iOS device stays WiFi-associated but stops answering ARP. An arp-only design reports a false “everyone left” and switches the TV off with the owner sitting in the room.
Empirically confirmed 2026-09-02 on this exact network. iPhone 38:7f:8b:df:2a:79 (spider-web) was simultaneously:
- PRESENT in
iwinfo phy0-ap0 assoclist - ABSENT from
arp-scan --interface=enp5s0 --localnet
The running daemon logged the same device oscillating across consecutive 60 s polls:
13:24:07 seen=router
13:25:11 seen=both
seen=router is the exact window in which an arp-only daemon would have powered the TV off. This is the same finding that forced the union in 2026-07-17-intruder-alarm — it is now confirmed twice, independently, on this LAN. Treat it as a hard site invariant, not a heuristic.
Gotcha — query only the telep1 APs
Do NOT query
phy1-ap1
AP ESSID Use phy0-ap0telep1(5 GHz)✅ query phy1-ap0telep1-2G(2.4 GHz)✅ query phy1-ap1telep-cc❌ camera VLAN — leaks camera devices into presence Same trap that leaked the Tapo camera into the alarm’s enrollable-device list. See Camera VLAN (telep-cc).
Gotcha — silent-empty router result
iwinfo <bad-ap> assoclist prints No such wireless device and exits 1, but a naive pipeline swallows that and yields an empty MAC set — indistinguishable from “nobody is associated”. The remote command is therefore written to fail loudly:
for ap in phy0-ap0 phy1-ap0; do iwinfo $ap assoclist || exit 1; doneA renamed or removed AP now fails the whole source instead of quietly reporting an empty house.
Gotcha — router host key rotated
The OpenWrt/dropbear host key at 192.168.1.1 changed, so SSH from root on telep-mainframe aborted with REMOTE HOST IDENTIFICATION HAS CHANGED.
ssh-keygen -f /root/.ssh/known_hosts -R 192.168.1.1
ssh -o StrictHostKeyChecking=accept-new -i /home/levander/.ssh/router_alarm root@192.168.1.1Newly pinned ED25519 fingerprint: SHA256:5WXvfnjJYyOyZa3h/iVwdsYrOb17SLqVFfXV6CLXugY
Router identifies as telep-router, Linux 6.6.73 aarch64.
Known-hosts is per-user
The daemon runs as
root, so it is/root/.ssh/known_hoststhat matters — not/home/levander/.ssh/known_hosts, even though the key lives in levander’s home.
Poll-failure rule
A failed poll is DISCARDED — entirely
No last-seen update, no presence evaluation, no aging out. A transient SSH/arp failure must never read as “everyone left”.
If one source succeeds, that is still a valid poll — presence is simply the union of one source.
Safety invariants
| Invariant | Why |
|---|---|
Presence initialises to None; the first successful poll is adopted without actuation | A daemon restart can never power-cycle the TV |
| Transitions only, never re-assert | A human who switches the TV off manually while present is not fought back on every cycle |
A failed tv on / tv off does not latch the transition | The action is retried next cycle rather than being lost |
Actuation target — renamed hdmi1 → camwall
tv hdmi1no longer exists — and that was deliberateThe GPU output is called
HDMI-1byxrandr, but the mainframe is physically in the television’s HDMI 2.switchInput HDMI_1therefore selected an empty socket and the TV showed “no signal” while the camwall was rendering perfectly. The CLI now has a singleTV_INPUT = "HDMI_2"constant and the subcommand is namedcamwallafter its intent.The
hdmi1alias was removed rather than repointed — a command namedhdmi1that switches toHDMI_2is a trap for the next reader.tv hdmi1exits 2 withunknown command. Any older snippet or note showingtv hdmi1is stale.Full write-up: Key finding 1 — TV input number ≠ GPU output name.
The daemon calls /opt/tv-control/tv on, tv camwall and tv off by subprocess. Nothing else about the daemon changed.
Validation status
Presence detection: proven. Actuation transport: proven.
The LG TV was paired 2026-09-02 16:13 and
tv on/tv off/tv camwallhave all been executed successfully against real hardware, including a Wake-on-LAN wake from deep standby. The samesubprocess-invocation mechanism the daemon uses is exercised in production bytv-http.
The absent→present path has still never fired for real
No actual arrival has been observed while the daemon was running. The path is verified only by code inspection plus the fact that the identical subprocess mechanism works through
tv-http. Say “very likely correct”, not “verified”.Related timing risk it has therefore never met in the wild: after a real wake from deep standby, webOS accepts TCP on 3000 seconds before SSAP will answer, so the very first post-wake
tv camwallcanTimeoutErroreven though the TV is fine. The CLI now retries (CONNECT_ATTEMPTS = 6,CONNECT_RETRY_DELAY = 3) — but that retry loop has not yet been exercised by the daemon itself, only by hand. See Key finding 5 — webOS accepts TCP before SSAP is ready (the post-wake race).
Unrelated but reusable — importing an extension-less python file
Hit while writing the test harness for /opt/tv-control/presence (a python file with no .py extension):
spec_from_file_locationsilently fails on extension-less filesIt returns a spec whose
loaderisNone, somodule_from_specblows up with a confusing error. The extension is what selects the loader.
import importlib.machinery, importlib.util
loader = importlib.machinery.SourceFileLoader("presence", "/opt/tv-control/presence")
spec = importlib.util.spec_from_loader("presence", loader)
mod = importlib.util.module_from_spec(spec)
loader.exec_module(mod)Related
- 2026-07-17-intruder-alarm — the original union-presence engine; same lesson, same router SSH key
- 2026-09-02-lg-tv-network-control-presence — the
tv-controlCLI, SSAP/WebSocket path,tv-httpservice and theHDMI_2finding; paired and verified - telep-mainframe — host running the daemon
- telep-router — the OpenWrt AP whose
assoclistis source #1 - 2026-07-28-camwall-4-substream-composite — what the TV actually displays
- 2026-08-15-dhcp-outage-duplicate-reservation-postmortem — why tracking is by MAC, not IP