Documentation · README
TVPowerSync
Automated power synchronization, dock/lid state tracking, hardware DPMS monitoring, and DRM capabilities analysis for a TV used as an external monitor on Linux / KDE Plasma 6 (Wayland). Samsung Tizen is the reference backend; generic HDMI-CEC, Roku ECP, Android TV Remote v2, Fire TV ADB, LG webOS, and Vizio SmartCast backends are included for other models.
For an in-depth technical explanation of the hardware topology, protocols, state machine, and edge-case mitigations, see ARCHITECTURE.md.
Full Feature Set#
-
Real-Time Hardware DPMS Synchronization (
tv_power_daemon.py): - Adaptive DPMS tracking: polls the internal panel at 250ms only while the session is locked, and idles at 2000ms when unlocked. This is the display's DPMS state, not the lid; lid transitions are event-driven. - Screen Off: locks the session (loginctl lock-session) and asks the configured backend for a decisive standby. The Samsung backend uses discrete HDMI-CECSTANDBYwhen docked and falls back to WebSocketKEY_POWERonly after a NACKed or ignored command is proven not to have landed. Roku, Android TV, Fire TV, LG webOS and Vizio SmartCast use their own API after CEC, and every path verifies delivery instead of trusting an exit status. - Screen On: sends a 3-burst Wake-on-LAN hint before the pre-wake CEC/API state query, so an extended-standby TV starts booting immediately, then runs the backend's full multi-NIC Wake-on-LAN plus discrete CEC/API wake. Once the TV confirms it is awake, the native mode is re-asserted viakscreen-doctor output.<connector>.mode.<TV_MODE>. - Non-blocking event loop: every CEC/WoL/WebSocket/kscreen call runs in a worker thread. The GLib loop only performs cheap sysfs/socket reads, handles D-Bus signals, and dispatches intents. - Startup reconciliation: a daemon start while the panel is already off commands the TV off; a start while the panel is on and docked wakes the TV if it is in standby. -
Event-Driven Dock Hotplug & Undock Wi-Fi Sleep: - Kernel Netlink UEvents: DRM hotplug and USB dock attach/detach are pushed into the GLib loop. Events are coalesced with a 400ms debounce because USB-C/PD renegotiation emits bursts. - Real fallback: if the netlink listener cannot be bound or later fails, the daemon polls external display state every 2s instead of silently losing hotplug detection. - Dock Plug-In (
disconnected -> connected): clears wake backoff and runs the decisive wake sequence. - Dock Unplug (connected -> disconnected): cancels any in-flight wake, skips the now-dead CEC adapter, and retries the backend's Wi-Fi path across the NetworkManager route failover window. -
Event-Driven Laptop Lid Synchronization: - Reads
LidClosedonce at startup throughsystemd-logindon the System D-Bus;/proc/acpiis only a one-time fallback if that initial query fails. The lid is never polled. - After startup, listens forPropertiesChangedonorg.freedesktop.login1.Manager(LidClosed). If the system-bus listener cannot be registered, registration is retried every 5s; there is no ACPI polling fallback. - Closing the lid creates a sleep intent. Opening it clears backoff and wakes the TV if the dock is connected. -
Intent Model, Serialized CEC & Exponential Backoff: - Every ON/OFF request is a monotonically numbered intent with its own cancellation event. A new intent cancels the previous one, and workers check cancellation before each side effect — a slow sleep retry can no longer deliver
KEY_POWERafter a wake has started. - HDMI-CEC access is serialized: only onecec-ctlprocess can touch/dev/cec0at a time, and a cancelled intent's command is dropped inside that lock. Wake, standby and query are each a singlecec-ctlinvocation. - The 5-second watchdog runs in a worker thread, probes the selected backend's control port (no forks while streaming; Samsung listens on8001for Tizen,55000for legacy), falls back to CEC when the port is closed, and requires two consecutiveofflinereadings. - Failed wakes back off exponentially (60s → 120s → ... → 15min) instead of retrying every ~75s forever. A dock connect, DPMS-on, lid-open or unlock resets the backoff. - 12-second duplicate-off cooldown, shared by CEC and WebSocket paths. -
Singleton Early-Boot Wake (
boot_wol.py&tv-boot-wol.service): - systemd oneshot (RemainAfterExit=yes) ordered afternetwork-online.targetand beforesddm.service. - Wakes the TV once per boot if the dock/display or home network is present and the panel is not in DPMS Off. -
Manual CLI Control (
control_tv.py) & DRM Analyzer (parse_drm_info.py) -control_tv: inspect display/CEC/screensaver/network status or manually trigger wake, standby, or WoL. -parse_drm_info: summarize GPU capabilities, CRTC pipes, planes, HDR metadata, and connector modes.
Backends#
TV_BACKEND selects how power commands reach the TV. HDMI-CEC is tried first
when an adapter is configured; the vendor API is the fallback, and is the
primary path after the dock (and therefore CEC) disappears. The deployed
service reads the value baked in by TV_BACKEND=roku ./deploy.sh; a permanent
change can also be made with systemctl --user edit tv-power-sync.service.
| Backend | Best for | Requirements |
|---|---|---|
cec |
Any CEC-capable TV | cec-utils, /dev/cec0 (udev rule included) |
samsung |
Samsung TVs, Tizen or pre-2016 | Tizen: samsungtvws, network control on port 8001. Pre-2016: built-in TCP client on port 55000 (TV_SAMSUNG_PROTOCOL=legacy), no extra dependency |
roku |
Roku TVs | ECP enabled (Settings > System > Advanced system settings > Control by mobile apps) |
androidtv |
Android TV / Google TV devices | androidtvremote2, run pair_android_tv.py once |
firetv |
Fire TV / Fire TV Edition televisions | adb, ADB debugging enabled and authorized |
lg |
LG webOS TVs | aiowebostv, run pair_lg_tv.py once; network standby and WoL enabled |
vizio |
Vizio SmartCast TVs | vizaio, run pair_vizio_tv.py once; port 7345 (9000 on older firmware) |
Older Samsung TVs#
Sets from before 2016 use a plain TCP remote-control protocol on port 55000
instead of the Tizen WebSocket API. The samsung backend speaks both: set
TV_SAMSUNG_PROTOCOL=legacy for those TVs. The TV asks once, on screen,
whether to allow the remote; approve it for TVPowerSync. Legacy sets use
KEY_POWEROFF (Tizen renamed it to KEY_POWER), stop answering on their
control port in standby, and cannot be woken through this API — Wake-on-LAN and
HDMI-CEC remain the wake paths. The protocol implementation is adapted from
samsungctl; see
THIRD_PARTY_NOTICES.md.
Set TV_IP to the device and TV_MAC for Wake-on-LAN. LG and Vizio use
their local API for state and standby, but wake a fully sleeping TV through
WoL or CEC, so TV_MAC is required for reliable power-on. For a streaming box
rather than a TV, set CEC_DEVICE= so the backend tracks the box's own power
instead of the TV it is plugged into.
Configuration#
All scripts read the same environment variables (defaults match the original setup):
| Variable | Default | Purpose |
|---|---|---|
TV_IP |
192.168.0.2 |
TV address |
TV_MAC |
40:16:3b:d0:4f:8b |
Wake-on-LAN target (leave unset for non-Samsung to disable WoL) |
TV_BACKEND |
samsung |
cec, samsung, roku, androidtv, firetv, lg or vizio |
TV_SAMSUNG_PROTOCOL |
ws |
Samsung control API: ws (Tizen) or legacy (pre-2016 TCP) |
TV_SAMSUNG_PORT |
8001 / 55000 |
Samsung network-control port; the default follows TV_SAMSUNG_PROTOCOL (8001 Tizen, 55000 legacy) |
CEC_DEVICE |
/dev/cec0 |
HDMI-CEC adapter (empty disables CEC) |
CEC_PHYS_ADDR |
1.0.0.0 |
CEC physical address advertised as the laptop's source |
TV_ECP_PORT |
8060 |
Roku ECP port |
TV_ADB_PORT |
5555 |
Fire TV ADB port |
ADB_BIN |
adb |
ADB executable |
ANDROIDTV_CERT |
~/.local/state/tvpowersync/androidtv_cert.pem |
Android TV client certificate |
ANDROIDTV_KEY |
~/.local/state/tvpowersync/androidtv_key.pem |
Android TV client key |
ANDROIDTV_CLIENT_NAME |
TVPowerSync |
Name shown on the TV while pairing |
LG_CLIENT_KEY_FILE |
~/.local/state/tvpowersync/lg_client_key.txt |
LG webOS client key written by pair_lg_tv.py |
VIZIO_TOKEN_FILE |
~/.local/state/tvpowersync/vizio_token.txt |
Vizio SmartCast token written by pair_vizio_tv.py |
TV_VIZIO_PORT |
7345 |
Vizio SmartCast HTTPS port (9000 on older firmware) |
TV_MODE |
3840x2160@60 |
Mode re-asserted via kscreen-doctor |
TV_LOG_LEVEL |
INFO |
Logging level (DEBUG is useful for CEC failures) |
TV_PYTHON |
$XDG_DATA_HOME/tvpowersync/venv/bin/python |
Interpreter substituted into the systemd unit by deploy.sh |
TV_TOKEN_FILE |
~/.local/state/tvpowersync/tv_token.txt |
Pairing token path |
TV_DRM_INFO |
drm_info.json (working directory) |
JSON snapshot read by parse_drm_info |
Override persistent values in the systemd unit (for example
systemctl --user edit tv-power-sync.service), not in the scripts.
Directory Structure & XDG Compliance#
- Source Repository:
~/src/TVPowerSync/(any location works) - Application Data:
~/.local/share/tvpowersync/(venv; honours$XDG_DATA_HOME) - Shared Helpers:
tvlib.py(config, logging, WoL, CEC, sysfs, backoff) andtv_backends.py(vendor power paths) - User Executables:
~/.local/bin/(control_tv,tv_power_daemon,boot_wol,parse_drm_info,pair_samsung_tv,pair_android_tv,pair_lg_tv,pair_vizio_tv,tvlib.py,tv_backends.py) - Systemd User Service:
~/.config/systemd/user/tv-power-sync.service - Systemd Boot Service:
/etc/systemd/system/tv-boot-wol.service - HDMI-CEC Udev Rule:
/etc/udev/rules.d/70-cec-uaccess.rules
Deployment#
The daemon needs dbus and gi (system packages) plus the vendor libraries
samsungtvws, androidtvremote2, aiowebostv, and vizaio (pip; the latter
three are only needed for their respective backends). deploy.sh provisions
them:
./deploy.sh
If no usable venv exists, the script creates one at
$XDG_DATA_HOME/tvpowersync/venv (default ~/.local/share/tvpowersync/venv)
with python3 -m venv --system-site-packages, so the system dbus/gi bindings
stay importable, and installs samsungtvws, androidtvremote2, aiowebostv,
and vizaio into it. The clone is only the source: after deployment it can be
moved or deleted, because the executables live in ~/.local/bin and the
services point at the venv in the data directory.
The script then installs tvlib.py alongside the scripts, substitutes the
selected interpreter into tv-power-sync.service and the boot unit
(TV_PYTHON overrides the choice), rewrites the installed shebangs to it, and
restarts the user service.
An existing venv that cannot see dbus/gi is repaired by adding a
version-matched 000-system-bindings.pth (equivalent to
--system-site-packages) instead of being re-created. The script fails with an
explicit message if no interpreter can provide the system bindings, and warns if
any vendor library is missing (the corresponding backend is disabled).
CLI Usage#
control_tv status # KDE DPMS, CEC power state, D-Bus lock state, backend control port
control_tv wake # Decisive wake (5x WoL + CEC HDMI 1 + mode sync)
control_tv standby # DPMS off + CEC standby, then the backend's network fallback
control_tv wol # Broadcast multi-NIC Wake-on-LAN magic packets only
pair_android_tv # One-time Android TV Remote pairing (certificate + on-screen code)
pair_lg_tv # One-time LG webOS pairing (client key + on-screen prompt)
pair_vizio_tv # One-time Vizio SmartCast pairing (auth token + on-screen PIN)
parse_drm_info # Summarize DRM GPU pipes, planes, and connector capabilities
Caveats#
- The watchdog's TCP fallback assumes the TV closes the backend's control port
in standby (for example the Samsung control port,
8001on Tizen or55000on legacy). If a firmware update keeps it open, CEC remains the authoritative state source (the daemon queries CEC when the port is closed, and reportsonotherwise). - A system suspend can begin before the asynchronous sleep worker finishes. The
daemon still issues the command as early as possible, but logind does not wait
for
PrepareForSleephandlers. - Roku ECP
PowerOffonly works on Roku TVs, not Roku streaming players. Fire TV control needs ADB debugging to stay authorized; some devices drop ADB in deep standby, in which case CEC remains the wake path. - Roku wake needs Wake-on-LAN (
TV_MAC) or Fast TV Start keeping ECP reachable in standby. Android TV, LG webOS and Vizio SmartCast wake through WoL/CEC; none of their local APIs can wake a fully sleeping TV. - LG webOS needs network standby / “Mobile TV On” enabled for WoL. Vizio
SmartCast usually closes its HTTPS API in standby, and Eco mode disables WoL;
re-run
pair_vizio_tv.pyafter a factory reset or token invalidation.
License#
TVPowerSync is licensed under the BSD 3-Clause License. See
LICENSE for the
full text. The pre-Tizen Samsung protocol in tvlib.py is adapted from
samsungctl (MIT, Copyright (c) 2014 Lauri Niskanen); the upstream license text
is reproduced in THIRD_PARTY_NOTICES.md.