Code walkthrough · lgpowercontrol

How the TV follows the computer

One binary talks to the television. Everything else in this program is machinery for deciding when to run it — and for surviving the two seconds in which the network disappears underneath it.

src/lgpowercontrol/ · 7 modules · 1 234 lines branch testing transport SSAP over WebSocket + WoL

One binary, six callers

cli.py · units.py · install.py

Every path through this program funnels into the same executable with one of four commands. Nothing else opens a socket to the TV. What differs between callers is the LGPC_SOURCE tag they set — it names the caller in the journal, and it is the key the off-switch guard reads to decide whether this particular event is allowed to turn the TV off.

TRIGGERS THE ONLY THING THAT TALKS TO THE TV LG C3 lgpowercontrol-boot after network-online.target lgpowercontrol-shutdown Conflicts=reboot.target 90-lgpowercontrol NM dispatcher · pre-down / up system-sleep/lgpowercontrol systemd-sleep · pre / post lgpowercontrol-sleep busctl monitor + inhibitor lgpowercontrol-monitor polls /sys/class/drm every 1 s you, at a shell no LGPC_SOURCE — never gated LGPC_SOURCE=… lgpowercontrol /opt/lgpowercontrol/bin/ ON wake, then claim input OFF guarded power_off SCREEN_OFF guarded, picture only STATUS SSAP over WebSocket · :3000 retries=3 by default magic packet · UDP 9 broadcast + unicast, both webOS Active Screen Off · Suspend
Fig. 1 — Six triggers, one executable, two protocols. The magic packet goes out twice on purpose: broadcast reaches an on-subnet TV that will not answer ARP while asleep, unicast reaches one across a VLAN, where webOS does answer.

ON — the wake loop

cli.py · main(), lines 239–358

Waking is the long path, because two things have to come back and neither announces itself: this computer's network after resume, and the TV after a magic packet. The budget is fifteen attempts one second apart — enough for roughly four seconds from Always Ready, five from deep standby, ten with no standby mode at all, plus a whole resend cycle if a packet is lost.

The loop also answers a second question along the way. woke_from_standby records whether the TV was actually down when we arrived, because that — not get_current_app — is what decides at the end whether this computer is entitled to take the input. Both branches that set the flag are deliberately hard to trip on a TV that was never asleep.

flock lgpowercontrol-on.lock already held → exit 0, dedupe the resume path fires ON twice — this drops the loser unlink /run/…-tv-off the TV is no longer known-off nm-online -q -t 15 times out → log and continue anyway send_wol() 255.255.255.255:9 and LGTV_IP:9 ATTEMPT 1…15 · sleep(1) AT THE TOP OF EACH get_power_state retries=1, quiet=True rc ≠ 0 — no answer at all 2nd miss in a row ⇒ woke_from_standby = True · send_wol() ↻ next processing ≠ "" — mid-transition state ∉ AWAKE ⇒ woke_from_standby = True · wait, don't act ↻ next state ∈ Active | Screen Off | Screen Saver turn_screen_on — error −102 also counts as awake break anything else — standby or unknown woke_from_standby = True · send_wol() again ↻ next exit 2 — gave up "last state: …" goes to the journal HDMI_INPUT set? empty = never switch input no → exit 0 SHARED_TV and not woke_from_standby? the TV was already awake — someone else's yes → exit 0, input left alone set_input HDMI_n · up to 5 tries exit 0, or exit 1 after the fifth
Fig. 2 — Only the green branch leaves the loop; the three blue and red branches resend and try again. The one round-trip the loop never spends is a second query at the end: whether the TV was asleep when we found it is already known by then, which is what makes the shared-TV check free.
Why not compare get_current_app instead

After the remote turns the TV off on another input, webOS restores that same input at the next wake. Comparing the foreground app would therefore see a foreign source on a TV we ourselves just woke, skip the switch, and leave this computer showing No Signal.

OFF — four ways to stand down

cli.py · disabled_off_event(), check_power_off_guard()

Turning a television off is the destructive direction, so OFF is a chain of gates rather than a command. Two of them fail open and two fail closed, and which is which was decided case by case: a mistyped conf value must never silently disable power-off for good, but an unreachable TV must never be reported as handled.

