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 renamed hdmi1camwall; there is no hdmi1 any more) Sources: iwinfo phy0-ap0 assoclist + iwinfo phy1-ap0 assoclist over SSH to telep-router (192.168.1.1), union arp-scan --interface=enp5s0 --localnet SSH 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’s HDMI_2, not HDMI_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

APESSIDUse
phy0-ap0telep1 (5 GHz)✅ query
phy1-ap0telep1-2G (2.4 GHz)✅ query
phy1-ap1telep-cccamera 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; done

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

Newly 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_hosts that 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

InvariantWhy
Presence initialises to None; the first successful poll is adopted without actuationA daemon restart can never power-cycle the TV
Transitions only, never re-assertA 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 transitionThe action is retried next cycle rather than being lost

Actuation target — renamed hdmi1camwall

tv hdmi1 no longer exists — and that was deliberate

The GPU output is called HDMI-1 by xrandr, but the mainframe is physically in the television’s HDMI 2. switchInput HDMI_1 therefore selected an empty socket and the TV showed “no signal” while the camwall was rendering perfectly. The CLI now has a single TV_INPUT = "HDMI_2" constant and the subcommand is named camwall after its intent.

The hdmi1 alias was removed rather than repointed — a command named hdmi1 that switches to HDMI_2 is a trap for the next reader. tv hdmi1 exits 2 with unknown command. Any older snippet or note showing tv hdmi1 is 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 camwall have all been executed successfully against real hardware, including a Wake-on-LAN wake from deep standby. The same subprocess-invocation mechanism the daemon uses is exercised in production by tv-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 camwall can TimeoutError even 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_location silently fails on extension-less files

It returns a spec whose loader is None, so module_from_spec blows 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)