From 0d93486f671493acbc1c291c346ce65d81c37888 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 18 Sep 2020 01:27:24 +0200 Subject: [PATCH] hass integration now moved to hass component --- music_assistant/constants.py | 9 +- music_assistant/models/player.py | 29 +- music_assistant/player_manager.py | 67 +++- .../providers/home_assistant/__init__.py | 294 ------------------ music_assistant/web.py | 5 +- requirements.txt | 1 + 6 files changed, 88 insertions(+), 317 deletions(-) delete mode 100644 music_assistant/providers/home_assistant/__init__.py diff --git a/music_assistant/constants.py b/music_assistant/constants.py index 67a72946..69f450c8 100755 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -1,6 +1,6 @@ """All constants for Music Assistant.""" -__version__ = "0.0.31" +__version__ = "0.0.32" REQUIRED_PYTHON_VER = "3.7" CONF_USERNAME = "username" @@ -32,4 +32,11 @@ EVENT_QUEUE_TIME_UPDATED = "queue time updated" EVENT_SHUTDOWN = "application shutdown" EVENT_PROVIDER_REGISTERED = "provider registered" EVENT_PLAYER_CONTROL_REGISTERED = "player control registered" +EVENT_PLAYER_CONTROL_UNREGISTERED = "player control unregistered" EVENT_PLAYER_CONTROL_UPDATED = "player control updated" +EVENT_SET_PLAYER_CONTROL_STATE = "set player control state" + +# websocket commands +EVENT_REGISTER_PLAYER_CONTROL = "register player control" +EVENT_UNREGISTER_PLAYER_CONTROL = "unregister player control" +EVENT_UPDATE_PLAYER_CONTROL = "update player control" diff --git a/music_assistant/models/player.py b/music_assistant/models/player.py index 63aa45fa..f2891775 100755 --- a/music_assistant/models/player.py +++ b/music_assistant/models/player.py @@ -6,6 +6,7 @@ from enum import Enum from typing import Any, List from mashumaro import DataClassDictMixin +from music_assistant.constants import EVENT_SET_PLAYER_CONTROL_STATE from music_assistant.models.config_entry import ConfigEntry from music_assistant.utils import CustomIntEnum @@ -84,7 +85,7 @@ class PlayerControlType(CustomIntEnum): @dataclass -class PlayerControl(DataClassDictMixin): +class PlayerControl: """ Model for a player control. @@ -93,7 +94,29 @@ class PlayerControl(DataClassDictMixin): """ type: PlayerControlType = PlayerControlType.UNKNOWN - id: str = "" + control_id: str = "" + provider: str = "" name: str = "" state: Any = None - set_state: Any = None + + async def async_set_state(self, new_state: Any): + """Handle command to set the state for a player control.""" + # by default we just signal an event on the eventbus + # pickup this event (e.g. from the websocket api) + # or override this method with your own implementation. + + # pylint: disable=no-member + self.mass.signal_event( + EVENT_SET_PLAYER_CONTROL_STATE, + {"control_id": self.control_id, "state": new_state}, + ) + + def to_dict(self): + """Return dict representation of this playercontrol.""" + return { + "type": int(self.type), + "control_id": self.control_id, + "provider": self.provider, + "name": self.name, + "state": self.state, + } diff --git a/music_assistant/player_manager.py b/music_assistant/player_manager.py index dba38674..3efc524b 100755 --- a/music_assistant/player_manager.py +++ b/music_assistant/player_manager.py @@ -2,7 +2,7 @@ import logging from datetime import datetime -from typing import Any, List, Optional +from typing import List, Optional from music_assistant.constants import ( CONF_ENABLED, @@ -12,6 +12,8 @@ from music_assistant.constants import ( EVENT_PLAYER_CONTROL_REGISTERED, EVENT_PLAYER_CONTROL_UPDATED, EVENT_PLAYER_REMOVED, + EVENT_REGISTER_PLAYER_CONTROL, + EVENT_UNREGISTER_PLAYER_CONTROL, ) from music_assistant.models.config_entry import ConfigEntry, ConfigEntryType from music_assistant.models.media_types import MediaItem, MediaType, Track @@ -51,6 +53,14 @@ class PlayerManager: self._poll_ticks = 0 self._controls = {} self._player_controls_config_entries = [] + self.mass.add_event_listener( + self.__handle_websocket_player_control_event, + [ + EVENT_REGISTER_PLAYER_CONTROL, + EVENT_UNREGISTER_PLAYER_CONTROL, + EVENT_PLAYER_CONTROL_UPDATED, + ], + ) async def async_setup(self): """Async initialize of module.""" @@ -162,26 +172,39 @@ class PlayerManager: async def async_register_player_control(self, control: PlayerControl): """Register a playercontrol with the player manager.""" - self._controls[control.id] = control - LOGGER.info("New %s PlayerControl registered: %s", control.type, control.name) - self.mass.signal_event(EVENT_PLAYER_CONTROL_REGISTERED, control.id) + # control.mass = self.mass + control.mass = self.mass + control.type = PlayerControlType(control.type) + self._controls[control.control_id] = control + LOGGER.info( + "New PlayerControl (%s) registered: %s\\%s", + control.type, + control.provider, + control.name, + ) await self.__async_create_playercontrol_config_entries() # update all players as they may want to use this control for player in self._players.values(): self.mass.add_job(self.async_update_player(player)) - async def async_update_player_control(self, control_id: str, new_state: Any): + async def async_update_player_control(self, control: PlayerControl): """Update a playercontrol's state on the player manager.""" - control = self._controls.get(control_id) - if not control or control.state == new_state: + if control.control_id not in self._controls: + return await self.async_register_player_control(control) + new_state = control.state + if self._controls[control.control_id].state == new_state: return - LOGGER.info("PlayerControl %s updated - new state: %s", control.name, new_state) - control.state = new_state - self.mass.signal_event(EVENT_PLAYER_CONTROL_UPDATED, control.id) + self._controls[control.control_id].state = new_state + LOGGER.debug( + "PlayerControl %s\\%s updated - new state: %s", + control.provider, + control.name, + new_state, + ) # update all players using this playercontrol for player_id, player in self._players.items(): conf = self.mass.config.player_settings[player_id] - if control.id in [ + if control.control_id in [ conf.get(CONF_POWER_CONTROL), conf.get(CONF_VOLUME_CONTROL), ]: @@ -361,7 +384,7 @@ class PlayerManager: if player_config.get(CONF_POWER_CONTROL): control = self.get_player_control(player_config[CONF_POWER_CONTROL]) if control: - self.mass.add_job(control.set_state, control.id, True) + await control.async_set_state(True) async def async_cmd_power_off(self, player_id: str) -> None: """ @@ -377,7 +400,7 @@ class PlayerManager: if player_config.get(CONF_POWER_CONTROL): control = self.get_player_control(player_config[CONF_POWER_CONTROL]) if control: - self.mass.add_job(control.set_state, control.id, False) + await control.async_set_state(False) # handle group power if player.is_group_player: # player is group, turn off all childs @@ -433,7 +456,7 @@ class PlayerManager: if player_config.get(CONF_VOLUME_CONTROL): control = self.get_player_control(player_config[CONF_VOLUME_CONTROL]) if control: - self.mass.add_job(control.set_state, control.id, volume_level) + await control.async_set_state(volume_level) # just force full volume on actual player if volume is outsourced to volumecontrol await player_prov.async_cmd_volume_set(player_id, 100) # handle group volume @@ -645,7 +668,8 @@ class PlayerManager: power_controls = self.get_player_controls(PlayerControlType.POWER) if power_controls: controls = [ - {"text": item.name, "value": item.id} for item in power_controls + {"text": f"{item.provider}: {item.name}", "value": item.control_id} + for item in power_controls ] entries.append( ConfigEntry( @@ -659,7 +683,8 @@ class PlayerManager: volume_controls = self.get_player_controls(PlayerControlType.VOLUME) if volume_controls: controls = [ - {"text": item.name, "value": item.id} for item in volume_controls + {"text": f"{item.provider}: {item.name}", "value": item.control_id} + for item in volume_controls ] entries.append( ConfigEntry( @@ -695,3 +720,13 @@ class PlayerManager: self.mass.add_job(self.async_update_player(child_player)) if player_id in self._player_queues and player.active_queue == player_id: self.mass.add_job(self._player_queues[player_id].async_update_state()) + + async def __handle_websocket_player_control_event(self, msg, msg_details): + """Handle player controls over the websockets api.""" + if msg in [EVENT_REGISTER_PLAYER_CONTROL, EVENT_PLAYER_CONTROL_UPDATED]: + # create or update a playercontrol registered through the websockets api + control = PlayerControl(**msg_details) + await self.async_update_player_control(control) + # send confirmation to the client that the register was successful + if msg == EVENT_PLAYER_CONTROL_REGISTERED: + self.mass.signal_event(EVENT_PLAYER_CONTROL_REGISTERED, control) diff --git a/music_assistant/providers/home_assistant/__init__.py b/music_assistant/providers/home_assistant/__init__.py deleted file mode 100644 index db96378c..00000000 --- a/music_assistant/providers/home_assistant/__init__.py +++ /dev/null @@ -1,294 +0,0 @@ -"""Plugin that enables integration with Home Assistant.""" - -import logging -from typing import List - -from hass_client import ( - EVENT_CONNECTED, - EVENT_STATE_CHANGED, - IS_SUPERVISOR, - HomeAssistant, -) -from music_assistant.constants import CONF_URL -from music_assistant.models.config_entry import ConfigEntry, ConfigEntryType -from music_assistant.models.player import PlayerControl, PlayerControlType -from music_assistant.models.provider import Provider -from music_assistant.utils import callback, try_parse_float - -PROV_ID = "homeassistant" -PROV_NAME = "Home Assistant integration" - -CONF_PUBLISH_PLAYERS = "hass_publish_players" -CONF_POWER_ENTITIES = "hass_power_entities" -CONF_VOLUME_ENTITIES = "hass_volume_entities" -CONF_TOKEN = "hass_token" - -LOGGER = logging.getLogger(PROV_ID) - -CONFIG_ENTRY_URL = ConfigEntry( - entry_key=CONF_URL, entry_type=ConfigEntryType.STRING, description_key="hass_url" -) -CONFIG_ENTRY_TOKEN = ConfigEntry( - entry_key=CONF_TOKEN, - entry_type=ConfigEntryType.PASSWORD, - description_key="hass_token", -) - - -async def async_setup(mass): - """Perform async setup of this Plugin/Provider.""" - prov = HomeAssistantPlugin() - await mass.async_register_provider(prov) - - -class HomeAssistantPlugin(Provider): - """Homeassistant plugin. - - allows using hass entities (like switches, media_players or gui inputs) to be triggered - """ - - def __init__(self, *args, **kwargs): - """Initialize.""" - self._hass: HomeAssistant = None - self._tasks = [] - self._tracked_entities = [] - self._sources = [] - super().__init__(*args, **kwargs) - - @property - def id(self) -> str: - """Return provider ID for this provider.""" - return PROV_ID - - @property - def name(self) -> str: - """Return provider Name for this provider.""" - return PROV_NAME - - @property - def config_entries(self) -> List[ConfigEntry]: - """Return Config Entries for this provider.""" - entries = [] - if not IS_SUPERVISOR: - entries.append(CONFIG_ENTRY_URL) - entries.append(CONFIG_ENTRY_TOKEN) - entries += [ - ConfigEntry( - entry_key=CONF_POWER_ENTITIES, - entry_type=ConfigEntryType.STRING, - description_key=CONF_POWER_ENTITIES, - default_value=[], - values=self.__get_power_control_entities(), - multi_value=True, - ), - ConfigEntry( - entry_key=CONF_VOLUME_ENTITIES, - entry_type=ConfigEntryType.STRING, - description_key=CONF_VOLUME_ENTITIES, - default_value=[], - values=self.__get_volume_control_entities(), - multi_value=True, - ), - ] - return entries - - async def async_on_start(self) -> bool: - """Handle initialization of the provider based on config.""" - config = self.mass.config.get_provider_config(PROV_ID) - if IS_SUPERVISOR: - self._hass = HomeAssistant(loop=self.mass.loop) - else: - self._hass = HomeAssistant( - config[CONF_URL], config[CONF_TOKEN], loop=self.mass.loop - ) - # register callbacks - self._hass.register_event_callback(self.__async_hass_event) - await self._hass.async_connect() - return True - - async def async_on_stop(self): - """Handle correct close/cleanup of the provider on exit.""" - for task in self._tasks: - task.cancel() - if self._hass: - await self._hass.async_close() - - async def __async_hass_event(self, event_type, event_data): - """Receive event from Home Assistant.""" - if event_type == EVENT_STATE_CHANGED: - if event_data["entity_id"] in self._tracked_entities: - new_state = event_data["new_state"] - await self.__async_update_player_controls(new_state) - elif event_type == EVENT_CONNECTED: - # register player controls on connect - self.mass.add_job(self.__async_register_player_controls()) - - @callback - def __get_power_control_entities(self): - """Return list of entities that can be used as power control.""" - if not self._hass or not self._hass.states: - return [] - result = [] - for entity in self._hass.media_players + self._hass.switches: - if not entity: - continue - entity_id = entity["entity_id"] - entity_name = entity["attributes"].get("friendly_name", entity_id) - if entity_id.startswith("media_player.mass_"): - continue - source_list = entity["attributes"].get("source_list", [""]) - for source in source_list: - result.append( - { - "value": f"power_{entity_id}_{source}", - "text": f"{entity_name}: {source}" if source else entity_name, - "entity_id": entity_id, - "source": source, - } - ) - return result - - @callback - def __get_volume_control_entities(self): - """Return list of entities that can be used as volume control.""" - if not self._hass or not self._hass.states: - return [] - result = [] - for entity in self._hass.media_players: - if not entity: - continue - entity_id = entity["entity_id"] - entity_name = entity["attributes"].get("friendly_name", entity_id) - if entity_id.startswith("media_player.mass_"): - continue - result.append( - { - "value": f"volume_{entity_id}", - "text": entity_name, - "entity_id": entity_id, - } - ) - return result - - async def __async_update_player_controls(self, entity_obj): - """Update player control(s) when a new entity state comes in.""" - for control_entity in self.__get_power_control_entities(): - if control_entity["entity_id"] != entity_obj["entity_id"]: - continue - cur_state = entity_obj["state"] not in ["off", "unavailable"] - if control_entity.get("source"): - cur_state = ( - entity_obj["attributes"].get("source") == control_entity["source"] - ) - await self.mass.player_manager.async_update_player_control( - control_entity["value"], cur_state - ) - for control_entity in self.__get_volume_control_entities(): - if control_entity["entity_id"] != entity_obj["entity_id"]: - continue - cur_state = int( - try_parse_float(entity_obj["attributes"].get("volume_level")) * 100 - ) - await self.mass.player_manager.async_update_player_control( - control_entity["value"], cur_state - ) - - async def __async_register_player_controls(self): - """Register all (enabled) player controls.""" - await self.__async_register_power_controls() - await self.__async_register_volume_controls() - - async def __async_register_power_controls(self): - """Register all (enabled) power controls.""" - conf = self.mass.config.providers[PROV_ID] - enabled_controls = conf[CONF_POWER_ENTITIES] - for control_entity in self.__get_power_control_entities(): - enabled_controls = conf[CONF_POWER_ENTITIES] - if not control_entity["value"] in enabled_controls: - continue - entity_id = control_entity["entity_id"] - if entity_id not in self._hass.states: - LOGGER.warning("entity not found: %s", entity_id) - continue - state_obj = self._hass.states[entity_id] - cur_state = state_obj["state"] not in ["off", "unavailable"] - source = control_entity.get("source") - if source: - cur_state = ( - state_obj["attributes"].get("source") == control_entity["source"] - ) - - control = PlayerControl( - type=PlayerControlType.POWER, - id=control_entity["value"], - name=control_entity["text"], - state=cur_state, - set_state=self.async_power_control_set_state, - ) - # store some vars on the control object for convenience - control.entity_id = entity_id - control.source = source - await self.mass.player_manager.async_register_player_control(control) - if entity_id not in self._tracked_entities: - self._tracked_entities.append(entity_id) - - async def __async_register_volume_controls(self): - """Register all (enabled) power controls.""" - conf = self.mass.config.providers[PROV_ID] - enabled_controls = conf[CONF_VOLUME_ENTITIES] - for control_entity in self.__get_volume_control_entities(): - if not control_entity["value"] in enabled_controls: - continue - entity_id = control_entity["entity_id"] - if entity_id not in self._hass.states: - LOGGER.warning("entity not found: %s", entity_id) - continue - cur_volume = ( - try_parse_float(self._hass.get_state(entity_id, "volume_level")) * 100 - ) - control = PlayerControl( - type=PlayerControlType.VOLUME, - id=control_entity["value"], - name=control_entity["text"], - state=cur_volume, - set_state=self.async_volume_control_set_state, - ) - # store some vars on the control object for convenience - control.entity_id = entity_id - await self.mass.player_manager.async_register_player_control(control) - if entity_id not in self._tracked_entities: - self._tracked_entities.append(entity_id) - - async def async_power_control_set_state(self, control_id: str, new_state: bool): - """Set state callback for power control.""" - control = self.mass.player_manager.get_player_control(control_id) - if control.source: - cur_source = self._hass.get_state(control.entity_id, "source") - if cur_source is not None and cur_source != control.source: - return - if new_state and control.source: - # select source - await self._hass.async_call_service( - "media_player", - "select_source", - {"source": control.source, "entity_id": control.entity_id}, - ) - elif new_state: - # simple turn off - await self._hass.async_call_service( - "homeassistant", "turn_on", {"entity_id": control.entity_id} - ) - else: - # simple turn off - await self._hass.async_call_service( - "homeassistant", "turn_off", {"entity_id": control.entity_id} - ) - - async def async_volume_control_set_state(self, control_id: str, new_state: int): - """Set state callback for volume control.""" - control = self.mass.player_manager.get_player_control(control_id) - await self._hass.async_call_service( - "media_player", - "volume_set", - {"volume_level": new_state / 100, "entity_id": control.entity_id}, - ) diff --git a/music_assistant/web.py b/music_assistant/web.py index 04cfb03c..14e7becb 100755 --- a/music_assistant/web.py +++ b/music_assistant/web.py @@ -728,8 +728,6 @@ class Web: self.mass.add_event_listener(async_send_message, msg_details) ) await async_send_message("event listener subscribed", msg_details) - elif msg == "signal_event": - self.mass.signal_event(msg, msg_details) elif msg == "player_command": player_id = msg_details.get("player_id") cmd = msg_details.get("cmd") @@ -744,7 +742,8 @@ class Web: msg_details = {"cmd": cmd, "result": result} await async_send_message("player_command_result", msg_details) else: - await async_send_message("error", "invalid command") + # simply echo the message on the eventbus + self.mass.signal_event(msg, msg_details) except (AssertionError, asyncio.CancelledError) as exc: LOGGER.warning("Websocket disconnected - %s", str(exc)) diff --git a/requirements.txt b/requirements.txt index a96be77b..e9611cc8 100755 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ aiohttp[speedups]==3.6.2 requests==2.24.0 pychromecast==7.2.1 asyncio-throttle==1.0.1 +aiofile==3.1.0 aiosqlite==0.15.0 pytaglib==1.4.6 python-slugify==4.0.1 -- 2.34.1