"""Data containers for Songpal.""" import logging from datetime import timedelta from typing import List, Optional import attr _LOGGER = logging.getLogger(__name__) def make(cls, **kwargs): """Create a container. Reports extra keys as well as missing ones. Thanks to habnabit for the idea! """ cls_attrs = {f.name: f for f in attr.fields(cls)} unknown = {k: v for k, v in kwargs.items() if k not in cls_attrs} if len(unknown) > 0: _LOGGER.debug( "Got unknowns for %s: %s - please create an issue!", cls.__name__, unknown ) missing = [k for k in cls_attrs if k not in kwargs] data = {k: v for k, v in kwargs.items() if k in cls_attrs} # initialize missing values to avoid passing default=None # for the attrs attribute definitions for m in missing: default = cls_attrs[m].default if isinstance(default, attr.Factory): if not default.takes_self: data[m] = default.factory() else: raise NotImplementedError else: _LOGGER.debug("Missing key %s with no default for %s", m, cls.__name__) data[m] = None # initialize and store raw data for debug purposes inst = cls(**data) setattr(inst, "raw", kwargs) return inst def convert_to_bool(x) -> bool: """Convert string 'true' to bool.""" return x == "true" def convert_is_active(x) -> bool: """Convert string 'active' to bool.""" return True if x == "active" else False def convert_title(x) -> str: """Trim trailing characters on the title.""" return x.strip() @attr.s class Scheme: """Input scheme container.""" make = classmethod(make) scheme = attr.ib() # type: str @attr.s class PlaybackFunction: """Playback function.""" make = classmethod(make) function = attr.ib() @attr.s class PlaybackFunctions: """Container for playback functions.""" make = classmethod(make) def _convert_playback_functions(x) -> List[PlaybackFunction]: return [PlaybackFunction.make(**y) for y in x] # type: ignore uri = attr.ib() functions = attr.ib(converter=_convert_playback_functions) class AvailablePlaybackFunctions(PlaybackFunctions): """Container for available playback functions.""" class SupportedPlaybackFunctions(PlaybackFunctions): """Container for supported playback functions.""" # Name change backwards compatibility SupportedFunctions = SupportedPlaybackFunctions @attr.s class ContentInfo: """Information about available contents.""" make = classmethod(make) capability = attr.ib() count = attr.ib() @attr.s class Content: """Content infrormation.""" make = classmethod(make) isBrowsable = attr.ib() uri = attr.ib() contentKind = attr.ib() isPlayable = attr.ib() index = attr.ib() title = attr.ib() # no idea why this would be missing.. folderNo = attr.ib() # files do not have this fileNo = attr.ib() # folders do not have this parentUri = attr.ib() # toplevel has no parents fileSizeByte = attr.ib() # dirs do not have this createdTime = attr.ib() broadcastFreqBand = attr.ib() broadcastFreq = attr.ib() def __str__(self): return f"{self.title} ({self.uri}, kind: {self.contentKind})" @attr.s class StateInfo: """Playback state.""" make = classmethod(make) state = attr.ib() supplement = attr.ib() @attr.s class PlayContentInfo: """Playback content info.""" make = classmethod(make) thumbnailUrl = attr.ib() @attr.s class PlayInfo: """Information about played content. This is only tested on music files, the outs for the method call is much, much larger """ make = classmethod(make) def _make(x) -> StateInfo: if x is not None: return StateInfo.make(**x) # type: ignore return None def _make_content(x) -> PlayContentInfo: if x is not None: return PlayContentInfo.make(**x) # type: ignore return None stateInfo = attr.ib(converter=_make) contentKind = attr.ib() uri = attr.ib() output = attr.ib() # only available when being played applicationName = attr.ib() service = attr.ib() artist = attr.ib() albumName = attr.ib() title = attr.ib() durationMsec = attr.ib() mediaType = attr.ib() parentUri = attr.ib() positionMsec = attr.ib() repeatType = attr.ib() source = attr.ib() # Documented only for "radio:" but used for Cast and Spotify too. content = attr.ib(converter=_make_content) @property def is_idle(self): """Return if content is being played.""" return self.title is None @property def state(self): """Return playback state.""" if self.stateInfo is None: return None return self.stateInfo.state @property def duration(self): """Return total media duration.""" if self.durationMsec is not None: return timedelta(milliseconds=self.durationMsec) @property def position(self): """Return current media position.""" if self.positionMsec is not None: return timedelta(milliseconds=self.positionMsec) def __str__(self): return "{} on {} {} ({}/{}), state {}".format( self.source, self.output, self.title, self.position, self.duration, self.state, ) @attr.s class InterfaceInfo: """Information about the product.""" make = classmethod(make) productName = attr.ib() modelName = attr.ib() productCategory = attr.ib() interfaceVersion = attr.ib() serverName = attr.ib() @attr.s class Sysinfo: """System information.""" make = classmethod(make) bdAddr = attr.ib() macAddr = attr.ib() version = attr.ib() wirelessMacAddr = attr.ib() bssid = attr.ib() ssid = attr.ib() bleID = attr.ib() serialNumber = attr.ib() generation = attr.ib() model = attr.ib() @attr.s class SoftwareUpdateInfoDetails: """Details for the available software update.""" make = classmethod(make) swInfo = attr.ib() estimatedTimeSec = attr.ib() target = attr.ib() updatableVersion = attr.ib() forcedUpdate = attr.ib(converter=convert_to_bool) @attr.s class SoftwareUpdateInfo: """Software update information.""" make = classmethod(make) def _convert_if_available(x) -> Optional[SoftwareUpdateInfoDetails]: if x is not None: return SoftwareUpdateInfoDetails.make(**x[0]) # type: ignore return None isUpdatable = attr.ib(converter=convert_to_bool) swInfo = attr.ib(converter=_convert_if_available) @attr.s class Source: """Source information.""" make = classmethod(make) title = attr.ib() source = attr.ib() iconUrl = attr.ib() isBrowsable = attr.ib() isPlayable = attr.ib() meta = attr.ib() playAction = attr.ib() outputs = attr.ib() def __str__(self): s = f"{self.title} ({self.source})" if self.outputs is not None: s += " - outs: %s" % self.outputs return s @attr.s class Volume: """Volume information.""" make = classmethod(make) services = attr.ib(repr=False) maxVolume = attr.ib() minVolume = attr.ib() mute = attr.ib() output = attr.ib() step = attr.ib() volume = attr.ib() @property def is_muted(self): """Return True if volume is muted.""" return self.mute == "on" def __str__(self): if self.output and self.output.rfind("=") > 0: s = "Zone {} Volume: {}/{}".format( self.output[self.output.rfind("=") + 1 :], self.volume, self.maxVolume, ) else: s = f"Volume: {self.volume}/{self.maxVolume}" if self.is_muted: s += " (muted)" return s async def set_mute(self, activate: bool): """Set mute on/off.""" enabled = "off" if activate: enabled = "on" return await self.services["audio"]["setAudioMute"]( mute=enabled, output=self.output ) async def toggle_mute(self): """Toggle mute.""" return await self.services["audio"]["setAudioMute"]( mute="toggle", output=self.output ) async def set_volume(self, volume: int): """Set volume level.""" return await self.services["audio"]["setAudioVolume"]( volume=str(volume), output=self.output ) @attr.s class Power: """Information about power status. This implements __bool__() for easy checking if the device is turned on or not. """ make = classmethod(make) status = attr.ib(converter=convert_is_active) standbyDetail = attr.ib() def __bool__(self): return self.status def __str__(self): if self.status: return "Power on" else: return "Power off" @attr.s class Zone: """Zone information.""" make = classmethod(make) meta = attr.ib() connection = attr.ib() title = attr.ib(converter=convert_title) uri = attr.ib() services = attr.ib(repr=False) active = attr.ib(converter=convert_is_active) label = attr.ib() iconUrl = attr.ib() def __str__(self): s = f"{self.title} (uri: {self.uri})" if self.active: s += " (active)" return s async def activate(self, activate): """Activate this zone.""" return await self.services["avContent"]["setActiveTerminal"]( active="active" if activate else "inactive", uri=self.uri ) @attr.s class Input: """Input information.""" make = classmethod(make) meta = attr.ib() connection = attr.ib() title = attr.ib(converter=convert_title) uri = attr.ib() services = attr.ib(repr=False) active = attr.ib(converter=convert_is_active) label = attr.ib() iconUrl = attr.ib() outputs = attr.ib(default=attr.Factory(list)) def __str__(self): s = f"{self.title} (uri: {self.uri})" if self.active: s += " (active)" return s async def activate(self, output: Zone = None): """Activate this input.""" output_uri = output.uri if output else "" return await self.services["avContent"]["setPlayContent"]( uri=self.uri, output=output_uri ) @attr.s class Storage: """Storage information.""" make = classmethod(make) def _make(x) -> bool: return True if x == "mounted" else False deviceName = attr.ib() uri = attr.ib() volumeLabel = attr.ib() freeCapacityMB = attr.ib() systemAreaCapacityMB = attr.ib() wholeCapacityMB = attr.ib() formattable = attr.ib() formatting = attr.ib() isAvailable = attr.ib(converter=convert_to_bool) mounted = attr.ib(converter=_make) permission = attr.ib() position = attr.ib() def __str__(self): return "{} ({}) in {} ({}/{} free), available: {}, mounted: {}".format( self.deviceName, self.uri, self.position, self.freeCapacityMB, self.wholeCapacityMB, self.isAvailable, self.mounted, ) @attr.s class ApiMapping: """API mapping for some setting setters/getters.""" make = classmethod(make) service = attr.ib() getApi = attr.ib() setApi = attr.ib() target = attr.ib() targetSuppl = attr.ib() @attr.s class SettingsEntry: """Presentation of a single setting.""" make = classmethod(make) isAvailable = attr.ib() type = attr.ib() def _convert_if_available(x) -> Optional[List["SettingsEntry"]]: if x is not None: return [SettingsEntry.make(**y) for y in x] # type: ignore return None def _convert_if_available_mapping(x) -> Optional[ApiMapping]: if x is not None: return ApiMapping.make(**x) # type: ignore return None async def get_value(self, dev): """Return current value for this setting.""" res = await dev.get_setting( self.apiMapping.service, self.apiMapping.getApi["name"], self.apiMapping.target, ) return Setting.make(**res.pop()) @property def is_directory(self): """Return True if the setting is a directory.""" return self.type == "directory" apiMapping = attr.ib(converter=_convert_if_available_mapping, repr=False) settings = attr.ib(converter=_convert_if_available) title = attr.ib() titleTextID = attr.ib() usage = attr.ib() deviceUIInfo = attr.ib() def __str__(self): return f"{self.title} ({self.titleTextID}, {self.type})" @attr.s class SettingCandidate: """Representation of a setting candidate aka. option.""" make = classmethod(make) title = attr.ib() value = attr.ib() isAvailable = attr.ib() min = attr.ib() max = attr.ib() step = attr.ib() titleTextID = attr.ib() @attr.s class Setting: """Representation of a setting. Use `candidate` to access the potential values. """ make = classmethod(make) def _create_candidates(x) -> List[SettingCandidate]: if x is not None: return [SettingCandidate.make(**y) for y in x] # type: ignore return [] currentValue = attr.ib() target = attr.ib() type = attr.ib() candidate = attr.ib(converter=_create_candidates) isAvailable = attr.ib() title = attr.ib() titleTextID = attr.ib() deviceUIInfo = attr.ib() uri = attr.ib()