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 assoclist UNION arp-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.

Goal

Make telep-mainframe drive the TV that shows the camwall:

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

com.webos.app.hdmi1   id=HDMI_1   connected=False
com.webos.app.hdmi2   id=HDMI_2   connected=True

Diagnostic rule — record this and use it first

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 hdmi1camwall 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.

Related NVIDIA display quirks on this same box: TV wall (HDMI) (nvidia-drm modeset=1 is mandatory) and 2026-08-11-camwall-hdmi-mode-fallback-power-recovery.

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:

DISPLAY=:0 /usr/bin/ffmpeg -f x11grab -video_size 3840x2160 -i :0.0 -frames:v 1 ...

then per-quadrant signalstats YAVG. 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:

OptionNotes
Vendor network API ✅ chosenwebOS 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 blasterFire-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.)

What actually identified it — mDNS TXT records:

avahi-browse -rt _airplay._tcp
model=43UP75003LF
manufacturer=LG Electronics
fv=p20.03.53.45
serialNumber=209MAAKHWU24_ac:5a:f0:8b:48:da

Exact model, firmware and MAC in one shot.

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.)

Red herring: telep-tv is not the TV

telep-tv in mDNS is the mainframe’s own uxplay AirPlay receiver (uxplay -n telep-tv), documented in 2026-08-04-telep-mainframe-airplay-receiver-uxplay and 2026-08-30-telep-tv-airplay-10g-port-multicast-not-flooded. The real TV advertises as LGwebOSTV.

The device

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

CONNECT_ATTEMPTS = 6
CONNECT_RETRY_DELAY = 3
WebOsClient(..., connect_timeout=10)

connect_timeout is a supported WebOsClient constructor parameter (unlike RECEIVE_TIMEOUT — see Key finding 6 — the aiowebostv pairing window is 10 seconds).

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

aiowebostv 0.10.0 module-level constants in webos_client.py:

ConstantValue
CONNECT_TIMEOUT2
RECEIVE_TIMEOUT10
REQUEST_TIMEOUT20
HEARTBEAT5

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:

webos_client.RECEIVE_TIMEOUT = PAIR_RECEIVE_TIMEOUT

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-http PAIR_TIMEOUT = 210 > tv PAIR_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, aiowebostv 0.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.

Other build facts:

/opt/tv-control/tv-http — the tailnet HTTP service

Stdlib-only HTTP service, no framework.

PropertyValue
Bind127.0.0.2:8102
Publishedhttps://telep-mainframe.taild4189d.ts.net:8451 (tailnet-only, no Funnel)
RoutesPOST /api/on, /api/off, /api/camwall, /api/pair
GETreturns 405 — actions are side-effecting by design
Subprocess timeoutPAIR_TIMEOUT = 210 (must exceed the CLI’s 180 s pairing window)

The 127.0.0.2 bind is an invariant, not an accident

Binding to 127.0.0.2 rather than 127.0.0.1 is the standing tailnet plaintext-forwarder convention on this host — see 2026-08-31-tailnet-plaintext-port-hardening and tailnet-service-exposure-convention. Services that bind 127.0.0.1 are reachable by anything local; the forwarder pattern here expects 127.0.0.2.

Buttons and tiles

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.

TransitionAction
absent → presenttv on, then tv camwall
present → absenttv 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.
  • Whole-subnet scan, not fixed IPs, so DHCP reassignment does not silently break tracking (cf. 2026-08-15-dhcp-outage-duplicate-reservation-postmortem).
  • 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 same 127.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

  1. 🔴 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 aiowebostvunconfirmed on this firmware. This is the last thing standing between the build and “done”.
  2. ⚠️ 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.
  3. 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).