Repository: Tenzer/alga (language: python)

Issue:
sendButton
is there a way to sendButton like in LGWebOSRemote
I like controlling my Plex from the keyboard.


Discussion / hints:
It is not something I have implemented yet, as I've wanted to make sure the commands available in `alga` are the most useful ones, and they are named in a way that makes them intuitive to use. 

I had an idea, that if/when time would come to implement the functionality to send button presses, I would want it to be done in a more interactive UI, instead of having to send individual button presses as separate commands. A bit like a TUI version of the mobile app LG makes that also can work as a remote. 

Part of this, is because to send those button presses you first need to set up one WebSocket connection to the TV (like for all other commands) which you use to send a command that gives you back the address for a different WebSocket connection, where you can then connect and send the button presses. 

There's no obvious way to persist the connections across different invocations of `alga`, which means the connections have to be re-established for every button press. If there instead was a TUI interface which allowed you to send multiple button presses quickly after each other, the connections can be reused, making for much lower latency and a better experience in the end.

That said, it might be the `alga adhoc` command can be extended to support sending button presses, it could possibly be a good first step.
I would think about allowing users to run the button commands, becaus I bind all my remote buttons with tiling window mangers so there is a usecase for not using a tui but individual commands. I allso bind everything like my tv rooms light to keyboard shortcuts so I only need my keyboard when watching tv.

examples from my hyperland.config using the broken LGWebOSRemote 
bind = $mainMod_ALT, RIGHT, execr, /opt/lgtv-venv/bin/lgtv --name TV --ssl sendButton right

this is usaly not used a lot at a time, just navigate plex/other tv app and selecting media, that way I dont need to pick up the remote.



Fix patch:
diff --git a/src/alga/__main__.py b/src/alga/__main__.py
index fb9ff64..c82b65b 100644
--- a/src/alga/__main__.py
+++ b/src/alga/__main__.py
@@ -9,6 +9,7 @@
     cli_input,
     cli_media,
     cli_power,
+    cli_remote,
     cli_setup,
     cli_sound_output,
     cli_volume,
@@ -21,6 +22,7 @@
 app.add_typer(cli_input.app, name="input")
 app.add_typer(cli_media.app, name="media")
 app.add_typer(cli_power.app, name="power")
+app.add_typer(cli_remote.app, name="remote")
 app.add_typer(cli_sound_output.app, name="sound-output")
 app.add_typer(cli_volume.app, name="volume")
 
diff --git a/src/alga/cli_remote.py b/src/alga/cli_remote.py
new file mode 100644
index 0000000..bb3f1da
--- /dev/null
+++ b/src/alga/cli_remote.py
@@ -0,0 +1,34 @@
+import ssl
+from collections.abc import Iterator
+from contextlib import contextmanager
+from typing import Annotated
+
+from typer import Argument, Typer
+from websocket import WebSocket
+
+from alga import client
+
+
+app = Typer(no_args_is_help=True, help="Remote control button presses")
+
+
+@contextmanager
+def _input_connection() -> Iterator[WebSocket]:  # pragma: no cover
+    response = client.request(
+        "ssap://com.webos.service.networkinput/getPointerInputSocket"
+    )
+
+    connection = WebSocket(sslopt={"cert_reqs": ssl.CERT_NONE})
+    connection.connect(response["socketPath"], suppress_origin=True, timeout=3)  # type: ignore[no-untyped-call]
+
+    try:
+        yield connection
+    finally:
+        connection.close()
+
+
+@app.command()
+def send(button: Annotated[str, Argument()]) -> None:
+    """Send a button press to the TV"""
+    with _input_connection() as connection:
+        connection.send(f"type:button\nname:{button.upper()}\n\n")
diff --git a/usage.md b/usage.md
index 80c89dd..c10a496 100644
--- a/usage.md
+++ b/usage.md
@@ -20,6 +20,7 @@ $ alga [OPTIONS] COMMAND [ARGS]...
 * `input`: HDMI and similar inputs
 * `media`: Control the playing media
 * `power`: Turn TV (or screen) on and off
+* `remote`: Remote control button presses
 * `setup`: Pair a new TV
 * `sound-output`: Audio output device
 * `version`: Print Alga version
@@ -465,6 +466,42 @@ $ alga power screen-on [OPTIONS]
 
 * `--help`: Show this message and exit.
 
+## `alga remote`
+
+Remote control button presses
+
+**Usage**:
+
+```console
+$ alga remote [OPTIONS] COMMAND [ARGS]...
+```
+
+**Options**:
+
+* `--help`: Show this message and exit.
+
+**Commands**:
+
+* `send`: Send a button press to the TV
+
+### `alga remote send`
+
+Send a button press to the TV
+
+**Usage**:
+
+```console
+$ alga remote send [OPTIONS] BUTTON
+```
+
+**Arguments**:
+
+* `BUTTON`: [required]
+
+**Options**:
+
+* `--help`: Show this message and exit.
+
 ## `alga setup`
 
 Pair a new TV


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index c4c0c37..2caa1f9 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -8,3 +8,9 @@
 def mock_request() -> Iterator[mock.MagicMock]:
     with mock.patch("alga.client.request") as mocked:
         yield mocked
+
+
+@pytest.fixture
+def mock_input() -> Iterator[mock.MagicMock]:
+    with mock.patch("alga.cli_remote._input_connection") as mocked:
+        yield mocked.return_value.__enter__.return_value.send
diff --git a/tests/test_cli_remote.py b/tests/test_cli_remote.py
new file mode 100644
index 0000000..cddf9c4
--- /dev/null
+++ b/tests/test_cli_remote.py
@@ -0,0 +1,19 @@
+from unittest.mock import MagicMock
+
+from faker import Faker
+from typer.testing import CliRunner
+
+from alga.__main__ import app
+
+
+runner = CliRunner()
+
+
+def test_send(faker: Faker, mock_input: MagicMock) -> None:
+    button = faker.pystr()
+
+    result = runner.invoke(app, ["remote", "send", button])
+
+    mock_input.assert_called_once_with(f"type:button\nname:{button.upper()}\n\n")
+    assert result.exit_code == 0
+    assert result.stdout == ""