The order matters too. The conf gate runs before the guard, because a disabled event must not spend a network round-trip asking the TV anything — least of all inside the pre-down window, where the clock is already against us.

GATE STANDS DOWN WHEN RESULT disabled_off_event() keyed on LGPC_SOURCE POWER_OFF_AT_SUSPEND or _AT_SHUTDOWN = "0" only a literal 0 disables — a typo leaves today's behaviour exit 0 shared_tv_app_id() no network cost SHARED_TV ≠ 1, or HDMI_INPUT is not an input number no guard configured — this is the ordinary single-user case falls through get_current_app retries=1 — the only round-trip rc 2 — the TV is unreachable propagated, not swallowed, so monitor.py still logs it exit 2 any other error fail open — cannot check, so proceed falls through appId == target? com.webos.app.hdmiN a different app is in the foreground someone is watching the console — leave the picture alone exit 0 appId is "" or None the TV went to standby by itself — power_off would be a no-op exit 0 power_off touch /run/lgpowercontrol-tv-off the hint that lets the sleep path skip a second power_off exit 0
Fig. 3 — Green down the left is "keep going"; a sideways exit is a stand-down. Only rc 2 leaves by the red path with a non-zero code — every other stand-down reports success, because not turning the TV off was the correct outcome.

Suspend — one behaviour, three entry points

suspend.py · dispatcher(), hook(), listener()

This is the part of the program that exists because of a race. NetworkManager tears its connections down roughly 17 ms after logind announces PrepareForSleep, and it will not wait for a foreign inhibitor. That leaves exactly one stage that runs synchronously while the network is still up: the dispatcher's pre-down.

The other two entry points are not redundancy for its own sake. Each covers a system where the primary path cannot run at all — and each has to detect that another path already handled this same suspend, which is what the two flag files in /run are for.

Which path gets installed

install.py probes the system, then wires up dispatcher.d exists, /usr writable dispatcher + sleep hook the ordinary desktop distro dispatcher.d exists, /usr read-only dispatcher + sleep.service ostree: Bazzite, Silverblue no dispatcher.d systemd-networkd only hook only — wake side only TV-off at suspend unsupported here
Fig. 4 — The listener service is a fallback for immutable /usr, never a replacement on an ordinary system: the hook's after-every-inhibitor ordering is a guarantee, the listener's grace wait is only a heuristic.

The 17 milliseconds

t = 0 +17 ms logind PrepareForSleep = true, broadcast to everyone at once NetworkManager two things, in parallel device-state transition — pre-down BLOCKS here DHCP cancel + IP flush not blocked — can finish first and take the link out dispatcher() primary pre-down → preparing_for_sleep()? → SLEEP_FLAG lgpowercontrol OFF, synchronous, network still up runs once per suspend, not once per NIC hook() NIC-WoL setups systemd-sleep pre — after every inhibitor skipped if SLEEP_FLAG already exists with WoL on the card, NM skips the device — no teardown at all listener() read-only /usr same signal as NM, not after holds a delay inhibitor poll SLEEP_FLAG 10 × 100 ms still absent → act itself, then release the inhibitor signal — time, not to scale — kernel sleeps
Fig. 5pre-down is not a guarantee. It blocks the device-state transition, but the red bar runs alongside it and can strip the address first — observed in the journal on 2026-07-27. That is the whole reason the installer offers to enable Wake-on-LAN on this computer's own card: it makes NetworkManager skip the device, so there is no teardown left to race.
Proven dead ends — do not retry these

A sleep-target unit (the network is already gone), an inhibitor held by us on an ordinary system (it delays the kernel, not NetworkManager), PowerDevil's aboutToSuspend (milliseconds of margin), and a NetworkManager config option for the ordering (there is no such setting).

Idle — beating the TV's own timer

monitor.py · DpmsWatcher

The monitor reads /sys/class/drm/card*/dpms once a second and mirrors it to the TV. Screen off means SCREEN_OFF — but leaving it there is a trap, because webOS then drops into deep standby on an internal timer around thirteen minutes, and deep standby wakes slowly. A power_off lands the TV in Always Ready instead, which wakes in about four seconds.

