Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,24 @@ to [Semantic Versioning](https://semver.org/).
and all controller detectors are reset: even when the nodes survive suspend
untouched, the stale volatility masks and the permanently-latching
"unparseable" guard no longer outlive a sleep/wake cycle.
- **Wake-on-input now works on QuickStart+ TVs.** The wake gate was a bare TCP
probe of the API port — but TVs with QuickStart+ (e.g. 2025 OLEDs) keep port
3001 accepting in Active Standby, so a remote-control power-off read as "TV is
on" and key presses never woke it. The daemon now asks each TV for its real
power state (`getPowerState` over a short-lived probe session, 3 s budget) and
powers on only TVs that aren't `Active` — so it still never touches a TV that
is showing another input or app. A rejected pairing key suppresses waking
(each register would pop an on-screen pairing prompt) and logs a re-pair hint.
Note: with `persistent_connection: "off"`, a key press while the TV is on now
opens a brief probe session at most once per cooldown window.

### Changed
- **Steam Controller input can now power on a fully-off TV** — new
`steam_controller.wake` knob, **on by default** (matching keyboard
wake-on-input). The hidraw detector can't tell buttons from stick movement, so
any genuine controller input wakes an off TV; restore the old unblank-only
behavior with `lgtvc setup steam-controller --wake off` (or the tray
checkbox).

## [0.2.6] — 2026-09-15

Expand Down
34 changes: 31 additions & 3 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,37 @@ systemctl --user restart lgtvc-agent # the agent reads the setting at startup
and, with `--seconds`, show its report changing while you press buttons. If it
shows `NO READ ACCESS`, install Steam's udev rules (the `steam-devices` package —
present by default on Bazzite/Steam Deck) so the seat user can read the node. The
detector only ever *reads* the controller, so Steam is undisturbed. Controller
input **unblanks** the TV but won't power on a fully-off TV (use a keyboard for
that, or turn on wake-on-input).
detector only ever *reads* the controller, so Steam is undisturbed.

By default controller input can also **power on a fully-off TV**
(`steam_controller.wake`). The detector can't tell buttons from stick movement,
so *any* genuine controller input — including picking the controller up — wakes
an off TV. If you'd rather the controller only unblank:

```sh
lgtvc setup steam-controller --wake off
systemctl --user restart lgtvc-agent
```

## Key presses don't wake the TV from standby (QuickStart+)

Wake-on-input asks the TV for its **real power state** rather than just probing
the API port: TVs with QuickStart+ (e.g. 2025 OLEDs) keep port 3001 accepting
connections in Active Standby, so "the port is open" cannot distinguish *on*
from *standby* (versions before 0.2.7 got this wrong and never woke such TVs).
A TV that reports `Active` — whatever app or input it is showing — is never
touched, so wake-on-input still can't yank a TV somebody is watching.

If key presses still don't wake the TV, check the daemon log:

```sh
journalctl -u lgtvc-daemon -b | grep -i wake
```

A `stored client key rejected` warning means the TV no longer accepts your
pairing key — re-pair with `sudo lgtvc setup pair --host <tv-ip>`. (Waking is
deliberately suppressed on a rejected key, otherwise every probe would pop a
pairing prompt on-screen.)

## Nothing happens on suspend / resume

Expand Down
28 changes: 16 additions & 12 deletions src/lgtvcompanion/agent/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,16 @@ def _load_ignored_keys() -> set[int]:
return parse_ignored_keys(cfg.global_.idle.ignored_keys)


def _steam_controller_enabled() -> bool:
def _steam_controller_config() -> config_mod.SteamControllerConfig:
"""Best-effort load; dataclass defaults (enabled + wake) when the config is
missing or unreadable."""
path = config_mod.find_config()
if path is None:
return True # default-on (SteamControllerConfig.enabled)
try:
return config_mod.load(path).global_.steam_controller.enabled
except (ValueError, OSError):
return True
if path is not None:
try:
return config_mod.load(path).global_.steam_controller
except (ValueError, OSError):
pass
return config_mod.SteamControllerConfig()


