"""Code to handle the api connection to a Roon server.""" import asyncio import logging from roonapi import RoonApi, RoonDiscovery from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.util.dt import utcnow from .const import CONF_ROON_ID, ROON_APPINFO _LOGGER = logging.getLogger(__name__) INITIAL_SYNC_INTERVAL = 5 FULL_SYNC_INTERVAL = 30 class RoonServer: """Manages a single Roon Server.""" def __init__(self, hass, config_entry): """Initialize the system.""" self.config_entry = config_entry self.hass = hass self.roonapi = None self.roon_id = None self.all_player_ids = set() self.all_playlists = [] self.offline_devices = set() self._exit = False self._roon_name_by_id = {} self._id_by_roon_name = {} async def async_setup(self, tries=0): """Set up a roon server based on config parameters.""" def get_roon_host(): host = self.config_entry.data.get(CONF_HOST) port = self.config_entry.data.get(CONF_PORT) if host: _LOGGER.debug("static roon core host=%s port=%s", host, port) return (host, port) discover = RoonDiscovery(core_id) server = discover.first() discover.stop() _LOGGER.debug("dynamic roon core core_id=%s server=%s", core_id, server) return (server[0], server[1]) def get_roon_api(): token = self.config_entry.data[CONF_API_KEY] (host, port) = get_roon_host() return RoonApi(ROON_APPINFO, token, host, port, blocking_init=True) core_id = self.config_entry.data.get(CONF_ROON_ID) self.roonapi = await self.hass.async_add_executor_job(get_roon_api) self.roonapi.register_state_callback( self.roonapi_state_callback, event_filter=["zones_changed"] ) # Default to 'host' for compatibility with older configs without core_id self.roon_id = ( core_id if core_id is not None else self.config_entry.data[CONF_HOST] ) # Initialize Roon background polling asyncio.create_task(self.async_do_loop()) return True async def async_reset(self): """Reset this connection to default state. Will cancel any scheduled setup retry and will unload the config entry. """ self.stop_roon() return True @property def zones(self): """Return list of zones.""" return self.roonapi.zones def add_player_id(self, entity_id, roon_name): """Register a roon player.""" self._roon_name_by_id[entity_id] = roon_name self._id_by_roon_name[roon_name] = entity_id def roon_name(self, entity_id): """Get the name of the roon player from entity_id.""" return self._roon_name_by_id.get(entity_id) def entity_id(self, roon_name): """Get the id of the roon player from the roon name.""" return self._id_by_roon_name.get(roon_name) def stop_roon(self): """Stop background worker.""" self.roonapi.stop() self._exit = True def roonapi_state_callback(self, event, changed_zones): """Callbacks from the roon api websockets.""" self.hass.add_job(self.async_update_changed_players(changed_zones)) async def async_do_loop(self): """Background work loop.""" self._exit = False await asyncio.sleep(INITIAL_SYNC_INTERVAL) while not self._exit: await self.async_update_players() await asyncio.sleep(FULL_SYNC_INTERVAL) async def async_update_changed_players(self, changed_zones_ids): """Update the players which were reported as changed by the Roon API.""" _LOGGER.debug("async_update_changed_players %s", changed_zones_ids) for zone_id in changed_zones_ids: if zone_id not in self.roonapi.zones: # device was removed ? continue zone = self.roonapi.zones[zone_id] for device in zone["outputs"]: dev_name = device["display_name"] if dev_name == "Unnamed" or not dev_name: # ignore unnamed devices continue player_data = await self.async_create_player_data(zone, device) dev_id = player_data["dev_id"] player_data["is_available"] = True if dev_id in self.offline_devices: # player back online self.offline_devices.remove(dev_id) async_dispatcher_send(self.hass, "roon_media_player", player_data) self.all_player_ids.add(dev_id) async def async_update_players(self): """Periodic full scan of all devices.""" zone_ids = self.roonapi.zones.keys() _LOGGER.debug("async_update_players %s", zone_ids) await self.async_update_changed_players(zone_ids) # check for any removed devices all_devs = {} for zone in self.roonapi.zones.values(): for device in zone["outputs"]: player_data = await self.async_create_player_data(zone, device) dev_id = player_data["dev_id"] all_devs[dev_id] = player_data for dev_id in self.all_player_ids: if dev_id in all_devs: continue # player was removed! player_data = {"dev_id": dev_id} player_data["is_available"] = False async_dispatcher_send(self.hass, "roon_media_player", player_data) self.offline_devices.add(dev_id) async def async_create_player_data(self, zone, output): """Create player object dict by combining zone with output.""" new_dict = zone.copy() new_dict.update(output) new_dict.pop("outputs") new_dict["roon_id"] = self.roon_id new_dict["is_synced"] = len(zone["outputs"]) > 1 new_dict["zone_name"] = zone["display_name"] new_dict["display_name"] = output["display_name"] new_dict["last_changed"] = utcnow() # we don't use the zone_id or output_id for now as unique id as I've seen cases were it changes for some reason new_dict["dev_id"] = f"roon_{self.roon_id}_{output['display_name']}" return new_dict