So the escalation at ten minutes is not a power-saving feature. It is a race the program has to win against the television.

screen on off_since = None screen off off_since = now escalated Always Ready DPMS off SCREEN_OFF DPMS on → ON 600 s elapsed no SLEEP_FLAG → OFF DPMS on → ON · the flock in cli.py dedupes this against the resume path clock jumped > 30 s — the machine was asleep, restart the count DPMS off while preparing_for_sleep() — no command, the sleep path owns it THE RACE screen off, picture only Always Ready deep standby, slow wake 0 s 600 s — we get in first ~780 s — the TV's own timer
Fig. 6 — Keep-alive polling was tried and does not hold the TV's internal timer off; only a power_off reaches Always Ready, and screen-off alone never does. The dashed self-loop is the resume case: time spent suspended must not count towards the ten minutes.

State on disk

common.py · /run

The entry points are separate processes started by different subsystems, so they cannot share memory. Four files in /run carry everything they need to tell each other, and each has exactly one writer and one clearer.

FileSet byCleared byWhat it prevents
…-on.lock every ON, flock process exit The dispatcher's up and the DPMS watcher both fire ON at resume. The loser returns 0 immediately.
…-tv-off a successful OFF ON A second power_off from the sleep path when the idle escalation already turned the TV off.
…-sleep dispatcher, pre-down dispatcher, up Firing once per NIC instead of once per suspend — and it is what the hook and the listener check to stay out of the way.
…-hook-sleep hook or listener the matching wake Turning the TV on at every up event, including boot and a cable replug, where it would be wrong.

A stale …-sleep would suppress every idle escalation from then on, so the DPMS watcher removes one it finds while logind reports no suspend in progress — and says so in the journal rather than doing it quietly.

dpms-monitor: DPMS state: on -> off, turning screen off dpms-monitor: Screen off for 10 min - escalating to full power off (fast wake via Always Ready) nm-dispatcher: System going to sleep - TV already off, skipping resume: get_power_state failed (attempt 3/15): unreachable: TimeoutError: resume: TV in standby (Suspend) - resending WoL (attempt 4/15) resume: TV awake (Active Standby), screen turned on (attempt 6/15) resume: Setting input to HDMI_1

Exit codes

cli.py · tv_cmd()

Every failure the library can raise is mapped to one of four codes, and the catch-all exists so that a bug in this program surfaces as an ordinary error rather than as a traceback in the journal. Two exceptions are named explicitly because catching them by category does not work — the ordering in the source is load-bearing.

rcMeansRaised byWho cares
0done — , or turn_screen_on answering −102 on a TV already proven awake everyone
1error PyLGTVServiceNotFoundError, PyLGTVCmdError, PyLGTVCmdException, and any unexpected exception run_lgpc logs it
2unreachable OSError, timeouts, WebSocket errors — and CancelledError, which subclasses BaseException and would otherwise escape as a traceback the wake loop, and the OFF guard
3unpaired PyLGTVPairException authorize — and only this code may delete the pairing key
Why PyLGTVServiceNotFoundError is caught first

It subclasses PyLGTVCmdError but is raised with a plain string instead of the response dict. Caught in the wrong order, the payload lookup raises TypeError from inside an except block — which escapes the whole try and cannot be caught by any handler below it.

The warning, and why it polls

notify.py

One component never touches the TV at all. The notify service watches for Plasma's idle dim and puts a countdown on screen before the screen-off timeout takes the picture away. It polls kscreen-doctor -o because Plasma's idle dimming is invisible on D-Bus — no brightness signal, no compositor effect, no session-bus event; a listener for each was tried, and the per-output "dimming to N%" line in the plain-text output is the only observable there is.

IDLE, PROFILE = AC activity stops screen dims — the anchor notification screen off → TV notify_delay OFF_WARNING_SECONDS notify_delay = off_timeout − dim_timeout − OFF_WARNING_SECONDS, floored at 0 · both timeouts re-read from powerdevilrc at every dim, so re-enabling the setting needs no restart
Fig. 7 — The dim is the anchor, not the idle timer: with "Dim automatically" turned off in System Settings there is nothing to count from, and the service says so once at startup instead of failing silently.