Skip to content

Instantly share code, notes, and snippets.

@gdamjan
Last active February 28, 2026 20:39
Show Gist options
  • Select an option

  • Save gdamjan/b1c65007bba4ed84ca8ed8b1d903e1b4 to your computer and use it in GitHub Desktop.

Select an option

Save gdamjan/b1c65007bba4ed84ca8ed8b1d903e1b4 to your computer and use it in GitHub Desktop.
Samsung TV Remote over websocket - tested with Samsung Q60 Series (50)
#!/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