Network control for the LG TV that displays the camwall on telep-mainframe — power on/off, input switch to the camwall, an assoclist∪arp presence daemon, a tailnet HTTP service and Homepage buttons. The obvious approach (HDMI-CEC over the existing cable) is impossible on this box, so everything goes over the TV’s webOS SSAP WebSocket API. Paired and verified end to end on 2026-09-02.
Status: PAIRED AND WORKING
Paired 2026-09-02 16:13; /opt/tv-control/client-key.json exists (0600). Verified live: pair, status, on (including a real Wake-on-LAN wake from deep standby), off, camwall input switch, and the tv-http service + Homepage buttons. Wake-on-LAN is confirmed enabled on the TV. Two things remain: Auto Power Off is still not disabled at the set (see Still open), and the presence daemon’s absent→present path has still never fired for real.
For Agents — Quick Reference
TV: LG 43UP75003LF, webOS 6, firmware p20.03.53.45, 192.168.1.171, LGwebOSTV.lan, MAC ac:5a:f0:8b:48:da.
The mainframe is in the TV’s HDMI_2, NOT HDMI_1 — see Key finding 1 — TV input number ≠ GPU output name. TV_INPUT = "HDMI_2".
Control path: SSAP WebSocket — 3000 plain, 3001 TLS (self-signed LG cert → SSL verification must be off). Not CEC.
CLI:/opt/tv-control/tv (venv /opt/tv-control/venv, aiowebostv 0.10.0) — pair | status | on | off | camwall | input <ID>. There is no hdmi1 subcommand any more (removed on purpose; tv hdmi1 exits 2).
HTTP:/opt/tv-control/tv-http on 127.0.0.2:8102, tailnet-only at https://telep-mainframe.taild4189d.ts.net:8451, POST /api/{on,off,camwall,pair}.
Daemon:tv-presence.service → /opt/tv-control/presence, router assoclistUNIONarp-scan on enp5s0 every 60 s, 900 s presence window — see 2026-09-02-tv-presence-wifi-union-daemon.
Trap:telep-tv in mDNS is the mainframe’s own uxplay receiver (2026-08-04-telep-mainframe-airplay-receiver-uxplay), not this TV.
auto power-on + auto switch to the camwall input when a tracked person arrives
auto power-off when everyone has left
manual buttons on the dashboard for the override case
Key finding 1 — TV input number ≠ GPU output name
⭐ HDMI-1 in xrandr is the GPU's port index. It says NOTHING about which socket on the TV the cable is in.
xrandr on telep-mainframe reports the output as HDMI-1. The mainframe is physically plugged into the television’s HDMI 2. These two numbers are unrelated namespaces that happen to look identical.
The whole build assumed HDMI-1 (GPU) meant HDMI_1 (TV). Issuing switchInput HDMI_1 moved the TV to an empty socket, and it showed “no signal” — while xrandr, the framebuffer and the camwall were all provably healthy. A perfect false trail: both ends look fine and the screen is blank.
The TV will tell you the truth directly. WebOsTvState.inputs carries per-input connection state:
When an LG shows “no signal” but the source looks healthy, check connected per input BEFORE touching modes, cables or Deep Colour settings. One SSAP status call settles it.
Fix applied: a single TV_INPUT = "HDMI_2" constant, and the subcommand renamed hdmi1 → camwall across all three files (tv, tv-http, presence).
The hdmi1 alias was deliberately REMOVED, not repointed
A command named hdmi1 that switches the TV to HDMI_2 is a trap for the next reader. tv hdmi1 now exits 2 with unknown command. The subcommand is named after intent (camwall), not after a port number on either end.
Proving the source is healthy while the screen is black
During this incident the camwall was verified rendering correctly even though the TV was blank — worth reusing as the standard “is the wall actually drawing?” check:
then per-quadrant signalstatsYAVG. Result: 4 live panes, YAVG 115–128 per quadrant, correct 3840x2160 mode. A black or frozen pane shows up immediately as a collapsed YAVG. Because this came back clean, the fault had to be downstream of the GPU — which is what pointed at the TV input.
Key finding 2 — NVIDIA GPUs have no HDMI-CEC, ever
Do not waste time on cec-client on this host
/dev/cec* and /sys/class/cec/do not exist and never will. NVIDIA consumer GPUs do not implement HDMI-CEC — there is no driver option, module parameter or kernel module that adds it. Verified on driver 610.57.04. The HDMI cable that already carries the camwall picture cannot carry control.
Workarounds, in order of what was actually chosen:
Option
Notes
Vendor network API ✅ chosen
webOS SSAP over WebSocket. No hardware, no cost.
USB-CEC adapter (Pulse-Eight)
~EUR 40. ⚠️ must be wired INLINE: GPU → adapter HDMI-in → adapter HDMI-out → TV. Hanging it off a spare port makes “set active source” switch to the wrong input.
IR blaster
Fire-and-forget, no state feedback.
Key finding 3 — identifying an unknown display: xrandr lies, mDNS tells the truth
xrandr physical dimensions are a FALLBACK, not a measurement, under the NVIDIA blob
xrandr reported HDMI-1 as 1600mm x 900mm, which implies a 72–75” panel. The TV is 43”. The NVIDIA proprietary driver does not populate /sys/class/drm/*/edid, and 1600x900mm is a suspiciously round 16:9 placeholder. Never size a display from xrandr on this driver.
(Note the pattern: xrandr under this driver misled on both the panel size and, by naming, the TV input. Treat it as a GPU-side view only.)
Reusable: mDNS TXT is the highest-yield way to identify a smart TV on the LAN
Here SSDP M-SEARCH returned nothing at all, and port scanning found open ports but could not name the device. avahi-browse -rt did in one command what neither could. (Contrast with the Bambu printer, where SSDP was the right probe — 2026-08-26-router-reflash-mac-5ghz-dfs-and-bambu-offline.)
LG 43UP75003LF, webOS 6, firmware p20.03.53.45 (a 2021 UP75-series set)
192.168.1.171, hostname LGwebOSTV.lan, MAC ac:5a:f0:8b:48:da
Mainframe is in TV input HDMI_2.HDMI_1 is empty.
SSAP WebSocket
port 3000 — plain, returns HTTP 101 Switching Protocols
port 3001 — TLS, self-signed LG chain CN=LGE TV SSG / O=LG Electronics Inc. → SSL verification must be disabled
Also advertises AirPlay (7000), Miracast sink (7250), HomeKit_hap._tcp
Key finding 4 — Active Standby defeats a “is it already on?” Wake-on-LAN guard
In Active Standby, SSAP port 3000 stays OPEN — so an open-port check is NOT an "already awake" test
webOS has two standby depths. In deep standby the network stack is down and 3000 is closed. In Active Standby the TV keeps its network stack up and 3000 answers.
The original cmd_on guarded WoL behind a port probe:
if not await ssap_port_is_open(): send_magic_packet()
In Active Standby the probe reads open, so WoL was skipped entirely and the code fell back on SSAP power_on() — which LG TVs honour unreliably. That is the whole of the user-reported “tv on is flaky”.
Fix: send the magic packet unconditionally. It is a harmless UDP broadcast when the TV is already on, and it costs nothing to be wrong in that direction.
Wake-on-LAN is CONFIRMED ENABLED on this TV
A magic packet woke the set from deep standby with port 3000 closed. The LG “Mobile TV On” setting (Settings → General → Mobile TV On) is therefore already on — this is no longer an open item.
Key finding 5 — webOS accepts TCP before SSAP is ready (the post-wake race)
Port 3000 opens several seconds before the SSAP service will answer
After a real wake from deep standby, tv on, tv status and the input switch all failed with TimeoutError within ~9 s — while the TV was booting perfectly well and came up fine moments later. The commands reported failure while actually succeeding. Observed live.
Fix — retry the connect, don’t lengthen everything:
Honest nuance — do NOT "fix" steady-state timeouts from post-wake symptoms
CONNECT_TIMEOUT = 2 (the aiowebostv default) is fine in steady state: measured 0/10 failures at 0.29–0.41 s over ten runs. It is only too short in the seconds after a wake. The correct fix is retries around the wake, not a globally inflated timeout that makes every normal command sluggish to fail.
Key finding 6 — the aiowebostv pairing window is 10 seconds
aiowebostv0.10.0 module-level constants in webos_client.py:
Constant
Value
CONNECT_TIMEOUT
2
RECEIVE_TIMEOUT
10
REQUEST_TIMEOUT
20
HEARTBEAT
5
In _check_registration, after the TV puts the pairing prompt on screen it does await ws.receive_json(timeout=RECEIVE_TIMEOUT) — so a human has exactly 10 seconds to pick up the remote and press Accept, or it raises. That is not enough time for “walk to the TV, find the remote, read the prompt”.
RECEIVE_TIMEOUT is not a constructor arg — monkeypatch the module global
Only connect_timeout is exposed through WebOsClient.__init__. The constant is read at call time, so patching the module global before connecting works:
PAIR_RECEIVE_TIMEOUT = 180 is set in cmd_pair only, leaving normal commands at the responsive 10 s.
Layered-timeout gotcha — the outer timeout must EXCEED the inner one
If the HTTP wrapper’s own subprocess timeout is shorter than the pairing window it fires first and masks the real error. Hence tv-httpPAIR_TIMEOUT = 210 > tvPAIR_RECEIVE_TIMEOUT = 180. Any time you nest timeouts, the outermost must be the longest or you lose the diagnosis.
Key finding 7 — an error-reporting bug that hid everything for several rounds
print(f"{command} failed: {err or type(err).__name__}") # WRONG
err or ... tests the truthiness of the exception object, which is always True. The fallback therefore never fires, and the f-string interpolates str(err) — which is empty for exceptions raised with no message. Every failure printed:
pair failed:
…with nothing after it. That is why the real cause stayed invisible for several rounds of debugging.
print(f"{command} failed: {str(err) or type(err).__name__}") # RIGHT
General Python idiom trap
x or fallback where x is an object is a truthiness test on the object, not on its string form. When you mean “the message, or else the class name”, you must write str(x) or fallback. Exceptions, Path, and most custom classes are always truthy.
What was built
/opt/tv-control/tv — the CLI
Python CLI, venv at /opt/tv-control/venv, aiowebostv0.10.0. Subcommands:
tv pair | status | on | off | camwall | input <ID>
Client key persisted at /opt/tv-control/client-key.json, mode 0600, created 2026-09-02 16:13. Exit codes 0 / 1 / 2.
tv status now returns real data:
power: Active
on: True
input: HDMI_2
input: unknown in standby is NORMAL, not a fault
While the TV is in standby, current_app_id is empty, so status prints input: unknown. Do not chase this.
Gotcha — connect() puts a pairing prompt ON SCREEN
In aiowebostv 0.10.0, connect()always calls _check_registration(), which sends the registration handshake. With no client key that throws a pairing prompt onto the TV. So status does a bare TCP probe first and refuses to open a WebSocket when unpaired. Anything that “just checks state” must avoid connect() on an unpaired TV.
Gotcha — aiowebostv 0.10.0 has no __version__
Use importlib.metadata.version("aiowebostv"). Reading aiowebostv.__version__ raises.
The TV buttons now live directly on Homepage, embedded via the gethomepage iframe service widget pointing at tv-http. (/home/levander/homepage/config/services.yaml, 🛠️ Eszközök & Média group — 2026-08-17-home-dashboard.)
They were moved off OliveTin because the user wanted them on Homepage directly — not because of any OliveTin limitation. The earlier TV be / TV ki OliveTin actions followed the ssh 192.168.1.123 '...' pattern from 2026-08-15-admin-portal-passkey-olivetin.
UNRESOLVED — whether OliveTin 3000.19.0 supports a GET-triggered action route
This was never settled and the two investigations disagree:
One tested StartActionByGet and got 405 with Allow: POST (i.e. POST-only, method not marked no_side_effects).
Another found both StartActionByGet and StartActionByGetAndWait registered in the binary but did not test them.
Record this as disputed, not fact. What is established for 3000.x: the old REST route /api/StartActionByGet/<id> is gone, the API is Connect-RPC at /api/olivetin.api.v1.OliveTinApiService/<Method>, and action identifiers are bindingId UUIDs minted at config-load time, so any hardcoded trigger URL rots on every restart. A webhook subsystem exists in the binary (internal/webhooks, execOnWebhooks) but its YAML schema was not confirmed.
/opt/tv-control/presence + tv-presence.service
Design unchanged — see 2026-09-02-tv-presence-wifi-union-daemon for the full write-up. The only change from this session: the absent→present path now calls tv camwall instead of the removed tv hdmi1.
Transition
Action
absent → present
tv on, then tv camwall
present → absent
tv off
Tracked MACs: 38:7f:8b:df:2a:79 (spider-web.lan, iPhone/iPad) and a4:40:e1:02:01:e9 (F_M.lan).
Design decisions worth keeping
Acts on TRANSITIONS ONLY, and does not actuate on startup. A daemon restart therefore cannot power-cycle the TV, and a human who manually switches the TV off is not fought back on every poll cycle.
Union of router assoclist and arp-scan — active ARP probing alone is not enough, because a sleeping iPhone ignores the probe while staying WiFi-associated. Same lesson as 2026-07-17-intruder-alarm.
Side findings picked up in passing
These are not TV-specific but were found during this session and are individually expensive to rediscover.
docker restart homepage does NOT apply config changes
A prerendered /app/.next/server/pages/en.html survives restarts. Restarting the container looks like it should reload services.yaml; it does not. What actually applies a config change is:
curl http://127.0.0.2:3010/api/revalidate
This very likely explains past “I edited the dashboard and nothing happened” episodes. See 2026-08-17-home-dashboard.
Homepage's tailnet mount at :8450 was silently broken (502) — found and fixed in passing
tailscale serve pointed at 127.0.0.1:3010, but next-server binds 127.0.0.2:3010. Result: a clean-looking serve config returning 502. This is the same127.0.0.1-vs-127.0.0.2 split as 2026-08-31-tailnet-plaintext-port-hardening — when a tailnet mount 502s on this host, check which loopback address the backend actually binds before anything else.
2xl:h-48 in the Homepage widget config was a dead class Tailwind never compiled — it silently does nothing. Homepage’s Tailwind build only includes classes it can see at build time; arbitrary responsive variants added via YAML are not guaranteed to exist.
Still open
🔴 Auto Power Off must be disabled at the TV. webOS powers the set off after 4 hours with no button pressed (a “will go to sleep in 5 minutes” countdown first) — and nobody ever touches the remote in front of a camera wall. Path: All Settings → General → Timers → Auto Power Off → Off; also check Eco Mode / Energy Saving → Auto Power Off / Screen Off. Possibly settable via luna://com.webos.settingsservice/setSystemSettings, which would need bscpylgtv rather than aiowebostv — unconfirmed on this firmware. This is the last thing standing between the build and “done”.
⚠️ The presence daemon’s absent→present path has never fired for real. Verified by code inspection and by the identical subprocess mechanism working through tv-http, but no actual arrival has been observed. Treat auto-on-arrival as very likely rather than proven.
❓ OliveTin GET-triggered actions — disputed, see above. Not blocking; the buttons are on Homepage.
Resolved since the first draft: pairing (done 2026-09-02 16:13), Wake-on-LAN enablement (confirmed already on), and whether this 2021 UP75 reports power_state (it does — power: Active).
Related
telep-mainframe — the host running the camwall, uxplay and tv-control