class Agent:
Expand All @@ -62,8 +64,9 @@ def __init__(self, socket_path: str):
self._monitor = InputMonitor(self._on_input)
# In gamescope the Steam client claims the controller over hidraw, so it
# emits no evdev events; read it directly to keep the TV awake there.
self._sc_monitor = (HidrawMonitor(self._on_sc_input)
if _steam_controller_enabled() else None)
sc = _steam_controller_config()
self._sc_monitor = HidrawMonitor(self._on_sc_input) if sc.enabled else None
self._sc_wake = sc.wake
self._send_queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=16)
self._state: dict = {"mpris_playing": False, "fullscreen": None}
# streaming is the OR of all sources (sunshine log + process watch); the
Expand All @@ -82,14 +85,15 @@ def _on_input(self, path: str, etype: int, code: int, value: int) -> None:
self._send_queue.put_nowait({"activity": True, "key": etype == EV_KEY})

def _on_sc_input(self, path: str) -> None:
# Steam-controller input over hidraw: unblank only (key=False), never
# power on a fully-off TV. Throttled like _on_input.
# Steam-controller input over hidraw. With steam_controller.wake (the
# default) it counts as a key press so the daemon may power on a fully
# off TV; wake=false keeps it unblank-only. Throttled like _on_input.
now = time.monotonic()
if now - self._last_sc_report < INPUT_REPORT_INTERVAL:
return
self._last_sc_report = now
with contextlib.suppress(asyncio.QueueFull):
self._send_queue.put_nowait({"activity": True, "key": False})
self._send_queue.put_nowait({"activity": True, "key": self._sc_wake})

async def _state_loop(self) -> None:
from dbus_fast import BusType
Expand Down
19 changes: 15 additions & 4 deletions src/lgtvcompanion/cli/setup_cmds.py
Original file line number Diff line number Diff line change
Expand Up @@ -480,17 +480,26 @@ def cmd_offline_mode(args: argparse.Namespace) -> int:
def cmd_steam_controller(args: argparse.Namespace) -> int:
"""Toggle steam_controller: detect Steam Controller input over hidraw, so it
keeps the TV awake in gamescope where Steam claims the controller and no
evdev events are emitted. Inert without a Valve controller."""
evdev events are emitted. Inert without a Valve controller. --wake controls
whether controller input may also power on a fully-off TV."""
cfg_path = config_mod.find_config()
if cfg_path is None:
sys.exit("no config found — run: lgtvc setup pair --host <tv-ip>")
cfg = config_mod.load(cfg_path)
if args.state is None:
if args.state is None and args.wake is None:
# first line stays the bare enabled state for script compatibility
print("on" if cfg.global_.steam_controller.enabled else "off")
print(f"wake = {'on' if cfg.global_.steam_controller.wake else 'off'}")
return 0
cfg.global_.steam_controller.enabled = args.state == "on"
if args.state is not None:
cfg.global_.steam_controller.enabled = args.state == "on"
if args.wake is not None:
cfg.global_.steam_controller.wake = args.wake == "on"
config_mod.save(cfg, cfg_path)
print(f"steam_controller = {args.state}")
if args.state is not None:
print(f"steam_controller = {args.state}")
if args.wake is not None:
print(f"steam_controller.wake = {args.wake}")
print("Restart the agent for it to take effect:")
print(" systemctl --user restart lgtvc-agent")
return 0
Expand Down Expand Up @@ -628,6 +637,8 @@ def main(argv: list[str]) -> int:
help="detect Steam Controller input in gamescope (hidraw)")
p.add_argument("state", nargs="?", choices=["on", "off"],
help="omit to print the current state")
p.add_argument("--wake", choices=["on", "off"], default=None,
help="controller input may power on a fully-off TV")
p.set_defaults(func=cmd_steam_controller)

p = sub.add_parser("hidraw-scan",
Expand Down
5 changes: 5 additions & 0 deletions src/lgtvcompanion/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ class SteamControllerConfig:
# so it emits no evdev events; the agent reads /dev/hidraw* directly to keep
# the TV awake. Inert on any system without a Valve (28de) controller.
enabled: bool = True
# Controller input may also power ON a fully-off TV (reported to the daemon
# as a key press). The hidraw detector can't tell buttons from stick
# movement, so ANY genuine controller input wakes an off TV — set False to
# keep the controller unblank-only.
wake: bool = True


@dataclass
Expand Down
54 changes: 43 additions & 11 deletions src/lgtvcompanion/daemon/devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from ..config import DeviceConfig
from ..ssap import power
from ..ssap.client import KeyRejected, SsapClient
from ..ssap.client import KeyRejected, SsapClient, SsapError
from ..ssap.commands import (
Command,
Kind,
Expand All @@ -24,6 +24,10 @@
log = logging.getLogger(__name__)

KEEPALIVE_INTERVAL = 60.0
# Wake-on-input probe budget. Short: it sits on the key-press → WoL path when
# the TV is fully off (silent packet drop -> connect timeout), but long enough
# for TLS + SSAP register on a live-but-slow TV. Never cfg.timeout (10 s).
PROBE_TIMEOUT = 3.0


class DeviceSession:
Expand Down Expand Up @@ -55,12 +59,12 @@ def _coalesce(self, key: str, coro_factory):
task.add_done_callback(lambda _t, k=key: self._inflight.pop(k, None))
return task

def _new_client(self) -> SsapClient:
def _new_client(self, *, timeout: float | None = None) -> SsapClient:
return SsapClient(
self.cfg.host,
use_ssl=self.cfg.ssl,
client_key=self.keystore.load(self.cfg.id),
timeout=self.cfg.timeout,
timeout=timeout if timeout is not None else self.cfg.timeout,
on_new_key=lambda key: self.keystore.save(self.cfg.id, key),
)

Expand Down Expand Up @@ -233,15 +237,43 @@ async def _unblank(self) -> str:
await self.settle()
return "on"

async def is_reachable(self, timeout: float = 1.0) -> bool:
port = 3001 if self.cfg.ssl else 3000
async def probe_power_state(self, *, timeout: float = PROBE_TIMEOUT) -> str:
"""Live "is the TV actually on?" probe for the wake-on-input gate.

