Last active
February 28, 2026 20:39
-
-
Save gdamjan/b1c65007bba4ed84ca8ed8b1d903e1b4 to your computer and use it in GitHub Desktop.
Samsung TV Remote over websocket - tested with Samsung Q60 Series (50)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env -S uv run --script | |
| # /// script | |
| # requires-python = ">=3.12" | |
| # dependencies = [ | |
| # "websockets>=16.0", | |
| # "httpx>=0.28", | |
| # ] | |
| # /// | |
| """Samsung Smart TV remote control via WebSocket API. | |
| How it works: | |
| 1. On first connect, the TV will show an "Allow/Deny" popup on screen — press Allow on the TV | |
| 2. The TV responds with a token in the WebSocket message (save it!) | |
| 3. On subsequent connects, append the token: ?name=...&token=YOUR_TOKEN | |
| """ | |
| import argparse | |
| import asyncio | |
| import base64 | |
| import json | |
| import ssl | |
| import sys | |
| import tomllib | |
| from pathlib import Path | |
| import websockets | |
| import httpx | |
| CONFIG_PATH = Path.home() / ".config" / "samsung-tv-remote" / "config.toml" | |
| APP_NAME = "PythonRemote" | |
| def load_config() -> dict: | |
| if CONFIG_PATH.exists(): | |
| with open(CONFIG_PATH, "rb") as fp: | |
| file_cfg = tomllib.load(fp) | |
| tv_cfg = file_cfg.get("tv", {}) | |
| if 'app_name' not in tv_cfg: | |
| tv_cfg['app_name'] = APP_NAME | |
| return tv_cfg | |
| APPS = { | |
| "netflix": "3201907018807", | |
| "youtube": "111299001912", | |
| "prime": "3201512006785", | |
| "disney": "3201901017640", | |
| "appletv": "3201807016597", | |
| "browser": "3201907018784", | |
| "spotify": "3201606009684", | |
| "plex": "3201512006963", | |
| } | |
| KEYS = { | |
| # Power | |
| "power": "KEY_POWER", | |
| "poweroff": "KEY_POWEROFF", | |
| # Volume | |
| "volup": "KEY_VOLUP", | |
| "voldown": "KEY_VOLDOWN", | |
| "mute": "KEY_MUTE", | |
| # Channels | |
| "chup": "KEY_CHUP", | |
| "chdown": "KEY_CHDOWN", | |
| # Navigation | |
| "up": "KEY_UP", | |
| "down": "KEY_DOWN", | |
| "left": "KEY_LEFT", | |
| "right": "KEY_RIGHT", | |
| "enter": "KEY_ENTER", | |
| "back": "KEY_RETURN", | |
| "home": "KEY_HOME", | |
| "exit": "KEY_EXIT", | |
| # Media | |
| "play": "KEY_PLAY", | |
| "pause": "KEY_PAUSE", | |
| "stop": "KEY_STOP", | |
| "ff": "KEY_FF", | |
| "rw": "KEY_REWIND", | |
| # Input | |
| "source": "KEY_SOURCE", | |
| "hdmi1": "KEY_HDMI1", | |
| "hdmi2": "KEY_HDMI2", | |
| "hdmi3": "KEY_HDMI3", | |
| "hdmi4": "KEY_HDMI4", | |
| # Numbers | |
| **{str(i): f"KEY_{i}" for i in range(10)}, | |
| # Misc | |
| "info": "KEY_INFO", | |
| "menu": "KEY_MENU", | |
| "guide": "KEY_GUIDE", | |
| "tools": "KEY_TOOLS", | |
| } | |
| def build_url(host: str, port: int, token: str | None, app_name: str) -> str: | |
| name_b64 = base64.b64encode(app_name.encode()).decode() | |
| url = f"wss://{host}:{port}/api/v2/channels/samsung.remote.control?name={name_b64}" | |
| if token: | |
| url += f"&token={token}" | |
| return url | |
| def key_cmd(key_code: str) -> str: | |
| return json.dumps( | |
| { | |
| "method": "ms.remote.control", | |
| "params": { | |
| "Cmd": "Click", | |
| "DataOfCmd": key_code, | |
| "Option": "false", | |
| "TypeOfRemote": "SendRemoteKey", | |
| }, | |
| } | |
| ) | |
| async def send_keys( | |
| host: str, port: int, token: str | None, app_name: str, keys: list[str], delay: float | |
| ): | |
| url = build_url(host, port, token, app_name) | |
| ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) | |
| ssl_ctx.check_hostname = False | |
| ssl_ctx.verify_mode = ssl.CERT_NONE | |
| async with websockets.connect(url, ssl=ssl_ctx) as ws: | |
| # Wait for the connect event | |
| resp = json.loads(await ws.recv()) | |
| event = resp.get("event", "") | |
| if event == "ms.channel.unauthorized": | |
| print("Unauthorized. Check your TV screen for an Allow/Deny prompt.") | |
| return | |
| if event == "ms.channel.connect": | |
| new_token = resp.get("data", {}).get("token") | |
| if new_token and new_token != token: | |
| print(f"New token: {new_token} (update TV_TOKEN in the script)") | |
| for i, key_code in enumerate(keys): | |
| await ws.send(key_cmd(key_code)) | |
| print(f"Sent: {key_code}") | |
| if i < len(keys) - 1 and delay > 0: | |
| await asyncio.sleep(delay) | |
| # Brief pause to let the last command be processed | |
| await asyncio.sleep(0.3) | |
| def launch_app(host: str, app_id: str): | |
| url = f"http://{host}:8001/api/v2/applications/{app_id}" | |
| resp = httpx.post(url) | |
| if resp.status_code == 200: | |
| print(f"Launched app {app_id}") | |
| else: | |
| print(f"Failed to launch app {app_id}: HTTP {resp.status_code}") | |
| async def interactive(host: str, port: int, token: str | None, app_name: str): | |
| url = build_url(host, port, token, app_name) | |
| ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) | |
| ssl_ctx.check_hostname = False | |
| ssl_ctx.verify_mode = ssl.CERT_NONE | |
| async with websockets.connect(url, ssl=ssl_ctx) as ws: | |
| resp = json.loads(await ws.recv()) | |
| if resp.get("event") == "ms.channel.unauthorized": | |
| print("Unauthorized. Check your TV screen for an Allow/Deny prompt.") | |
| return | |
| print("Connected. Type key names (e.g. volup, mute, home) or 'q' to quit.") | |
| print(f"Available keys: {', '.join(sorted(KEYS.keys()))}\n") | |
| while True: | |
| try: | |
| line = await asyncio.get_event_loop().run_in_executor( | |
| None, lambda: input("remote> ") | |
| ) | |
| except (EOFError, KeyboardInterrupt): | |
| break | |
| line = line.strip().lower() | |
| if line in ("q", "quit"): | |
| break | |
| if not line: | |
| continue | |
| parts = line.split() | |
| for part in parts: | |
| code = KEYS.get(part, None) | |
| if code is None: | |
| # Allow raw KEY_ codes | |
| if part.startswith("key_"): | |
| code = part.upper() | |
| else: | |
| print(f"Unknown key: {part}") | |
| continue | |
| await ws.send(key_cmd(code)) | |
| print(f"Sent: {code}") | |
| if len(parts) > 1: | |
| await asyncio.sleep(0.15) | |
| print("Disconnected.") | |
| def main(): | |
| config = load_config() | |
| parser = argparse.ArgumentParser( | |
| description="Samsung Smart TV Remote Control", | |
| epilog=f"Config file: {CONFIG_PATH}", | |
| ) | |
| parser.add_argument( | |
| "--host", default=config["host"], help=f"TV IP address (default: {config['host']})" | |
| ) | |
| parser.add_argument( | |
| "--port", type=int, default=config["port"], help=f"TV WSS port (default: {config['port']})" | |
| ) | |
| parser.add_argument("--token", default=config["token"], help="Auth token") | |
| parser.add_argument( | |
| "--delay", | |
| type=float, | |
| default=0.3, | |
| help="Delay between keys in seconds (default: 0.3)", | |
| ) | |
| sub = parser.add_subparsers(dest="command") | |
| send_p = sub.add_parser("send", help="Send one or more key presses") | |
| send_p.add_argument( | |
| "keys", nargs="+", help=f"Keys to send: {', '.join(sorted(KEYS.keys()))}" | |
| ) | |
| sub.add_parser("interactive", help="Interactive remote control session") | |
| sub.add_parser("list", help="List available key names") | |
| app_p = sub.add_parser("app", help="Launch a TV app") | |
| app_p.add_argument("app_name", help=f"App to launch: {', '.join(sorted(APPS.keys()))}, or a numeric app ID") | |
| args = parser.parse_args() | |
| if args.command == "list": | |
| print("Keys:") | |
| for name, code in sorted(KEYS.items()): | |
| print(f" {name:10s} -> {code}") | |
| print("\nApps:") | |
| for name, app_id in sorted(APPS.items()): | |
| print(f" {name:10s} -> {app_id}") | |
| return | |
| if args.command == "send": | |
| codes = [] | |
| for k in args.keys: | |
| k = k.lower() | |
| code = KEYS.get(k) | |
| if code is None: | |
| if k.startswith("key_"): | |
| code = k.upper() | |
| else: | |
| parser.error(f"Unknown key: {k}. Use 'list' to see available keys.") | |
| codes.append(code) | |
| asyncio.run(send_keys(args.host, args.port, args.token, config["app_name"], codes, args.delay)) | |
| elif args.command == "interactive": | |
| asyncio.run(interactive(args.host, args.port, args.token, config["app_name"])) | |
| elif args.command == "app": | |
| name = args.app_name.lower() | |
| app_id = APPS.get(name, name) # allow raw app IDs | |
| launch_app(args.host, app_id) | |
| else: | |
| parser.print_help() | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment