TVPowerSyncMulti-vendor TV control

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#

  1. 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-CEC STANDBY when docked and falls back to WebSocket KEY_POWER only 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 via kscreen-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.

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

  3. Event-Driven Laptop Lid Synchronization: - Reads LidClosed once at startup through systemd-logind on the System D-Bus; /proc/acpi is only a one-time fallback if that initial query fails. The lid is never polled. - After startup, listens for PropertiesChanged on org.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.

  4. 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_POWER after a wake has started. - HDMI-CEC access is serialized: only one cec-ctl process can touch /dev/cec0 at a time, and a cancelled intent's command is dropped inside that lock. Wake, standby and query are each a single cec-ctl invocation. - The 5-second watchdog runs in a worker thread, probes the selected backend's control port (no forks while streaming; Samsung listens on 8001 for Tizen, 55000 for legacy), falls back to CEC when the port is closed, and requires two consecutive offline readings. - 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.

  5. Singleton Early-Boot Wake (boot_wol.py & tv-boot-wol.service): - systemd oneshot (RemainAfterExit=yes) ordered after network-online.target and before sddm.service. - Wakes the TV once per boot if the dock/display or home network is present and the panel is not in DPMS Off.

  6. 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) and tv_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, 8001 on Tizen or 55000 on legacy). If a firmware update keeps it open, CEC remains the authoritative state source (the daemon queries CEC when the port is closed, and reports on otherwise).
  • 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 PrepareForSleep handlers.
  • Roku ECP PowerOff only 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.py after 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.