A bare TCP probe cannot work here: QuickStart+ TVs keep the API port
accepting in Active Standby (they reject the SSAP register with close
1008 "Try Again Later", normalized to ConnectionError), so only a real
power-state query can tell "on" from "standby-but-reachable".

Returns a PowerState.value ("Active", "Active Standby", "Screen Off",
"Suspend", "Unknown"), or "Unreachable" (TCP refused/timeout, or the
standby register rejection), or "KeyRejected" (TV on, stale pairing
key — the caller must NOT wake, or every probe pops a pairing prompt).
Single attempt, no WoL; when the session isn't connected a throwaway
client is used and always closed, so the probe is side-effect-free
(a concurrent connect()/keepalive is undisturbed — see power_on)."""
if self.client.connected:
try:
state = await asyncio.wait_for(
power.get_power_state(self.client), timeout)
except (ConnectionError, TimeoutError, SsapError):
self.power_state = "Unknown"
await self.client.close()
return "Unreachable"
self.power_state = state.value
return state.value
probe = self._new_client(timeout=timeout)
try:
_, writer = await asyncio.wait_for(
asyncio.open_connection(self.cfg.host, port), timeout=timeout)
writer.close()
return True
except (OSError, TimeoutError):
return False
await probe.connect()
state = await power.get_power_state(probe)
except KeyRejected:
return "KeyRejected"
except (ConnectionError, TimeoutError, SsapError, OSError):
return "Unreachable"
finally:
await probe.close()
self.power_state = state.value
return state.value

# -- command dispatch -----------------------------------------------------

Expand Down
43 changes: 33 additions & 10 deletions src/lgtvcompanion/daemon/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@

log = logging.getLogger("lgtvc-daemon")

# Wake-on-input never touches an Active TV (it may be showing another HDMI
# source or app), and never wakes on a rejected pairing key (a WoL+connect
# would loop on KeyRejected, and each register attempt pops an on-screen
# pairing prompt).
NO_WAKE_STATES = frozenset({"Active", "KeyRejected"})


class Daemon:
def __init__(self, cfg: config_mod.Config, keystore: KeyStore, socket_path: str,
Expand All @@ -43,8 +49,7 @@ def __init__(self, cfg: config_mod.Config, keystore: KeyStore, socket_path: str,
self.wake_on_input: WakeOnInput | None = None
if cfg.global_.wake_on_input.enabled:
self.wake_on_input = WakeOnInput(
is_tv_reachable=self._any_tv_reachable,
wake=self._wake_all,
wake_if_needed=self._wake_if_tv_off,
cooldown_s=cfg.global_.wake_on_input.cooldown_s)
self.streams = StreamController(cfg.global_.remote_stream, self._managed)
self.topology: TopologyWatcher | None = None
Expand Down Expand Up @@ -190,14 +195,32 @@ async def _idle_unblank(self) -> None:
except Exception as e:
log.error("%s: idle wake failed: %s", s.cfg.id, e)

async def _any_tv_reachable(self) -> bool:
for s in self._managed():
if await s.is_reachable():
return True
return False

async def _wake_all(self) -> None:
await self._power_all("on")
async def _wake_if_tv_off(self, source: str) -> None:
"""Wake-on-input decision: probe each managed TV's real power state and
power on only the ones that aren't Active. Per-session on purpose — a
blanket power-on would run set_hdmi_input on an Active TV showing
another source."""
sessions = [s for s in self._managed() if not s.busy]
if not sessions:
return
states = await asyncio.gather(*[s.probe_power_state() for s in sessions])
if any(st == "KeyRejected" for st in states):
log.warning("wake-on-input: stored client key rejected — re-pair "
"with `lgtvc setup pair`")
need = [(s, st) for s, st in zip(sessions, states, strict=True)
if st not in NO_WAKE_STATES]
if not need:
return
for s, st in need:
log.info("key press on %s while %s is %s — waking", source,
s.cfg.id, st)
results = await asyncio.gather(*[s.power_on() for s, _ in need],
return_exceptions=True)
for (s, _), r in zip(need, results, strict=True):
if isinstance(r, BaseException):
log.error("%s: wake failed: %s", s.cfg.id, r)
else:
log.info("%s: wake -> %s", s.cfg.id, r)

# -- power orchestration -----------------------------------------------------

Expand Down
20 changes: 9 additions & 11 deletions src/lgtvcompanion/daemon/wake.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
"""Wake the TV on keyboard input while the PC is on but the TV is off.

