├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── samsungctl.conf ├── samsungctl ├── __init__.py ├── __main__.py ├── application.py ├── exceptions.py ├── interactive.py ├── remote.py ├── remote_legacy.py ├── remote_websocket.py ├── token.txt └── upnp.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | build/ 3 | dist/ 4 | *.egg-info/ 5 | *.pyc 6 | *.swp 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Jakub Pas 4 | Copyright (c) 2014 Lauri Niskanen 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | 24 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE 3 | include samsungctl.conf 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | samsungctl 2 | ========== 3 | 4 | samsungctl is a library and a command line tool for remote controlling Samsung QLED 5 | televisions via a websocket. Its a fork of https://github.com/Ape/samsungctl but support for moder QLED TV's was addad 6 | also in interactive mode menu was redesigned to match QLED remote control. 7 | At this moment installation breaks with newest vestion on websocket-client co be sure tu install version <=0.57.0 8 | 9 | The app should also runs with older samung TV when run in legacy mode. 10 | 11 | Before running app go to TV Manu and choose: 12 | 13 | General -> External Device Manager -> Device Connection Manager -> Access Notification = Allways on 14 | 15 | After that run application and accept incoming connection to tv. 16 | 17 | then switch 18 | 19 | General -> External Device Manager -> Device Connection Manager -> Access Notification = off 20 | 21 | Otherwise you will have problem with connection to tv or you will be asked to authenticate everytime the app is run 22 | 23 | This is work in progress. The sometimes have problem with websocket connection and drops with broken pipe error but 24 | it should work most of the time. 25 | 26 | 27 | Dependencies 28 | ============ 29 | 30 | - Python 3 31 | - ``websocket-client==0.56.0`` 32 | - ``curses`` - for the interactive mode 33 | 34 | Installation and running 35 | ======================== 36 | 37 | :: 38 | 39 | pip install 'websocket-client==0.56.0' 40 | 41 | :: 42 | 43 | sudo python setup.py install 44 | 45 | :: 46 | 47 | python -m samsungctl --host samsung --method websocket --port 8002 -i 48 | 49 | Other than that you can follow manual of original samsungctl: 50 | 51 | Command line usage 52 | ================== 53 | 54 | You can use ``samsungctl`` command to send keys to a TV: 55 | 56 | :: 57 | 58 | $ samsungctl --host [options] [key ...] 59 | 60 | ``host`` is the hostname or IP address of the TV. ``key`` is a key code, e.g. 61 | ``KEY_VOLDOWN``. See `Key codes`_. 62 | 63 | There is also an interactive mode (ncurses) for sending the key presses: 64 | 65 | :: 66 | 67 | $ samsungctl --host [options] --interactive 68 | 69 | Use ``samsungctl --help`` for more information about the command line 70 | arguments: 71 | 72 | :: 73 | 74 | usage: samsungctl [-h] [--version] [-v] [-q] [-i] [--host HOST] [--port PORT] 75 | [--method METHOD] [--name NAME] [--description DESC] 76 | [--id ID] [--timeout TIMEOUT] 77 | [key [key ...]] 78 | 79 | Remote control Samsung televisions via TCP/IP connection 80 | 81 | positional arguments: 82 | key keys to be sent (e.g. KEY_VOLDOWN) 83 | 84 | optional arguments: 85 | -h, --help show this help message and exit 86 | --version show program's version number and exit 87 | -v, --verbose increase output verbosity 88 | -q, --quiet suppress non-fatal output 89 | -i, --interactive interactive control 90 | --host HOST TV hostname or IP address 91 | --port PORT TV port number (TCP) 92 | --method METHOD Connection method (legacy or websocket) 93 | --name NAME remote control name 94 | --description DESC remote control description 95 | --id ID remote control id 96 | --timeout TIMEOUT socket timeout in seconds (0 = no timeout) 97 | 98 | E.g. samsungctl --host 192.168.0.10 --name myremote KEY_VOLDOWN 99 | 100 | The settings can be loaded from a configuration file. The file is searched from 101 | ``$XDG_CONFIG_HOME/samsungctl.conf``, ``~/.config/samsungctl.conf``, and 102 | ``/etc/samsungctl.conf`` in this order. A simple default configuration is 103 | bundled with the source as `samsungctl.conf `_. 104 | 105 | Library usage 106 | ============= 107 | 108 | samsungctl can be imported as a Python 3 library: 109 | 110 | .. code-block:: python 111 | 112 | import samsungctl 113 | 114 | A context managed remote controller object of class ``Remote`` can be 115 | constructed using the ``with`` statement: 116 | 117 | .. code-block:: python 118 | 119 | with samsungctl.Remote(config) as remote: 120 | # Use the remote object 121 | 122 | The constructor takes a configuration dictionary as a parameter. All 123 | configuration items must be specified. 124 | 125 | =========== ====== =========================================== 126 | Key Type Description 127 | =========== ====== =========================================== 128 | host string Hostname or IP address of the TV. 129 | port int TCP port number. (Default: ``55000``) 130 | method string Connection method (``legacy`` or ``websocket``) 131 | name string Name of the remote controller. 132 | description string Remote controller description. 133 | id string Additional remote controller ID. 134 | timeout int Timeout in seconds. ``0`` means no timeout. 135 | =========== ====== =========================================== 136 | 137 | The ``Remote`` object is very simple and you only need the ``control(key)`` 138 | method. The only parameter is a string naming the key to be sent (e.g. 139 | ``KEY_VOLDOWN``). See `Key codes`_. You can call ``control`` multiple times 140 | using the same ``Remote`` object. The connection is automatically closed when 141 | exiting the ``with`` statement. 142 | 143 | When something goes wrong you will receive an exception: 144 | 145 | ================= ======================================= 146 | Exception Description 147 | ================= ======================================= 148 | AccessDenied The TV does not allow you to send keys. 149 | ConnectionClosed The connection was closed. 150 | UnhandledResponse An unexpected response was received. 151 | socket.timeout The connection timed out. 152 | ================= ======================================= 153 | 154 | Example program 155 | --------------- 156 | 157 | This simple program opens and closes the menu a few times. 158 | 159 | .. code-block:: python 160 | 161 | #!/usr/bin/env python3 162 | 163 | import samsungctl 164 | import time 165 | 166 | config = { 167 | "name": "samsungctl", 168 | "description": "PC", 169 | "id": "", 170 | "host": "192.168.0.10", 171 | "port": 55000, 172 | "method": "legacy", 173 | "timeout": 0, 174 | } 175 | 176 | with samsungctl.Remote(config) as remote: 177 | for i in range(10): 178 | remote.control("KEY_MENU") 179 | time.sleep(0.5) 180 | 181 | Key codes 182 | ========= 183 | 184 | The list of accepted keys may vary depending on the TV model, but the following 185 | list has some common key codes and their descriptions. 186 | 187 | ================= ============ 188 | Key code Description 189 | ================= ============ 190 | KEY_POWEROFF Power off 191 | KEY_UP Up 192 | KEY_DOWN Down 193 | KEY_LEFT Left 194 | KEY_RIGHT Right 195 | KEY_CHUP P Up 196 | KEY_CHDOWN P Down 197 | KEY_ENTER Enter 198 | KEY_RETURN Return 199 | KEY_CH_LIST Channel List 200 | KEY_MENU Menu 201 | KEY_SOURCE Source 202 | KEY_GUIDE Guide 203 | KEY_TOOLS Tools 204 | KEY_INFO Info 205 | KEY_RED A / Red 206 | KEY_GREEN B / Green 207 | KEY_YELLOW C / Yellow 208 | KEY_BLUE D / Blue 209 | KEY_PANNEL_CHDOWN 3D 210 | KEY_VOLUP Volume Up 211 | KEY_VOLDOWN Volume Down 212 | KEY_MUTE Mute 213 | KEY_0 0 214 | KEY_1 1 215 | KEY_2 2 216 | KEY_3 3 217 | KEY_4 4 218 | KEY_5 5 219 | KEY_6 6 220 | KEY_7 7 221 | KEY_8 8 222 | KEY_9 9 223 | KEY_DTV TV Source 224 | KEY_HDMI HDMI Source 225 | KEY_CONTENTS SmartHub 226 | ================= ============ 227 | 228 | Please note that some codes are different on the 2016+ TVs. For example, 229 | ``KEY_POWEROFF`` is ``KEY_POWER`` on the newer TVs. 230 | 231 | References 232 | ========== 233 | 234 | I did not reverse engineer the control protocol myself and samsungctl is not 235 | the only implementation. Here is the list of things that inspired samsungctl. 236 | 237 | - http://sc0ty.pl/2012/02/samsung-tv-network-remote-control-protocol/ 238 | - https://gist.github.com/danielfaust/998441 239 | - https://github.com/Bntdumas/SamsungIPRemote 240 | - https://github.com/kyleaa/homebridge-samsungtv2016 241 | -------------------------------------------------------------------------------- /samsungctl.conf: -------------------------------------------------------------------------------- 1 | { 2 | "name": "samsungctl", 3 | "description": "PC", 4 | "id": "", 5 | "method": "websocket", 6 | "port": 8002, 7 | "timeout": 1 8 | } 9 | -------------------------------------------------------------------------------- /samsungctl/__init__.py: -------------------------------------------------------------------------------- 1 | """Remote control Samsung televisions via TCP/IP connection""" 2 | 3 | from .remote import Remote 4 | from .application import Application 5 | from .upnp import Upnp 6 | 7 | __title__ = "samsungctl" 8 | __version__ = "0.8" 9 | __url__ = "https://github.com/jakubpas/samsungctl" 10 | __author__ = "Jakub Pas" 11 | __author_email__ = "" 12 | __license__ = "MIT" 13 | -------------------------------------------------------------------------------- /samsungctl/__main__.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import collections 3 | import json 4 | import logging 5 | import os 6 | import socket 7 | import errno 8 | 9 | from . import __doc__ as doc 10 | from . import __title__ as title 11 | from . import __version__ as version 12 | from . import exceptions 13 | from . import Remote 14 | 15 | 16 | def _read_config(): 17 | config = collections.defaultdict(lambda: None, { 18 | "name": "samsungctl", 19 | "description": "PC", 20 | "id": "", 21 | "method": "legacy", 22 | "timeout": 0, 23 | }) 24 | 25 | file_loaded = False 26 | directories = [] 27 | 28 | xdg_config = os.getenv("XDG_CONFIG_HOME") 29 | if xdg_config: 30 | directories.append(xdg_config) 31 | 32 | directories.append(os.path.join(os.getenv("HOME"), ".config")) 33 | directories.append("/etc") 34 | 35 | for directory in directories: 36 | path = os.path.join(directory, "samsungctl.conf") 37 | try: 38 | config_file = open(path) 39 | except IOError as e: 40 | if e.errno == errno.ENOENT: 41 | continue 42 | else: 43 | raise 44 | else: 45 | file_loaded = True 46 | break 47 | 48 | if not file_loaded: 49 | return config 50 | 51 | with config_file: 52 | try: 53 | config_json = json.load(config_file) 54 | except ValueError as e: 55 | message = "Warning: Could not parse the configuration file.\n %s" 56 | logging.warning(message, e) 57 | return config 58 | 59 | config.update(config_json) 60 | 61 | return config 62 | 63 | 64 | def main(): 65 | epilog = "E.g. %(prog)s --host 192.168.0.10 --name myremote KEY_VOLDOWN" 66 | parser = argparse.ArgumentParser(prog=title, description=doc, 67 | epilog=epilog) 68 | parser.add_argument("--version", action="version", 69 | version="%(prog)s {0}".format(version)) 70 | parser.add_argument("-v", "--verbose", action="count", 71 | help="increase output verbosity") 72 | parser.add_argument("-q", "--quiet", action="store_true", 73 | help="suppress non-fatal output") 74 | parser.add_argument("-i", "--interactive", action="store_true", 75 | help="interactive control") 76 | parser.add_argument("--host", help="TV hostname or IP address") 77 | parser.add_argument("--port", type=int, help="TV port number (TCP)") 78 | parser.add_argument("--method", 79 | help="Connection method (legacy or websocket)") 80 | parser.add_argument("--name", help="remote control name") 81 | parser.add_argument("--description", metavar="DESC", 82 | help="remote control description") 83 | parser.add_argument("--id", help="remote control id") 84 | parser.add_argument("--timeout", type=float, 85 | help="socket timeout in seconds (0 = no timeout)") 86 | parser.add_argument("key", nargs="*", 87 | help="keys to be sent (e.g. KEY_VOLDOWN)") 88 | 89 | args = parser.parse_args() 90 | 91 | if args.quiet: 92 | log_level = logging.ERROR 93 | elif not args.verbose: 94 | log_level = logging.WARNING 95 | elif args.verbose == 1: 96 | log_level = logging.INFO 97 | else: 98 | log_level = logging.DEBUG 99 | 100 | logging.basicConfig(format="%(message)s", level=log_level) 101 | 102 | config = _read_config() 103 | config.update({k: v for k, v in vars(args).items() if v is not None}) 104 | 105 | if not config["host"]: 106 | logging.error("Error: --host must be set") 107 | return 108 | 109 | try: 110 | with Remote(config) as remote: 111 | for key in args.key: 112 | remote.control(key) 113 | 114 | if args.interactive: 115 | logging.getLogger().setLevel(logging.ERROR) 116 | from . import interactive 117 | interactive.run(remote) 118 | elif len(args.key) == 0: 119 | logging.warning("Warning: No keys specified.") 120 | except exceptions.ConnectionClosed: 121 | logging.error("Error: Connection closed!") 122 | except exceptions.AccessDenied: 123 | logging.error("Error: Access denied!") 124 | except exceptions.UnknownMethod: 125 | logging.error("Error: Unknown method '{}'".format(config["method"])) 126 | except socket.timeout: 127 | logging.error("Error: Timed out!") 128 | except OSError as e: 129 | logging.error("Error: %s", e.strerror) 130 | 131 | 132 | if __name__ == "__main__": 133 | main() 134 | -------------------------------------------------------------------------------- /samsungctl/application.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import requests 4 | 5 | APP_URL_FORMAT = "http://{}:8001/api/v2/applications/{}" 6 | 7 | APPS = {'YouTube': '111299001912', 8 | 'Plex': '3201512006963', 9 | 'Prime Video': '3201512006785', 10 | 'Universal Guide': '3201710015067', 11 | 'Netflix': '11101200001', 12 | 'Apple TV': '3201807016597', 13 | 'Steam Link': '3201702011851', 14 | 'MyCANAL': '3201606009910', 15 | # 'Browser': 'org.tizen.browser', 16 | 'Spotify': '3201606009684', 17 | 'Molotov': '3201611011210', 18 | 'SmartThings': '3201710015016', 19 | 'e-Manual': '20182100010', 20 | 'Google Play': '3201601007250', 21 | 'Gallery': '3201710015037', 22 | 'Rakuten TV': '3201511006428', 23 | 'RMC Sport': '3201704012212', 24 | 'MYTF1 VOD': '3201905018355', 25 | 'Blacknut': '3201811017333', 26 | 'Facebook Watch': '11091000000', 27 | 'McAfee Security for TV': '3201612011418', 28 | 'OCS': '3201703012029', 29 | 'Playzer': '3201810017091' 30 | } 31 | 32 | 33 | class Application: 34 | """ Handle applications.""" 35 | 36 | def __init__(self, config): 37 | self._ip = config['host'] 38 | 39 | def state(self, app): 40 | """ Get the state of the app.""" 41 | try: 42 | response = requests.get(APP_URL_FORMAT.format(self._ip, APPS[app]), timeout=0.2) 43 | return response.content.decode('utf-8') 44 | except: 45 | return """{"id":"","name":"","running":false,"version":"","visible":false}""" 46 | 47 | def is_running(self, app): 48 | """ Is the app running.""" 49 | app_state = json.loads(self.state(app)) 50 | return app_state['running'] 51 | 52 | def is_visible(self, app): 53 | """ Is the app visible.""" 54 | app_state = json.loads(self.state(app)) 55 | 56 | if 'visible' in app_state: 57 | return app_state['visible'] 58 | 59 | return False 60 | 61 | def start(self, app): 62 | """ Start an application.""" 63 | return os.system("curl -X POST " + APP_URL_FORMAT.format(self._ip, APPS[app])) 64 | 65 | def stop(self, app): 66 | """ Stop an application.""" 67 | return os.system("curl -X DELETE " + APP_URL_FORMAT.format(self._ip, APPS[app])) 68 | 69 | def current_app(self): 70 | """ Get the current visible app.""" 71 | current_app = None 72 | for app in APPS: 73 | if self.is_visible(app) is True: 74 | current_app = app 75 | return current_app 76 | 77 | def app_list(self): 78 | """ List running apps.""" 79 | apps = [] 80 | for app in APPS: 81 | if self.is_running(app) is True: 82 | apps.append(app) 83 | return apps 84 | -------------------------------------------------------------------------------- /samsungctl/exceptions.py: -------------------------------------------------------------------------------- 1 | class AccessDenied(Exception): 2 | """Connection was denied.""" 3 | pass 4 | 5 | 6 | class ConnectionClosed(Exception): 7 | """Connection was closed.""" 8 | pass 9 | 10 | 11 | class UnhandledResponse(Exception): 12 | """Received unknown response.""" 13 | pass 14 | 15 | 16 | class UnknownMethod(Exception): 17 | """Unknown method.""" 18 | pass 19 | -------------------------------------------------------------------------------- /samsungctl/interactive.py: -------------------------------------------------------------------------------- 1 | import curses 2 | 3 | _wake_on_lan = '44:5C:E9:51:C8:29' 4 | _mappings = [ 5 | ["p", "KEY_POWER", "P", "Power off"], 6 | ["h", "KEY_HOME", "H", "Home"], 7 | ["KEY_UP", "KEY_UP", "Up", "Up"], 8 | ["KEY_DOWN", "KEY_DOWN", "Down", "Down"], 9 | ["KEY_LEFT", "KEY_LEFT", "Left", "Left"], 10 | ["KEY_RIGHT", "KEY_RIGHT", "Right", "Right"], 11 | ["\n", "KEY_ENTER", "Enter", "Enter"], 12 | ["KEY_BACKSPACE", "KEY_RETURN", "Backspace", "Return"], 13 | ["e", "KEY_EXIT", "E", "Exit"], 14 | [" ", "KEY_PLAY", "Space", "Play/Pause"], 15 | ["m", "KEY_MENU", "M", "Menu"], 16 | ["s", "KEY_SOURCE", "S", "Source"], 17 | ["+", "KEY_VOLUP", "+", "Volume Up"], 18 | ["-", "KEY_VOLDOWN", "-", "Volume Down"], 19 | ["*", "KEY_MUTE", "*", "Mute"], 20 | ["s", "KEY_HDMI", "S", "HDMI Source"], 21 | ["i", "KEY_INFO", "I", "Info"], 22 | ["n", "KEY_MORE", "D", "Numbers"], 23 | ] 24 | 25 | 26 | def run(remote): 27 | """Run interactive remote control application.""" 28 | curses.wrapper(_control, remote) 29 | 30 | 31 | def _control(std_scr, remote): 32 | height, width = std_scr.getmaxyx() 33 | 34 | std_scr.addstr("Interactive mode, press 'Q' to exit.\n") 35 | std_scr.addstr("Key mappings:\n") 36 | 37 | column_len = max(len(mapping[2]) for mapping in _mappings) + 1 38 | mappings_dict = {} 39 | for mapping in _mappings: 40 | mappings_dict[mapping[0]] = mapping[1] 41 | 42 | row = std_scr.getyx()[0] + 2 43 | if row < height: 44 | line = " {}= {} ({})\n".format(mapping[2].ljust(column_len), 45 | mapping[3], mapping[1]) 46 | std_scr.addstr(line) 47 | elif row == height: 48 | std_scr.addstr("[Terminal is too small to show all keys]\n") 49 | 50 | running = True 51 | while running: 52 | key = std_scr.getkey() 53 | 54 | if key == "q": 55 | running = False 56 | 57 | if key in mappings_dict: 58 | remote.control(mappings_dict[key]) 59 | 60 | try: 61 | std_scr.addstr(".") 62 | except curses.error: 63 | std_scr.deleteln() 64 | std_scr.move(std_scr.getyx()[0], 0) 65 | std_scr.addstr(".") 66 | -------------------------------------------------------------------------------- /samsungctl/remote.py: -------------------------------------------------------------------------------- 1 | from . import exceptions 2 | from .remote_legacy import RemoteLegacy 3 | from .remote_websocket import RemoteWebsocket 4 | 5 | 6 | class Remote: 7 | def __init__(self, config): 8 | if config["method"] == "legacy": 9 | self.remote = RemoteLegacy(config) 10 | elif config["method"] == "websocket": 11 | self.remote = RemoteWebsocket(config) 12 | else: 13 | raise exceptions.UnknownMethod() 14 | 15 | def __enter__(self): 16 | return self.remote.__enter__() 17 | 18 | def __exit__(self, kind, value, traceback): 19 | self.remote.__exit__(type, value, traceback) 20 | 21 | def close(self): 22 | return self.remote.close() 23 | 24 | def control(self, key): 25 | return self.remote.control(key) 26 | 27 | def get_installed_apps(self): 28 | return self.remote.get_installed_apps() 29 | -------------------------------------------------------------------------------- /samsungctl/remote_legacy.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import logging 3 | import socket 4 | import time 5 | 6 | from . import exceptions 7 | 8 | 9 | class RemoteLegacy: 10 | """Object for remote control connection.""" 11 | 12 | def __init__(self, config): 13 | if not config["port"]: 14 | config["port"] = 55000 15 | 16 | """Make a new connection.""" 17 | self.connection = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 18 | 19 | if config["timeout"]: 20 | self.connection.settimeout(config["timeout"]) 21 | 22 | self.connection.connect((config["host"], config["port"])) 23 | 24 | payload = b"\x64\x00" \ 25 | + self._serialize_string(config["description"]) \ 26 | + self._serialize_string(config["id"]) \ 27 | + self._serialize_string(config["name"]) 28 | packet = b"\x00\x00\x00" + self._serialize_string(payload, True) 29 | 30 | logging.info("Sending handshake.") 31 | self.connection.send(packet) 32 | self._read_response(True) 33 | 34 | def __enter__(self): 35 | return self 36 | 37 | def __exit__(self, kind, value, traceback): 38 | self.close() 39 | 40 | def close(self): 41 | """Close the connection.""" 42 | if self.connection: 43 | self.connection.close() 44 | self.connection = None 45 | logging.debug("Connection closed.") 46 | 47 | def control(self, key): 48 | """Send a control command.""" 49 | if not self.connection: 50 | raise exceptions.ConnectionClosed() 51 | 52 | payload = b"\x00\x00\x00" + self._serialize_string(key) 53 | packet = b"\x00\x00\x00" + self._serialize_string(payload, True) 54 | 55 | logging.info("Sending control command: %s", key) 56 | self.connection.send(packet) 57 | self._read_response() 58 | time.sleep(self._key_interval) 59 | 60 | _key_interval = 0.2 61 | 62 | def _read_response(self, first_time=False): 63 | header = self.connection.recv(3) 64 | tv_name_len = int.from_bytes(header[1:3], 65 | byteorder="little") 66 | tv_name = self.connection.recv(tv_name_len) 67 | 68 | if first_time: 69 | logging.debug("Connected to '%s'.", tv_name.decode()) 70 | 71 | response_len = int.from_bytes(self.connection.recv(2), 72 | byteorder="little") 73 | response = self.connection.recv(response_len) 74 | 75 | if len(response) == 0: 76 | self.close() 77 | raise exceptions.ConnectionClosed() 78 | 79 | if response == b"\x64\x00\x01\x00": 80 | logging.debug("Access granted.") 81 | return 82 | elif response == b"\x64\x00\x00\x00": 83 | raise exceptions.AccessDenied() 84 | elif response[0:1] == b"\x0a": 85 | if first_time: 86 | logging.warning("Waiting for authorization...") 87 | return self._read_response() 88 | elif response[0:1] == b"\x65": 89 | logging.warning("Authorization cancelled.") 90 | raise exceptions.AccessDenied() 91 | elif response == b"\x00\x00\x00\x00": 92 | logging.debug("Control accepted.") 93 | return 94 | 95 | raise exceptions.UnhandledResponse(response) 96 | 97 | @staticmethod 98 | def _serialize_string(string, raw=False): 99 | if isinstance(string, str): 100 | string = str.encode(string) 101 | 102 | if not raw: 103 | string = base64.b64encode(string) 104 | 105 | return bytes([len(string)]) + b"\x00" + string 106 | -------------------------------------------------------------------------------- /samsungctl/remote_websocket.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | import logging 4 | import websocket 5 | import time 6 | import os 7 | import ssl 8 | 9 | from . import exceptions 10 | 11 | URL_FORMAT = "ws://{}:{}/api/v2/channels/samsung.remote.control?name={}" 12 | SSL_URL_FORMAT = "wss://{}:{}/api/v2/channels/samsung.remote.control?name={}" 13 | 14 | 15 | class RemoteWebsocket: 16 | """Object for remote control connection.""" 17 | 18 | def __init__(self, config): 19 | self.token_file = os.path.dirname(os.path.realpath(__file__)) + "/token.txt" 20 | 21 | if not config["port"]: 22 | config["port"] = 8001 23 | 24 | if config["timeout"] == 0: 25 | config["timeout"] = None 26 | 27 | if config["port"] == 8001: 28 | url = URL_FORMAT.format(config["host"], config["port"], 29 | self._serialize_string(config["name"])) 30 | 31 | self.connection = websocket.create_connection(url, config["timeout"]) 32 | elif config["port"] == 8002: 33 | url = SSL_URL_FORMAT.format(config["host"], config["port"], 34 | self._serialize_string(config["name"])) 35 | if os.path.isfile(self.token_file): 36 | with open(self.token_file, "r") as token_file: 37 | url += "&token=" + token_file.readline() 38 | self.connection = websocket.create_connection(url, config["timeout"], sslopt={"cert_reqs": ssl.CERT_NONE}) 39 | 40 | self._read_response() 41 | 42 | def __enter__(self): 43 | return self 44 | 45 | def __exit__(self, kind, value, traceback): 46 | self.close() 47 | 48 | def close(self): 49 | """Close the connection.""" 50 | if self.connection: 51 | self.connection.close() 52 | self.connection = None 53 | logging.debug("Connection closed.") 54 | 55 | def control(self, key): 56 | """Send a control command.""" 57 | if not self.connection: 58 | raise exceptions.ConnectionClosed() 59 | 60 | payload = json.dumps({ 61 | "method": "ms.remote.control", 62 | "params": { 63 | "Cmd": "Click", 64 | "DataOfCmd": key, 65 | "Option": "false", 66 | "TypeOfRemote": "SendRemoteKey" 67 | } 68 | }) 69 | 70 | logging.info("Sending control command: %s", key) 71 | self.connection.send(payload) 72 | time.sleep(self._key_interval) 73 | 74 | _key_interval = 0.2 75 | 76 | def _read_response(self): 77 | response = self.connection.recv() 78 | response = json.loads(response) 79 | 80 | if 'data' in response and 'token' in response["data"]: 81 | with open(self.token_file, "w") as token_file: 82 | token_file.write(response['data']["token"]) 83 | 84 | if response["event"] != "ms.channel.connect": 85 | self.close() 86 | raise exceptions.UnhandledResponse(response) 87 | 88 | logging.debug("Access granted.") 89 | 90 | @staticmethod 91 | def _serialize_string(string): 92 | if isinstance(string, str): 93 | string = str.encode(string) 94 | 95 | return base64.b64encode(string).decode("utf-8") 96 | 97 | def get_installed_apps(self): 98 | """Send a control command.""" 99 | if not self.connection: 100 | raise exceptions.ConnectionClosed() 101 | 102 | payload = json.dumps({ 103 | "method": "ms.channel.emit", 104 | "params": { 105 | "data": "", 106 | "event": "ed.installedApp.get", 107 | # "event": "ed.edenApp.get", 108 | "to": "host" 109 | } 110 | }) 111 | 112 | logging.info("Asking for installed apps.") 113 | self.connection.send(payload) 114 | data = json.loads(self.connection.recv()) 115 | installed_apps = [] 116 | for app in data['data']['data']: 117 | installed_apps.append([app['name'], app['appId']]) 118 | return installed_apps 119 | -------------------------------------------------------------------------------- /samsungctl/token.txt: -------------------------------------------------------------------------------- 1 | 10985872 -------------------------------------------------------------------------------- /samsungctl/upnp.py: -------------------------------------------------------------------------------- 1 | import xml.etree.ElementTree as Et 2 | import requests 3 | 4 | 5 | class Upnp: 6 | def __init__(self, config): 7 | self._host = config['host'] 8 | self.mute = False 9 | self.volume = 0 10 | 11 | def soap_request(self, action, arguments, protocole): 12 | headers = {'SOAPAction': '"urn:schemas-upnp-org:service:{protocole}:1#{action}"'.format(action=action, 13 | protocole=protocole), 14 | 'content-type': 'text/xml'} 15 | body = """ 16 | 17 | 18 | 19 | 0 20 | {arguments} 21 | 22 | 23 | """.format(action=action, arguments=arguments, protocole=protocole) 24 | response = None 25 | try: 26 | response = requests.post( 27 | "http://{host}:9197/upnp/control/{protocole}1".format(host=self._host, protocole=protocole), data=body, 28 | headers=headers, timeout=0.2) 29 | response = response.content 30 | except: 31 | pass 32 | return response 33 | 34 | def get_volume(self): 35 | self.volume = 0 36 | response = self.soap_request('GetVolume', "Master", 'RenderingControl') 37 | if response is not None: 38 | volume_xml = response.decode('utf8') 39 | tree = Et.fromstring(volume_xml) 40 | for elem in tree.iter(tag='CurrentVolume'): 41 | self.volume = elem.text 42 | return self.volume 43 | 44 | def set_volume(self, volume): 45 | self.soap_request('SetVolume', "Master{}".format(volume), 46 | 'RenderingControl') 47 | 48 | def get_mute(self): 49 | mute = 0 50 | self.mute = False 51 | response = self.soap_request('GetMute', "Master", 'RenderingControl') 52 | if response is not None: 53 | mute_xml = response.decode('utf8') 54 | tree = Et.fromstring(mute_xml) 55 | for elem in tree.iter(tag='CurrentMute'): 56 | mute = elem.text 57 | if int(mute) == 0: 58 | self.mute = False 59 | else: 60 | self.mute = True 61 | return self.mute 62 | 63 | def set_current_media(self, url): 64 | """ Set media to playback.""" 65 | self.soap_request('SetAVTransportURI', 66 | "{url}".format(url=url), 67 | 'AVTransport') 68 | 69 | def play(self): 70 | """ Play media that was already set as current.""" 71 | self.soap_request('Play', "1", 'AVTransport') 72 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import setuptools 4 | 5 | import samsungctl 6 | 7 | setuptools.setup( 8 | name=samsungctl.__title__, 9 | version=samsungctl.__version__, 10 | description=samsungctl.__doc__, 11 | url=samsungctl.__url__, 12 | author=samsungctl.__author__, 13 | author_email=samsungctl.__author_email__, 14 | license=samsungctl.__license__, 15 | long_description=open("README.md").read(), 16 | entry_points={ 17 | "console_scripts": ["samsungctl=samsungctl.__main__:main"] 18 | }, 19 | packages=["samsungctl"], 20 | install_requires=[], 21 | extras_require={ 22 | "websocket": ["websocket-client"], 23 | "interactive_ui": ["curses"], 24 | }, 25 | classifiers=[ 26 | "Development Status :: 4 - Beta", 27 | "Environment :: Console", 28 | "License :: OSI Approved :: MIT License", 29 | "Programming Language :: Python :: 3", 30 | "Topic :: Home Automation", 31 | ], 32 | ) 33 | --------------------------------------------------------------------------------