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 linesbranch testingtransport 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.
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.
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.
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.
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
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
Fig. 5 — pre-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.
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.
File
Set by
Cleared by
What 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.
rc
Means
Raised by
Who cares
0
done
— , or turn_screen_on answering −102 on a TV already proven awake
everyone
1
error
PyLGTVServiceNotFoundError, PyLGTVCmdError,
PyLGTVCmdException, and any unexpected exception
run_lgpc logs it
2
unreachable
OSError, timeouts, WebSocket errors — and CancelledError,
which subclasses BaseException and would otherwise escape as a traceback
the wake loop, and the OFF guard
3
unpaired
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.
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.