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
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,18 @@ to [Semantic Versioning](https://semver.org/).

## [Unreleased]

_Nothing yet._
### Fixed
- **Controller input from a game stream no longer turns the TV on.** Streaming
hosts inject the remote client's gamepad on the box as a virtual device —
Punktfunk emulates it as a Valve Steam Deck / Steam Controller 2 over UHID or
usbip, with the same `28DE` vendor ID as a real Steam Controller. The hidraw
monitor matched it as the couch controller and (with `steam_controller.wake`)
woke the TV mid-stream; its evdev events counted as key presses too. Devices
are now classified by sysfs origin (`/sys/devices/virtual/…` or `vhci_hcd` =
injected): virtual Valve hidraw nodes are skipped, and virtual evdev input
still counts as activity but never as a wake. Wake-on-input is also
suspended while a remote stream is connected. `lgtvc setup hidraw-scan` marks
virtual pads. Docs gain Punktfunk `session.started`/`session.ended` hooks.

## [0.2.7] — 2026-09-25

Expand Down
14 changes: 14 additions & 0 deletions docs/external-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,20 @@ off/blanked. Three ways to drive that:
lgtvc -streaming_disconnect # restore per remote_stream.on_disconnect
```
(`on_disconnect` = `on` | `keep_off` | `restore`.)
- **Punktfunk** — its host is always running, so wire its session hooks in
`~/.config/punktfunk/hooks.json`:
```json
{"on": "session.started", "run": "lgtvc -streaming_connect"},
{"on": "session.ended", "run": "lgtvc -streaming_disconnect"}
```

While a stream is connected, wake-on-input is suspended — key presses then
belong to the stream client, not someone at the TV. Independently of stream
detection, input from **virtual devices** (uinput / UHID / usbip — how
streaming hosts inject the client's controller and keyboard, including
Punktfunk's emulated Steam Deck pad) counts as activity but never wakes the TV,
and virtual Valve pads are ignored by the Steam Controller monitor
(`lgtvc setup hidraw-scan` marks them).

## Subscribe to events

Expand Down
5 changes: 4 additions & 1 deletion src/lgtvcompanion/agent/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,10 @@ def _on_input(self, path: str, etype: int, code: int, value: int) -> None:
return
self._last_input_report = now
with contextlib.suppress(asyncio.QueueFull):
self._send_queue.put_nowait({"activity": True, "key": etype == EV_KEY})
# injected devices (a stream client's pad/keyboard) keep the session
# active but never count as a key press — they must not wake the TV
key = etype == EV_KEY and path not in self._monitor.synthetic
self._send_queue.put_nowait({"activity": True, "key": key})

def _on_sc_input(self, path: str) -> None:
# Steam-controller input over hidraw. With steam_controller.wake (the
Expand Down
9 changes: 6 additions & 3 deletions src/lgtvcompanion/cli/setup_cmds.py
Original file line number Diff line number Diff line change
Expand Up @@ -557,7 +557,8 @@ def cmd_hidraw_scan(args: argparse.Namespace) -> int:
show which bytes move."""
import glob as globmod

from ..daemon.hidraw import VALVE_VID, hidraw_info
from ..daemon.hidraw import SYSFS_HIDRAW, VALVE_VID, hidraw_info
from ..daemon.inputdev import is_synthetic

nodes = sorted(globmod.glob("/dev/hidraw*"))
if not nodes:
Expand All @@ -569,10 +570,12 @@ def cmd_hidraw_scan(args: argparse.Namespace) -> int:
vidpid = (f"{vid:04x}:{pid:04x}" if vid is not None and pid is not None
else "?")
readable = os.access(path, os.R_OK)
mark = " <- Valve" if vid == VALVE_VID else ""
virtual = vid == VALVE_VID and is_synthetic(path, SYSFS_HIDRAW)
mark = (" <- Valve (virtual: emulated/streamed pad, ignored)" if virtual
else " <- Valve" if vid == VALVE_VID else "")
print(f"{path} {vidpid} "
f"{'readable' if readable else 'NO READ ACCESS'} {name}{mark}")
if vid == VALVE_VID and readable:
if vid == VALVE_VID and readable and not virtual:
valve.append(path)
if not valve:
print("\nno readable Valve (28de) controller found — if you have one, "
Expand Down
12 changes: 10 additions & 2 deletions src/lgtvcompanion/daemon/hidraw.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from pathlib import Path

from ..steamcontroller import VALVE_VID, SteamControllerActivity
from .inputdev import _HotplugMonitor
from .inputdev import _HotplugMonitor, is_synthetic

log = logging.getLogger(__name__)

Expand Down Expand Up @@ -92,14 +92,22 @@ class HidrawMonitor(_HotplugMonitor):
LABEL = "hidraw"
READ_SIZE = 256 # a single HID report; SC reports are <=64 bytes

def __init__(self, callback: Callable[[str], None]):
def __init__(self, callback: Callable[[str], None],
sysfs_root: str = SYSFS_HIDRAW):
super().__init__(callback)
self.sysfs_root = sysfs_root
self._detectors: dict[str, SteamControllerActivity] = {}
self._suspend_epoch = _boottime_delta()

def _accept(self, path: str, fd: int) -> bool:
if hidraw_vendor(path) != VALVE_VID:
return False
# Streaming hosts (Punktfunk) emulate the remote client's pad as a
# Valve Steam Deck / Controller over UHID or usbip — same 28DE vendor,
# but the person holding it is not at this TV.
if is_synthetic(path, self.sysfs_root):
log.info("skipping virtual Valve hidraw %s (emulated/streamed pad)", path)
return False
self._detectors[path] = SteamControllerActivity()
log.info("watching Steam controller hidraw %s", path)
return True
Expand Down
32 changes: 31 additions & 1 deletion src/lgtvcompanion/daemon/inputdev.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,22 @@

INPUT_PROP_ACCELEROMETER = 0x06

SYSFS_INPUT = "/sys/class/input"


def is_synthetic(path: str, sysfs_class: str) -> bool:
"""Whether a /dev node is backed by a software-injected device rather than
physical hardware: uinput and UHID devices live under /sys/devices/virtual/,
usbip-emulated USB hangs off vhci_hcd. Game-streaming hosts (Punktfunk,
Sunshine, Steam Remote Play) inject the remote client's controller/keyboard
exactly this way — that input comes from another room, so it must never be
mistaken for someone at the TV. Unknown/unresolvable → False (physical)."""
link = os.path.join(sysfs_class, os.path.basename(path), "device")
if not os.path.exists(link):
return False
real = os.path.realpath(link)
return "/devices/virtual/" in real or "vhci_hcd" in real


def _ioc(direction: int, nr: int, size: int) -> int:
return (direction << 30) | (size << 16) | (ord("E") << 8) | nr
Expand Down Expand Up @@ -239,15 +255,29 @@ class InputMonitor(_HotplugMonitor):
LABEL = "input"
READ_SIZE = EVENT_SIZE * 64

def __init__(self, callback: Callable[[str, int, int, int], None]):
def __init__(self, callback: Callable[[str, int, int, int], None],
sysfs_root: str = SYSFS_INPUT):
super().__init__(callback)
self.sysfs_root = sysfs_root
# open nodes backed by injected (uinput/UHID/usbip) devices — callers
# treat their events as activity, never as someone at the TV
self.synthetic: set[str] = set()

def _accept(self, path: str, fd: int) -> bool:
if is_accelerometer(fd):
log.debug("%s: accelerometer, skipped", path)
return False
if is_synthetic(path, self.sysfs_root):
self.synthetic.add(path)
log.info("%s: virtual (injected) input device", path)
else:
self.synthetic.discard(path)
return True

def _close_device(self, path: str) -> None:
self.synthetic.discard(path)
super()._close_device(path)

def _handle(self, path: str, fd: int, data: bytes) -> None:
for off in range(0, len(data) - EVENT_SIZE + 1, EVENT_SIZE):
_sec, _usec, etype, code, value = struct.unpack_from(
Expand Down
5 changes: 4 additions & 1 deletion src/lgtvcompanion/daemon/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,11 @@ def _on_report(self, report: dict) -> None:
self.idle_engine.notify_activity()
# only key presses wake an off TV; pointer noise shouldn't. Skip if
# a power operation is already in flight, else a slow/failing wake
# keeps re-triggering (the TV reads "unreachable" mid-connect).
# keeps re-triggering (the TV reads "unreachable" mid-connect), and
# while a remote stream is live — that input is the stream client's,
# the panel is dark on purpose.
if (report.get("key", True) and self.wake_on_input is not None
and not self.streams.streaming
and not any(s.busy for s in self.sessions)):
self.wake_on_input.notify_input()

Expand Down
2 changes: 2 additions & 0 deletions src/lgtvcompanion/daemon/wake.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ def stop(self) -> None:
def _on_event(self, path: str, etype: int, code: int, value: int) -> None:
if etype != EV_KEY or value != KEY_PRESS:
return
if path in self._monitor.synthetic: # injected (stream client) input
return
self.notify_input(path)

def notify_input(self, source: str = "agent") -> None:
Expand Down
9 changes: 9 additions & 0 deletions tests/test_agent_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,15 @@ def test_mouse_motion_debounced_then_reports(monkeypatch, tmp_path):
assert a._send_queue.get_nowait() == {"activity": True, "key": False}


def test_key_from_virtual_device_is_activity_not_wake(monkeypatch, tmp_path):
# a stream client's injected pad/keyboard keeps the session active but must
# never wake the TV
a = _agent(monkeypatch, tmp_path)
a._monitor.synthetic.add("/dev/input/event18")
a._on_input("/dev/input/event18", EV_KEY, 304, 1)
assert a._send_queue.get_nowait() == {"activity": True, "key": False}


# -- _on_sc_input (Steam Controller via hidraw) -------------------------------

def test_sc_input_wakes_by_default(monkeypatch, tmp_path):
Expand Down
10 changes: 10 additions & 0 deletions tests/test_daemon_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,16 @@ async def test_no_wake_when_tv_active(tmp_path):
assert d.sessions[0].calls == ["probe"] # probed, never powered on


async def test_no_wake_while_streaming(tmp_path):
# during a remote stream the key presses are the stream client's — the TV
# is dark on purpose and must not be probed or powered on
d = _wake_daemon(tmp_path, FakeSession("tv1", probe_state="Unreachable"))
d.streams.streaming = True
d._on_report({"activity": True, "key": True})
await asyncio.sleep(0.05)
assert d.sessions[0].calls == []


async def test_no_wake_on_rejected_pairing_key(tmp_path):
# Waking would loop on KeyRejected and pop pairing prompts on-screen.
d = _wake_daemon(tmp_path, FakeSession("tv1", probe_state="KeyRejected"))
Expand Down
13 changes: 13 additions & 0 deletions tests/test_hidraw.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,3 +203,16 @@ def test_close_device_drops_detector():
assert "/dev/hidraw0" not in m._detectors
assert "/dev/hidraw0" not in m._fds
os.close(w)


def test_accept_skips_virtual_valve_pad(monkeypatch, tmp_path):
# Punktfunk emulates the stream client's pad as a Valve Steam Deck over
# UHID — same vendor as the couch controller, but it must not be watched.
real = tmp_path / "sys" / "devices" / "virtual" / "misc" / "uhid" / "0003:28DE:1205.0009"
real.mkdir(parents=True)
(tmp_path / "class" / "hidraw8").mkdir(parents=True)
(tmp_path / "class" / "hidraw8" / "device").symlink_to(real)
monkeypatch.setattr(hraw, "hidraw_vendor", lambda p: VALVE_VID)
m = hraw.HidrawMonitor(lambda path: None, sysfs_root=str(tmp_path / "class"))
assert m._accept("/dev/hidraw8", 3) is False
assert m._detectors == {}
42 changes: 42 additions & 0 deletions tests/test_inputdev.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,3 +377,45 @@ async def test_setup_inotify_real_watch():
assert m._inotify_fd is not None
finally:
m.stop()


# --- is_synthetic / virtual-device tagging ------------------------------------

def _sysfs_node(root, name, target):
"""Fake /sys/class/<cls>/<name>/device -> <root>/sys/devices/<target>."""
real = root / "sys" / "devices" / target
real.mkdir(parents=True)
node = root / "class" / name
node.mkdir(parents=True)
(node / "device").symlink_to(real)
return str(root / "class")


@pytest.mark.parametrize("target,expected", [
("pci0000:00/0000:0a:00.0/usb1/1-5/1-5:1.2/input/input8", False), # real USB
("virtual/input/input32", True), # uinput
("virtual/misc/uhid/0003:28DE:1205.0009/input/input40", True), # UHID pad
("platform/vhci_hcd.0/usb3/3-1/3-1:1.0/input/input41", True), # usbip
])
def test_is_synthetic_classifies_by_sysfs_path(tmp_path, target, expected):
cls = _sysfs_node(tmp_path, "event7", target)
assert idev.is_synthetic("/dev/input/event7", cls) is expected


def test_is_synthetic_unknown_node_is_physical(tmp_path):
assert idev.is_synthetic("/dev/input/event99", str(tmp_path)) is False


def test_input_monitor_tags_synthetic_until_closed(monkeypatch, tmp_path):
monkeypatch.setattr(idev, "is_accelerometer", lambda fd: False)
cls = _sysfs_node(tmp_path, "event4", "virtual/input/input4")
m = idev.InputMonitor(lambda *a: None, sysfs_root=cls)
r, w = os.pipe()
try:
assert m._accept("/dev/input/event4", r) is True # still watched
assert m.synthetic == {"/dev/input/event4"}
m._fds["/dev/input/event4"] = r
m._close_device("/dev/input/event4")
assert m.synthetic == set()
finally:
os.close(w)
15 changes: 15 additions & 0 deletions tests/test_setup_cmds.py
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,21 @@ def test_hidraw_scan_lists_nodes(monkeypatch, capsys):
assert "--seconds" in out # hint to capture


def test_hidraw_scan_marks_virtual_valve_pad(monkeypatch, capsys):
from lgtvcompanion.cli import setup_cmds as sc
from lgtvcompanion.daemon import hidraw as hraw
from lgtvcompanion.daemon import inputdev as idev
monkeypatch.setattr("glob.glob", lambda pat: ["/dev/hidraw8"])
monkeypatch.setattr(sc.os, "access", lambda p, mode: True)
monkeypatch.setattr(hraw, "hidraw_info",
lambda p: (0x28DE, 0x1205, "Steam Deck Controller"))
monkeypatch.setattr(idev, "is_synthetic", lambda p, root: True)
assert sc.cmd_hidraw_scan(SimpleNamespace(seconds=0.0)) == 0
out = capsys.readouterr().out
assert "virtual" in out
assert "no readable Valve" in out # not offered for capture


def test_hidraw_scan_no_nodes(monkeypatch, capsys):
from lgtvcompanion.cli import setup_cmds as sc
monkeypatch.setattr("glob.glob", lambda pat: [])
Expand Down
9 changes: 9 additions & 0 deletions tests/test_wake_and_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ def test_on_event_only_reacts_to_key_press():
assert seen == ["/dev/input/event0"]


def test_on_event_ignores_virtual_devices():
seen: list = []
w = WakeOnInput(wake_if_needed=None, cooldown_s=0.0)
w.notify_input = lambda src="agent": seen.append(src) # type: ignore[method-assign]
w._monitor.synthetic.add("/dev/input/event18")
w._on_event("/dev/input/event18", EV_KEY, 304, KEY_PRESS)
assert seen == []


# -- IpcServer ----------------------------------------------------------------


Expand Down
Loading