├── setup.cfg ├── MANIFEST.in ├── aiopylgtv ├── __init__.py ├── constants.py ├── utils.py ├── endpoints.py ├── handshake.py ├── lut_tools.py └── webos_client.py ├── setup.py ├── .gitignore ├── LICENSE.txt └── README.md /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.txt 2 | include README.md 3 | -------------------------------------------------------------------------------- /aiopylgtv/__init__.py: -------------------------------------------------------------------------------- 1 | from .webos_client import WebOsClient 2 | from .webos_client import PyLGTVPairException 3 | from .webos_client import PyLGTVCmdException 4 | from .lut_tools import unity_lut_1d, unity_lut_3d, read_cube_file, read_cal_file 5 | -------------------------------------------------------------------------------- /aiopylgtv/constants.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | CALIBRATION_TYPE_MAP = { "uint8" : "unsigned char", "uint16" : "unsigned integer16", "float32" : "float" } 4 | DEFAULT_CAL_DATA = np.array([0.,0.,0.,0.,0.,0.,0.0044,-0.0453,1.041],dtype=np.float32) 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name = 'aiopylgtv', 5 | packages = ['aiopylgtv'], 6 | install_requires = ['websockets', 'asyncio', 'numpy'], 7 | zip_safe = True, 8 | version = '0.2.1', 9 | description = 'Library to control webOS based LG Tv devices', 10 | author = 'Josh Bendavid', 11 | author_email = 'joshbendavid@gmail.com', 12 | url = 'https://github.com/bendavid/aiopylgtv', 13 | download_url = 'https://github.com/bendavid/aiopylgtv/archive/0.2.0.tar.gz', 14 | keywords = ['webos', 'tv'], 15 | classifiers = [], 16 | entry_points={ 17 | 'console_scripts': [ 18 | 'aiopylgtvcommand=aiopylgtv.utils:aiopylgtvcommand', 19 | ], 20 | }, 21 | ) 22 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /aiopylgtv/utils.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import argparse 3 | from aiopylgtv import WebOsClient 4 | 5 | async def runloop(client, command, parameters): 6 | await client.connect() 7 | print(await getattr(client, command)(*parameters)) 8 | await client.disconnect() 9 | 10 | def aiopylgtvcommand(): 11 | parser = argparse.ArgumentParser(description='Send command to LG WebOs TV.') 12 | parser.add_argument('host', type=str, 13 | help='hostname or ip address of the TV to connect to') 14 | parser.add_argument('command', type=str, 15 | help='command to send to the TV (can be any function of WebOsClient)') 16 | parser.add_argument('parameters', type=str, nargs='*', 17 | help='additional parameters to be passed to WebOsClient function call') 18 | 19 | args = parser.parse_args() 20 | 21 | client = WebOsClient(args.host, timeout_connect=2) 22 | 23 | asyncio.get_event_loop().run_until_complete(runloop(client, args.command, args.parameters)) 24 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /aiopylgtv/endpoints.py: -------------------------------------------------------------------------------- 1 | EP_GET_SERVICES = "api/getServiceList" 2 | EP_SET_MUTE = "audio/setMute" 3 | EP_GET_AUDIO_STATUS = "audio/getStatus" 4 | EP_GET_VOLUME = "audio/getVolume" 5 | EP_SET_VOLUME = "audio/setVolume" 6 | EP_VOLUME_UP = "audio/volumeUp" 7 | EP_VOLUME_DOWN= "audio/volumeDown" 8 | EP_GET_CURRENT_APP_INFO = "com.webos.applicationManager/getForegroundAppInfo" 9 | EP_LAUNCH_APP = "com.webos.applicationManager/launch" 10 | EP_GET_APPS = "com.webos.applicationManager/listLaunchPoints" 11 | EP_GET_APP_STATUS = "com.webos.service.appstatus/getAppStatus" 12 | EP_SEND_ENTER = "com.webos.service.ime/sendEnterKey" 13 | EP_SEND_DELETE = "com.webos.service.ime/deleteCharacters" 14 | EP_3D_ON = "com.webos.service.tv.display/set3DOn" 15 | EP_3D_OFF = "com.webos.service.tv.display/set3DOff" 16 | EP_GET_SOFTWARE_INFO = "com.webos.service.update/getCurrentSWInformation" 17 | EP_MEDIA_PLAY = "media.controls/play" 18 | EP_MEDIA_STOP = "media.controls/stop" 19 | EP_MEDIA_PAUSE = "media.controls/pause" 20 | EP_MEDIA_REWIND = "media.controls/rewind" 21 | EP_MEDIA_FAST_FORWARD = "media.controls/fastForward" 22 | EP_MEDIA_CLOSE = "media.viewer/close" 23 | EP_POWER_OFF = "system/turnOff" 24 | EP_POWER_ON = "system/turnOn" 25 | EP_SHOW_MESSAGE = "system.notifications/createToast" 26 | EP_LAUNCHER_CLOSE = "system.launcher/close" 27 | EP_GET_APP_STATE = "system.launcher/getAppState" 28 | EP_GET_SYSTEM_INFO = "system/getSystemInfo" 29 | EP_LAUNCH = "system.launcher/launch" 30 | EP_OPEN = "system.launcher/open" 31 | EP_GET_SYSTEM_SETTINGS = "settings/getSystemSettings" 32 | EP_TV_CHANNEL_DOWN = "tv/channelDown" 33 | EP_TV_CHANNEL_UP = "tv/channelUp" 34 | EP_GET_TV_CHANNELS = "tv/getChannelList" 35 | EP_GET_CHANNEL_INFO = "tv/getChannelProgramInfo" 36 | EP_GET_CURRENT_CHANNEL = "tv/getCurrentChannel" 37 | EP_GET_INPUTS = "tv/getExternalInputList" 38 | EP_SET_CHANNEL = "tv/openChannel" 39 | EP_SET_INPUT = "tv/switchInput" 40 | EP_CLOSE_WEB_APP = "webapp/closeWebApp" 41 | EP_INPUT_SOCKET = "com.webos.service.networkinput/getPointerInputSocket" 42 | EP_CALIBRATION = "externalpq/setExternalPqData" 43 | -------------------------------------------------------------------------------- /aiopylgtv/handshake.py: -------------------------------------------------------------------------------- 1 | SIGNATURE = ("eyJhbGdvcml0aG0iOiJSU0EtU0hBMjU2Iiwia2V5SWQiOiJ0ZXN0LXNpZ25pbm" + 2 | "ctY2VydCIsInNpZ25hdHVyZVZlcnNpb24iOjF9.hrVRgjCwXVvE2OOSpDZ58hR" + 3 | "+59aFNwYDyjQgKk3auukd7pcegmE2CzPCa0bJ0ZsRAcKkCTJrWo5iDzNhMBWRy" + 4 | "aMOv5zWSrthlf7G128qvIlpMT0YNY+n/FaOHE73uLrS/g7swl3/qH/BGFG2Hu4" + 5 | "RlL48eb3lLKqTt2xKHdCs6Cd4RMfJPYnzgvI4BNrFUKsjkcu+WD4OO2A27Pq1n" + 6 | "50cMchmcaXadJhGrOqH5YmHdOCj5NSHzJYrsW0HPlpuAx/ECMeIZYDh6RMqaFM" + 7 | "2DXzdKX9NmmyqzJ3o/0lkk/N97gfVRLW5hA29yeAwaCViZNCP8iC9aO0q9fQoj" + 8 | "oa7NQnAtw==") 9 | 10 | REGISTRATION_PAYLOAD = { 11 | "forcePairing": False, 12 | "manifest": { 13 | "appVersion": "1.1", 14 | "manifestVersion": 1, 15 | "permissions": [ 16 | "LAUNCH", 17 | "LAUNCH_WEBAPP", 18 | "APP_TO_APP", 19 | "CLOSE", 20 | "TEST_OPEN", 21 | "TEST_PROTECTED", 22 | "CONTROL_AUDIO", 23 | "CONTROL_DISPLAY", 24 | "CONTROL_INPUT_JOYSTICK", 25 | "CONTROL_INPUT_MEDIA_RECORDING", 26 | "CONTROL_INPUT_MEDIA_PLAYBACK", 27 | "CONTROL_INPUT_TV", 28 | "CONTROL_POWER", 29 | "READ_APP_STATUS", 30 | "READ_CURRENT_CHANNEL", 31 | "READ_INPUT_DEVICE_LIST", 32 | "READ_NETWORK_STATE", 33 | "READ_RUNNING_APPS", 34 | "READ_TV_CHANNEL_LIST", 35 | "WRITE_NOTIFICATION_TOAST", 36 | "READ_POWER_STATE", 37 | "READ_COUNTRY_INFO", 38 | "CONTROL_INPUT_TEXT", 39 | "CONTROL_MOUSE_AND_KEYBOARD", 40 | "READ_INSTALLED_APPS", 41 | "READ_SETTINGS", 42 | ], 43 | "signatures": [ 44 | { 45 | "signature": SIGNATURE, 46 | "signatureVersion": 1 47 | } 48 | ], 49 | "signed": { 50 | "appId": "com.lge.test", 51 | "created": "20140509", 52 | "localizedAppNames": { 53 | "": "LG Remote App", 54 | "ko-KR": u"리모컨 앱", 55 | "zxx-XX": u"ЛГ Rэмotэ AПП" 56 | }, 57 | "localizedVendorNames": { 58 | "": "LG Electronics" 59 | }, 60 | "permissions": [ 61 | "TEST_SECURE", 62 | "CONTROL_INPUT_TEXT", 63 | "CONTROL_MOUSE_AND_KEYBOARD", 64 | "READ_INSTALLED_APPS", 65 | "READ_LGE_SDX", 66 | "READ_NOTIFICATIONS", 67 | "SEARCH", 68 | "WRITE_SETTINGS", 69 | "WRITE_NOTIFICATION_ALERT", 70 | "CONTROL_POWER", 71 | "READ_CURRENT_CHANNEL", 72 | "READ_RUNNING_APPS", 73 | "READ_UPDATE_INFO", 74 | "UPDATE_FROM_REMOTE_APP", 75 | "READ_LGE_TV_INPUT_EVENTS", 76 | "READ_TV_CURRENT_TIME" 77 | ], 78 | "serial": "2f930e2d2cfe083771f68e4fe7bb07", 79 | "vendorId": "com.lge" 80 | } 81 | }, 82 | "pairingType": "PROMPT" 83 | } 84 | 85 | 86 | REGISTRATION_MESSAGE = { 87 | 'type': "register", 88 | 'id': "register_0", 89 | 'payload': REGISTRATION_PAYLOAD, 90 | } 91 | -------------------------------------------------------------------------------- /aiopylgtv/lut_tools.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | def unity_lut_1d(): 4 | lutmono = np.arange(0, 32768, 32, dtype=np.uint16) 5 | lut = np.stack([lutmono]*3, axis=0) 6 | return lut 7 | 8 | def unity_lut_3d(n=33): 9 | spacing = complex(0,n) 10 | lut = np.mgrid[0.:4095.:spacing,0.:4095.:spacing,0.:4095.:spacing] 11 | lut = np.rint(lut).astype(np.uint16) 12 | lut = np.transpose(lut, axes=(1,2,3,0)) 13 | lut = np.flip(lut, axis=-1) 14 | return lut 15 | 16 | def read_cube_file(filename): 17 | nheader = 0 18 | lut_1d_size = None 19 | lut_3d_size = None 20 | domain_min = None 21 | domain_max = None 22 | with open(filename, "r") as f: 23 | for line in f: 24 | icomment = line.find("#") 25 | if icomment>=0: 26 | line = line[:icomment] 27 | 28 | splitline = line.split() 29 | if splitline: 30 | keyword = splitline[0] 31 | else: 32 | keyword = None 33 | 34 | if keyword is None: 35 | pass 36 | elif keyword == "TITLE": 37 | pass 38 | elif keyword == "LUT_1D_SIZE": 39 | lut_1d_size = int(splitline[1]) 40 | if lut_1d_size<2 or lut_1d_size>65536: 41 | raise ValueError(f"Invalid value {lut_1d_size} for LUT_1D_SIZE, must be in range [2,65536].") 42 | elif keyword == "LUT_3D_SIZE": 43 | lut_3d_size = int(splitline[1]) 44 | if lut_3d_size<2 or lut_3d_size>256: 45 | raise ValueError(f"Invalid value {lut_3d_size} for LUT_3D_SIZE, must be in range [2,256].") 46 | elif keyword == "DOMAIN_MIN": 47 | domain_min = np.genfromtxt([line], usecols=(1,2,3), dtype=np.float64) 48 | if domain_min.shape != (3,): 49 | raise ValueError("DOMAIN_MIN must provide exactly 3 values.") 50 | if np.amin(domain_min) < -1e37 or np.amax(domain_min) > 1e37: 51 | raise ValueError("Invalid value in DOMAIN_MIN, must be in range [-1e37,1e37].") 52 | elif keyword == "DOMAIN_MAX": 53 | domain_max = np.genfromtxt([line], usecols=(1,2,3), dtype=np.float64) 54 | if domain_max.shape != (3,): 55 | raise ValueError("DOMAIN_MIN must provide exactly 3 values.") 56 | if np.amin(domain_max) < -1e37 or np.amax(domain_max) > 1e37: 57 | raise ValueError("Invalid value in DOMAIN_MAX, must be in range [-1e37,1e37].") 58 | else: 59 | break 60 | 61 | nheader += 1 62 | 63 | if lut_1d_size and lut_3d_size: 64 | raise ValueError("Cannot specify both LUT_1D_SIZE and LUT_3D_SIZE.") 65 | 66 | if not lut_1d_size and not lut_3d_size: 67 | raise ValueError("Must specify one of LUT_1D_SIZE or LUT_3D_SIZE.") 68 | 69 | if domain_min is None: 70 | domain_min = np.zeros((3,), dtype=np.float64) 71 | 72 | if domain_max is None: 73 | domain_max = np.ones((3,), dtype=np.float64) 74 | 75 | lut = np.genfromtxt(filename, skip_header=nheader, comments="#", dtype=np.float64) 76 | if np.amin(lut) < -1e37 or np.amax(lut) > 1e37: 77 | raise ValueError("Invalid value in DOMAIN_MAX, must be in range [-1e37,1e37].") 78 | 79 | domain_min = np.reshape(domain_min, (1,3)) 80 | domain_max = np.reshape(domain_max, (1,3)) 81 | 82 | #shift and scale lut to range [0.,1.] 83 | lut = (lut-domain_min)/(domain_max-domain_min) 84 | 85 | if lut_1d_size: 86 | if lut.shape != (lut_1d_size,3): 87 | raise ValueError(f"Expected shape {(lut_1d_size,3)} for 1D LUT, but got {lut.shape}.") 88 | #convert to integer with appropriate range 89 | lut = np.rint(lut*32767.).astype(np.uint16) 90 | #transpose to get the correct element order 91 | lut = np.transpose(lut) 92 | elif lut_3d_size: 93 | if lut.shape != (lut_3d_size**3, 3): 94 | raise ValueError(f"Expected shape {(lut_3d_size**3, 3)} for 3D LUT, but got {lut.shape}.") 95 | lut = np.reshape(lut, (lut_3d_size, lut_3d_size, lut_3d_size, 3)) 96 | lut = np.rint(lut*4095.).astype(np.uint16) 97 | 98 | return lut 99 | 100 | def read_cal_file(filename): 101 | nheader = 0 102 | with open(filename, "r") as f: 103 | caldata = f.readlines() 104 | 105 | dataidx = caldata.index("BEGIN_DATA\n") 106 | lut_1d_size_in = int(caldata[dataidx-1].split()[1]) 107 | 108 | lut = np.genfromtxt(caldata[dataidx+1:dataidx+1+lut_1d_size_in], dtype=np.float64) 109 | 110 | if lut.shape != (lut_1d_size_in,4): 111 | raise ValueError(f"Expected shape {(lut_1d_size_in,3)} for 1D LUT, but got {lut.shape}.") 112 | 113 | lut_1d_size = 1024 114 | 115 | #interpolate if necessary 116 | if lut_1d_size_in != lut_1d_size: 117 | x = np.linspace(0., 1., lut_1d_size, dtype=np.float64) 118 | lutcomponents = [] 119 | for i in range(1,4): 120 | lutcomponent = np.interp(x, lut[:,0], lut[:,i]) 121 | lutcomponents.append(lutcomponent) 122 | lut = np.stack(lutcomponents, axis=-1) 123 | else: 124 | lut = lut[:,1:] 125 | 126 | #convert to integer with appropriate range 127 | lut = np.rint(32767.*lut).astype(np.uint16) 128 | #transpose to get the correct element order 129 | lut = np.transpose(lut) 130 | 131 | return lut 132 | -------------------------------------------------------------------------------- /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.8 8 | 9 | ## Install 10 | ``` 11 | pip install aiopylgtv 12 | ``` 13 | 14 | ## Install from Source 15 | ``` 16 | python setup.py sdist bdist_wheel 17 | pip install --upgrade dist/aiopylgtv-0.2.1-py3-none-any.whl 18 | ``` 19 | 20 | ## Basic Example 21 | 22 | ```python 23 | import asyncio 24 | from aiopylgtv import WebOsClient 25 | 26 | async def runloop(client): 27 | await client.connect() 28 | apps = await client.get_apps() 29 | for app in apps: 30 | print(app) 31 | 32 | await client.disconnect() 33 | 34 | client = WebOsClient('192.168.1.53') 35 | asyncio.get_event_loop().run_until_complete(runloop(client)) 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 | ``` 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 | await client.register_state_update_callback(on_state_change) 58 | 59 | await client.connect() 60 | 61 | print(client.inputs) 62 | ret = await client.set_input("HDMI_3") 63 | print(ret) 64 | 65 | await client.disconnect() 66 | 67 | client = WebOsClient('192.168.1.53') 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 | 120 | ``` 121 | import asyncio 122 | from aiopylgtv import WebOsClient 123 | 124 | async def runloop(): 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 | client = WebOsClient('192.168.1.53') 140 | asyncio.get_event_loop().run_until_complete(runloop()) 141 | ``` 142 | -------------------------------------------------------------------------------- /aiopylgtv/webos_client.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import base64 3 | import codecs 4 | import json 5 | import os 6 | import websockets 7 | import logging 8 | import sys 9 | import copy 10 | import numpy as np 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | from .endpoints import * 15 | from .constants import CALIBRATION_TYPE_MAP, DEFAULT_CAL_DATA 16 | from .handshake import REGISTRATION_MESSAGE 17 | from .lut_tools import unity_lut_1d, unity_lut_3d, read_cube_file, read_cal_file 18 | 19 | KEY_FILE_NAME = '.aiopylgtv' 20 | USER_HOME = 'HOME' 21 | 22 | class PyLGTVPairException(Exception): 23 | def __init__(self, id, message): 24 | self.id = id 25 | self.message = message 26 | 27 | class PyLGTVCmdException(Exception): 28 | def __init__(self, message): 29 | self.message = message 30 | 31 | class WebOsClient(object): 32 | def __init__(self, ip, key_file_path=None, timeout_connect=2, ping_interval=20, standby_connection = False): 33 | """Initialize the client.""" 34 | self.ip = ip 35 | self.port = 3000 36 | self.key_file_path = key_file_path 37 | self.client_key = None 38 | self.web_socket = None 39 | self.command_count = 0 40 | self.timeout_connect = timeout_connect 41 | self.ping_interval = ping_interval 42 | self.standby_connection = standby_connection 43 | self.connect_task = None 44 | self.connect_result = None 45 | self.connection = None 46 | self.input_connection = None 47 | self.callbacks = {} 48 | self.futures = {} 49 | self._current_appId = "" 50 | self._muted = muted = False 51 | self._volume = 0 52 | self._current_channel = None 53 | self._apps = {} 54 | self._extinputs = {} 55 | self._system_info = None 56 | self._software_info = None 57 | self.state_update_callbacks = [] 58 | self.doStateUpdate = False 59 | 60 | self.load_key_file() 61 | 62 | @staticmethod 63 | def _get_key_file_path(): 64 | """Return the key file path.""" 65 | if os.getenv(USER_HOME) is not None and os.access(os.getenv(USER_HOME), 66 | os.W_OK): 67 | return os.path.join(os.getenv(USER_HOME), KEY_FILE_NAME) 68 | 69 | return os.path.join(os.getcwd(), KEY_FILE_NAME) 70 | 71 | def load_key_file(self): 72 | """Try to load the client key for the current ip.""" 73 | self.client_key = None 74 | if self.key_file_path: 75 | key_file_path = self.key_file_path 76 | else: 77 | key_file_path = self._get_key_file_path() 78 | key_dict = {} 79 | 80 | logger.debug('load keyfile from %s', key_file_path); 81 | 82 | if os.path.isfile(key_file_path): 83 | with open(key_file_path, 'r') as f: 84 | raw_data = f.read() 85 | if raw_data: 86 | key_dict = json.loads(raw_data) 87 | 88 | logger.debug('getting client_key for %s from %s', self.ip, key_file_path); 89 | if self.ip in key_dict: 90 | self.client_key = key_dict[self.ip] 91 | 92 | def save_key_file(self): 93 | """Save the current client key.""" 94 | if self.client_key is None: 95 | return 96 | 97 | if self.key_file_path: 98 | key_file_path = self.key_file_path 99 | else: 100 | key_file_path = self._get_key_file_path() 101 | 102 | logger.debug('save keyfile to %s', key_file_path); 103 | 104 | with open(key_file_path, 'w+') as f: 105 | raw_data = f.read() 106 | key_dict = {} 107 | 108 | if raw_data: 109 | key_dict = json.loads(raw_data) 110 | 111 | key_dict[self.ip] = self.client_key 112 | 113 | f.write(json.dumps(key_dict)) 114 | 115 | async def connect(self): 116 | if not self.is_connected(): 117 | self.connect_result = asyncio.Future() 118 | self.connect_task = asyncio.create_task(self.connect_handler(self.connect_result)) 119 | return await self.connect_result 120 | 121 | async def disconnect(self): 122 | if self.is_connected(): 123 | self.connect_task.cancel() 124 | try: 125 | await self.connect_task 126 | except asyncio.CancelledError: 127 | pass 128 | 129 | def is_registered(self): 130 | """Paired with the tv.""" 131 | return self.client_key is not None 132 | 133 | def is_connected(self): 134 | return (self.connect_task is not None and not self.connect_task.done()) 135 | 136 | def registration_msg(self): 137 | handshake = copy.deepcopy(REGISTRATION_MESSAGE) 138 | handshake['payload']['client-key'] = self.client_key 139 | return handshake 140 | 141 | async def connect_handler(self, res): 142 | 143 | handler_tasks = set() 144 | ws = None 145 | inputws = None 146 | try: 147 | ws = await asyncio.wait_for(websockets.connect(f"ws://{self.ip}:{self.port}", 148 | ping_interval=None, 149 | close_timeout=self.timeout_connect), 150 | timeout = self.timeout_connect) 151 | await ws.send(json.dumps(self.registration_msg())) 152 | raw_response = await ws.recv() 153 | response = json.loads(raw_response) 154 | 155 | if response['type'] == 'response' and \ 156 | response['payload']['pairingType'] == 'PROMPT': 157 | raw_response = await ws.recv() 158 | response = json.loads(raw_response) 159 | if response['type'] == 'registered': 160 | self.client_key = response['payload']['client-key'] 161 | self.save_key_file() 162 | 163 | if not self.client_key: 164 | raise PyLGTVPairException("Unable to pair") 165 | 166 | self.callbacks = {} 167 | self.futures = {} 168 | 169 | handler_tasks.add(asyncio.create_task(self.consumer_handler(ws,self.callbacks,self.futures))) 170 | if self.ping_interval is not None: 171 | handler_tasks.add(asyncio.create_task(self.ping_handler(ws, self.ping_interval))) 172 | self.connection = ws 173 | 174 | #open additional connection needed to send button commands 175 | #the url is dynamically generated and returned from the EP_INPUT_SOCKET 176 | #endpoint on the main connection 177 | sockres = await self.request(EP_INPUT_SOCKET) 178 | inputsockpath = sockres.get("socketPath") 179 | inputws = await asyncio.wait_for(websockets.connect(inputsockpath, 180 | ping_interval=None, 181 | close_timeout=self.timeout_connect), 182 | timeout = self.timeout_connect) 183 | 184 | handler_tasks.add(asyncio.create_task(inputws.wait_closed())) 185 | if self.ping_interval is not None: 186 | handler_tasks.add(asyncio.create_task(self.ping_handler(inputws, self.ping_interval))) 187 | self.input_connection = inputws 188 | 189 | #set static state and subscribe to state updates 190 | #avoid partial updates during initial subscription 191 | 192 | self.doStateUpdate = False 193 | self._system_info, self._software_info = await asyncio.gather(self.get_system_info(), 194 | self.get_software_info(), 195 | ) 196 | await asyncio.gather(self.subscribe_current_app(self.set_current_app_state), 197 | self.subscribe_muted(self.set_muted_state), 198 | self.subscribe_volume(self.set_volume_state), 199 | self.subscribe_apps(self.set_apps_state), 200 | self.subscribe_inputs(self.set_inputs_state), 201 | ) 202 | #Channel state subscription may not work in all cases 203 | try: 204 | await self.subscribe_current_channel(self.set_current_channel_state) 205 | except PyLGTVCmdException: 206 | pass 207 | self.doStateUpdate = True 208 | if self.state_update_callbacks: 209 | await self.do_state_update_callbacks() 210 | 211 | res.set_result(True) 212 | 213 | await asyncio.wait(handler_tasks, return_when=asyncio.FIRST_COMPLETED) 214 | 215 | except Exception as ex: 216 | if not res.done(): 217 | res.set_exception(ex) 218 | finally: 219 | for task in handler_tasks: 220 | if not task.done(): 221 | task.cancel() 222 | 223 | for future in self.futures.values(): 224 | future.cancel() 225 | 226 | closeout = set() 227 | closeout.update(handler_tasks) 228 | 229 | if ws is not None: 230 | closeout.add(asyncio.create_task(ws.close())) 231 | if inputws is not None: 232 | closeout.add(asyncio.create_task(inputws.close())) 233 | 234 | self.connection = None 235 | self.input_connection = None 236 | 237 | self._current_appId = "" 238 | self._muted = muted = False 239 | self._volume = 0 240 | self._current_channel = None 241 | self._apps = {} 242 | self._extinputs = {} 243 | self._system_info = None 244 | self._software_info = None 245 | 246 | self.doStateUpdate = True 247 | 248 | for callback in self.state_update_callbacks: 249 | closeout.add(callback()) 250 | 251 | if closeout: 252 | closeout_task = asyncio.create_task(asyncio.wait(closeout)) 253 | 254 | while not closeout_task.done(): 255 | try: 256 | await asyncio.shield(closeout_task) 257 | except asyncio.CancelledError: 258 | pass 259 | 260 | async def ping_handler(self, ws, interval=20): 261 | try: 262 | while True: 263 | await asyncio.sleep(interval) 264 | if self.current_appId != "" or not self.standby_connection: 265 | ping_waiter = await ws.ping() 266 | await asyncio.wait_for(ping_waiter, timeout = self.timeout_connect) 267 | except (asyncio.TimeoutError, asyncio.CancelledError, websockets.exceptions.ConnectionClosedError): 268 | pass 269 | 270 | async def consumer_handler(self, ws, callbacks={}, futures={}): 271 | try: 272 | async for raw_msg in ws: 273 | if callbacks or futures: 274 | msg = json.loads(raw_msg) 275 | uid = msg.get('id') 276 | if uid in self.callbacks: 277 | payload = msg.get('payload') 278 | await self.callbacks[uid](payload) 279 | if uid in self.futures: 280 | self.futures[uid].set_result(msg) 281 | except (websockets.exceptions.ConnectionClosedError, asyncio.CancelledError): 282 | pass 283 | 284 | #manage state 285 | @property 286 | def current_appId(self): 287 | return self._current_appId 288 | 289 | @property 290 | def muted(self): 291 | return self._muted 292 | 293 | @property 294 | def volume(self): 295 | return self._volume 296 | 297 | @property 298 | def current_channel(self): 299 | return self._current_channel 300 | 301 | @property 302 | def apps(self): 303 | return self._apps 304 | 305 | @property 306 | def inputs(self): 307 | return self._extinputs 308 | 309 | @property 310 | def system_info(self): 311 | return self._system_info 312 | 313 | @property 314 | def software_info(self): 315 | return self._software_info 316 | 317 | def calibration_support_info(self): 318 | info = { "lut1d" : False, 319 | "lut3d_size" : None, 320 | "custom_tone_mapping" : False, 321 | "dv_config_type" : None, 322 | } 323 | model_name = self._system_info["modelName"] 324 | if model_name.startswith("OLED") and len(model_name)>7: 325 | model = model_name[6] 326 | year = int(model_name[7]) 327 | if year >= 8: 328 | info["lut1d"] = True 329 | if model == "B": 330 | info["lut3d_size"] = 17 331 | else: 332 | info["lut3d_size"] = 33 333 | if year == 8: 334 | info["dv_config_type"] = 2018 335 | elif year == 9: 336 | info["custom_tone_mapping"] = True 337 | info["dv_config_type"] = 2019 338 | elif len(model_name)>5: 339 | size = None 340 | try: 341 | size = int(model_name[0:2]) 342 | except ValueError: 343 | pass 344 | if size: 345 | modeltype = model_name[2] 346 | modelyear = model_name[3] 347 | modelseries = model_name[4] 348 | modelnumber = model_name[5] 349 | 350 | if modeltype=="S" and modelyear in ["K", "M"] and modelseries>=8: 351 | info["lut1d"] = True 352 | if modelseries==9 and modelnumber==9: 353 | info["lut3d_size"] = 33 354 | else: 355 | info["lut3d_size"] = 17 356 | if modelyear == "K": 357 | info["dv_config_type"] = 2018 358 | elif modelyear == "M": 359 | info["custom_tone_mapping"] = True 360 | info["dv_config_type"] = 2019 361 | 362 | return info 363 | 364 | async def register_state_update_callback(self, callback): 365 | self.state_update_callbacks.append(callback) 366 | if self.doStateUpdate: 367 | await callback() 368 | 369 | def unregister_state_update_callback(self, callback): 370 | if callback in self.state_update_callbacks: 371 | self.state_update_callbacks.remove(callback) 372 | 373 | def clear_state_update_callbacks(self): 374 | self.state_update_callbacks = [] 375 | 376 | async def do_state_update_callbacks(self): 377 | callbacks = set() 378 | for callback in self.state_update_callbacks: 379 | callbacks.add(callback()) 380 | 381 | if callbacks: 382 | await asyncio.gather(*callbacks) 383 | 384 | async def set_current_app_state(self, appId): 385 | self._current_appId = appId 386 | 387 | if self.state_update_callbacks and self.doStateUpdate: 388 | await self.do_state_update_callbacks() 389 | 390 | async def set_muted_state(self, muted): 391 | self._muted = muted 392 | 393 | if self.state_update_callbacks and self.doStateUpdate: 394 | await self.do_state_update_callbacks() 395 | 396 | async def set_volume_state(self, volume): 397 | self._volume = volume 398 | 399 | if self.state_update_callbacks and self.doStateUpdate: 400 | await self.do_state_update_callbacks() 401 | 402 | async def set_current_channel_state(self, channel): 403 | self._current_channel = channel 404 | 405 | if self.state_update_callbacks and self.doStateUpdate: 406 | await self.do_state_update_callbacks() 407 | 408 | async def set_apps_state(self, apps): 409 | self._apps = {} 410 | for app in apps: 411 | self._apps[app["id"]] = app 412 | 413 | if self.state_update_callbacks and self.doStateUpdate: 414 | await self.do_state_update_callbacks() 415 | 416 | async def set_inputs_state(self, extinputs): 417 | self._extinputs = {} 418 | for extinput in extinputs: 419 | self._extinputs[extinput["appId"]] = extinput 420 | 421 | if self.state_update_callbacks and self.doStateUpdate: 422 | await self.do_state_update_callbacks() 423 | 424 | #low level request handling 425 | 426 | async def command(self, request_type, uri, payload=None, uid=None): 427 | """Build and send a command.""" 428 | if uid is None: 429 | uid = self.command_count 430 | self.command_count += 1 431 | 432 | if payload is None: 433 | payload = {} 434 | 435 | message = { 436 | 'id': uid, 437 | 'type': request_type, 438 | 'uri': f"ssap://{uri}", 439 | 'payload': payload, 440 | } 441 | 442 | if self.connection is None: 443 | raise PyLGTVCmdException("Not connected, can't execute command.") 444 | 445 | await self.connection.send(json.dumps(message)) 446 | 447 | async def request(self, uri, payload=None, cmd_type='request', uid=None): 448 | """Send a request and wait for response.""" 449 | if uid is None: 450 | uid = self.command_count 451 | self.command_count += 1 452 | res = asyncio.Future() 453 | self.futures[uid] = res 454 | try: 455 | await self.command(cmd_type, uri, payload, uid) 456 | except (asyncio.CancelledError, PyLGTVCmdException): 457 | del self.futures[uid] 458 | raise 459 | try: 460 | response = await res 461 | except asyncio.CancelledError: 462 | if uid in self.futures: 463 | del self.futures[uid] 464 | raise 465 | del self.futures[uid] 466 | 467 | payload = response.get('payload') 468 | if payload is None: 469 | raise PyLGTVCmdException(f"Invalid request response {response}") 470 | 471 | if cmd_type == 'request': 472 | returnValue = payload.get('returnValue') 473 | elif cmd_type == 'subscribe': 474 | returnValue = payload.get('subscribed') 475 | else: 476 | returnValue = None 477 | 478 | if returnValue is None: 479 | raise PyLGTVCmdException(f"Invalid request response {response}") 480 | elif not returnValue: 481 | raise PyLGTVCmdException(f"Request failed with response {response}") 482 | 483 | return payload 484 | 485 | async def subscribe(self, callback, uri, payload=None): 486 | """Subscribe to updates.""" 487 | uid = self.command_count 488 | self.command_count += 1 489 | self.callbacks[uid] = callback 490 | try: 491 | return await self.request(uri, payload=payload, cmd_type='subscribe', uid=uid) 492 | except: 493 | del self.callbacks[uid] 494 | raise 495 | 496 | async def input_command(self, message): 497 | if self.input_connection is None: 498 | raise PyLGTVCmdException("Couldn't execute input command.") 499 | 500 | await self.input_connection.send(message) 501 | 502 | #high level request handling 503 | 504 | async def button(self, name): 505 | """Send button press command.""" 506 | 507 | message = f"type:button\nname:{name}\n\n" 508 | await self.input_command(message) 509 | 510 | async def move(self, dx, dy, down=0): 511 | """Send cursor move command.""" 512 | 513 | message = f"type:move\ndx:{dx}\ndy:{dy}\ndown:{down}\n\n" 514 | await self.input_command(message) 515 | 516 | async def click(self): 517 | """Send cursor click command.""" 518 | 519 | message = f"type:click\n\n" 520 | await self.input_command(message) 521 | 522 | async def scroll(self, dx, dy): 523 | """Send scroll command.""" 524 | 525 | message = f"type:scroll\ndx:{dx}\ndy:{dy}\n\n" 526 | await self.input_command(message) 527 | 528 | async def send_message(self, message, icon_path=None): 529 | """Show a floating message.""" 530 | icon_encoded_string = '' 531 | icon_extension = '' 532 | 533 | if icon_path is not None: 534 | icon_extension = os.path.splitext(icon_path)[1][1:] 535 | with open(icon_path, 'rb') as icon_file: 536 | icon_encoded_string = base64.b64encode(icon_file.read()).decode('ascii') 537 | 538 | return await self.request(EP_SHOW_MESSAGE, { 539 | 'message': message, 540 | 'iconData': icon_encoded_string, 541 | 'iconExtension': icon_extension 542 | }) 543 | 544 | # Apps 545 | async def get_apps(self): 546 | """Return all apps.""" 547 | res = await self.request(EP_GET_APPS) 548 | return res.get('launchPoints') 549 | 550 | async def subscribe_apps(self, callback): 551 | """Subscribe to changes in available apps.""" 552 | 553 | async def apps(payload): 554 | await callback(payload.get('launchPoints')) 555 | 556 | return await self.subscribe(apps, EP_GET_APPS) 557 | 558 | async def get_current_app(self): 559 | """Get the current app id.""" 560 | res = await self.request(EP_GET_CURRENT_APP_INFO) 561 | return res.get('appId') 562 | 563 | async def subscribe_current_app(self, callback): 564 | """Subscribe to changes in the current app id.""" 565 | 566 | async def current_app(payload): 567 | await callback(payload.get('appId')) 568 | 569 | return await self.subscribe(current_app, EP_GET_CURRENT_APP_INFO) 570 | 571 | async def launch_app(self, app): 572 | """Launch an app.""" 573 | return await self.request(EP_LAUNCH, { 574 | 'id': app 575 | }) 576 | 577 | async def launch_app_with_params(self, app, params): 578 | """Launch an app with parameters.""" 579 | return await self.request(EP_LAUNCH, { 580 | 'id': app, 581 | 'params': params 582 | }) 583 | 584 | async def launch_app_with_content_id(self, app, contentId): 585 | """Launch an app with contentId.""" 586 | return await self.request(EP_LAUNCH, { 587 | 'id': app, 588 | 'contentId': contentId 589 | }) 590 | 591 | async def close_app(self, app): 592 | """Close the current app.""" 593 | return await self.request(EP_LAUNCHER_CLOSE, { 594 | 'id': app 595 | }) 596 | 597 | # Services 598 | async def get_services(self): 599 | """Get all services.""" 600 | res = await self.request(EP_GET_SERVICES) 601 | return res.get('services') 602 | 603 | async def get_software_info(self): 604 | """Return the current software status.""" 605 | return await self.request(EP_GET_SOFTWARE_INFO) 606 | 607 | async def get_system_info(self): 608 | """Return the system information.""" 609 | return await self.request(EP_GET_SYSTEM_INFO) 610 | 611 | async def power_off(self, disconnect=None): 612 | """Power off TV.""" 613 | if disconnect is None: 614 | disconnect = not self.standby_connection 615 | 616 | if disconnect: 617 | #if tv is shutting down and standby++ option is not enabled, 618 | #response is unreliable, so don't wait for one, 619 | #and force immediate disconnect 620 | await self.command('request', EP_POWER_OFF) 621 | await self.disconnect() 622 | else: 623 | #if standby++ option is enabled, connection stays open 624 | #and TV responds gracefully to power off request 625 | return await self.request(EP_POWER_OFF) 626 | 627 | async def power_on(self): 628 | """Play media.""" 629 | return await self.request(EP_POWER_ON) 630 | 631 | # 3D Mode 632 | async def turn_3d_on(self): 633 | """Turn 3D on.""" 634 | return await self.request(EP_3D_ON) 635 | 636 | async def turn_3d_off(self): 637 | """Turn 3D off.""" 638 | return await self.request(EP_3D_OFF) 639 | 640 | # Inputs 641 | async def get_inputs(self): 642 | """Get all inputs.""" 643 | res = await self.request(EP_GET_INPUTS) 644 | return res.get('devices') 645 | 646 | async def subscribe_inputs(self, callback): 647 | """Subscribe to changes in available inputs.""" 648 | 649 | async def inputs(payload): 650 | await callback(payload.get('devices')) 651 | 652 | return await self.subscribe(inputs, EP_GET_INPUTS) 653 | 654 | async def get_input(self): 655 | """Get current input.""" 656 | return await self.get_current_app() 657 | 658 | async def set_input(self, input): 659 | """Set the current input.""" 660 | return await self.request(EP_SET_INPUT, { 661 | 'inputId': input 662 | }) 663 | 664 | # Audio 665 | async def get_audio_status(self): 666 | """Get the current audio status""" 667 | return await self.request(EP_GET_AUDIO_STATUS) 668 | 669 | async def get_muted(self): 670 | """Get mute status.""" 671 | status = await self.get_audio_status() 672 | return status.get('mute') 673 | 674 | async def subscribe_muted(self, callback): 675 | """Subscribe to changes in the current mute status.""" 676 | 677 | async def muted(payload): 678 | await callback(payload.get('mute')) 679 | 680 | return await self.subscribe(muted, EP_GET_AUDIO_STATUS) 681 | 682 | async def set_mute(self, mute): 683 | """Set mute.""" 684 | return await self.request(EP_SET_MUTE, { 685 | 'mute': mute 686 | }) 687 | 688 | async def get_volume(self): 689 | """Get the current volume.""" 690 | res = await self.request(EP_GET_VOLUME) 691 | return res.get('volume') 692 | 693 | async def subscribe_volume(self, callback): 694 | """Subscribe to changes in the current volume.""" 695 | 696 | async def volume(payload): 697 | await callback(payload.get('volume')) 698 | 699 | return await self.subscribe(volume, EP_GET_VOLUME) 700 | 701 | async def set_volume(self, volume): 702 | """Set volume.""" 703 | volume = max(0, volume) 704 | return await self.request(EP_SET_VOLUME, { 705 | 'volume': volume 706 | }) 707 | 708 | async def volume_up(self): 709 | """Volume up.""" 710 | return await self.request(EP_VOLUME_UP) 711 | 712 | async def volume_down(self): 713 | """Volume down.""" 714 | return await self.request(EP_VOLUME_DOWN) 715 | 716 | # TV Channel 717 | async def channel_up(self): 718 | """Channel up.""" 719 | return await self.request(EP_TV_CHANNEL_UP) 720 | 721 | async def channel_down(self): 722 | """Channel down.""" 723 | return await self.request(EP_TV_CHANNEL_DOWN) 724 | 725 | async def get_channels(self): 726 | """Get all tv channels.""" 727 | res = await self.request(EP_GET_TV_CHANNELS) 728 | return res.get('channelList') 729 | 730 | async def get_current_channel(self): 731 | """Get the current tv channel.""" 732 | return await self.request(EP_GET_CURRENT_CHANNEL) 733 | 734 | async def subscribe_current_channel(self, callback): 735 | """Subscribe to changes in the current tv channel.""" 736 | return await self.subscribe(callback, EP_GET_CURRENT_CHANNEL) 737 | 738 | async def get_channel_info(self): 739 | """Get the current channel info.""" 740 | return await self.request(EP_GET_CHANNEL_INFO) 741 | 742 | async def set_channel(self, channel): 743 | """Set the current channel.""" 744 | return await self.request(EP_SET_CHANNEL, { 745 | 'channelId': channel 746 | }) 747 | 748 | # Media control 749 | async def play(self): 750 | """Play media.""" 751 | return await self.request(EP_MEDIA_PLAY) 752 | 753 | async def pause(self): 754 | """Pause media.""" 755 | return await self.request(EP_MEDIA_PAUSE) 756 | 757 | async def stop(self): 758 | """Stop media.""" 759 | return await self.request(EP_MEDIA_STOP) 760 | 761 | async def close(self): 762 | """Close media.""" 763 | return await self.request(EP_MEDIA_CLOSE) 764 | 765 | async def rewind(self): 766 | """Rewind media.""" 767 | return await self.request(EP_MEDIA_REWIND) 768 | 769 | async def fast_forward(self): 770 | """Fast Forward media.""" 771 | return await self.request(EP_MEDIA_FAST_FORWARD) 772 | 773 | # Keys 774 | async def send_enter_key(self): 775 | """Send enter key.""" 776 | return await self.request(EP_SEND_ENTER) 777 | 778 | async def send_delete_key(self): 779 | """Send delete key.""" 780 | return await self.request(EP_SEND_DELETE) 781 | 782 | # Web 783 | async def open_url(self, url): 784 | """Open URL.""" 785 | return await self.request(EP_OPEN, { 786 | 'target': url 787 | }) 788 | 789 | async def close_web(self): 790 | """Close web app.""" 791 | return await self.request(EP_CLOSE_WEB_APP) 792 | 793 | #Emulated button presses 794 | async def left_button(self): 795 | """left button press.""" 796 | await self.button("LEFT") 797 | 798 | async def right_button(self): 799 | """right button press.""" 800 | await self.button("RIGHT") 801 | 802 | async def down_button(self): 803 | """down button press.""" 804 | await self.button("DOWN") 805 | 806 | async def up_button(self): 807 | """up button press.""" 808 | await self.button("UP") 809 | 810 | async def home_button(self): 811 | """home button press.""" 812 | await self.button("HOME") 813 | 814 | async def back_button(self): 815 | """back button press.""" 816 | await self.button("BACK") 817 | 818 | async def ok_button(self): 819 | """ok button press.""" 820 | await self.button("ENTER") 821 | 822 | async def dash_button(self): 823 | """dash button press.""" 824 | await self.button("DASH") 825 | 826 | async def info_button(self): 827 | """info button press.""" 828 | await self.button("INFO") 829 | 830 | async def asterisk_button(self): 831 | """asterisk button press.""" 832 | await self.button("ASTERISK") 833 | 834 | async def cc_button(self): 835 | """cc button press.""" 836 | await self.button("CC") 837 | 838 | async def exit_button(self): 839 | """exit button press.""" 840 | await self.button("EXIT") 841 | 842 | async def mute_button(self): 843 | """mute button press.""" 844 | await self.button("MUTE") 845 | 846 | async def red_button(self): 847 | """red button press.""" 848 | await self.button("RED") 849 | 850 | async def green_button(self): 851 | """green button press.""" 852 | await self.button("GREEN") 853 | 854 | async def blue_button(self): 855 | """blue button press.""" 856 | await self.button("BLUE") 857 | 858 | async def volume_up_button(self): 859 | """volume up button press.""" 860 | await self.button("VOLUMEUP") 861 | 862 | async def volume_down_button(self): 863 | """volume down button press.""" 864 | await self.button("VOLUMEDOWN") 865 | 866 | async def channel_up_button(self): 867 | """channel up button press.""" 868 | await self.button("CHANNELUP") 869 | 870 | async def channel_down_button(self): 871 | """channel down button press.""" 872 | await self.button("CHANNELDOWN") 873 | 874 | async def number_button(self, num): 875 | """numeric button press.""" 876 | if not (num>=0 and num<=9): 877 | raise ValueError 878 | 879 | await self.button(f"""{num}""") 880 | 881 | def validateCalibrationData(self, data, shape, dtype): 882 | if type(data) is not np.ndarray: 883 | raise TypeError 884 | if data.shape != shape: 885 | raise ValueError 886 | if data.dtype != dtype: 887 | raise TypeError 888 | 889 | async def calibration_request(self, command, picMode, data): 890 | dataenc = base64.b64encode(data.tobytes()).decode() 891 | 892 | payload = { 893 | "command" : command, 894 | "data" : dataenc, 895 | "dataCount" : data.size, 896 | "dataOpt" : 1, 897 | "dataType" : CALIBRATION_TYPE_MAP[data.dtype.name], 898 | "profileNo" : 0, 899 | "programID" : 1, 900 | "picMode" : picMode, 901 | } 902 | 903 | return await self.request(EP_CALIBRATION, payload) 904 | 905 | async def start_calibration(self, picMode, data=DEFAULT_CAL_DATA): 906 | self.validateCalibrationData(data, (9,), np.float32) 907 | return await self.calibration_request("CAL_START", picMode, data) 908 | 909 | async def end_calibration(self, picMode, data=DEFAULT_CAL_DATA): 910 | self.validateCalibrationData(data, (9,), np.float32) 911 | return await self.calibration_request("CAL_END", picMode, data) 912 | 913 | async def upload_1d_lut(self, picMode, data=None): 914 | info = self.calibration_support_info() 915 | if not info["lut1d"]: 916 | model = self._system_info["modelName"] 917 | raise PyLGTVCmdException(f"1D LUT Upload not supported by tv model {model}.") 918 | if data is None: 919 | data = unity_lut_1d() 920 | self.validateCalibrationData(data, (3,1024), np.uint16) 921 | return await self.calibration_request("1D_DPG_DATA", picMode, data) 922 | 923 | async def upload_3d_lut(self, command, picMode, data): 924 | if command not in ["BT709_3D_LUT_DATA", "BT2020_3D_LUT_DATA"]: 925 | raise PyLGTVCmdException(f"Invalid 3D LUT Upload command {command}.") 926 | info = self.calibration_support_info() 927 | lut3d_size = info["lut3d_size"] 928 | if not lut3d_size: 929 | model = self._system_info["modelName"] 930 | raise PyLGTVCmdException(f"3D LUT Upload not supported by tv model {model}.") 931 | if data is None: 932 | data = unity_lut_3d(lut3d_size) 933 | lut3d_shape = (lut3d_size,lut3d_size,lut3d_size,3) 934 | self.validateCalibrationData(data, lut3d_shape, np.uint16) 935 | return await self.calibration_request(command, picMode, data) 936 | 937 | async def upload_3d_lut_bt709(self, picMode, data=None): 938 | return await self.upload_3d_lut("BT709_3D_LUT_DATA", picMode, data) 939 | 940 | async def upload_3d_lut_bt2020(self, picMode, data=None): 941 | return await self.upload_3d_lut("BT2020_3D_LUT_DATA", picMode, data) 942 | 943 | async def set_ui_data(self, command, picMode, value): 944 | if isinstance(value, str): 945 | value = int(value) 946 | 947 | if not (value>=0 and value <=100): 948 | raise ValueError 949 | 950 | data = np.array(value, dtype=np.uint16) 951 | return await self.calibration_request(command, picMode, data) 952 | 953 | async def set_brightness(self, picMode, value=50): 954 | return await self.set_ui_data("BRIGHTNESS_UI_DATA", picMode, value) 955 | 956 | async def set_contrast(self, picMode, value=85): 957 | return await self.set_ui_data("CONTRAST_UI_DATA", picMode, value) 958 | 959 | async def set_oled_light(self, picMode, value=80): 960 | return await self.set_ui_data("BACKLIGHT_UI_DATA", picMode, value) 961 | 962 | async def set_color(self, picMode, value=50): 963 | return await self.set_ui_data("COLOR_UI_DATA", picMode, value) 964 | 965 | async def set_1d_2_2_en(self, picMode, value=0): 966 | data = np.array(value, dtype=np.uint16) 967 | return await self.calibration_request("1D_2_2_EN", picMode, data) 968 | 969 | async def set_1d_0_45_en(self, picMode, value=0): 970 | data = np.array(value, dtype=np.uint16) 971 | return await self.calibration_request("1D_0_45_EN", picMode, data) 972 | 973 | async def set_bt709_3by3_gamut_data(self, picMode, data=np.identity(3, dtype=np.float32)): 974 | self.validateCalibrationData(data, (3,3), np.float32) 975 | return await self.calibration_request("BT709_3BY3_GAMUT_DATA", picMode, data) 976 | 977 | async def set_bt2020_3by3_gamut_data(self, picMode, data=np.identity(3, dtype=np.float32)): 978 | self.validateCalibrationData(data, (3,3), np.float32) 979 | return await self.calibration_request("BT709_3BY3_GAMUT_DATA", picMode, data) 980 | 981 | async def ddc_reset(self, picMode): 982 | await self.set_brightness(picMode) 983 | await self.set_contrast(picMode) 984 | await self.set_oled_light(picMode) 985 | await self.set_color(picMode) 986 | await self.set_1d_2_2_en(picMode) 987 | await self.set_1d_0_45_en(picMode) 988 | await self.set_bt709_3by3_gamut_data(picMode) 989 | await self.set_bt2020_3by3_gamut_data(picMode) 990 | await self.upload_3d_lut_bt709(picMode) 991 | await self.upload_3d_lut_bt2020(picMode) 992 | await self.upload_1d_lut(picMode) 993 | 994 | async def get_picture_settings(self, keys=["contrast","backlight","brightness","color"]): 995 | payload = { 996 | "category" : "picture", 997 | "keys" : keys, 998 | } 999 | ret = await self.request(EP_GET_SYSTEM_SETTINGS, payload=payload) 1000 | return ret["settings"] 1001 | 1002 | async def upload_1d_lut_from_file(self, picMode, filename): 1003 | ext = filename.split(".")[-1].lower() 1004 | if ext == "cal": 1005 | lut = read_cal_file(filename) 1006 | elif ext == "cube": 1007 | lut = read_cube_file(filename) 1008 | else: 1009 | raise ValueError(f"Unsupported file format {ext} for 1D LUT. Supported file formats are cal and cube.") 1010 | 1011 | return await self.upload_1d_lut(picMode, lut) 1012 | 1013 | async def upload_3d_lut_from_file(self, command, picMode, filename): 1014 | ext = filename.split(".")[-1].lower() 1015 | if ext == "cube": 1016 | lut = read_cube_file(filename) 1017 | else: 1018 | raise ValueError(f"Unsupported file format {ext} for 3D LUT. Supported file formats are cube.") 1019 | 1020 | return await self.upload_3d_lut(command, picMode, lut) 1021 | 1022 | async def upload_3d_lut_bt709_from_file(self, picMode, filename): 1023 | return await self.upload_3d_lut_from_file("BT709_3D_LUT_DATA", picMode, filename) 1024 | 1025 | async def upload_3d_lut_bt2020_from_file(self, picMode, filename): 1026 | return await self.upload_3d_lut_from_file("BT2020_3D_LUT_DATA", picMode, filename) 1027 | --------------------------------------------------------------------------------