"""Python library for remote control of Sony Bravia TV.""" from __future__ import annotations import asyncio import logging import socket from contextlib import suppress from datetime import datetime, timedelta from http import HTTPStatus from types import TracebackType from typing import Any from aiohttp import BasicAuth, ClientError, ClientSession, ClientTimeout, CookieJar from yarl import URL from .const import ( CODE_POWER_ON, DEFAULT_AUDIO_TARGET, DEFAULT_TIMEOUT, DEFAULT_VERSION, PAIR_PIN, SERVICE_ACCESS_CONTROL, SERVICE_APP_CONTROL, SERVICE_AUDIO, SERVICE_AV_CONTENT, SERVICE_GUIDE, SERVICE_IRCC, SERVICE_SYSTEM, SERVICE_VIDEO, ) from .exceptions import ( BraviaAuthError, BraviaConnectionError, BraviaConnectionTimeout, BraviaError, BraviaNotFound, BraviaNotSupported, BraviaTurnedOff, ) from .util import deep_redact, normalize_cookies _LOGGER = logging.getLogger(__name__) class BraviaClient: """Represent a Bravia Client.""" def __init__( self, host: str, mac: str | None = None, session: ClientSession | None = None, ssl: bool = False, ssl_verify: bool = False, ) -> None: """Initialize the device.""" self.host = host self.mac = mac self._session = session self._base_url = URL.build(scheme="https" if ssl else "http", host=host) self._ssl_verify = ssl_verify self._auth: BasicAuth | None = None self._psk: str | None = None self._ircc_time: datetime | None = None self._ircc_endpoint = SERVICE_IRCC self._commands: dict[str, str] = {} async def connect( self, pin: str | None = None, psk: str | None = None, clientid: str | None = None, nickname: str | None = None, ) -> None: """Connect to device with PIN or PSK.""" self._psk = pin[4:] if pin and pin[:4] == "psk:" else psk if self._psk: _LOGGER.debug("Connecting with PSK") else: _LOGGER.debug("Connecting with PIN") if self._psk is None: assert pin is not None assert clientid is not None assert nickname is not None await self.register(pin, clientid, nickname) elif self._session is not None: self._auth = None self._session.cookie_jar.clear() system_info = await self.get_system_info() if not system_info: raise BraviaNotSupported if self._psk: _LOGGER.debug("Connected with PSK") else: _LOGGER.debug( "Connected with PIN, cookie len: %s", len(self._session._cookie_jar), # type: ignore[union-attr] ) async def register(self, pin: str, clientid: str, nickname: str) -> None: """Register the device with PIN.""" self._auth = BasicAuth("", pin) params = [ {"clientid": clientid, "nickname": nickname, "level": "private"}, [{"value": "yes", "function": "WOL"}], ] await self.send_rest_req( SERVICE_ACCESS_CONTROL, "actRegister", params, ) async def pair(self, clientid: str, nickname: str) -> None: """Register with PIN "0000" to start the pairing process on the TV.""" with suppress(BraviaAuthError): await self.register(PAIR_PIN, clientid, nickname) async def disconnect(self) -> None: """Close connection.""" if self._session: await self._session.close() self._auth = None self._psk = None self._session = None async def send_wol_req(self) -> bool: """Send WOL packet to device.""" if self.mac is None: return False mac = self.mac.replace(":", "") packet = bytes.fromhex("F" * 12 + mac * 16) await asyncio.to_thread(self._send_wol_packet, packet) return True def _send_wol_packet(self, packet: bytes) -> None: """Send WOL packet synchronously (runs in thread pool).""" with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) sock.sendto(packet, ("", 9)) async def send_req( self, url: URL, data: Any = None, headers: dict[str, Any] | None = None, json: bool = True, timeout: int = DEFAULT_TIMEOUT, ) -> Any: """Send HTTP request.""" result: Any = {} if json else False if self._session is None: self._session = ClientSession( cookie_jar=CookieJar(unsafe=True, quote_cookie=False), ) if headers is None: headers = {} if self._psk: headers["X-Auth-PSK"] = self._psk headers["Cache-Control"] = "no-cache" headers["Connection"] = "keep-alive" _LOGGER.debug( "Request %s, data: %s, headers: %s", url, data, deep_redact(headers, ["X-Auth-PSK"]), ) try: post_kwargs: dict[str, Any] = dict( url=url, headers=headers, timeout=ClientTimeout(total=timeout), auth=self._auth, ssl=self._ssl_verify, ) if json: post_kwargs["json"] = data else: post_kwargs["data"] = data response = await self._session.post(**post_kwargs) status = HTTPStatus(response.status) _LOGGER.debug("Response status: %s", status) # Normalize non RFC-compliant cookie # https://github.com/Drafteed/pybravia/issues/1#issuecomment-1237452709 cookies = response.headers.getall("set-cookie", None) if cookies: normalized_cookies = normalize_cookies(cookies) self._session.cookie_jar.update_cookies( cookies=normalized_cookies, response_url=response.url, ) if status == HTTPStatus.OK: result = await response.json() if json else True _LOGGER.debug( "Response result: %s", deep_redact(result, ["cid", "serial", "macAddr"]), ) elif status == HTTPStatus.NOT_FOUND: raise BraviaNotFound elif status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN): raise BraviaAuthError except ClientError as err: _LOGGER.debug("Request error %s", err) raise BraviaConnectionError from err except ConnectionError as err: _LOGGER.debug("Connection error %s", err) raise BraviaConnectionError from err except asyncio.exceptions.TimeoutError as err: _LOGGER.debug("Request timeout %s", err) raise BraviaConnectionTimeout from err return result async def send_ircc_req(self, code: str, timeout: int = DEFAULT_TIMEOUT) -> bool: """Send IRCC request to device.""" # After about 13 minutes of inactivity, some TVs # ignores the first command without giving any error. # This make an empty request to 'wake up' the api. # https://github.com/dcnielsen90/python-bravia-tv/issues/15 # The endpoint of some TVs is case sensitive, this also detects this. # https://github.com/home-assistant/core/issues/86132 if code != "": time = datetime.now() if not self._ircc_time or (time - self._ircc_time) > timedelta(minutes=10): try: await self.send_ircc_req("") except BraviaNotFound: self._ircc_endpoint = self._ircc_endpoint.swapcase() except BraviaError: pass self._ircc_time = time url = self._base_url / "sony" / self._ircc_endpoint headers = { "SOAPACTION": '"urn:schemas-sony-com:service:IRCC:1#X_SendIRCC"', "Content-Type": "text/xml; charset=UTF-8", } data = ( "' "" '' f"{code}" "" "" "" ) return await self.send_req( url=url, data=data, headers=headers, json=False, timeout=timeout, ) async def send_rest_req( self, service: str, method: str, params: Any = None, headers: dict[str, Any] | None = None, version: str = DEFAULT_VERSION, timeout: int = DEFAULT_TIMEOUT, ) -> Any: """Send REST request to device.""" url = self._base_url / "sony" / service params = params if isinstance(params, list) else [params] if params else [] data = { "method": method, "params": params, "id": 1, "version": version, } resp = await self.send_req( url=url, data=data, headers=headers, json=True, timeout=timeout, ) if error := resp.get("error"): if "not power-on" in error: raise BraviaTurnedOff return resp async def send_rest_quick(self, *args: Any, **kwargs: Any) -> bool: """Send and quick check REST request to device.""" resp = await self.send_rest_req(*args, **kwargs) return "result" in resp async def send_command(self, command: str) -> bool: """Send IRCC command to device.""" code = await self.get_command_code(command) if code is None: return False return await self.send_ircc_req(code) async def get_sound_settings(self) -> str: """Get current sound output.""" resp = await self.send_rest_req( SERVICE_AUDIO, "getSoundSettings", {"target": "outputTerminal"}, version="1.1", ) result = resp.get("result", [[{}]])[0][0] return result.get("currentValue", "") async def get_power_status(self) -> str: """Get current power status.""" resp = await self.send_rest_req(SERVICE_SYSTEM, "getPowerStatus", timeout=5) result = resp.get("result", [{}])[0] return result.get("status", "off") async def get_power_saving_mode(self) -> str: """Get current power saving mode.""" resp = await self.send_rest_req(SERVICE_SYSTEM, "getPowerSavingMode") result = resp.get("result", [{}])[0] return result.get("mode", "") async def get_system_info(self) -> dict[str, str]: """Get general information of the device.""" resp = await self.send_rest_req(SERVICE_SYSTEM, "getSystemInformation") result = resp.get("result", [{}])[0] self.mac = result.get("macAddr", self.mac) return result async def get_api_info(self, services: list[str]) -> list[dict[str, Any]]: """Get supported services and their information.""" resp = await self.send_rest_req( SERVICE_GUIDE, "getSupportedApiInfo", {"services": services}, ) result = resp.get("result", [[]])[0] return result async def get_wol_mode(self) -> bool: """Get information on the device's WOL mode settings.""" resp = await self.send_rest_req(SERVICE_SYSTEM, "getWolMode") result = resp.get("result", [{}])[0] return result.get("enabled", False) async def get_led_status(self) -> dict[str, str]: """Get the LED Indicator mode and status.""" resp = await self.send_rest_req(SERVICE_SYSTEM, "getLEDIndicatorStatus") result = resp.get("result", [{}])[0] return result async def get_remote_info(self) -> list[Any]: """Get information of the device's remote controller.""" resp = await self.send_rest_req(SERVICE_SYSTEM, "getRemoteControllerInfo") result = resp.get("result", [{}, []]) return result async def get_command_list(self) -> dict[str, str]: """Get list of all IRCC commands.""" result = await self.get_remote_info() return {x["name"]: x["value"] for x in result[1]} async def get_command_code(self, command: str) -> str | None: """Get IRCC command code by name.""" if not self._commands: self._commands = await self.get_command_list() return self._commands.get(command) async def get_volume_info( self, target: str = DEFAULT_AUDIO_TARGET, ) -> dict[str, Any]: """Get the sound volume information with preferred target.""" result = await self.get_volume_info_full() value = {} for output in result: value = output if target == output.get("target"): break return value async def get_volume_info_full(self) -> list[dict[str, Any]]: """Get information about the sound volume and mute status.""" resp = await self.send_rest_req(SERVICE_AUDIO, "getVolumeInformation") result = resp.get("result", [[]])[0] return result async def get_picture_setting(self, target: str = "") -> list[dict[str, Any]]: """Get information about the picture settings.""" resp = await self.send_rest_req( SERVICE_VIDEO, "getPictureQualitySettings", {"target": target}, ) result = resp.get("result", [[]])[0] return result async def get_app_list(self) -> list[dict[str, str]]: """Get list of applications.""" resp = await self.send_rest_req(SERVICE_APP_CONTROL, "getApplicationList") result = resp.get("result", [[]])[0] return result async def get_scheme_list(self) -> list[str]: """Get list of schemes that the device can handle.""" resp = await self.send_rest_req(SERVICE_AV_CONTENT, "getSchemeList") result = resp.get("result", [[]])[0] return [d["scheme"] for d in result if "scheme" in d] async def get_source_list(self, scheme: str) -> list[str]: """Get list of sources in the scheme.""" resp = await self.send_rest_req( SERVICE_AV_CONTENT, "getSourceList", {"scheme": scheme}, ) result = resp.get("result", [[]])[0] return [d["source"] for d in result if "source" in d] async def get_content_count(self, source: str) -> int: """Get count of contents in the source.""" # Extended timeout due to # https://github.com/home-assistant/core/issues/91781#issuecomment-1532151925 resp = await self.send_rest_req( SERVICE_AV_CONTENT, "getContentCount", {"source": source}, timeout=20, ) result = resp.get("result", [{}])[0] return result.get("count", 0) async def get_content_list( self, source: str, index: int = 0, count: int = 50, ) -> list[dict[str, Any]]: """Get list of contents in the source.""" # Extended timeout due to # https://github.com/home-assistant/core/issues/91781#issuecomment-1532168838 resp = await self.send_rest_req( SERVICE_AV_CONTENT, "getContentList", {"source": source, "stIdx": index, "cnt": count}, timeout=20, ) result = resp.get("result", [[]])[0] return result async def get_content_list_full(self, source: str) -> list[dict[str, Any]]: """Get full list of contents in the source.""" result = [] total = await self.get_content_count(source) for index in range(0, total, 50): count = min(50, total - index) resp = await self.get_content_list(source, index, count) result.extend(resp) return result async def get_content_list_all(self, scheme: str) -> list[dict[str, Any]]: """Get full list of contents in the scheme.""" result = [] sources = await self.get_source_list(scheme) for source in sources: resp = await self.get_content_list_full(source) result.extend(resp) return result async def get_external_status( self, version: str = DEFAULT_VERSION, ) -> list[dict[str, Any]]: """Get information of all external input sources.""" resp = await self.send_rest_req( SERVICE_AV_CONTENT, "getCurrentExternalInputsStatus", version=version, ) result = resp.get("result", [[]])[0] return result async def get_playing_info(self) -> dict[str, Any]: """Get information of the currently playing content.""" resp = await self.send_rest_req(SERVICE_AV_CONTENT, "getPlayingContentInfo") result = resp.get("result", [{}])[0] return result async def set_picture_setting(self, target: str, value: str) -> bool: """Set a picture setting to a new value.""" return await self.send_rest_req( SERVICE_VIDEO, "setPictureQualitySettings", {"settings": [{"target": target, "value": value}]}, version="1.0", ) async def set_wol_mode(self, mode: bool) -> bool: """Set WOL mode settings of the device.""" return await self.send_rest_quick( SERVICE_SYSTEM, "setWolMode", {"enabled": mode}, ) async def set_led_status(self, mode: str, status: str | None = None) -> bool: """Set the LED Indicator mode and status.""" return await self.send_rest_quick( SERVICE_SYSTEM, "setLEDIndicatorStatus", {"mode": mode, "status": status}, version="1.1", ) async def set_active_app(self, uri: str) -> bool: """Launch an application by URI.""" return await self.send_rest_quick( SERVICE_APP_CONTROL, "setActiveApp", {"uri": uri}, ) async def set_play_content(self, uri: str) -> bool: """Play content by URI.""" return await self.send_rest_quick( SERVICE_AV_CONTENT, "setPlayContent", {"uri": uri}, ) async def set_sound_settings(self, output: str) -> bool: """Change current sound output.""" return await self.send_rest_quick( SERVICE_AUDIO, "setSoundSettings", [{"settings": [{"value": output, "target": "outputTerminal"}]}], version="1.1", ) async def set_power_status(self, status: bool) -> bool: """Change the current power status of the device.""" return await self.send_rest_quick( SERVICE_SYSTEM, "setPowerStatus", {"status": status}, ) async def set_power_saving_mode(self, mode: str) -> bool: """Change the setting of the power saving mode.""" return await self.send_rest_quick( SERVICE_SYSTEM, "setPowerSavingMode", {"mode": mode}, ) async def set_text_form(self, text: str) -> bool: """Input text on the field of the software keyboard.""" return await self.send_rest_quick(SERVICE_APP_CONTROL, "setTextForm", text) async def turn_on(self) -> bool: """Turn on the device.""" await self.send_wol_req() with suppress(BraviaError): if await self.get_power_status() != "active": with suppress(BraviaError): await self.set_power_status(True) with suppress(BraviaError): await self.send_ircc_req(CODE_POWER_ON) return True async def turn_off(self) -> bool: """Turn off the device.""" return await self.set_power_status(False) async def volume_up(self, step: int = 1, **kwargs: str) -> bool: """Increase the volume.""" level = f"+{step}" return await self.volume_level(level, **kwargs) async def volume_down(self, step: int = 1, **kwargs: str) -> bool: """Decrease the volume.""" level = f"-{step}" return await self.volume_level(level, **kwargs) async def volume_level( self, level: int | str, target: str = DEFAULT_AUDIO_TARGET, ui_mode: str | None = None, version: str = DEFAULT_VERSION, ) -> bool: """Change the audio volume level.""" params = {"target": target, "volume": str(level)} if ui_mode is not None: params["ui"] = ui_mode return await self.send_rest_quick( SERVICE_AUDIO, "setAudioVolume", params, version=version, ) async def volume_mute(self, mute: bool | None = None) -> bool: """Change the audio mute status.""" if mute is None: volume_info = await self.get_volume_info() mute = not volume_info.get("mute") return await self.send_rest_quick( SERVICE_AUDIO, "setAudioMute", {"status": mute}, ) async def play(self) -> bool: """Send play command.""" return await self.send_command("Play") async def pause(self) -> bool: """Send pause command.""" return await self.send_command("Pause") async def stop(self) -> bool: """Send stop command.""" return await self.send_command("Stop") async def next_track(self) -> bool: """Send next track command.""" return await self.send_command("Next") async def previous_track(self) -> bool: """Send previous track command.""" return await self.send_command("Prev") async def channel_up(self) -> bool: """Send next channel command.""" return await self.send_command("ChannelUp") async def channel_down(self) -> bool: """Send previous channel command.""" return await self.send_command("ChannelDown") async def terminate_apps(self) -> bool: """Terminate all opened applications.""" return await self.send_rest_quick(SERVICE_APP_CONTROL, "terminateApps") async def reboot(self) -> bool: """Send command to reboot the device.""" return await self.send_rest_quick(SERVICE_SYSTEM, "requestReboot") async def __aenter__(self) -> BraviaClient: """Connect the client with context manager.""" return self async def __aexit__( self, exc_type: Exception, exc_value: str, traceback: TracebackType, ) -> None: """Disconnect from context manager.""" await self.disconnect()