Port of the proven tv-wake-on-input daemon: any EV_KEY press → if the TV's
API port is unreachable (TV off / network-asleep), run the power-on sequence.
No-op when the TV is already on, so it never yanks another HDMI source.
Port of the proven tv-wake-on-input daemon, upgraded: any EV_KEY press →
`wake_if_needed`, which probes each TV's real power state and powers on the
ones that aren't Active (see Daemon._wake_if_tv_off). The old TCP-reachability
gate lived here, but QuickStart+ TVs keep the API port open in standby, so
"reachable" could not distinguish on from off. This class is now just the
input-side debouncer: cooldown + single-flight.
"""

from __future__ import annotations
Expand All @@ -21,12 +24,10 @@ class WakeOnInput:
def __init__(
self,
*,
is_tv_reachable: Callable[[], Awaitable[bool]],
wake: Callable[[], Awaitable[None]],
wake_if_needed: Callable[[str], Awaitable[None]],
cooldown_s: float = 15.0,
):
self.is_tv_reachable = is_tv_reachable
self.wake = wake
self.wake_if_needed = wake_if_needed
self.cooldown_s = cooldown_s
self._monitor = InputMonitor(self._on_event)
self._last_attempt = 0.0
Expand Down Expand Up @@ -56,10 +57,7 @@ def notify_input(self, source: str = "agent") -> None:
async def _maybe_wake(self, source: str) -> None:
self._busy = True
try:
if await self.is_tv_reachable():
return
log.info("key press on %s while TV unreachable — waking TV", source)
await self.wake()
await self.wake_if_needed(source)
except Exception:
log.exception("wake-on-input failed")
finally:
Expand Down
4 changes: 4 additions & 0 deletions src/lgtvcompanion/tray/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ def _global_tab(self, QWidget, QFormLayout, QCheckBox, QSpinBox,
self.steam_controller = QCheckBox()
self.steam_controller.setChecked(g.steam_controller.enabled)
form.addRow("Detect Steam Controller (gamescope)", self.steam_controller)
self.steam_controller_wake = QCheckBox()
self.steam_controller_wake.setChecked(g.steam_controller.wake)
form.addRow(" Controller can power on the TV", self.steam_controller_wake)
return w

def _devices_tab(self, QWidget, QVBoxLayout, QFormLayout, QLineEdit,
Expand Down Expand Up @@ -171,6 +174,7 @@ def _collect(self):
g.topology.enabled = self.topology.isChecked()
g.offline_mode = self.offline_mode.isChecked()
g.steam_controller.enabled = self.steam_controller.isChecked()
g.steam_controller.wake = self.steam_controller_wake.isChecked()
for dev, name, host, mac, ssl, hdmi, guard, wol in self.device_widgets:
dev.name = name.text().strip()
dev.host = host.text().strip()
Expand Down
Loading
Loading