diff --git a/CHANGELOG.md b/CHANGELOG.md index 29ac847..23248e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/external-api.md b/docs/external-api.md index bdef15e..4828d02 100644 --- a/docs/external-api.md +++ b/docs/external-api.md @@ -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 diff --git a/src/lgtvcompanion/agent/main.py b/src/lgtvcompanion/agent/main.py index 639fb33..927f645 100644 --- a/src/lgtvcompanion/agent/main.py +++ b/src/lgtvcompanion/agent/main.py @@ -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 diff --git a/src/lgtvcompanion/cli/setup_cmds.py b/src/lgtvcompanion/cli/setup_cmds.py index 6707edd..eab722c 100644 --- a/src/lgtvcompanion/cli/setup_cmds.py +++ b/src/lgtvcompanion/cli/setup_cmds.py @@ -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: @@ -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, " diff --git a/src/lgtvcompanion/daemon/hidraw.py b/src/lgtvcompanion/daemon/hidraw.py index d07f24c..0242f63 100644 --- a/src/lgtvcompanion/daemon/hidraw.py +++ b/src/lgtvcompanion/daemon/hidraw.py @@ -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__) @@ -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 diff --git a/src/lgtvcompanion/daemon/inputdev.py b/src/lgtvcompanion/daemon/inputdev.py index dbe533f..6f77b84 100644 --- a/src/lgtvcompanion/daemon/inputdev.py +++ b/src/lgtvcompanion/daemon/inputdev.py @@ -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 @@ -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( diff --git a/src/lgtvcompanion/daemon/main.py b/src/lgtvcompanion/daemon/main.py index c590bba..e856700 100644 --- a/src/lgtvcompanion/daemon/main.py +++ b/src/lgtvcompanion/daemon/main.py @@ -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() diff --git a/src/lgtvcompanion/daemon/wake.py b/src/lgtvcompanion/daemon/wake.py index 1aee303..ce3637b 100644 --- a/src/lgtvcompanion/daemon/wake.py +++ b/src/lgtvcompanion/daemon/wake.py @@ -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: diff --git a/tests/test_agent_main.py b/tests/test_agent_main.py index 4d278b9..f16a9d1 100644 --- a/tests/test_agent_main.py +++ b/tests/test_agent_main.py @@ -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): diff --git a/tests/test_daemon_lifecycle.py b/tests/test_daemon_lifecycle.py index d9003f1..d2ce3e0 100644 --- a/tests/test_daemon_lifecycle.py +++ b/tests/test_daemon_lifecycle.py @@ -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")) diff --git a/tests/test_hidraw.py b/tests/test_hidraw.py index 1189d9b..3aefac1 100644 --- a/tests/test_hidraw.py +++ b/tests/test_hidraw.py @@ -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 == {} diff --git a/tests/test_inputdev.py b/tests/test_inputdev.py index 6521ae4..fae930a 100644 --- a/tests/test_inputdev.py +++ b/tests/test_inputdev.py @@ -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///device -> /sys/devices/.""" + 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) diff --git a/tests/test_setup_cmds.py b/tests/test_setup_cmds.py index 631b458..bd8e865 100644 --- a/tests/test_setup_cmds.py +++ b/tests/test_setup_cmds.py @@ -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: []) diff --git a/tests/test_wake_and_server.py b/tests/test_wake_and_server.py index 037ca4b..931afca 100644 --- a/tests/test_wake_and_server.py +++ b/tests/test_wake_and_server.py @@ -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 ----------------------------------------------------------------