├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── aiopylgtv ├── __init__.py ├── buttons.py ├── cal_commands.py ├── constants.py ├── endpoints.py ├── handshake.py ├── lut_tools.py ├── utils.py └── webos_client.py ├── azure-pipelines.yml ├── setup.cfg └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | 61 | #Ipython Notebook 62 | .ipynb_checkpoints 63 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v1.2.3 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-docstring-first 8 | - id: check-yaml 9 | - id: debug-statements 10 | - id: name-tests-test 11 | - repo: https://gitlab.com/pycqa/flake8 12 | rev: 3.7.8 13 | hooks: 14 | - id: flake8 15 | args: 16 | - --max-line-length=500 17 | - --ignore=E203,E266,E501,W503 18 | - --max-complexity=18 19 | - --select=B,C,E,F,W,T4,B9 20 | - repo: https://github.com/ambv/black 21 | rev: 19.3b0 22 | hooks: 23 | - id: black 24 | language_version: python3 25 | - repo: https://github.com/asottile/pyupgrade 26 | rev: v1.25.2 27 | hooks: 28 | - id: pyupgrade 29 | args: ['--py37-plus'] 30 | - repo: https://github.com/pre-commit/mirrors-isort 31 | rev: v4.3.21 32 | hooks: 33 | - id: isort 34 | args: 35 | - --multi-line=3 36 | - --trailing-comma 37 | - --force-grid-wrap=0 38 | - --use-parentheses 39 | - --line-width=88 40 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Dennis Karpienski 4 | Copyright (c) 2019 Josh Bendavid 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 all 14 | 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 THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.txt 2 | include README.md 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aiopylgtv 2 | Library to control webOS based LG Tv devices. 3 | 4 | Based on pylgtv library at https://github.com/TheRealLink/pylgtv which is no longer maintained. 5 | 6 | ## Requirements 7 | - Python >= 3.7 8 | 9 | ## Install 10 | ```bash 11 | pip install aiopylgtv 12 | ``` 13 | 14 | ## Install from Source 15 | Run the following command inside this folder 16 | ```bash 17 | pip install --upgrade . 18 | ``` 19 | 20 | ## Basic Example 21 | 22 | ```python 23 | import asyncio 24 | from aiopylgtv import WebOsClient 25 | 26 | async def runloop(): 27 | client = await WebOsClient.create('192.168.1.53') 28 | await client.connect() 29 | apps = await client.get_apps() 30 | for app in apps: 31 | print(app) 32 | 33 | await client.disconnect() 34 | 35 | asyncio.get_event_loop().run_until_complete(runloop()) 36 | ``` 37 | 38 | ## Subscribed state updates 39 | A callback coroutine can be registered with the client in order to be notified of any state changes. 40 | ```python 41 | import asyncio 42 | from aiopylgtv import WebOsClient 43 | 44 | async def on_state_change(): 45 | print("State changed:") 46 | print(client.current_appId) 47 | print(client.muted) 48 | print(client.volume) 49 | print(client.current_channel) 50 | print(client.apps) 51 | print(client.inputs) 52 | print(client.system_info) 53 | print(client.software_info) 54 | 55 | 56 | async def runloop(): 57 | client = await WebOsClient.create('192.168.1.53') 58 | await client.register_state_update_callback(on_state_change) 59 | 60 | await client.connect() 61 | 62 | print(client.inputs) 63 | ret = await client.set_input("HDMI_3") 64 | print(ret) 65 | 66 | await client.disconnect() 67 | 68 | asyncio.get_event_loop().run_until_complete(runloop()) 69 | ``` 70 | 71 | ## Calibration functionality 72 | WARNING: Messing with the calibration data COULD brick your TV in some circumstances, requiring a mainboard replacement. 73 | All of the currently implemented functions SHOULD be safe, but no guarantees. 74 | 75 | On supported models, calibration functionality and upload to internal LUTs is supported. The supported input formats for LUTs are IRIDAS .cube format for both 1D and 3D LUTs, and ArgyllCMS .cal files for 1D LUTs. 76 | 77 | Not yet supported: 78 | -Dolby Vision config upload 79 | -Custom tone mapping for 2019 models (functionality does not exist on 2018 models) 80 | 81 | Supported models: 82 | LG 2019 Alpha 9 G2 OLED R9 Z9 W9 W9S E9 C9 NanoCell SM99 83 | LG 2019 Alpha 7 G2 NanoCell (8000 & higher model numbers) 84 | LG 2018 Alpha 7 Super UHD LED (8000 & higher model numbers) 85 | LG 2018 Alpha 7 OLED B8 86 | LG 2018 Alpha 9 OLED C8 E8 G8 W8 87 | 88 | Models with Alpha 9 use 33 point 3D LUTs, while those with Alpha 7 use 17 points. 89 | 90 | n.b. this has only been extensively tested for the 2018 Alpha 9 case, so fixes may be needed still for the others. 91 | 92 | WARNING: When running the ddc_reset or uploading LUT data on 2018 models the only way to restore the factory 93 | LUTs and behaviour for a given input mode is to do a factory reset of the TV. 94 | ddc_reset uploads unity 1d and 3d luts and resets oled light/brightness/contrast/color/ to default values (80/50/85/50). 95 | When running the ddc_reset or uploading any 1D LUT data, service menu white balance settings are ignored, and gamma, 96 | colorspace, and white balance settings in the user menu are greyed out and inaccessible. 97 | 98 | Calibration data is specific to each picture mode, and picture modes are independent for SDR, HDR10+HLG, and Dolby Vision. 99 | Picture modes from each of the three groups are only accessible when the TV is in the appropriate mode. Ie to upload 100 | calibration data for HDR10 picture modes, one has to send the TV an HDR10 signal or play an HDR10 file, and similarly 101 | for Dolby Vision. 102 | 103 | For SDR and HDR10 modes there are two 3D LUTs which will be automatically selected depending on the colorspace flags of the signal 104 | or content. In principle almost all SDR content should be bt709 and HDR10 content should be bt2020 but there could be 105 | nonstandard cases where this is not true. 106 | 107 | For Dolby Vision the bt709 3d LUT seems to be active and the only one used. 108 | 109 | Known supported picMode strings are: 110 | SDR: cinema, expert1, expert2, game, technicolorExpert 111 | HDR10(+HLG): hdr_technicolorExpert, hdr_cinema, hdr_game 112 | DV: dolby_cinema_dark, dolby_cinema_bright, dolby_game 113 | 114 | Calibration commands can only be run while in calibration mode (controlled by "start_calibration" and "end_calibration"). 115 | 116 | While in calibration mode for HDR10 tone mapping is bypassed. 117 | There may be other not fully known/understood changes in the image processing pipeline while in calibration mode. 118 | 119 | ```python 120 | import asyncio 121 | from aiopylgtv import WebOsClient 122 | 123 | async def runloop(): 124 | client = await WebOsClient.create('192.168.1.53') 125 | await client.connect() 126 | 127 | await client.set_input("HDMI_2") 128 | await client.start_calibration(picMode="expert1") 129 | await client.ddc_reset(picMode="expert1") 130 | await client.set_oled_light(picMode="expert1", value=26) 131 | await client.set_contrast(picMode="expert1", value=100) 132 | await client.upload_1d_lut_from_file(picMode="expert1", filename="test.cal") 133 | await client.upload_3d_lut_bt709_from_file(picMode="expert1", filename="test3d.cube") 134 | await client.upload_3d_lut_bt2020_from_file(picMode="expert1", filename="test3d.cube") 135 | await client.end_calibration(picMode="expert1") 136 | 137 | await client.disconnect() 138 | 139 | asyncio.run(runloop()) 140 | ``` 141 | 142 | ## Development of `aiopylgtv` 143 | 144 | We use [`pre-commit`](https://pre-commit.com) to keep a consistent code style, so ``pip install pre_commit`` and run 145 | ```bash 146 | pre-commit install 147 | ``` 148 | to install the hooks. 149 | -------------------------------------------------------------------------------- /aiopylgtv/__init__.py: -------------------------------------------------------------------------------- 1 | from .lut_tools import ( 2 | create_dolby_vision_config, 3 | read_cal_file, 4 | read_cube_file, 5 | unity_lut_1d, 6 | unity_lut_3d, 7 | write_dolby_vision_config, 8 | ) 9 | from .webos_client import PyLGTVCmdException, PyLGTVPairException, WebOsClient 10 | 11 | __all__ = [ 12 | "create_dolby_vision_config", 13 | "read_cal_file", 14 | "read_cube_file", 15 | "unity_lut_1d", 16 | "unity_lut_3d", 17 | "write_dolby_vision_config", 18 | "PyLGTVCmdException", 19 | "PyLGTVPairException", 20 | "WebOsClient", 21 | ] 22 | -------------------------------------------------------------------------------- /aiopylgtv/buttons.py: -------------------------------------------------------------------------------- 1 | LEFT = "LEFT" 2 | RIGHT = "RIGHT" 3 | DOWN = "DOWN" 4 | UP = "UP" 5 | HOME = "HOME" 6 | BACK = "BACK" 7 | ENTER = "ENTER" 8 | DASH = "DASH" 9 | INFO = "INFO" 10 | ASTERISK = "ASTERISK" 11 | CC = "CC" 12 | EXIT = "EXIT" 13 | MUTE = "MUTE" 14 | RED = "RED" 15 | GREEN = "GREEN" 16 | BLUE = "BLUE" 17 | VOLUMEUP = "VOLUMEUP" 18 | VOLUMEDOWN = "VOLUMEDOWN" 19 | CHANNELUP = "CHANNELUP" 20 | CHANNELDOWN = "CHANNELDOWN" 21 | PLAY = "PLAY" 22 | PAUSE = "PAUSE" 23 | ZERO = "0" 24 | ONE = "1" 25 | TWO = "2" 26 | THREE = "3" 27 | FOUR = "4" 28 | FIVE = "5" 29 | SIX = "6" 30 | SEVEN = "7" 31 | EIGHT = "8" 32 | NINE = "9" 33 | -------------------------------------------------------------------------------- /aiopylgtv/cal_commands.py: -------------------------------------------------------------------------------- 1 | CAL_START = "CAL_START" 2 | CAL_END = "CAL_END" 3 | UPLOAD_1D_LUT = "1D_DPG_DATA" 4 | UPLOAD_3D_LUT_BT709 = "BT709_3D_LUT_DATA" 5 | UPLOAD_3D_LUT_BT2020 = "BT2020_3D_LUT_DATA" 6 | BRIGHTNESS_UI_DATA = "BRIGHTNESS_UI_DATA" 7 | CONTRAST_UI_DATA = "CONTRAST_UI_DATA" 8 | BACKLIGHT_UI_DATA = "BACKLIGHT_UI_DATA" 9 | COLOR_UI_DATA = "COLOR_UI_DATA" 10 | ENABLE_GAMMA_2_2_TRANSFORM = "1D_2_2_EN" 11 | ENABLE_GAMMA_0_45_TRANSFORM = "1D_0_45_EN" 12 | BT709_3BY3_GAMUT_DATA = "BT709_3BY3_GAMUT_DATA" 13 | BT2020_3BY3_GAMUT_DATA = "BT2020_3BY3_GAMUT_DATA" 14 | DOLBY_CFG_DATA = "DOLBY_CFG_DATA" 15 | SET_TONEMAP_PARAM = "1D_TONEMAP_PARAM" 16 | -------------------------------------------------------------------------------- /aiopylgtv/constants.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | CALIBRATION_TYPE_MAP = { 4 | "uint8": "unsigned char", 5 | "uint16": "unsigned integer16", 6 | "float32": "float", 7 | } 8 | DEFAULT_CAL_DATA = np.array( 9 | [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0044, -0.0453, 1.041], dtype=np.float32 10 | ) 11 | # xr, yr, xg, yg, xb, yb, xw, yw 12 | BT2020_PRIMARIES = (0.708, 0.292, 0.170, 0.797, 0.131, 0.046, 0.3127, 0.3290) 13 | DV_CONFIG_FILENAMES = { 14 | 2018: "DolbyVision_UserDisplayConfiguration.txt", 15 | 2019: "DolbyVision_UserDisplayConfiguration_2019.txt", 16 | } 17 | -------------------------------------------------------------------------------- /aiopylgtv/endpoints.py: -------------------------------------------------------------------------------- 1 | GET_SERVICES = "api/getServiceList" 2 | SET_MUTE = "audio/setMute" 3 | GET_AUDIO_STATUS = "audio/getStatus" 4 | GET_VOLUME = "audio/getVolume" 5 | SET_VOLUME = "audio/setVolume" 6 | VOLUME_UP = "audio/volumeUp" 7 | VOLUME_DOWN = "audio/volumeDown" 8 | GET_CURRENT_APP_INFO = "com.webos.applicationManager/getForegroundAppInfo" 9 | LAUNCH_APP = "com.webos.applicationManager/launch" 10 | GET_APPS = "com.webos.applicationManager/listLaunchPoints" 11 | GET_APP_STATUS = "com.webos.service.appstatus/getAppStatus" 12 | SEND_ENTER = "com.webos.service.ime/sendEnterKey" 13 | SEND_DELETE = "com.webos.service.ime/deleteCharacters" 14 | INSERT_TEXT = "com.webos.service.ime/insertText" 15 | SET_3D_ON = "com.webos.service.tv.display/set3DOn" 16 | SET_3D_OFF = "com.webos.service.tv.display/set3DOff" 17 | GET_SOFTWARE_INFO = "com.webos.service.update/getCurrentSWInformation" 18 | MEDIA_PLAY = "media.controls/play" 19 | MEDIA_STOP = "media.controls/stop" 20 | MEDIA_PAUSE = "media.controls/pause" 21 | MEDIA_REWIND = "media.controls/rewind" 22 | MEDIA_FAST_FORWARD = "media.controls/fastForward" 23 | MEDIA_CLOSE = "media.viewer/close" 24 | POWER_OFF = "system/turnOff" 25 | POWER_ON = "system/turnOn" 26 | SHOW_MESSAGE = "system.notifications/createToast" 27 | CLOSE_TOAST = "system.notifications/closeToast" 28 | CREATE_ALERT = "system.notifications/createAlert" 29 | CLOSE_ALERT = "system.notifications/closeAlert" 30 | LAUNCHER_CLOSE = "system.launcher/close" 31 | GET_APP_STATE = "system.launcher/getAppState" 32 | GET_SYSTEM_INFO = "system/getSystemInfo" 33 | LAUNCH = "system.launcher/launch" 34 | OPEN = "system.launcher/open" 35 | GET_SYSTEM_SETTINGS = "settings/getSystemSettings" 36 | TV_CHANNEL_DOWN = "tv/channelDown" 37 | TV_CHANNEL_UP = "tv/channelUp" 38 | GET_TV_CHANNELS = "tv/getChannelList" 39 | GET_CHANNEL_INFO = "tv/getChannelProgramInfo" 40 | GET_CURRENT_CHANNEL = "tv/getCurrentChannel" 41 | GET_INPUTS = "tv/getExternalInputList" 42 | SET_CHANNEL = "tv/openChannel" 43 | SET_INPUT = "tv/switchInput" 44 | CLOSE_WEB_APP = "webapp/closeWebApp" 45 | INPUT_SOCKET = "com.webos.service.networkinput/getPointerInputSocket" 46 | CALIBRATION = "externalpq/setExternalPqData" 47 | GET_CALIBRATION = "externalpq/getExternalPqData" 48 | GET_SOUND_OUTPUT = "com.webos.service.apiadapter/audio/getSoundOutput" 49 | CHANGE_SOUND_OUTPUT = "com.webos.service.apiadapter/audio/changeSoundOutput" 50 | GET_POWER_STATE = "com.webos.service.tvpower/power/getPowerState" 51 | TURN_OFF_SCREEN = "com.webos.service.tvpower/power/turnOffScreen" 52 | TURN_ON_SCREEN = "com.webos.service.tvpower/power/turnOnScreen" 53 | -------------------------------------------------------------------------------- /aiopylgtv/handshake.py: -------------------------------------------------------------------------------- 1 | SIGNATURE = ( 2 | "eyJhbGdvcml0aG0iOiJSU0EtU0hBMjU2Iiwia2V5SWQiOiJ0ZXN0LXNpZ25pbm" 3 | "ctY2VydCIsInNpZ25hdHVyZVZlcnNpb24iOjF9.hrVRgjCwXVvE2OOSpDZ58hR" 4 | "+59aFNwYDyjQgKk3auukd7pcegmE2CzPCa0bJ0ZsRAcKkCTJrWo5iDzNhMBWRy" 5 | "aMOv5zWSrthlf7G128qvIlpMT0YNY+n/FaOHE73uLrS/g7swl3/qH/BGFG2Hu4" 6 | "RlL48eb3lLKqTt2xKHdCs6Cd4RMfJPYnzgvI4BNrFUKsjkcu+WD4OO2A27Pq1n" 7 | "50cMchmcaXadJhGrOqH5YmHdOCj5NSHzJYrsW0HPlpuAx/ECMeIZYDh6RMqaFM" 8 | "2DXzdKX9NmmyqzJ3o/0lkk/N97gfVRLW5hA29yeAwaCViZNCP8iC9aO0q9fQoj" 9 | "oa7NQnAtw==" 10 | ) 11 | 12 | REGISTRATION_PAYLOAD = { 13 | "forcePairing": False, 14 | "manifest": { 15 | "appVersion": "1.1", 16 | "manifestVersion": 1, 17 | "permissions": [ 18 | "LAUNCH", 19 | "LAUNCH_WEBAPP", 20 | "APP_TO_APP", 21 | "CLOSE", 22 | "TEST_OPEN", 23 | "TEST_PROTECTED", 24 | "CONTROL_AUDIO", 25 | "CONTROL_DISPLAY", 26 | "CONTROL_INPUT_JOYSTICK", 27 | "CONTROL_INPUT_MEDIA_RECORDING", 28 | "CONTROL_INPUT_MEDIA_PLAYBACK", 29 | "CONTROL_INPUT_TV", 30 | "CONTROL_POWER", 31 | "CONTROL_TV_SCREEN", 32 | "READ_APP_STATUS", 33 | "READ_CURRENT_CHANNEL", 34 | "READ_INPUT_DEVICE_LIST", 35 | "READ_NETWORK_STATE", 36 | "READ_RUNNING_APPS", 37 | "READ_TV_CHANNEL_LIST", 38 | "WRITE_NOTIFICATION_TOAST", 39 | "READ_POWER_STATE", 40 | "READ_COUNTRY_INFO", 41 | "CONTROL_INPUT_TEXT", 42 | "CONTROL_MOUSE_AND_KEYBOARD", 43 | "READ_INSTALLED_APPS", 44 | "READ_SETTINGS", 45 | ], 46 | "signatures": [{"signature": SIGNATURE, "signatureVersion": 1}], 47 | "signed": { 48 | "appId": "com.lge.test", 49 | "created": "20140509", 50 | "localizedAppNames": { 51 | "": "LG Remote App", 52 | "ko-KR": "리모컨 앱", 53 | "zxx-XX": "ЛГ Rэмotэ AПП", 54 | }, 55 | "localizedVendorNames": {"": "LG Electronics"}, 56 | "permissions": [ 57 | "TEST_SECURE", 58 | "CONTROL_INPUT_TEXT", 59 | "CONTROL_MOUSE_AND_KEYBOARD", 60 | "READ_INSTALLED_APPS", 61 | "READ_LGE_SDX", 62 | "READ_NOTIFICATIONS", 63 | "SEARCH", 64 | "WRITE_SETTINGS", 65 | "WRITE_NOTIFICATION_ALERT", 66 | "CONTROL_POWER", 67 | "READ_CURRENT_CHANNEL", 68 | "READ_RUNNING_APPS", 69 | "READ_UPDATE_INFO", 70 | "UPDATE_FROM_REMOTE_APP", 71 | "READ_LGE_TV_INPUT_EVENTS", 72 | "READ_TV_CURRENT_TIME", 73 | ], 74 | "serial": "2f930e2d2cfe083771f68e4fe7bb07", 75 | "vendorId": "com.lge", 76 | }, 77 | }, 78 | "pairingType": "PROMPT", 79 | } 80 | 81 | 82 | REGISTRATION_MESSAGE = { 83 | "type": "register", 84 | "id": "register_0", 85 | "payload": REGISTRATION_PAYLOAD, 86 | } 87 | -------------------------------------------------------------------------------- /aiopylgtv/lut_tools.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from .constants import BT2020_PRIMARIES, DV_CONFIG_FILENAMES 4 | 5 | 6 | def unity_lut_1d(): 7 | lutmono = np.linspace(0.0, 32767.0, 1024, dtype=np.float64) 8 | lut = np.stack([lutmono] * 3, axis=0) 9 | lut = np.rint(lut).astype(np.uint16) 10 | return lut 11 | 12 | 13 | def unity_lut_3d(n=33): 14 | spacing = complex(0, n) 15 | endpoint = 4096.0 16 | lut = np.mgrid[0.0:endpoint:spacing, 0.0:endpoint:spacing, 0.0:endpoint:spacing] 17 | lut = np.rint(lut).astype(np.uint16) 18 | lut = np.clip(lut, 0, 4095) 19 | lut = np.transpose(lut, axes=(1, 2, 3, 0)) 20 | lut = np.flip(lut, axis=-1) 21 | return lut 22 | 23 | 24 | def read_cube_file(filename): # noqa: C901 25 | nheader = 0 26 | lut_1d_size = None 27 | lut_3d_size = None 28 | domain_min = None 29 | domain_max = None 30 | 31 | with open(filename) as f: 32 | lines = f.readlines() 33 | 34 | def domain_check(line, which): 35 | domain_limit = np.genfromtxt([line], usecols=(1, 2, 3), dtype=np.float64) 36 | if domain_limit.shape != (3,): 37 | raise ValueError(f"DOMAIN_{which} must provide exactly 3 values.") 38 | if np.amin(domain_limit) < -1e37 or np.amax(domain_limit) > 1e37: 39 | raise ValueError( 40 | f"Invalid value in DOMAIN_{which}, must be in range [-1e37,1e37]." 41 | ) 42 | return domain_limit 43 | 44 | def lut_size(splitline, dim): 45 | lut_size = int(splitline[1]) 46 | upper_limit = {1: 65536, 3: 256}[dim] 47 | if lut_size < 2 or lut_size > upper_limit: 48 | raise ValueError( 49 | f"Invalid value {lut_size} for LUT_{dim}D_SIZE," 50 | f" must be in range [2, {upper_limit}]." 51 | ) 52 | return lut_size 53 | 54 | for line in lines: 55 | icomment = line.find("#") 56 | if icomment >= 0: 57 | line = line[:icomment] 58 | 59 | splitline = line.split() 60 | if splitline: 61 | keyword = splitline[0] 62 | else: 63 | keyword = None 64 | 65 | if keyword is None: 66 | pass 67 | elif keyword == "TITLE": 68 | pass 69 | elif keyword == "LUT_1D_SIZE": 70 | lut_1d_size = lut_size(splitline, dim=1) 71 | elif keyword == "LUT_3D_SIZE": 72 | lut_3d_size = lut_size(splitline, dim=3) 73 | elif keyword == "DOMAIN_MIN": 74 | domain_min = domain_check(line, "MIN") 75 | elif keyword == "DOMAIN_MAX": 76 | domain_max = domain_check(line, "MAX") 77 | else: 78 | break 79 | 80 | nheader += 1 81 | 82 | if lut_1d_size and lut_3d_size: 83 | raise ValueError("Cannot specify both LUT_1D_SIZE and LUT_3D_SIZE.") 84 | 85 | if not lut_1d_size and not lut_3d_size: 86 | raise ValueError("Must specify one of LUT_1D_SIZE or LUT_3D_SIZE.") 87 | 88 | if domain_min is None: 89 | domain_min = np.zeros((1, 3), dtype=np.float64) 90 | 91 | if domain_max is None: 92 | domain_max = np.ones((1, 3), dtype=np.float64) 93 | 94 | lut = np.genfromtxt(lines[nheader:], comments="#", dtype=np.float64) 95 | if np.amin(lut) < -1e37 or np.amax(lut) > 1e37: 96 | raise ValueError("Invalid value in DOMAIN_MAX, must be in range [-1e37,1e37].") 97 | 98 | # shift and scale lut to range [0.,1.] 99 | lut = (lut - domain_min) / (domain_max - domain_min) 100 | 101 | if lut_1d_size: 102 | if lut.shape != (lut_1d_size, 3): 103 | raise ValueError( 104 | f"Expected shape {(lut_1d_size, 3)} for 1D LUT, but got {lut.shape}." 105 | ) 106 | # convert to integer with appropriate range 107 | lut = np.rint(lut * 32767.0).astype(np.uint16) 108 | # transpose to get the correct element order 109 | lut = np.transpose(lut) 110 | elif lut_3d_size: 111 | if lut.shape != (lut_3d_size ** 3, 3): 112 | raise ValueError( 113 | f"Expected shape {(lut_3d_size**3, 3)} for 3D LUT, but got {lut.shape}." 114 | ) 115 | lut = np.reshape(lut, (lut_3d_size, lut_3d_size, lut_3d_size, 3)) 116 | lut = np.rint(lut * 4096.0).astype(np.uint16) 117 | lut = np.clip(lut, 0, 4095) 118 | return lut 119 | 120 | 121 | def read_cal_file(filename): 122 | with open(filename, "r") as f: 123 | caldata = f.readlines() 124 | 125 | dataidx = caldata.index("BEGIN_DATA\n") 126 | lut_1d_size_in = int(caldata[dataidx - 1].split()[1]) 127 | 128 | lut = np.genfromtxt( 129 | caldata[dataidx + 1 : dataidx + 1 + lut_1d_size_in], dtype=np.float64 130 | ) 131 | 132 | if lut.shape != (lut_1d_size_in, 4): 133 | raise ValueError( 134 | f"Expected shape {(lut_1d_size_in,3)} for 1D LUT, but got {lut.shape}." 135 | ) 136 | 137 | lut_1d_size = 1024 138 | 139 | # interpolate if necessary 140 | if lut_1d_size_in != lut_1d_size: 141 | x = np.linspace(0.0, 1.0, lut_1d_size, dtype=np.float64) 142 | lutcomponents = [np.interp(x, lut[:, 0], lut[:, i]) for i in range(1, 4)] 143 | lut = np.stack(lutcomponents, axis=-1) 144 | else: 145 | lut = lut[:, 1:] 146 | 147 | # convert to integer with appropriate range 148 | lut = np.rint(32767.0 * lut).astype(np.uint16) 149 | # transpose to get the correct element order 150 | lut = np.transpose(lut) 151 | 152 | return lut 153 | 154 | 155 | def lms2rgb_matrix(primaries=BT2020_PRIMARIES): 156 | 157 | xy = np.array(primaries, dtype=np.float64) 158 | 159 | xy = np.resize(xy, (4, 2)) 160 | y = xy[:, 1] 161 | y = np.reshape(y, (4, 1)) 162 | zcol = 1.0 - np.sum(xy, axis=-1, keepdims=True) 163 | xyz = np.concatenate((xy, zcol), axis=-1) 164 | XYZ = xyz / y 165 | XYZ = np.transpose(XYZ) 166 | 167 | XYZrgb = XYZ[:, :3] 168 | XYZw = XYZ[:, 3:4] 169 | 170 | s = np.matmul(np.linalg.inv(XYZrgb), XYZw) 171 | s = np.reshape(s, (1, 3)) 172 | 173 | rgb2xyz = s * XYZrgb 174 | xyz2rgb = np.linalg.inv(rgb2xyz) 175 | 176 | # normalized to d65 177 | xyz2lms = np.array( 178 | [[0.4002, 0.7076, -0.0808], [-0.2263, 1.1653, 0.0457], [0.0, 0.0, 0.9182]], 179 | dtype=np.float64, 180 | ) 181 | lms2xyz = np.linalg.inv(xyz2lms) 182 | 183 | lms2rgb = np.matmul(xyz2rgb, lms2xyz) 184 | 185 | return lms2rgb 186 | 187 | 188 | def create_dolby_vision_config( 189 | version=2019, 190 | white_level=700.0, 191 | black_level=0.0, 192 | gamma=2.2, 193 | primaries=BT2020_PRIMARIES, 194 | ): 195 | 196 | if not (white_level >= 100.0 and white_level <= 999.0): 197 | raise ValueError( 198 | f"Invalid white_level {white_level}, must be between 100. and 999." 199 | ) 200 | if not (black_level >= 0.0 and black_level <= 0.99): 201 | raise ValueError( 202 | f"Invalid black level {black_level}, must be between 0. and 0.99" 203 | ) 204 | if not (gamma > 0.0 and gamma < 9.9): 205 | raise ValueError(f"Invalid gamma {gamma}, must be between 0. and 9.9") 206 | for value in primaries: 207 | if not (value >= 0.0 and value <= 1.0): 208 | raise ValueError( 209 | f"Invalid primary value {value}, must be between 0. and 1." 210 | ) 211 | 212 | xr, yr, xg, yg, xb, yb, xw, yw = primaries 213 | 214 | if version == 2018: 215 | lms2rgb = lms2rgb_matrix(primaries) 216 | tlms2rgb = np.reshape(lms2rgb, [9]) 217 | 218 | config = f"""# Dolby Vision User Display Configuration File 219 | # Generated by aiopylgtv 220 | # Display: Unspecified 221 | # DM Version:\x20 222 | PictureMode = 2 223 | Tmax = {white_level:#.15g} 224 | Tmin = {black_level:#.15g} 225 | Tgamma = {gamma:#.2g} 226 | ColorPrimaries = {xr:.4f} {yr:.4f} {xg:.4f} {yg:.4f} {xb:.4f} {yb:.4f} {xw:.4f} {yw:.4f} 227 | TLMS2RGBmat = {tlms2rgb[0]:#.15g} {tlms2rgb[1]:#.15g} {tlms2rgb[2]:#.15g} {tlms2rgb[3]:#.15g} {tlms2rgb[4]:#.15g} {tlms2rgb[5]:#.15g} {tlms2rgb[6]:#.15g} {tlms2rgb[7]:#.15g} {tlms2rgb[8]:#.15g} 228 | """ 229 | 230 | elif version == 2019: 231 | config = f"""# Dolby Vision User Display Configuration File 232 | # Generated by aiopylgtv 233 | # Display: Unspecified 234 | # DM Version:\x20 235 | [PictureMode = 2] 236 | Tmax = {white_level:#.15g} 237 | Tmin = {black_level:#.15g} 238 | Tgamma = {gamma:#.2g} 239 | TPrimaries = {xr:.4f} {yr:.4f} {xg:.4f} {yg:.4f} {xb:.4f} {yb:.4f} {xw:.4f} {yw:.4f} 240 | """ 241 | else: 242 | raise ValueError( 243 | f"Invalid dolby vision config version {version}, valid options are 2018 or 2019" 244 | ) 245 | 246 | config = config.replace("\n", "\r\n") 247 | return config 248 | 249 | 250 | def write_dolby_vision_config( 251 | version=2019, 252 | white_level=700.0, 253 | black_level=0.0, 254 | gamma=2.2, 255 | primaries=BT2020_PRIMARIES, 256 | ): 257 | 258 | config = create_dolby_vision_config( 259 | version=version, 260 | white_level=white_level, 261 | black_level=black_level, 262 | gamma=gamma, 263 | primaries=primaries, 264 | ) 265 | 266 | filename = DV_CONFIG_FILENAMES[version] 267 | 268 | with open(filename, "w") as f: 269 | f.write(config) 270 | -------------------------------------------------------------------------------- /aiopylgtv/utils.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import asyncio 3 | 4 | from aiopylgtv import WebOsClient 5 | 6 | 7 | async def runloop(args): 8 | client = await WebOsClient.create(args.host, timeout_connect=2) 9 | await client.connect() 10 | print(await getattr(client, args.command)(*args.parameters)) 11 | await client.disconnect() 12 | 13 | 14 | def convert_arg(arg): 15 | try: 16 | return int(arg) 17 | except ValueError: 18 | pass 19 | try: 20 | return float(arg) 21 | except ValueError: 22 | pass 23 | if arg.lower() == "true": 24 | return True 25 | elif arg.lower() == "false": 26 | return False 27 | return arg 28 | 29 | 30 | def aiopylgtvcommand(): 31 | parser = argparse.ArgumentParser(description="Send command to LG WebOs TV.") 32 | parser.add_argument( 33 | "host", type=str, help="hostname or ip address of the TV to connect to" 34 | ) 35 | parser.add_argument( 36 | "command", 37 | type=str, 38 | help="command to send to the TV (can be any function of WebOsClient)", 39 | ) 40 | parser.add_argument( 41 | "parameters", 42 | type=convert_arg, 43 | nargs="*", 44 | help="additional parameters to be passed to WebOsClient function call", 45 | ) 46 | 47 | args = parser.parse_args() 48 | 49 | asyncio.run(runloop(args)) 50 | -------------------------------------------------------------------------------- /aiopylgtv/webos_client.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import base64 3 | import copy 4 | import functools 5 | import json 6 | import logging 7 | import os 8 | from datetime import timedelta 9 | 10 | import numpy as np 11 | import websockets 12 | from sqlitedict import SqliteDict 13 | 14 | from . import buttons as btn 15 | from . import cal_commands as cal 16 | from . import endpoints as ep 17 | from .constants import BT2020_PRIMARIES, CALIBRATION_TYPE_MAP, DEFAULT_CAL_DATA 18 | from .handshake import REGISTRATION_MESSAGE 19 | from .lut_tools import ( 20 | create_dolby_vision_config, 21 | read_cal_file, 22 | read_cube_file, 23 | unity_lut_1d, 24 | unity_lut_3d, 25 | ) 26 | 27 | logger = logging.getLogger(__name__) 28 | 29 | 30 | KEY_FILE_NAME = ".aiopylgtv.sqlite" 31 | USER_HOME = "HOME" 32 | 33 | SOUND_OUTPUTS_TO_DELAY_CONSECUTIVE_VOLUME_STEPS = {"external_arc"} 34 | 35 | 36 | class PyLGTVPairException(Exception): 37 | def __init__(self, message): 38 | self.message = message 39 | 40 | 41 | class PyLGTVCmdException(Exception): 42 | def __init__(self, message): 43 | self.message = message 44 | 45 | 46 | class PyLGTVCmdError(PyLGTVCmdException): 47 | def __init__(self, message): 48 | self.message = message 49 | 50 | 51 | class PyLGTVServiceNotFoundError(PyLGTVCmdError): 52 | def __init__(self, message): 53 | self.message = message 54 | 55 | 56 | class WebOsClient: 57 | def __init__( 58 | self, 59 | ip, 60 | key_file_path=None, 61 | timeout_connect=2, 62 | ping_interval=1, 63 | ping_timeout=20, 64 | client_key=None, 65 | volume_step_delay_ms=None, 66 | ): 67 | """Initialize the client.""" 68 | self.ip = ip 69 | self.port = 3000 70 | self.key_file_path = key_file_path 71 | self.client_key = client_key 72 | self.web_socket = None 73 | self.command_count = 0 74 | self.timeout_connect = timeout_connect 75 | self.ping_interval = ping_interval 76 | self.ping_timeout = ping_timeout 77 | self.connect_task = None 78 | self.connect_result = None 79 | self.connection = None 80 | self.input_connection = None 81 | self.callbacks = {} 82 | self.futures = {} 83 | self._power_state = {} 84 | self._current_appId = None 85 | self._muted = None 86 | self._volume = None 87 | self._current_channel = None 88 | self._channel_info = None 89 | self._channels = None 90 | self._apps = {} 91 | self._extinputs = {} 92 | self._system_info = None 93 | self._software_info = None 94 | self._sound_output = None 95 | self._picture_settings = None 96 | self.state_update_callbacks = [] 97 | self.doStateUpdate = False 98 | self._volume_step_lock = asyncio.Lock() 99 | self._volume_step_delay = ( 100 | timedelta(milliseconds=volume_step_delay_ms) 101 | if volume_step_delay_ms is not None 102 | else None 103 | ) 104 | 105 | @classmethod 106 | async def create(cls, *args, **kwargs): 107 | client = cls(*args, **kwargs) 108 | await client.async_init() 109 | return client 110 | 111 | async def async_init(self): 112 | """Load client key from config file if in use.""" 113 | if self.client_key is None: 114 | self.client_key = await asyncio.get_running_loop().run_in_executor( 115 | None, self.read_client_key 116 | ) 117 | 118 | @staticmethod 119 | def _get_key_file_path(): 120 | """Return the key file path.""" 121 | if os.getenv(USER_HOME) is not None and os.access( 122 | os.getenv(USER_HOME), os.W_OK 123 | ): 124 | return os.path.join(os.getenv(USER_HOME), KEY_FILE_NAME) 125 | 126 | return os.path.join(os.getcwd(), KEY_FILE_NAME) 127 | 128 | def read_client_key(self): 129 | """Try to load the client key for the current ip.""" 130 | 131 | if self.key_file_path: 132 | key_file_path = self.key_file_path 133 | else: 134 | key_file_path = self._get_key_file_path() 135 | 136 | logger.debug("load keyfile from %s", key_file_path) 137 | 138 | with SqliteDict(key_file_path) as conf: 139 | return conf.get(self.ip) 140 | 141 | def write_client_key(self): 142 | """Save the current client key.""" 143 | if self.client_key is None: 144 | return 145 | 146 | if self.key_file_path: 147 | key_file_path = self.key_file_path 148 | else: 149 | key_file_path = self._get_key_file_path() 150 | 151 | logger.debug("save keyfile to %s", key_file_path) 152 | 153 | with SqliteDict(key_file_path) as conf: 154 | conf[self.ip] = self.client_key 155 | conf.commit() 156 | 157 | async def connect(self): 158 | if not self.is_connected(): 159 | self.connect_result = asyncio.Future() 160 | self.connect_task = asyncio.create_task( 161 | self.connect_handler(self.connect_result) 162 | ) 163 | return await self.connect_result 164 | 165 | async def disconnect(self): 166 | if self.is_connected(): 167 | self.connect_task.cancel() 168 | try: 169 | await self.connect_task 170 | except asyncio.CancelledError: 171 | pass 172 | 173 | def is_registered(self): 174 | """Paired with the tv.""" 175 | return self.client_key is not None 176 | 177 | def is_connected(self): 178 | return self.connect_task is not None and not self.connect_task.done() 179 | 180 | def registration_msg(self): 181 | handshake = copy.deepcopy(REGISTRATION_MESSAGE) 182 | handshake["payload"]["client-key"] = self.client_key 183 | return handshake 184 | 185 | async def connect_handler(self, res): 186 | 187 | handler_tasks = set() 188 | ws = None 189 | inputws = None 190 | try: 191 | ws = await asyncio.wait_for( 192 | websockets.connect( 193 | f"ws://{self.ip}:{self.port}", 194 | ping_interval=None, 195 | close_timeout=self.timeout_connect, 196 | max_size=None, 197 | ), 198 | timeout=self.timeout_connect, 199 | ) 200 | await ws.send(json.dumps(self.registration_msg())) 201 | raw_response = await ws.recv() 202 | response = json.loads(raw_response) 203 | 204 | if ( 205 | response["type"] == "response" 206 | and response["payload"]["pairingType"] == "PROMPT" 207 | ): 208 | raw_response = await ws.recv() 209 | response = json.loads(raw_response) 210 | if response["type"] == "registered": 211 | self.client_key = response["payload"]["client-key"] 212 | await asyncio.get_running_loop().run_in_executor( 213 | None, self.write_client_key 214 | ) 215 | 216 | if not self.client_key: 217 | raise PyLGTVPairException("Unable to pair") 218 | 219 | self.callbacks = {} 220 | self.futures = {} 221 | 222 | handler_tasks.add( 223 | asyncio.create_task( 224 | self.consumer_handler(ws, self.callbacks, self.futures) 225 | ) 226 | ) 227 | if self.ping_interval is not None: 228 | handler_tasks.add( 229 | asyncio.create_task( 230 | self.ping_handler(ws, self.ping_interval, self.ping_timeout) 231 | ) 232 | ) 233 | self.connection = ws 234 | 235 | # open additional connection needed to send button commands 236 | # the url is dynamically generated and returned from the ep.INPUT_SOCKET 237 | # endpoint on the main connection 238 | sockres = await self.request(ep.INPUT_SOCKET) 239 | inputsockpath = sockres.get("socketPath") 240 | inputws = await asyncio.wait_for( 241 | websockets.connect( 242 | inputsockpath, 243 | ping_interval=None, 244 | close_timeout=self.timeout_connect, 245 | ), 246 | timeout=self.timeout_connect, 247 | ) 248 | 249 | handler_tasks.add(asyncio.create_task(inputws.wait_closed())) 250 | if self.ping_interval is not None: 251 | handler_tasks.add( 252 | asyncio.create_task( 253 | self.ping_handler( 254 | inputws, self.ping_interval, self.ping_timeout 255 | ) 256 | ) 257 | ) 258 | self.input_connection = inputws 259 | 260 | # set static state and subscribe to state updates 261 | # avoid partial updates during initial subscription 262 | 263 | self.doStateUpdate = False 264 | self._system_info, self._software_info = await asyncio.gather( 265 | self.get_system_info(), self.get_software_info() 266 | ) 267 | subscribe_coros = { 268 | self.subscribe_power_state(self.set_power_state), 269 | self.subscribe_current_app(self.set_current_app_state), 270 | self.subscribe_muted(self.set_muted_state), 271 | self.subscribe_volume(self.set_volume_state), 272 | self.subscribe_apps(self.set_apps_state), 273 | self.subscribe_inputs(self.set_inputs_state), 274 | self.subscribe_sound_output(self.set_sound_output_state), 275 | self.subscribe_picture_settings(self.set_picture_settings_state), 276 | } 277 | subscribe_tasks = set() 278 | for coro in subscribe_coros: 279 | subscribe_tasks.add(asyncio.create_task(coro)) 280 | await asyncio.wait(subscribe_tasks) 281 | for task in subscribe_tasks: 282 | try: 283 | task.result() 284 | except PyLGTVServiceNotFoundError: 285 | pass 286 | # set placeholder power state if not available 287 | if not self._power_state: 288 | self._power_state = {"state": "Unknown"} 289 | self.doStateUpdate = True 290 | if self.state_update_callbacks: 291 | await self.do_state_update_callbacks() 292 | 293 | res.set_result(True) 294 | 295 | await asyncio.wait(handler_tasks, return_when=asyncio.FIRST_COMPLETED) 296 | 297 | except Exception as ex: 298 | if not res.done(): 299 | res.set_exception(ex) 300 | finally: 301 | for task in handler_tasks: 302 | if not task.done(): 303 | task.cancel() 304 | 305 | for future in self.futures.values(): 306 | future.cancel() 307 | 308 | closeout = set() 309 | closeout.update(handler_tasks) 310 | 311 | if ws is not None: 312 | closeout.add(asyncio.create_task(ws.close())) 313 | if inputws is not None: 314 | closeout.add(asyncio.create_task(inputws.close())) 315 | 316 | self.connection = None 317 | self.input_connection = None 318 | 319 | self.doStateUpdate = False 320 | 321 | self._power_state = {} 322 | self._current_appId = None 323 | self._muted = None 324 | self._volume = None 325 | self._current_channel = None 326 | self._channel_info = None 327 | self._channels = None 328 | self._apps = {} 329 | self._extinputs = {} 330 | self._system_info = None 331 | self._software_info = None 332 | self._sound_output = None 333 | self._picture_settings = None 334 | 335 | for callback in self.state_update_callbacks: 336 | closeout.add(callback()) 337 | 338 | if closeout: 339 | closeout_task = asyncio.create_task(asyncio.wait(closeout)) 340 | 341 | while not closeout_task.done(): 342 | try: 343 | await asyncio.shield(closeout_task) 344 | except asyncio.CancelledError: 345 | pass 346 | 347 | async def ping_handler(self, ws, interval, timeout): 348 | try: 349 | while True: 350 | await asyncio.sleep(interval) 351 | # In the "Suspend" state the tv can keep a connection alive, but will not respond to pings 352 | if self._power_state.get("state") != "Suspend": 353 | ping_waiter = await ws.ping() 354 | if timeout is not None: 355 | await asyncio.wait_for(ping_waiter, timeout=timeout) 356 | except ( 357 | asyncio.TimeoutError, 358 | asyncio.CancelledError, 359 | websockets.exceptions.ConnectionClosedError, 360 | ): 361 | pass 362 | 363 | async def callback_handler(self, queue, callback, future): 364 | try: 365 | while True: 366 | msg = await queue.get() 367 | payload = msg.get("payload") 368 | await callback(payload) 369 | if future is not None and not future.done(): 370 | future.set_result(msg) 371 | except asyncio.CancelledError: 372 | pass 373 | 374 | async def consumer_handler(self, ws, callbacks={}, futures={}): 375 | 376 | callback_queues = {} 377 | callback_tasks = {} 378 | 379 | try: 380 | async for raw_msg in ws: 381 | if callbacks or futures: 382 | msg = json.loads(raw_msg) 383 | uid = msg.get("id") 384 | callback = self.callbacks.get(uid) 385 | future = self.futures.get(uid) 386 | if callback is not None: 387 | if uid not in callback_tasks: 388 | queue = asyncio.Queue() 389 | callback_queues[uid] = queue 390 | callback_tasks[uid] = asyncio.create_task( 391 | self.callback_handler(queue, callback, future) 392 | ) 393 | callback_queues[uid].put_nowait(msg) 394 | elif future is not None and not future.done(): 395 | self.futures[uid].set_result(msg) 396 | 397 | except (websockets.exceptions.ConnectionClosedError, asyncio.CancelledError): 398 | pass 399 | finally: 400 | for task in callback_tasks.values(): 401 | if not task.done(): 402 | task.cancel() 403 | 404 | tasks = set() 405 | tasks.update(callback_tasks.values()) 406 | 407 | if tasks: 408 | closeout_task = asyncio.create_task(asyncio.wait(tasks)) 409 | 410 | while not closeout_task.done(): 411 | try: 412 | await asyncio.shield(closeout_task) 413 | except asyncio.CancelledError: 414 | pass 415 | 416 | # manage state 417 | @property 418 | def power_state(self): 419 | return self._power_state 420 | 421 | @property 422 | def current_appId(self): 423 | return self._current_appId 424 | 425 | @property 426 | def muted(self): 427 | return self._muted 428 | 429 | @property 430 | def volume(self): 431 | return self._volume 432 | 433 | @property 434 | def current_channel(self): 435 | return self._current_channel 436 | 437 | @property 438 | def channel_info(self): 439 | return self._channel_info 440 | 441 | @property 442 | def channels(self): 443 | return self._channels 444 | 445 | @property 446 | def apps(self): 447 | return self._apps 448 | 449 | @property 450 | def inputs(self): 451 | return self._extinputs 452 | 453 | @property 454 | def system_info(self): 455 | return self._system_info 456 | 457 | @property 458 | def software_info(self): 459 | return self._software_info 460 | 461 | @property 462 | def sound_output(self): 463 | return self._sound_output 464 | 465 | @property 466 | def picture_settings(self): 467 | return self._picture_settings 468 | 469 | @property 470 | def is_on(self): 471 | state = self._power_state.get("state") 472 | if state == "Unknown": 473 | # fallback to current app id for some older webos versions which don't support explicit power state 474 | if self._current_appId in [None, ""]: 475 | return False 476 | else: 477 | return True 478 | elif state in [None, "Power Off", "Suspend", "Active Standby"]: 479 | return False 480 | else: 481 | return True 482 | 483 | @property 484 | def is_screen_on(self): 485 | if self.is_on: 486 | return self._power_state.get("state") != "Screen Off" 487 | return False 488 | 489 | def calibration_support_info(self): 490 | info = { 491 | "lut1d": False, 492 | "lut3d_size": None, 493 | "custom_tone_mapping": False, 494 | "dv_config_type": None, 495 | } 496 | model_name = self._system_info["modelName"] 497 | if model_name.startswith("OLED") and len(model_name) > 7: 498 | model = model_name[6] 499 | year = int(model_name[7]) 500 | if year >= 8: 501 | info["lut1d"] = True 502 | if model == "B": 503 | info["lut3d_size"] = 17 504 | else: 505 | info["lut3d_size"] = 33 506 | if year == 8: 507 | info["dv_config_type"] = 2018 508 | elif year == 9: 509 | info["custom_tone_mapping"] = True 510 | info["dv_config_type"] = 2019 511 | elif len(model_name) > 5: 512 | size = None 513 | try: 514 | size = int(model_name[0:2]) 515 | except ValueError: 516 | pass 517 | if size: 518 | modeltype = model_name[2] 519 | modelyear = model_name[3] 520 | modelseries = model_name[4] 521 | modelnumber = model_name[5] 522 | 523 | if modeltype == "S" and modelyear in ["K", "M"] and modelseries >= 8: 524 | info["lut1d"] = True 525 | if modelseries == 9 and modelnumber == 9: 526 | info["lut3d_size"] = 33 527 | else: 528 | info["lut3d_size"] = 17 529 | if modelyear == "K": 530 | info["dv_config_type"] = 2018 531 | elif modelyear == "M": 532 | info["custom_tone_mapping"] = True 533 | info["dv_config_type"] = 2019 534 | 535 | return info 536 | 537 | async def register_state_update_callback(self, callback): 538 | self.state_update_callbacks.append(callback) 539 | if self.doStateUpdate: 540 | await callback() 541 | 542 | def unregister_state_update_callback(self, callback): 543 | if callback in self.state_update_callbacks: 544 | self.state_update_callbacks.remove(callback) 545 | 546 | def clear_state_update_callbacks(self): 547 | self.state_update_callbacks = [] 548 | 549 | async def do_state_update_callbacks(self): 550 | callbacks = set() 551 | for callback in self.state_update_callbacks: 552 | callbacks.add(callback()) 553 | 554 | if callbacks: 555 | await asyncio.gather(*callbacks) 556 | 557 | async def set_power_state(self, payload): 558 | self._power_state = payload 559 | 560 | if self.state_update_callbacks and self.doStateUpdate: 561 | await self.do_state_update_callbacks() 562 | 563 | async def set_current_app_state(self, appId): 564 | """Set current app state variable. This function also handles subscriptions to current channel and channel list, since the current channel subscription can only succeed when Live TV is running, and the channel list subscription can only succeed after channels have been configured.""" 565 | self._current_appId = appId 566 | 567 | if self._channels is None: 568 | try: 569 | await self.subscribe_channels(self.set_channels_state) 570 | except PyLGTVCmdException: 571 | pass 572 | 573 | if appId == "com.webos.app.livetv" and self._current_channel is None: 574 | try: 575 | await self.subscribe_current_channel(self.set_current_channel_state) 576 | except PyLGTVCmdException: 577 | pass 578 | 579 | if self.state_update_callbacks and self.doStateUpdate: 580 | await self.do_state_update_callbacks() 581 | 582 | async def set_muted_state(self, muted): 583 | self._muted = muted 584 | 585 | if self.state_update_callbacks and self.doStateUpdate: 586 | await self.do_state_update_callbacks() 587 | 588 | async def set_volume_state(self, volume): 589 | self._volume = volume 590 | 591 | if self.state_update_callbacks and self.doStateUpdate: 592 | await self.do_state_update_callbacks() 593 | 594 | async def set_channels_state(self, channels): 595 | self._channels = channels 596 | 597 | if self.state_update_callbacks and self.doStateUpdate: 598 | await self.do_state_update_callbacks() 599 | 600 | async def set_current_channel_state(self, channel): 601 | """Set current channel state variable. This function also handles the channel info subscription, since that call may fail if channel information is not available when it's called.""" 602 | 603 | self._current_channel = channel 604 | 605 | if self._channel_info is None: 606 | try: 607 | await self.subscribe_channel_info(self.set_channel_info_state) 608 | except PyLGTVCmdException: 609 | pass 610 | 611 | if self.state_update_callbacks and self.doStateUpdate: 612 | await self.do_state_update_callbacks() 613 | 614 | async def set_channel_info_state(self, channel_info): 615 | self._channel_info = channel_info 616 | 617 | if self.state_update_callbacks and self.doStateUpdate: 618 | await self.do_state_update_callbacks() 619 | 620 | async def set_apps_state(self, payload): 621 | apps = payload.get("launchPoints") 622 | if apps is not None: 623 | self._apps = {} 624 | for app in apps: 625 | self._apps[app["id"]] = app 626 | else: 627 | change = payload["change"] 628 | app_id = payload["id"] 629 | if change == "removed": 630 | del self._apps[app_id] 631 | else: 632 | self._apps[app_id] = payload 633 | 634 | if self.state_update_callbacks and self.doStateUpdate: 635 | await self.do_state_update_callbacks() 636 | 637 | async def set_inputs_state(self, extinputs): 638 | self._extinputs = {} 639 | for extinput in extinputs: 640 | self._extinputs[extinput["appId"]] = extinput 641 | 642 | if self.state_update_callbacks and self.doStateUpdate: 643 | await self.do_state_update_callbacks() 644 | 645 | async def set_sound_output_state(self, sound_output): 646 | self._sound_output = sound_output 647 | 648 | if self.state_update_callbacks and self.doStateUpdate: 649 | await self.do_state_update_callbacks() 650 | 651 | async def set_picture_settings_state(self, picture_settings): 652 | self._picture_settings = picture_settings 653 | 654 | if self.state_update_callbacks and self.doStateUpdate: 655 | await self.do_state_update_callbacks() 656 | 657 | # low level request handling 658 | 659 | async def command(self, request_type, uri, payload=None, uid=None): 660 | """Build and send a command.""" 661 | if uid is None: 662 | uid = self.command_count 663 | self.command_count += 1 664 | 665 | if payload is None: 666 | payload = {} 667 | 668 | message = { 669 | "id": uid, 670 | "type": request_type, 671 | "uri": f"ssap://{uri}", 672 | "payload": payload, 673 | } 674 | 675 | if self.connection is None: 676 | raise PyLGTVCmdException("Not connected, can't execute command.") 677 | 678 | await self.connection.send(json.dumps(message)) 679 | 680 | async def request(self, uri, payload=None, cmd_type="request", uid=None): 681 | """Send a request and wait for response.""" 682 | if uid is None: 683 | uid = self.command_count 684 | self.command_count += 1 685 | res = asyncio.Future() 686 | self.futures[uid] = res 687 | try: 688 | await self.command(cmd_type, uri, payload, uid) 689 | except (asyncio.CancelledError, PyLGTVCmdException): 690 | del self.futures[uid] 691 | raise 692 | try: 693 | response = await res 694 | except asyncio.CancelledError: 695 | if uid in self.futures: 696 | del self.futures[uid] 697 | raise 698 | del self.futures[uid] 699 | 700 | payload = response.get("payload") 701 | if payload is None: 702 | raise PyLGTVCmdException(f"Invalid request response {response}") 703 | 704 | returnValue = payload.get("returnValue") or payload.get("subscribed") 705 | 706 | if response.get("type") == "error": 707 | error = response.get("error") 708 | if error == "404 no such service or method": 709 | raise PyLGTVServiceNotFoundError(error) 710 | else: 711 | raise PyLGTVCmdError(response) 712 | elif returnValue is None: 713 | raise PyLGTVCmdException(f"Invalid request response {response}") 714 | elif not returnValue: 715 | raise PyLGTVCmdException(f"Request failed with response {response}") 716 | 717 | return payload 718 | 719 | async def subscribe(self, callback, uri, payload=None): 720 | """Subscribe to updates.""" 721 | uid = self.command_count 722 | self.command_count += 1 723 | self.callbacks[uid] = callback 724 | try: 725 | return await self.request( 726 | uri, payload=payload, cmd_type="subscribe", uid=uid 727 | ) 728 | except Exception: 729 | del self.callbacks[uid] 730 | raise 731 | 732 | async def input_command(self, message): 733 | if self.input_connection is None: 734 | raise PyLGTVCmdException("Couldn't execute input command.") 735 | 736 | await self.input_connection.send(message) 737 | 738 | # high level request handling 739 | 740 | async def button(self, name): 741 | """Send button press command.""" 742 | 743 | message = f"type:button\nname:{name}\n\n" 744 | await self.input_command(message) 745 | 746 | async def move(self, dx, dy, down=0): 747 | """Send cursor move command.""" 748 | 749 | message = f"type:move\ndx:{dx}\ndy:{dy}\ndown:{down}\n\n" 750 | await self.input_command(message) 751 | 752 | async def click(self): 753 | """Send cursor click command.""" 754 | 755 | message = f"type:click\n\n" 756 | await self.input_command(message) 757 | 758 | async def scroll(self, dx, dy): 759 | """Send scroll command.""" 760 | 761 | message = f"type:scroll\ndx:{dx}\ndy:{dy}\n\n" 762 | await self.input_command(message) 763 | 764 | async def send_message(self, message, icon_path=None): 765 | """Show a floating message.""" 766 | icon_encoded_string = "" 767 | icon_extension = "" 768 | 769 | if icon_path is not None: 770 | icon_extension = os.path.splitext(icon_path)[1][1:] 771 | with open(icon_path, "rb") as icon_file: 772 | icon_encoded_string = base64.b64encode(icon_file.read()).decode("ascii") 773 | 774 | return await self.request( 775 | ep.SHOW_MESSAGE, 776 | { 777 | "message": message, 778 | "iconData": icon_encoded_string, 779 | "iconExtension": icon_extension, 780 | }, 781 | ) 782 | 783 | async def get_power_state(self): 784 | """Get current power state.""" 785 | return await self.request(ep.GET_POWER_STATE) 786 | 787 | async def subscribe_power_state(self, callback): 788 | """Subscribe to current power state.""" 789 | return await self.subscribe(callback, ep.GET_POWER_STATE) 790 | 791 | # Apps 792 | async def get_apps(self): 793 | """Return all apps.""" 794 | res = await self.request(ep.GET_APPS) 795 | return res.get("launchPoints") 796 | 797 | async def subscribe_apps(self, callback): 798 | """Subscribe to changes in available apps.""" 799 | return await self.subscribe(callback, ep.GET_APPS) 800 | 801 | async def get_current_app(self): 802 | """Get the current app id.""" 803 | res = await self.request(ep.GET_CURRENT_APP_INFO) 804 | return res.get("appId") 805 | 806 | async def subscribe_current_app(self, callback): 807 | """Subscribe to changes in the current app id.""" 808 | 809 | async def current_app(payload): 810 | await callback(payload.get("appId")) 811 | 812 | return await self.subscribe(current_app, ep.GET_CURRENT_APP_INFO) 813 | 814 | async def launch_app(self, app): 815 | """Launch an app.""" 816 | return await self.request(ep.LAUNCH, {"id": app}) 817 | 818 | async def launch_app_with_params(self, app, params): 819 | """Launch an app with parameters.""" 820 | return await self.request(ep.LAUNCH, {"id": app, "params": params}) 821 | 822 | async def launch_app_with_content_id(self, app, contentId): 823 | """Launch an app with contentId.""" 824 | return await self.request(ep.LAUNCH, {"id": app, "contentId": contentId}) 825 | 826 | async def close_app(self, app): 827 | """Close the current app.""" 828 | return await self.request(ep.LAUNCHER_CLOSE, {"id": app}) 829 | 830 | # Services 831 | async def get_services(self): 832 | """Get all services.""" 833 | res = await self.request(ep.GET_SERVICES) 834 | return res.get("services") 835 | 836 | async def get_software_info(self): 837 | """Return the current software status.""" 838 | return await self.request(ep.GET_SOFTWARE_INFO) 839 | 840 | async def get_system_info(self): 841 | """Return the system information.""" 842 | return await self.request(ep.GET_SYSTEM_INFO) 843 | 844 | async def power_off(self): 845 | """Power off TV.""" 846 | 847 | # protect against turning tv back on if it is off 848 | if not self.is_on: 849 | return 850 | 851 | # if tv is shutting down and standby+ option is not enabled, 852 | # response is unreliable, so don't wait for one, 853 | await self.command("request", ep.POWER_OFF) 854 | 855 | async def power_on(self): 856 | """Play media.""" 857 | return await self.request(ep.POWER_ON) 858 | 859 | async def turn_screen_off(self): 860 | """Turn TV Screen off.""" 861 | await self.command("request", ep.TURN_OFF_SCREEN) 862 | 863 | async def turn_screen_on(self): 864 | """Turn TV Screen on.""" 865 | await self.command("request", ep.TURN_ON_SCREEN) 866 | 867 | # 3D Mode 868 | async def turn_3d_on(self): 869 | """Turn 3D on.""" 870 | return await self.request(ep.SET_3D_ON) 871 | 872 | async def turn_3d_off(self): 873 | """Turn 3D off.""" 874 | return await self.request(ep.SET_3D_OFF) 875 | 876 | # Inputs 877 | async def get_inputs(self): 878 | """Get all inputs.""" 879 | res = await self.request(ep.GET_INPUTS) 880 | return res.get("devices") 881 | 882 | async def subscribe_inputs(self, callback): 883 | """Subscribe to changes in available inputs.""" 884 | 885 | async def inputs(payload): 886 | await callback(payload.get("devices")) 887 | 888 | return await self.subscribe(inputs, ep.GET_INPUTS) 889 | 890 | async def get_input(self): 891 | """Get current input.""" 892 | return await self.get_current_app() 893 | 894 | async def set_input(self, input): 895 | """Set the current input.""" 896 | return await self.request(ep.SET_INPUT, {"inputId": input}) 897 | 898 | # Audio 899 | async def get_audio_status(self): 900 | """Get the current audio status""" 901 | return await self.request(ep.GET_AUDIO_STATUS) 902 | 903 | async def get_muted(self): 904 | """Get mute status.""" 905 | status = await self.get_audio_status() 906 | return status.get("mute") 907 | 908 | async def subscribe_muted(self, callback): 909 | """Subscribe to changes in the current mute status.""" 910 | 911 | async def muted(payload): 912 | await callback(payload.get("mute")) 913 | 914 | return await self.subscribe(muted, ep.GET_AUDIO_STATUS) 915 | 916 | async def set_mute(self, mute): 917 | """Set mute.""" 918 | return await self.request(ep.SET_MUTE, {"mute": mute}) 919 | 920 | async def get_volume(self): 921 | """Get the current volume.""" 922 | res = await self.request(ep.GET_VOLUME) 923 | return res.get("volumeStatus", res).get("volume") 924 | 925 | async def subscribe_volume(self, callback): 926 | """Subscribe to changes in the current volume.""" 927 | 928 | async def volume(payload): 929 | await callback(payload.get("volumeStatus", payload).get("volume")) 930 | 931 | return await self.subscribe(volume, ep.GET_VOLUME) 932 | 933 | async def set_volume(self, volume): 934 | """Set volume.""" 935 | volume = max(0, volume) 936 | return await self.request(ep.SET_VOLUME, {"volume": volume}) 937 | 938 | async def volume_up(self): 939 | """Volume up.""" 940 | return await self._volume_step(ep.VOLUME_UP) 941 | 942 | async def volume_down(self): 943 | """Volume down.""" 944 | return await self._volume_step(ep.VOLUME_DOWN) 945 | 946 | async def _volume_step(self, endpoint): 947 | """Volume step and conditionally sleep afterwards if a consecutive volume step shouldn't be possible to perform immediately after.""" 948 | if ( 949 | self.sound_output in SOUND_OUTPUTS_TO_DELAY_CONSECUTIVE_VOLUME_STEPS 950 | and self._volume_step_delay is not None 951 | ): 952 | async with self._volume_step_lock: 953 | response = await self.request(endpoint) 954 | await asyncio.sleep(self._volume_step_delay.total_seconds()) 955 | return response 956 | else: 957 | return await self.request(endpoint) 958 | 959 | # TV Channel 960 | async def channel_up(self): 961 | """Channel up.""" 962 | return await self.request(ep.TV_CHANNEL_UP) 963 | 964 | async def channel_down(self): 965 | """Channel down.""" 966 | return await self.request(ep.TV_CHANNEL_DOWN) 967 | 968 | async def get_channels(self): 969 | """Get list of tv channels.""" 970 | res = await self.request(ep.GET_TV_CHANNELS) 971 | return res.get("channelList") 972 | 973 | async def subscribe_channels(self, callback): 974 | """Subscribe to list of tv channels.""" 975 | 976 | async def channels(payload): 977 | await callback(payload.get("channelList")) 978 | 979 | return await self.subscribe(channels, ep.GET_TV_CHANNELS) 980 | 981 | async def get_current_channel(self): 982 | """Get the current tv channel.""" 983 | return await self.request(ep.GET_CURRENT_CHANNEL) 984 | 985 | async def subscribe_current_channel(self, callback): 986 | """Subscribe to changes in the current tv channel.""" 987 | return await self.subscribe(callback, ep.GET_CURRENT_CHANNEL) 988 | 989 | async def get_channel_info(self): 990 | """Get the current channel info.""" 991 | return await self.request(ep.GET_CHANNEL_INFO) 992 | 993 | async def subscribe_channel_info(self, callback): 994 | """Subscribe to current channel info.""" 995 | return await self.subscribe(callback, ep.GET_CHANNEL_INFO) 996 | 997 | async def set_channel(self, channel): 998 | """Set the current channel.""" 999 | return await self.request(ep.SET_CHANNEL, {"channelId": channel}) 1000 | 1001 | async def get_sound_output(self): 1002 | """Get the current audio output.""" 1003 | res = await self.request(ep.GET_SOUND_OUTPUT) 1004 | return res.get("soundOutput") 1005 | 1006 | async def subscribe_sound_output(self, callback): 1007 | """Subscribe to changes in current audio output.""" 1008 | 1009 | async def sound_output(payload): 1010 | await callback(payload.get("soundOutput")) 1011 | 1012 | return await self.subscribe(sound_output, ep.GET_SOUND_OUTPUT) 1013 | 1014 | async def change_sound_output(self, output): 1015 | """Change current audio output.""" 1016 | return await self.request(ep.CHANGE_SOUND_OUTPUT, {"output": output}) 1017 | 1018 | # Media control 1019 | async def play(self): 1020 | """Play media.""" 1021 | return await self.request(ep.MEDIA_PLAY) 1022 | 1023 | async def pause(self): 1024 | """Pause media.""" 1025 | return await self.request(ep.MEDIA_PAUSE) 1026 | 1027 | async def stop(self): 1028 | """Stop media.""" 1029 | return await self.request(ep.MEDIA_STOP) 1030 | 1031 | async def close(self): 1032 | """Close media.""" 1033 | return await self.request(ep.MEDIA_CLOSE) 1034 | 1035 | async def rewind(self): 1036 | """Rewind media.""" 1037 | return await self.request(ep.MEDIA_REWIND) 1038 | 1039 | async def fast_forward(self): 1040 | """Fast Forward media.""" 1041 | return await self.request(ep.MEDIA_FAST_FORWARD) 1042 | 1043 | # Keys 1044 | async def send_enter_key(self): 1045 | """Send enter key.""" 1046 | return await self.request(ep.SEND_ENTER) 1047 | 1048 | async def send_delete_key(self): 1049 | """Send delete key.""" 1050 | return await self.request(ep.SEND_DELETE) 1051 | 1052 | # Text entry 1053 | async def insert_text(self, text, replace=False): 1054 | """Insert text into field, optionally replace existing text.""" 1055 | return await self.request(ep.INSERT_TEXT, {"text": text, "replace": replace}) 1056 | 1057 | # Web 1058 | async def open_url(self, url): 1059 | """Open URL.""" 1060 | return await self.request(ep.OPEN, {"target": url}) 1061 | 1062 | async def close_web(self): 1063 | """Close web app.""" 1064 | return await self.request(ep.CLOSE_WEB_APP) 1065 | 1066 | # Emulated button presses 1067 | async def left_button(self): 1068 | """left button press.""" 1069 | await self.button(btn.LEFT) 1070 | 1071 | async def right_button(self): 1072 | """right button press.""" 1073 | await self.button(btn.RIGHT) 1074 | 1075 | async def down_button(self): 1076 | """down button press.""" 1077 | await self.button(btn.DOWN) 1078 | 1079 | async def up_button(self): 1080 | """up button press.""" 1081 | await self.button(btn.UP) 1082 | 1083 | async def home_button(self): 1084 | """home button press.""" 1085 | await self.button(btn.HOME) 1086 | 1087 | async def back_button(self): 1088 | """back button press.""" 1089 | await self.button(btn.BACK) 1090 | 1091 | async def ok_button(self): 1092 | """ok button press.""" 1093 | await self.button(btn.ENTER) 1094 | 1095 | async def dash_button(self): 1096 | """dash button press.""" 1097 | await self.button(btn.DASH) 1098 | 1099 | async def info_button(self): 1100 | """info button press.""" 1101 | await self.button(btn.INFO) 1102 | 1103 | async def asterisk_button(self): 1104 | """asterisk button press.""" 1105 | await self.button(btn.ASTERISK) 1106 | 1107 | async def cc_button(self): 1108 | """cc button press.""" 1109 | await self.button(btn.CC) 1110 | 1111 | async def exit_button(self): 1112 | """exit button press.""" 1113 | await self.button(btn.EXIT) 1114 | 1115 | async def mute_button(self): 1116 | """mute button press.""" 1117 | await self.button(btn.MUTE) 1118 | 1119 | async def red_button(self): 1120 | """red button press.""" 1121 | await self.button(btn.RED) 1122 | 1123 | async def green_button(self): 1124 | """green button press.""" 1125 | await self.button(btn.GREEN) 1126 | 1127 | async def blue_button(self): 1128 | """blue button press.""" 1129 | await self.button(btn.BLUE) 1130 | 1131 | async def volume_up_button(self): 1132 | """volume up button press.""" 1133 | await self.button(btn.VOLUMEUP) 1134 | 1135 | async def volume_down_button(self): 1136 | """volume down button press.""" 1137 | await self.button(btn.VOLUMEDOWN) 1138 | 1139 | async def channel_up_button(self): 1140 | """channel up button press.""" 1141 | await self.button(btn.CHANNELUP) 1142 | 1143 | async def channel_down_button(self): 1144 | """channel down button press.""" 1145 | await self.button(btn.CHANNELDOWN) 1146 | 1147 | async def play_button(self): 1148 | """play button press.""" 1149 | await self.button(btn.PLAY) 1150 | 1151 | async def pause_button(self): 1152 | """pause button press.""" 1153 | await self.button(btn.PAUSE) 1154 | 1155 | async def number_button(self, num): 1156 | """numeric button press.""" 1157 | if not (num >= 0 and num <= 9): 1158 | raise ValueError 1159 | 1160 | await self.button(f"""{num}""") 1161 | 1162 | async def luna_request(self, uri, params): 1163 | """luna api call.""" 1164 | # n.b. this is a hack which abuses the alert API 1165 | # to call the internal luna API which is otherwise 1166 | # not exposed through the websocket interface 1167 | # An important limitation is that any returned 1168 | # data is not accessible 1169 | 1170 | # set desired action for click, fail and close 1171 | # for redundancy/robustness 1172 | 1173 | lunauri = f"luna://{uri}" 1174 | 1175 | buttons = [{"label": "", "onClick": lunauri, "params": params}] 1176 | payload = { 1177 | "message": " ", 1178 | "buttons": buttons, 1179 | "onclose": {"uri": lunauri, "params": params}, 1180 | "onfail": {"uri": lunauri, "params": params}, 1181 | } 1182 | 1183 | ret = await self.request(ep.CREATE_ALERT, payload) 1184 | alertId = ret.get("alertId") 1185 | if alertId is None: 1186 | raise PyLGTVCmdException("Invalid alertId") 1187 | 1188 | return await self.request(ep.CLOSE_ALERT, payload={"alertId": alertId}) 1189 | 1190 | async def set_current_picture_mode(self, pic_mode): 1191 | """Set picture mode for current input, dynamic range and 3d mode. 1192 | 1193 | Known picture modes are: cinema, eco, expert1, expert2, game, 1194 | normal, photo, sports, technicolor, vivid, hdrEffect, hdrCinema, 1195 | hdrCinemaBright, hdrExternal, hdrGame, hdrStandard, hdrTechnicolor, 1196 | hdrVivid, dolbyHdrCinema, dolbyHdrCinemaBright, dolbyHdrDarkAmazon, 1197 | dolbyHdrGame, dolbyHdrStandard, dolbyHdrVivid, dolbyStandard 1198 | 1199 | Likely not all modes are valid for all tv models. 1200 | """ 1201 | 1202 | uri = "com.webos.settingsservice/setSystemSettings" 1203 | 1204 | params = {"category": "picture", "settings": {"pictureMode": pic_mode}} 1205 | 1206 | return await self.luna_request(uri, params) 1207 | 1208 | async def set_picture_mode( 1209 | self, pic_mode, tv_input, dynamic_range="sdr", stereoscopic="2d" 1210 | ): 1211 | """Set picture mode for specific input, dynamic range and 3d mode. 1212 | 1213 | Known picture modes are: cinema, eco, expert1, expert2, game, 1214 | normal, photo, sports, technicolor, vivid, hdrEffect, hdrCinema, 1215 | hdrCinemaBright, hdrExternal, hdrGame, hdrStandard, hdrTechnicolor, 1216 | hdrVivid, dolbyHdrCinema, dolbyHdrCinemaBright, dolbyHdrDarkAmazon, 1217 | dolbyHdrGame, dolbyHdrStandard, dolbyHdrVivid, dolbyStandard 1218 | 1219 | Known inputs are: atv, av1, av2, camera, comp1, comp2, comp3, 1220 | default, dtv, gallery, hdmi1, hdmi2, hdmi3, hdmi4, 1221 | hdmi1_pc, hdmi2_pc, hdmi3_pc, hdmi4_pc, ip, movie, 1222 | photo, pictest, rgb, scart, smhl 1223 | 1224 | Known dynamic range modes are: sdr, hdr, technicolorHdr, dolbyHdr 1225 | 1226 | Known stereoscopic modes are: 2d, 3d 1227 | 1228 | Likely not all inputs and modes are valid for all tv models. 1229 | """ 1230 | 1231 | uri = "com.webos.settingsservice/setSystemSettings" 1232 | 1233 | params = { 1234 | "category": f"picture${tv_input}.x.{stereoscopic}.{dynamic_range}", 1235 | "settings": {"pictureMode": pic_mode}, 1236 | } 1237 | 1238 | return await self.luna_request(uri, params) 1239 | 1240 | async def set_current_picture_settings(self, settings): 1241 | """Set picture settings for current picture mode, input, dynamic range and 3d mode. 1242 | 1243 | A possible list of settings and example values are below (not all settings are applicable 1244 | for all modes and/or tv models): 1245 | 1246 | "adjustingLuminance": [ 1247 | 0, 1248 | 0, 1249 | 0, 1250 | 0, 1251 | 0, 1252 | 0, 1253 | 0, 1254 | 0, 1255 | 0, 1256 | 0, 1257 | 0, 1258 | 0, 1259 | 0, 1260 | 0, 1261 | 0, 1262 | 0, 1263 | 0, 1264 | 0, 1265 | 0, 1266 | 0 1267 | ], 1268 | "backlight": "80", 1269 | "blackLevel": { 1270 | "ntsc": "auto", 1271 | "ntsc443": "auto", 1272 | "pal": "auto", 1273 | "pal60": "auto", 1274 | "palm": "auto", 1275 | "paln": "auto", 1276 | "secam": "auto", 1277 | "unknown": "auto" 1278 | }, 1279 | "brightness": "50", 1280 | "color": "50", 1281 | "colorFilter": "off", 1282 | "colorGamut": "auto", 1283 | "colorManagementColorSystem": "red", 1284 | "colorManagementHueBlue": "0", 1285 | "colorManagementHueCyan": "0", 1286 | "colorManagementHueGreen": "0", 1287 | "colorManagementHueMagenta": "0", 1288 | "colorManagementHueRed": "0", 1289 | "colorManagementHueYellow": "0", 1290 | "colorManagementLuminanceBlue": "0", 1291 | "colorManagementLuminanceCyan": "0", 1292 | "colorManagementLuminanceGreen": "0", 1293 | "colorManagementLuminanceMagenta": "0", 1294 | "colorManagementLuminanceRed": "0", 1295 | "colorManagementLuminanceYellow": "0", 1296 | "colorManagementSaturationBlue": "0", 1297 | "colorManagementSaturationCyan": "0", 1298 | "colorManagementSaturationGreen": "0", 1299 | "colorManagementSaturationMagenta": "0", 1300 | "colorManagementSaturationRed": "0", 1301 | "colorManagementSaturationYellow": "0", 1302 | "colorTemperature": "0", 1303 | "contrast": "80", 1304 | "dynamicColor": "off", 1305 | "dynamicContrast": "off", 1306 | "edgeEnhancer": "on", 1307 | "expertPattern": "off", 1308 | "externalPqlDbType": "none", 1309 | "gamma": "high2", 1310 | "grassColor": "0", 1311 | "hPosition": "0", 1312 | "hSharpness": "10", 1313 | "hSize": "0", 1314 | "hdrDynamicToneMapping": "on", 1315 | "hdrLevel": "medium", 1316 | "localDimming": "medium", 1317 | "motionEyeCare": "off", 1318 | "motionPro": "off", 1319 | "mpegNoiseReduction": "off", 1320 | "noiseReduction": "off", 1321 | "realCinema": "on", 1322 | "sharpness": "10", 1323 | "skinColor": "0", 1324 | "skyColor": "0", 1325 | "superResolution": "off", 1326 | "tint": "0", 1327 | "truMotionBlur": "10", 1328 | "truMotionJudder": "0", 1329 | "truMotionMode": "user", 1330 | "vPosition": "0", 1331 | "vSharpness": "10", 1332 | "vSize": "0", 1333 | "whiteBalanceApplyAllInputs": "off", 1334 | "whiteBalanceBlue": [ 1335 | 0, 1336 | 0, 1337 | 0, 1338 | 0, 1339 | 0, 1340 | 0, 1341 | 0, 1342 | 0, 1343 | 0, 1344 | 0, 1345 | 0, 1346 | 0, 1347 | 0, 1348 | 0, 1349 | 0, 1350 | 0, 1351 | 0, 1352 | 0, 1353 | 0, 1354 | 0 1355 | ], 1356 | "whiteBalanceBlueGain": "0", 1357 | "whiteBalanceBlueOffset": "0", 1358 | "whiteBalanceCodeValue": "19", 1359 | "whiteBalanceColorTemperature": "warm2", 1360 | "whiteBalanceGreen": [ 1361 | 0, 1362 | 0, 1363 | 0, 1364 | 0, 1365 | 0, 1366 | 0, 1367 | 0, 1368 | 0, 1369 | 0, 1370 | 0, 1371 | 0, 1372 | 0, 1373 | 0, 1374 | 0, 1375 | 0, 1376 | 0, 1377 | 0, 1378 | 0, 1379 | 0, 1380 | 0 1381 | ], 1382 | "whiteBalanceGreenGain": "0", 1383 | "whiteBalanceGreenOffset": "0", 1384 | "whiteBalanceIre": "100", 1385 | "whiteBalanceLuminance": "130", 1386 | "whiteBalanceMethod": "2", 1387 | "whiteBalancePattern": "outer", 1388 | "whiteBalancePoint": "high", 1389 | "whiteBalanceRed": [ 1390 | 0, 1391 | 0, 1392 | 0, 1393 | 0, 1394 | 0, 1395 | 0, 1396 | 0, 1397 | 0, 1398 | 0, 1399 | 0, 1400 | 0, 1401 | 0, 1402 | 0, 1403 | 0, 1404 | 0, 1405 | 0, 1406 | 0, 1407 | 0, 1408 | 0, 1409 | 0 1410 | ], 1411 | "whiteBalanceRedGain": "0", 1412 | "whiteBalanceRedOffset": "0", 1413 | "xvycc": "auto" 1414 | 1415 | 1416 | """ 1417 | 1418 | uri = "com.webos.settingsservice/setSystemSettings" 1419 | 1420 | params = {"category": "picture", "settings": settings} 1421 | 1422 | return await self.luna_request(uri, params) 1423 | 1424 | async def set_picture_settings( 1425 | self, settings, pic_mode, tv_input, stereoscopic="2d" 1426 | ): 1427 | """Set picture settings for specific picture mode, input, and 3d mode.""" 1428 | 1429 | uri = "com.webos.settingsservice/setSystemSettings" 1430 | 1431 | params = { 1432 | "category": f"picture${tv_input}.{pic_mode}.{stereoscopic}.x", 1433 | "settings": settings, 1434 | } 1435 | 1436 | return await self.luna_request(uri, params) 1437 | 1438 | def validateCalibrationData(self, data, shape, dtype): 1439 | if not isinstance(data, np.ndarray): 1440 | raise TypeError(f"data must be of type ndarray but is instead {type(data)}") 1441 | if data.shape != shape: 1442 | raise ValueError( 1443 | f"data should have shape {shape} but instead has {data.shape}" 1444 | ) 1445 | if data.dtype != dtype: 1446 | raise TypeError( 1447 | f"numpy dtype should be {dtype} but is instead {data.dtype}" 1448 | ) 1449 | 1450 | async def calibration_request(self, command, picMode, data): 1451 | dataenc = base64.b64encode(data.tobytes()).decode() 1452 | 1453 | payload = { 1454 | "command": command, 1455 | "data": dataenc, 1456 | "dataCount": data.size, 1457 | "dataOpt": 1, 1458 | "dataType": CALIBRATION_TYPE_MAP[data.dtype.name], 1459 | "profileNo": 0, 1460 | "programID": 1, 1461 | } 1462 | if picMode is not None: 1463 | payload["picMode"] = picMode 1464 | 1465 | return await self.request(ep.CALIBRATION, payload) 1466 | 1467 | async def start_calibration(self, picMode, data=DEFAULT_CAL_DATA): 1468 | self.validateCalibrationData(data, (9,), np.float32) 1469 | return await self.calibration_request(cal.CAL_START, picMode, data) 1470 | 1471 | async def end_calibration(self, picMode, data=DEFAULT_CAL_DATA): 1472 | self.validateCalibrationData(data, (9,), np.float32) 1473 | return await self.calibration_request(cal.CAL_END, picMode, data) 1474 | 1475 | async def upload_1d_lut(self, picMode, data=None): 1476 | info = self.calibration_support_info() 1477 | if not info["lut1d"]: 1478 | model = self._system_info["modelName"] 1479 | raise PyLGTVCmdException( 1480 | f"1D LUT Upload not supported by tv model {model}." 1481 | ) 1482 | if data is None: 1483 | data = await asyncio.get_running_loop().run_in_executor(None, unity_lut_1d) 1484 | self.validateCalibrationData(data, (3, 1024), np.uint16) 1485 | return await self.calibration_request(cal.UPLOAD_1D_LUT, picMode, data) 1486 | 1487 | async def upload_3d_lut(self, command, picMode, data): 1488 | if command not in [cal.UPLOAD_3D_LUT_BT709, cal.UPLOAD_3D_LUT_BT2020]: 1489 | raise PyLGTVCmdException(f"Invalid 3D LUT Upload command {command}.") 1490 | info = self.calibration_support_info() 1491 | lut3d_size = info["lut3d_size"] 1492 | if not lut3d_size: 1493 | model = self._system_info["modelName"] 1494 | raise PyLGTVCmdException( 1495 | f"3D LUT Upload not supported by tv model {model}." 1496 | ) 1497 | if data is None: 1498 | data = await asyncio.get_running_loop().run_in_executor( 1499 | None, unity_lut_3d, lut3d_size 1500 | ) 1501 | lut3d_shape = (lut3d_size, lut3d_size, lut3d_size, 3) 1502 | self.validateCalibrationData(data, lut3d_shape, np.uint16) 1503 | return await self.calibration_request(command, picMode, data) 1504 | 1505 | async def upload_3d_lut_bt709(self, picMode, data=None): 1506 | return await self.upload_3d_lut(cal.UPLOAD_3D_LUT_BT709, picMode, data) 1507 | 1508 | async def upload_3d_lut_bt2020(self, picMode, data=None): 1509 | return await self.upload_3d_lut(cal.UPLOAD_3D_LUT_BT2020, picMode, data) 1510 | 1511 | async def set_ui_data(self, command, picMode, value): 1512 | if not (value >= 0 and value <= 100): 1513 | raise ValueError 1514 | 1515 | data = np.array(value, dtype=np.uint16) 1516 | return await self.calibration_request(command, picMode, data) 1517 | 1518 | async def set_brightness(self, picMode, value): 1519 | return await self.set_ui_data(cal.BRIGHTNESS_UI_DATA, picMode, value) 1520 | 1521 | async def set_contrast(self, picMode, value): 1522 | return await self.set_ui_data(cal.CONTRAST_UI_DATA, picMode, value) 1523 | 1524 | async def set_oled_light(self, picMode, value): 1525 | return await self.set_ui_data(cal.BACKLIGHT_UI_DATA, picMode, value) 1526 | 1527 | async def set_color(self, picMode, value): 1528 | return await self.set_ui_data(cal.COLOR_UI_DATA, picMode, value) 1529 | 1530 | async def set_1d_2_2_en(self, picMode, value=0): 1531 | data = np.array(value, dtype=np.uint16) 1532 | return await self.calibration_request( 1533 | cal.ENABLE_GAMMA_2_2_TRANSFORM, picMode, data 1534 | ) 1535 | 1536 | async def set_1d_0_45_en(self, picMode, value=0): 1537 | data = np.array(value, dtype=np.uint16) 1538 | return await self.calibration_request( 1539 | cal.ENABLE_GAMMA_0_45_TRANSFORM, picMode, data 1540 | ) 1541 | 1542 | async def set_bt709_3by3_gamut_data( 1543 | self, picMode, data=np.identity(3, dtype=np.float32) 1544 | ): 1545 | self.validateCalibrationData(data, (3, 3), np.float32) 1546 | return await self.calibration_request(cal.BT709_3BY3_GAMUT_DATA, picMode, data) 1547 | 1548 | async def set_bt2020_3by3_gamut_data( 1549 | self, picMode, data=np.identity(3, dtype=np.float32) 1550 | ): 1551 | self.validateCalibrationData(data, (3, 3), np.float32) 1552 | return await self.calibration_request(cal.BT2020_3BY3_GAMUT_DATA, picMode, data) 1553 | 1554 | async def set_dolby_vision_config_data( 1555 | self, white_level=700.0, black_level=0.0, gamma=2.2, primaries=BT2020_PRIMARIES 1556 | ): 1557 | 1558 | info = self.calibration_support_info() 1559 | dv_config_type = info["dv_config_type"] 1560 | if dv_config_type is None: 1561 | model = self._system_info["modelName"] 1562 | raise PyLGTVCmdException( 1563 | f"Dolby Vision Configuration Upload not supported by tv model {model}." 1564 | ) 1565 | 1566 | config = await asyncio.get_running_loop().run_in_executor( 1567 | None, 1568 | functools.partial( 1569 | create_dolby_vision_config, 1570 | version=dv_config_type, 1571 | white_level=white_level, 1572 | black_level=black_level, 1573 | gamma=gamma, 1574 | primaries=primaries, 1575 | ), 1576 | ) 1577 | 1578 | data = np.frombuffer(config.encode(), dtype=np.uint8) 1579 | return await self.calibration_request( 1580 | command=cal.DOLBY_CFG_DATA, picMode=None, data=data 1581 | ) 1582 | 1583 | async def set_tonemap_params( 1584 | self, 1585 | picMode, 1586 | luminance=700, 1587 | mastering_peak_1=1000, 1588 | rolloff_point_1=70, 1589 | mastering_peak_2=4000, 1590 | rolloff_point_2=60, 1591 | mastering_peak_3=10000, 1592 | rolloff_point_3=50, 1593 | ): 1594 | 1595 | data = np.array( 1596 | [ 1597 | luminance, 1598 | mastering_peak_1, 1599 | rolloff_point_1, 1600 | mastering_peak_2, 1601 | rolloff_point_2, 1602 | mastering_peak_3, 1603 | rolloff_point_3, 1604 | ], 1605 | dtype=np.uint16, 1606 | ) 1607 | 1608 | return await self.calibration_request(cal.SET_TONEMAP_PARAM, picMode, data) 1609 | 1610 | async def ddc_reset(self, picMode, reset_1d_lut=True): 1611 | if not isinstance(reset_1d_lut, bool): 1612 | raise TypeError( 1613 | f"reset_1d_lut should be a bool, instead got {reset_1d_lut} of type {type(reset_1d_lut)}." 1614 | ) 1615 | 1616 | await self.set_1d_2_2_en(picMode) 1617 | await self.set_1d_0_45_en(picMode) 1618 | await self.set_bt709_3by3_gamut_data(picMode) 1619 | await self.set_bt2020_3by3_gamut_data(picMode) 1620 | await self.upload_3d_lut_bt709(picMode) 1621 | await self.upload_3d_lut_bt2020(picMode) 1622 | if reset_1d_lut: 1623 | await self.upload_1d_lut(picMode) 1624 | 1625 | return True 1626 | 1627 | async def get_picture_settings( 1628 | self, keys=["contrast", "backlight", "brightness", "color"] 1629 | ): 1630 | payload = {"category": "picture", "keys": keys} 1631 | ret = await self.request(ep.GET_SYSTEM_SETTINGS, payload=payload) 1632 | return ret["settings"] 1633 | 1634 | async def subscribe_picture_settings( 1635 | self, callback, keys=["contrast", "backlight", "brightness", "color"] 1636 | ): 1637 | async def settings(payload): 1638 | await callback(payload.get("settings")) 1639 | 1640 | payload = {"category": "picture", "keys": keys} 1641 | return await self.subscribe(settings, ep.GET_SYSTEM_SETTINGS, payload=payload) 1642 | 1643 | async def upload_1d_lut_from_file(self, picMode, filename): 1644 | ext = filename.split(".")[-1].lower() 1645 | if ext == "cal": 1646 | lut = await asyncio.get_running_loop().run_in_executor( 1647 | None, read_cal_file, filename 1648 | ) 1649 | elif ext == "cube": 1650 | lut = await asyncio.get_running_loop().run_in_executor( 1651 | None, read_cube_file, filename 1652 | ) 1653 | else: 1654 | raise ValueError( 1655 | f"Unsupported file format {ext} for 1D LUT. Supported file formats are cal and cube." 1656 | ) 1657 | 1658 | return await self.upload_1d_lut(picMode, lut) 1659 | 1660 | async def upload_3d_lut_from_file(self, command, picMode, filename): 1661 | ext = filename.split(".")[-1].lower() 1662 | if ext == "cube": 1663 | lut = await asyncio.get_running_loop().run_in_executor( 1664 | None, read_cube_file, filename 1665 | ) 1666 | else: 1667 | raise ValueError( 1668 | f"Unsupported file format {ext} for 3D LUT. Supported file formats are cube." 1669 | ) 1670 | 1671 | return await self.upload_3d_lut(command, picMode, lut) 1672 | 1673 | async def upload_3d_lut_bt709_from_file(self, picMode, filename): 1674 | return await self.upload_3d_lut_from_file( 1675 | cal.UPLOAD_3D_LUT_BT709, picMode, filename 1676 | ) 1677 | 1678 | async def upload_3d_lut_bt2020_from_file(self, picMode, filename): 1679 | return await self.upload_3d_lut_from_file( 1680 | cal.UPLOAD_3D_LUT_BT2020, picMode, filename 1681 | ) 1682 | -------------------------------------------------------------------------------- /azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | pool: 2 | vmImage: 'ubuntu-latest' 3 | strategy: 4 | matrix: 5 | ## We can enable different Python versions when 6 | ## there are unit tests. 7 | # Python38: 8 | # python.version: '3.8' 9 | Python37: 10 | python.version: '3.7' 11 | 12 | steps: 13 | - task: UsePythonVersion@0 14 | inputs: 15 | versionSpec: '$(python.version)' 16 | displayName: 'Use Python $(python.version)' 17 | 18 | - script: | 19 | python -m pip install --upgrade pip 20 | pip install -U . 21 | pip install pre-commit 22 | displayName: 'Install dependencies' 23 | 24 | - script: | 25 | pre-commit install 26 | pre-commit run --all-files 27 | displayName: 'Run pre-commit on all files' 28 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | with open("README.md") as f: 4 | readme = f.read() 5 | 6 | setup( 7 | name="aiopylgtv", 8 | packages=["aiopylgtv"], 9 | install_requires=["websockets>=8.1", "numpy>=1.17.0", "sqlitedict"], 10 | python_requires=">=3.7", 11 | zip_safe=True, 12 | version="0.4.1", 13 | description="Library to control webOS based LG TV devices.", 14 | long_description=readme, 15 | long_description_content_type="text/markdown", 16 | author="Josh Bendavid", 17 | author_email="joshbendavid@gmail.com", 18 | url="https://github.com/bendavid/aiopylgtv", 19 | keywords=["webos", "tv"], 20 | classifiers=[], 21 | entry_points={ 22 | "console_scripts": ["aiopylgtvcommand=aiopylgtv.utils:aiopylgtvcommand"] 23 | }, 24 | ) 25 | --------------------------------------------------------------------------------