"""Support for Devialet Phantom speakers1.""" from __future__ import annotations import asyncio import datetime import json import re import aiohttp from async_upnp_client.aiohttp import AiohttpRequester from async_upnp_client.client_factory import UpnpFactory from async_upnp_client.exceptions import (UpnpActionResponseError, UpnpXmlParseError) from async_upnp_client.profiles.dlna import DmrDevice from async_upnp_client.search import async_search from async_upnp_client.utils import CaseInsensitiveDict from .const import AV_TRANSPORT, LOGGER, NORMAL_INPUTS, MEDIA_RENDERER, SPEAKER_POSITIONS, UrlSuffix UPNP_SEARCH_INTERVAL = 120 class DevialetApi: """Devialet API class.""" def __init__(self, host:str, session:aiohttp.ClientSession): """Initialize the Devialet API.""" self._host = host self._session = session self._general_info = None self._volume = None self._muted = False self._source_state = None self._current_position = 0 self._sources = None self._night_mode = None self._equalizer = None self._source_list = {} self._position_updated_at = 0 self._media_duration = 0 self._device_role = "" self._is_available = False self._upnp_device = None self._dmr_device = None self._last_upnp_search: datetime.datetime = None async def async_update(self) -> bool | None: """Get the latest details from the device.""" if self._general_info is None: self._general_info = await self._async_get_request(UrlSuffix.GET_GENERAL_INFO) # Without general info the device has not been online yet if self._general_info is None: return False self._source_state = await self._async_get_request(UrlSuffix.GET_CURRENT_SOURCE) # The source state call is enough to find out if the device is available (On or Off) if not self._is_available: # Set upnp to none, so discovery will find the new port when it's online again self._upnp_device = None self._dmr_device = None return True if self._sources is None: self._sources = await self._async_get_request(UrlSuffix.GET_SOURCES) self._volume = await self._async_get_request(UrlSuffix.GET_VOLUME) self._night_mode = await self._async_get_request(UrlSuffix.GET_NIGHT_MODE) self._equalizer = await self._async_get_request(UrlSuffix.GET_EQUALIZER) try: self._media_duration = self._source_state["metadata"]["duration"] except (KeyError, TypeError): self._media_duration = None self._current_position = None self._position_updated_at = None if self._media_duration == 0: self._media_duration = None if self._media_duration is not None: position = await self._async_get_request(UrlSuffix.GET_CURRENT_POSITION) try: self._current_position = position["position"] self._position_updated_at = datetime.datetime.now(tz=datetime.timezone.utc).replace(tzinfo=None) except (KeyError, TypeError): self._current_position = None self._position_updated_at = None return True @property def is_available(self) -> bool | None: """Return available.""" return self._is_available @property def upnp_available(self) -> bool | None: """Return available.""" return self._upnp_device is not None @property def device_id(self) -> str | None: """Return the device id.""" try: return self._general_info["deviceId"] except (KeyError, TypeError): return None @property def is_system_leader(self) -> bool | None: """Return the boolean for system leader identification.""" try: return self._general_info["isSystemLeader"] except (KeyError, TypeError): return None @property def serial(self) -> str | None: """Return the serial.""" try: return self._general_info["serial"] except (KeyError, TypeError): return None @property def device_name(self) -> str | None: """Return the device name.""" try: return self._general_info["deviceName"] except (KeyError, TypeError): return None @property def device_role(self) -> str | None: """Return the device role.""" try: return self._general_info["role"] except (KeyError, TypeError): return None @property def model(self) -> str | None: """Return the device model.""" try: return self._general_info["model"] except (KeyError, TypeError): return None @property def version(self) -> str | None: """Return the device version.""" try: return self._general_info["release"]["version"] except (KeyError, TypeError): return None @property def source_state(self) -> any | None: """Return the source state object.""" return self._source_state @property def playing_state(self) -> str | None: """Return the state of the device.""" try: return self._source_state["playingState"] except (KeyError, TypeError): return None @property def volume_level(self) -> float | None: """Volume level of the media player (0..1).""" try: return self._volume["volume"] * 0.01 except (KeyError, TypeError): return None @property def is_volume_muted(self) -> bool | None: """Return boolean if volume is currently muted.""" try: return self._source_state["muteState"] == "muted" except (KeyError, TypeError): return None @property def dmr_device(self) -> DmrDevice | None: """Return DMR device class.""" return self._dmr_device @property def source_list(self) -> list | None: """Return the list of available input sources.""" if self._sources is None or len(self._source_list) > 0: return sorted(self._source_list) for source in self._sources["sources"]: source_type = source["type"] device_id = source["deviceId"] if self.device_role in SPEAKER_POSITIONS and source_type in ( "optical", "opticaljack", ): # Stereo devices have the role FrontLeft or FrontRight. # Add a suffix to the source to recognize the device. for role, position in SPEAKER_POSITIONS.items(): if (device_id == self.device_id and role == self.device_role) or ( device_id != self.device_id and role != self.device_role ): source_type = source_type + "_" + position for pretty_name, name in NORMAL_INPUTS.items(): if name == source_type: self._source_list[pretty_name] = name return sorted(self._source_list) @property def available_operations(self) -> any | None: """Return the list of available operations for this source.""" try: return self._source_state["availableOperations"] except (KeyError, TypeError): return None @property def media_artist(self) -> str | None: """Artist of current playing media, music track only.""" try: return self._source_state["metadata"]["artist"] except (KeyError, TypeError): return None @property def media_album_name(self) -> str | None: """Album name of current playing media, music track only.""" try: return self._source_state["metadata"]["album"] except (KeyError, TypeError): return None @property def media_title(self) -> str | None: """Return the current media info.""" try: return self._source_state["metadata"]["title"] except (KeyError, TypeError): return None @property def media_image_url(self) -> str | None: """Image url of current playing media, not available for Airplay.""" try: return self._source_state["metadata"]["coverArtUrl"] except (KeyError, TypeError): return None @property def media_duration(self) -> int | None: """Duration of current playing media in seconds.""" return self._media_duration @property def current_position(self) -> int | None: """Position of current playing media in seconds.""" return self._current_position @property def position_updated_at(self) -> datetime.datetime | None: """When was the position of the current playing media valid.""" return self._position_updated_at @property def source(self) -> str | None: """Return the current input source.""" try: source_id = self._source_state["source"]["sourceId"] device_id = self._source_state["source"]["deviceId"] # Devialet Arch has a different source description. for source in self._sources["sources"]: if source["sourceId"] == source_id and source["deviceId"] == device_id: source_type = source["type"] if ( source_type == "optical" or source_type == "opticaljack" ) and self.device_role in SPEAKER_POSITIONS: for role, position in SPEAKER_POSITIONS.items(): if ( device_id == self.device_id and role == self.device_role ) or ( device_id != self.device_id and role != self.device_role ): source_type = source_type + "_" + position return source_type except (KeyError, TypeError): return None return None @property def night_mode(self) -> bool | None: """Return the current nightmode state.""" try: return self._night_mode["nightMode"] == "on" except (KeyError, TypeError): return None @property def equalizer(self) -> str | None: """Return the current equalizer preset.""" try: if self._equalizer["enabled"]: return self._equalizer["preset"] except (KeyError, TypeError): return None async def async_get_diagnostics(self) -> any | None: """Return the diagnostic data.""" return { "is_available": self._is_available, "general_info": self._general_info, "sources": self._sources, "source_state": self._source_state, "volume": self._volume, "night_mode": self._night_mode, "equalizer": self._equalizer, "source_list": self.source_list, "source": self.source, "upnp_device_type": getattr(self._upnp_device.device_info, 'device_type') if self._upnp_device else "Not available", "upnp_device_url": getattr(self._upnp_device.device_info, 'url') if self._upnp_device else "Not available", } async def async_volume_up(self) -> None: """Volume up media player.""" await self._async_post_request(UrlSuffix.VOLUME_UP) async def async_volume_down(self) -> None: """Volume down media player.""" await self._async_post_request(UrlSuffix.VOLUME_DOWN) async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" await self._async_post_request( UrlSuffix.VOLUME_SET, json_body={"volume": volume * 100}, ) async def async_mute_volume(self, mute: bool) -> None: """Mute (true) or unmute (false) media player.""" if mute: await self._async_post_request(UrlSuffix.MUTE) else: await self._async_post_request(UrlSuffix.UNMUTE) async def async_media_play(self) -> None: """Play media player.""" await self._async_post_request(UrlSuffix.PLAY) async def async_media_pause(self) -> None: """Pause media player.""" await self._async_post_request(UrlSuffix.PAUSE) async def async_media_stop(self) -> None: """Pause media player.""" await self._async_post_request(UrlSuffix.PAUSE) async def async_media_next_track(self) -> None: """Send the next track command.""" await self._async_post_request(UrlSuffix.NEXT_TRACK) async def async_media_previous_track(self) -> None: """Send the previous track command.""" await self._async_post_request(UrlSuffix.PREVIOUS_TRACK) async def async_media_seek(self, position: float) -> None: """Send seek command.""" await self._async_post_request( UrlSuffix.SEEK, json_body={"position": int(position)}, ) async def async_set_night_mode(self, night_mode: bool) -> None: """Set the night mode.""" if night_mode: mode = "on" else: mode = "off" await self._async_post_request( UrlSuffix.NIGHT_MODE, json_body={"nightMode": mode}, ) async def async_set_equalizer(self, preset: str) -> None: """Set the equalizer preset.""" await self._async_post_request( UrlSuffix.EQUALIZER, json_body={"preset": preset}, ) async def async_turn_off(self) -> None: """Turn off media player.""" await self._async_post_request(UrlSuffix.TURN_OFF) async def async_select_source(self, source: str) -> None: """Select input source.""" source_id = None try: name = NORMAL_INPUTS[source] except KeyError: LOGGER.error("Unknown source %s selected", source) return if "_" in name: name_split = name.split("_") for role, position in SPEAKER_POSITIONS.items(): if position != name_split[1]: continue if role == self.device_role: for _source in self._sources["sources"]: if ( _source["deviceId"] == self.device_id and _source["type"] == name_split[0] ): source_id = _source["sourceId"] break else: for _source in self._sources["sources"]: if ( _source["deviceId"] != self.device_id and _source["type"] == name_split[0] ): source_id = _source["sourceId"] break break else: for _source in self._sources["sources"]: if _source["type"] == name: source_id = _source["sourceId"] if source_id is None: LOGGER.error("Source %s is not available", source) return await self._async_post_request( str(UrlSuffix.SELECT_SOURCE).replace("%SOURCE_ID%", source_id) ) async def _async_get_request(self, suffix: str) -> any | None: """Generic GET method.""" url = "http://" + self._host + str(suffix) try: async with self._session.get( url=url, allow_redirects=False, timeout=2 ) as response: response = await response.read() LOGGER.debug( "Host %s: HTTP Response data: %s", self._host, response, ) response_json = json.loads(response) self._is_available = True if "error" in response_json: LOGGER.debug(response_json["error"]) return None return response_json except aiohttp.ClientConnectorError as conn_err: LOGGER.debug("Host %s: Connection error %s", self._host, str(conn_err)) self._is_available = False return None except asyncio.TimeoutError: LOGGER.debug("Devialet connection timeout exception. Please check the connection") self._is_available = False return None except (TypeError, json.JSONDecodeError): LOGGER.debug("Get request: JSON error") return None except Exception: # pylint: disable=bare-except LOGGER.debug("Get request: unknown exception occurred") return None async def _async_post_request(self, suffix:str, json_body:str={}) -> bool | None: """Generic POST method.""" url = "http://" + self._host + str(suffix) try: async with self._session.post( url=url, json=json_body, allow_redirects=False, timeout=2 ) as response: response_data = await response.text() LOGGER.debug( "Host %s: HTTP %s Response data: %s", self._host, response.status, response_data, ) return True except aiohttp.ClientConnectorError as conn_err: LOGGER.debug("Host %s: Connection error %s", self._host, str(conn_err)) return False except asyncio.TimeoutError: LOGGER.debug( "Devialet connection timeout exception, please check the connection" ) return False except (TypeError, json.JSONDecodeError): LOGGER.debug("Post request: unknown response type") return False except Exception: # pylint: disable=bare-except LOGGER.debug("Post request: unknown exception occurred") return False async def _async_on_search_response(self, data: CaseInsensitiveDict) -> None: """UPnP device detected.""" location = data['location'] location_regex = re.compile(f"(?<=Location:[ ])*http://{self._host}:(.*)/.*.xml", re.IGNORECASE) location_result = location_regex.search(location) if location_result: requester = AiohttpRequester() factory = UpnpFactory(requester) self._upnp_device = await factory.async_create_device(location) self._dmr_device = DmrDevice(self._upnp_device, None) async def async_search_allowed(self) -> bool: """Conditions to check if UPnP search is allowed.""" if ( self.is_available and not self.upnp_available and self.is_system_leader and ( not self._last_upnp_search or ( datetime.datetime.now() - self._last_upnp_search).total_seconds() >= UPNP_SEARCH_INTERVAL ) ): return True return False async def async_discover_upnp_device(self) -> None: """Discover the UPnP device.""" self._last_upnp_search = datetime.datetime.now() await async_search(async_callback=self._async_on_search_response, timeout=10, search_target=MEDIA_RENDERER, source=("0.0.0.0", 0)) LOGGER.debug("Discovering UPnP device for %s", self._host) async def async_play_url_source(self, media_id: str, mime_type: str, media_title: str, default_title: bool=False) -> bool: """Play media uri over UPnP.""" if not self.upnp_available: LOGGER.error("No UPnP location discovered") return if default_title: media_title = await self.async_get_upnp_media_title(media_id) or media_title metadata = ( await self.dmr_device.construct_play_media_metadata( media_url=media_id, media_title=media_title, default_mime_type=mime_type ) ) service = self._upnp_device.service(AV_TRANSPORT) set_uri = service.action("SetAVTransportURI") try: result = await set_uri.async_call(InstanceID=0, CurrentURI=media_id, CurrentURIMetaData=metadata) LOGGER.debug("Action result: %s", str(result)) return True except UpnpActionResponseError as a: LOGGER.error("Error playing %s: %s", media_title, a.error_desc) return False except UpnpXmlParseError as x: LOGGER.error("Error playing %s %s", media_title, x.text) return False await self.async_upnp_play() async def async_upnp_play(self) -> None: """Send the play command over UPnP.""" if not self.upnp_available: LOGGER.error("No UPnP location discovered") return service = self._upnp_device.service(AV_TRANSPORT) set_uri = service.action("Play") try: await set_uri.async_call(InstanceID=0, Speed="1") return except UpnpActionResponseError: return except UpnpXmlParseError: return async def async_get_upnp_media_title(self, url: str) -> str | None: """Call the media URL with the HEAD method to get ICY metadata.""" try: async with self._session.head( url=url, allow_redirects=False, timeout=2, headers={"Icy-MetaData": "1"} ) as response: LOGGER.debug( "Host %s: HTTP Response data: %s", self._host, response.headers, ) title:str = response.headers.get("icy-description") or response.headers.get("icy-name") return title except aiohttp.ClientConnectorError: return except asyncio.TimeoutError: return except TypeError: return except Exception: # pylint: disable=bare-except return