"""RemoteControl class for Panasonic Viera TV control.""" import logging import random import socket import base64 import struct import hmac import hashlib from http import HTTPStatus import re import asyncio from xml.etree import ElementTree import aiohttp.web import xmltodict from Crypto.Cipher import AES try: from urllib.request import urlopen, Request, HTTPError, build_opener, HTTPHandler except ImportError: from urllib2 import urlopen, Request, HTTPError, build_opener, HTTPHandler from .constants import ( URN_RENDERING_CONTROL, URN_REMOTE_CONTROL, URL_TEMPLATE, URL_CONTROL_NRC_DDD, URL_CONTROL_NRC_DEF, URL_CONTROL_DMR, URL_CONTROL_NRC, TV_TYPE_NONENCRYPTED, TV_TYPE_ENCRYPTED, DEFAULT_PORT, pad, ) from .exceptions import SOAPError, EncryptionRequired from .keys import Keys from .apps import Apps _LOGGER = logging.getLogger(__name__) class RemoteControl: """This class represents a Panasonic Viera TV Remote Control.""" def __init__( self, host, port=DEFAULT_PORT, app_id=None, encryption_key=None, listen_host=None, listen_port=DEFAULT_PORT, ): """Initialise the remote control.""" self._host = host self._port = port self._app_id = app_id self._enc_key = encryption_key self._listen_host = listen_host self._listen_port = listen_port self._session_key = None self._session_iv = None self._session_id = None self._session_seq_num = None self._session_hmac_key = None self._service_to_sid = {} self._sid_to_service = {} self._aiohttp_server = None self._server = None if self._app_id is None or self._enc_key is None: self._type = TV_TYPE_NONENCRYPTED else: self._type = TV_TYPE_ENCRYPTED self._derive_session_keys() self._request_session_id() # Determine if the TV uses encryption or not if self._type == TV_TYPE_NONENCRYPTED: url = URL_TEMPLATE.format(self._host, self._port, URL_CONTROL_NRC_DEF) _LOGGER.debug("Determining TV type\n") res = urlopen(url, timeout=5).read() root = ElementTree.fromstring(res) for child in root: if child.tag.endswith("actionList"): for subchild in child.iter(): if ( subchild.tag.endswith("name") and subchild.text == "X_GetEncryptSessionId" ): self._type = TV_TYPE_ENCRYPTED tv_enc_type = ( "encrypted" if self._type == TV_TYPE_ENCRYPTED else "non-encrypted" ) _LOGGER.debug("Determined TV type is %s\n", tv_enc_type) def soap_request(self, url, urn, action, params, body_elem="m"): """Send a SOAP request to the TV.""" is_encrypted = False # Encapsulate URN_REMOTE_CONTROL command in an X_EncryptedCommand if we're using encryption if urn == URN_REMOTE_CONTROL and action not in [ "X_GetEncryptSessionId", "X_DisplayPinCode", "X_RequestAuth", ]: if None not in [ self._session_key, self._session_iv, self._session_hmac_key, self._session_id, self._session_seq_num, ]: is_encrypted = True self._session_seq_num += 1 body_elem = "u" encrypted_command = ( f"{self._session_id}" f"{self._session_seq_num:08d}" "" f'<{body_elem}:{action} xmlns:{body_elem}="urn:{urn}">' f"{params}" f"" "" ) encrypted_command = self._encrypt_soap_payload( encrypted_command, self._session_key, self._session_iv, self._session_hmac_key, ) action = "X_EncryptedCommand" params = ( f"{self._app_id}" f"{encrypted_command}" ) body_elem = "u" elif self._type == TV_TYPE_ENCRYPTED: raise EncryptionRequired( "Please refer to the docs for using encryption" ) # Construct SOAP request soap_body = ( '' '' "" f'<{body_elem}:{action} xmlns:{body_elem}="urn:{urn}">' f"{params}" f"" "" "" ).encode("utf-8") headers = { "Host": f"{self._host}:{self._port}", "Content-Length": len(soap_body), "Content-Type": "text/xml; charset=utf-8", "SOAPAction": f'"urn:{urn}#{action}"', } url = URL_TEMPLATE.format(self._host, self._port, url) _LOGGER.debug("Sending to %s:\n%s\n%s", url, headers, soap_body) req = Request(url, soap_body, headers) try: res = urlopen(req, timeout=5).read() except HTTPError as ex: if self._session_seq_num is not None: self._session_seq_num -= 1 raise ex # Pass to the next handler _LOGGER.debug("Response: %s", res) if is_encrypted: root = ElementTree.fromstring(res) enc_result = root.find(".//X_EncResult").text enc_result_decrypted = self._decrypt_soap_payload( enc_result, self._session_key, self._session_iv, self._session_hmac_key ) res = enc_result_decrypted return res def _derive_session_keys(self): init_vector = bytearray(base64.b64decode(self._enc_key)) self._session_iv = init_vector # Get character codes from IV bytes iv_vals = [c for c in init_vector] # Initialise key character codes array key_vals = [0] * 16 # Derive key from IV i = 0 while i < 16: key_vals[i] = iv_vals[i + 2] key_vals[i + 1] = iv_vals[i + 3] key_vals[i + 2] = iv_vals[i] key_vals[i + 3] = iv_vals[i + 1] i += 4 # Convert our key character codes to bytes # self._session_key = ''.join(chr(c) for c in key_vals) self._session_key = bytearray(c for c in key_vals) # HMAC key for comms is just the IV repeated twice self._session_hmac_key = init_vector * 2 def _encrypt_soap_payload(self, data, key, init_vector, hmac_key): # The encrypted payload must begin with a 16-byte header (12 random bytes, and 4 bytes for # the payload length in big endian) # Note: the server does not appear to ever send back valid payload lengths in bytes 13-16, # so I would assume these can also be randomized by the client, but we'll set them anyway # to be safe. payload = bytearray(random.randint(0, 255) for _ in range(12)) payload += struct.pack(">I", len(data)) payload += data.encode("latin-1") # For compatibility with both Python 2.x and 3.x, flattening types to 'str' or 'bytes' init_vector = init_vector.decode("latin-1").encode("latin-1") key = key.decode("latin-1").encode("latin-1") payload = pad(payload.decode("latin-1")).encode("latin-1") hmac_key = hmac_key.decode("latin-1").encode("latin-1") # Initialize AES-CBC with key and IV aes = AES.new(key, AES.MODE_CBC, init_vector) # Encrypt with zero-padding ciphertext = aes.encrypt(payload) # Compute HMAC-SHA-256 sig = hmac.new(hmac_key, ciphertext, hashlib.sha256).digest() # Concat HMAC with AES-encrypted payload return base64.b64encode(ciphertext + sig).decode("latin-1") def _decrypt_soap_payload(self, data, key, init_vector, hmac_key): # For compatibility with both Python 2.x and 3.x, flattening types to 'str' or 'bytes' key = key.decode("latin-1").encode("latin-1") init_vector = init_vector.decode("latin-1").encode("latin-1") # Initialize AES-CBC with key and IV aes = AES.new(key, AES.MODE_CBC, init_vector) # Decrypt decrypted = aes.decrypt(base64.b64decode(data)).decode("latin-1") # Unpad and return return decrypted[16:].split("\0")[0] def request_pin_code(self, name="My Remote"): # First let's ask for a pin code and get a challenge key back params = "" + name + "" try: res = self.soap_request( URL_CONTROL_NRC, URN_REMOTE_CONTROL, "X_DisplayPinCode", params, body_elem="u", ) except HTTPError as ex: if ex.code == 500: xml = ElementTree.fromstring(ex.fp.read()) for child in xml.iter(): if child.tag.endswith("errorDescription"): raise SOAPError(child.text) return raise ex # Pass to the next handler root = ElementTree.fromstring(res) self._challenge = bytearray( base64.b64decode(root.find(".//X_ChallengeKey").text) ) def authorize_pin_code(self, pincode): # Second, let's encrypt the pin code using the challenge key and send it back # to authenticate # Derive key from IV init_vector = self._challenge key = bytearray([0] * 16) i = 0 while i < 16: key[i] = ~init_vector[i + 3] & 0xFF key[i + 1] = ~init_vector[i + 2] & 0xFF key[i + 2] = ~init_vector[i + 1] & 0xFF key[i + 3] = ~init_vector[i] & 0xFF i += 4 # Derive HMAC key from IV & HMAC key mask (taken from libtvconnect.so) hmac_key_mask_vals = [ 0x15, 0xC9, 0x5A, 0xC2, 0xB0, 0x8A, 0xA7, 0xEB, 0x4E, 0x22, 0x8F, 0x81, 0x1E, 0x34, 0xD0, 0x4F, 0xA5, 0x4B, 0xA7, 0xDC, 0xAC, 0x98, 0x79, 0xFA, 0x8A, 0xCD, 0xA3, 0xFC, 0x24, 0x4F, 0x38, 0x54, ] hmac_key = bytearray([0] * 32) i = 0 while i < 32: hmac_key[i] = hmac_key_mask_vals[i] ^ init_vector[(i + 2) & 0xF] hmac_key[i + 1] = hmac_key_mask_vals[i + 1] ^ init_vector[(i + 3) & 0xF] hmac_key[i + 2] = hmac_key_mask_vals[i + 2] ^ init_vector[i & 0xF] hmac_key[i + 3] = hmac_key_mask_vals[i + 3] ^ init_vector[(i + 1) & 0xF] i += 4 # Encrypt X_PinCode argument and send it within an X_AuthInfo tag payload = self._encrypt_soap_payload( f"{pincode}", key, init_vector, hmac_key ) params = f"{payload}" try: res = self.soap_request( URL_CONTROL_NRC, URN_REMOTE_CONTROL, "X_RequestAuth", params, body_elem="u", ) except HTTPError as ex: if ex.code == 500: xml = ElementTree.fromstring(ex.fp.read()) for child in xml.iter(): if child.tag.endswith("errorCode") and child.text == "600": raise SOAPError("Invalid PIN Code!") elif child.tag.endswith("errorDescription"): raise SOAPError(child.text) return raise ex # Pass to the next handler # Parse and decrypt X_AuthResult root = ElementTree.fromstring(res) auth_result = root.find(".//X_AuthResult").text payload = self._decrypt_soap_payload(auth_result, key, init_vector, hmac_key) auth_result_decrypted = ElementTree.fromstring(f"{payload}") # Set session application ID and encryption key self._app_id = auth_result_decrypted.find(".//X_ApplicationId").text self._enc_key = auth_result_decrypted.find(".//X_Keyword").text # Derive AES & HMAC keys from X_Keyword self._derive_session_keys() # Request a session self._request_session_id() def _request_session_id(self): # Thirdly, let's ask for a session. We'll need to use a valid session ID for encrypted # NRC commands. # We need to send an encrypted version of X_ApplicationId encinfo = self._encrypt_soap_payload( "" + self._app_id + "", self._session_key, self._session_iv, self._session_hmac_key, ) # Send the encrypted SOAP request along with plaintext X_ApplicationId params = ( f"{self._app_id}" f"{encinfo}" ) try: res = self.soap_request( URL_CONTROL_NRC, URN_REMOTE_CONTROL, "X_GetEncryptSessionId", params, body_elem="u", ) except HTTPError as ex: if ex.code == 500: xml = ElementTree.fromstring(ex.fp.read()) for child in xml.iter(): if child.tag.endswith("errorDescription"): raise SOAPError(child.text) return raise ex # Pass to the next handler root = ElementTree.fromstring(res) enc_result = root.find(".//X_EncResult").text enc_result_decrypted = ElementTree.fromstring( "" + self._decrypt_soap_payload( enc_result, self._session_key, self._session_iv, self._session_hmac_key ) + "" ) # Set session ID and begin sequence number at 1. We have to increment the sequence number # upon each successful NRC command. self._session_id = enc_result_decrypted.find(".//X_SessionId").text self._session_seq_num = 1 # Taken from https://github.com/home-assistant/ file: home-assistant/homeassistant/util # /__init__.py def _get_local_ip(self): """Try to determine the local IP address of the machine.""" try: sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # Use Google Public DNS server to determine own IP sock.connect(("8.8.8.8", 80)) return sock.getsockname()[0] except socket.error: try: return socket.gethostbyname(socket.gethostname()) except socket.gaierror: return "127.0.0.1" finally: sock.close() def _do_custom_request(self, method, url, headers=None, timeout=10): opener = build_opener(HTTPHandler) req = Request(url, headers=headers, method=method) res = opener.open(req, timeout=timeout) status = res.status header = dict(res.info()) return status, header def upnp_service_subscribe(self, service, timeout=10): """Subscribe to a UPnP service.""" headers = { "NT": "upnp:event", "TIMEOUT": "Second-" + str(timeout), "HOST": f"{self._host}:{self._port}", "CALLBACK": f"", } status, headers = self._do_custom_request( "SUBSCRIBE", f"http://{self._host}:{self._port}/{service}", headers=headers, timeout=timeout, ) if "SID" in headers and headers["SID"]: self._service_to_sid[service] = headers["SID"] self._sid_to_service[headers["SID"]] = service return status, headers def upnp_service_resubscribe(self, service, timeout=10): """Renew subscription to a UPnP service.""" if service not in self._service_to_sid: _LOGGER.error("Couldn't renew subscription of service %s", service) return headers = { "HOST": f"{self._host}:{self._port}", "SID": self._service_to_sid[service], "TIMEOUT": "Second-" + str(timeout), } status, headers = self._do_custom_request( "SUBSCRIBE", f"http://{self._host}:{self._port}/{service}", headers=headers, timeout=timeout, ) if "SID" in headers and headers["SID"]: self._service_to_sid[service] = headers["SID"] self._sid_to_service[headers["SID"]] = service return status, headers def upnp_service_unsubscribe(self, service, timeout=10): """Unsubscribe from a UPnP service.""" if service not in self._service_to_sid: _LOGGER.debug("Couldn't unsubscribe from service %s", service) return headers = { "HOST": f"{self._host}:{self._port}", "SID": self._service_to_sid[service], "TIMEOUT": "Second-" + str(timeout), } status, headers = self._do_custom_request( "UNSUBSCRIBE", f"http://{self._host}:{self._port}/{service}", headers=headers, timeout=timeout, ) self._service_to_sid.pop(service) return status, headers async def async_start_server(self): """Start the HTTP server.""" self._listen_host = self._listen_host or self._get_local_ip() _LOGGER.debug("Creating server at %s:%d", self._listen_host, self._listen_port) self._aiohttp_server = aiohttp.web.Server(self._handle_request) loop = asyncio.get_event_loop() try: self._server = await loop.create_server( self._aiohttp_server, self._listen_host, self._listen_port ) except OSError as error: _LOGGER.error( "Failed to create HTTP server at %s:%d: %s", self._listen_host, self._listen_port, error, ) async def async_stop_server(self, timeout=10): """Stop the HTTP server.""" _LOGGER.debug("Stopping server") if self._aiohttp_server: await self._aiohttp_server.shutdown(timeout) if self._server: self._server.close() async def _handle_request(self, request): """Handle incoming requests.""" if request.method != "NOTIFY": _LOGGER.debug("Request received is not of method notify") return aiohttp.web.Response(status=405) headers = request.headers body = await request.text() if "NT" not in headers or "NTS" not in headers: _LOGGER.debug("Sending response: %s", HTTPStatus.BAD_REQUEST) return HTTPStatus.BAD_REQUEST if ( headers["NT"] != "upnp:event" or headers["NTS"] != "upnp:propchange" or "SID" not in headers ): _LOGGER.debug("Sending response: %s", HTTPStatus.PRECONDITION_FAILED) return HTTPStatus.PRECONDITION_FAILED sid = headers["SID"] service = None if sid in self._sid_to_service: service = self._sid_to_service[sid] body = body.strip().strip("\u0000") root = xmltodict.parse(body) properties = root["e:propertyset"]["e:property"] if "LastChange" in properties: last_change = properties["LastChange"] properties = xmltodict.parse(last_change)["Event"]["InstanceID"] _LOGGER.debug( "Received valid request from service %s. Handling properties:", service, ) _LOGGER.debug(properties) await self.on_event(service, properties) return HTTPStatus.OK async def on_event(self, service, properties): """Parse the received data. This method can be overridden by the user.""" _LOGGER.info("Please override the on_event method to handle the received data.") def get_device_info(self): """Retrieve information from the TV.""" url = URL_TEMPLATE.format(self._host, self._port, URL_CONTROL_NRC_DDD) res = urlopen(url, timeout=5).read() device_info = xmltodict.parse(res)["root"]["device"] return device_info def open_webpage(self, url): """Launch Web Browser and open url.""" resource_id = 1063 params = ( "vc_app" f"resource_id={resource_id}" ) res = self.soap_request( URL_CONTROL_NRC, URN_REMOTE_CONTROL, "X_LaunchApp", params, body_elem="s" ) root = ElementTree.fromstring(res) el_session_id = root.find(".//X_SessionId") # setup a server socket where URL will be served server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) localip = self._get_local_ip() localport = random.randint(1025, 65535) server_socket.bind((localip, localport)) server_socket.listen(1) _LOGGER.debug("Listening on %s:%d", localip, localport) params = ( "vc_app" f"{el_session_id.text}" "panasonic-viera 0.2" f"{localip}:{localport}" ) self.soap_request( URL_CONTROL_NRC, URN_REMOTE_CONTROL, "X_ConnectApp", params, body_elem="s" ) sockfd, addr = server_socket.accept() _LOGGER.debug("Client (%s, %s) connected" % addr) packet = bytearray([0xF4, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, len(url)]) packet.extend(map(ord, url)) packet.append(0x00) sockfd.send(packet) sockfd.close() server_socket.close() def get_apps(self): """Return the list of apps on the TV.""" res = self.soap_request( URL_CONTROL_NRC, URN_REMOTE_CONTROL, "X_GetAppList", None ) apps = res.split("vc_app")[1:] app_list = {} for app in apps: prod_id = re.search(r"(?<=product_id\=)(.*?)(?=')", app).group(0) name = re.search("(?<=" + prod_id + "')(.*?)(?=')", app).group(0) app_list[name] = prod_id return app_list def get_vector_info(self): """Return the vector info on the TV.""" res = self.soap_request( URL_CONTROL_NRC, URN_REMOTE_CONTROL, "X_GetVectorInfo", None ) return res def get_volume(self): """Return the current volume level.""" params = "0Master" res = self.soap_request( URL_CONTROL_DMR, URN_RENDERING_CONTROL, "GetVolume", params ) root = ElementTree.fromstring(res) el_volume = root.find(".//CurrentVolume") return int(el_volume.text) def set_volume(self, volume): """Set a new volume level.""" if volume > 100 or volume < 0: raise Exception( "Bad request to volume control. " "Must be between 0 and 100" ) params = ( "0Master" f"{volume}" ) self.soap_request(URL_CONTROL_DMR, URN_RENDERING_CONTROL, "SetVolume", params) def get_mute(self): """Return if the TV is muted.""" params = "0Master" res = self.soap_request( URL_CONTROL_DMR, URN_RENDERING_CONTROL, "GetMute", params ) root = ElementTree.fromstring(res) el_mute = root.find(".//CurrentMute") return el_mute.text != "0" def set_mute(self, enable): """Mute or unmute the TV.""" data = "1" if enable else "0" params = ( "0Master" f"{data}" ) self.soap_request(URL_CONTROL_DMR, URN_RENDERING_CONTROL, "SetMute", params) def send_key(self, key): """Send a key command to the TV.""" if isinstance(key, Keys): key = key.value params = f"{key}" self.soap_request(URL_CONTROL_NRC, URN_REMOTE_CONTROL, "X_SendKey", params) def launch_app(self, app): """Launch an app.""" if isinstance(app, Apps): app = app.value params = "vc_app" if len(app) != 16: params = params + f"resource_id={app}" else: params = params + f"product_id={app}" self.soap_request(URL_CONTROL_NRC, URN_REMOTE_CONTROL, "X_LaunchApp", params) def turn_off(self): """Turn off media player.""" self.send_key(Keys.POWER) def turn_on(self): """Turn on media player.""" self.send_key(Keys.POWER) def volume_up(self): """Volume up the media player.""" self.send_key(Keys.VOLUME_UP) def volume_down(self): """Volume down media player.""" self.send_key(Keys.VOLUME_DOWN) def mute_volume(self): """Send mute command.""" self.send_key(Keys.MUTE) def media_play(self): """Send play command.""" self.send_key(Keys.PLAY) def media_pause(self): """Send media pause command to media player.""" self.send_key(Keys.PAUSE) def media_next_track(self): """Send next track command.""" self.send_key(Keys.FAST_FORWARD) def media_previous_track(self): """Send the previous track command.""" self.send_key(Keys.REWIND) @property def type(self): """Return TV type.""" return self._type @property def app_id(self): """Return application ID.""" return self._app_id @property def enc_key(self): """Return encryption key.""" return self._enc_key