hass integration now moved to hass component
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Thu, 17 Sep 2020 23:27:24 +0000 (01:27 +0200)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Thu, 17 Sep 2020 23:27:24 +0000 (01:27 +0200)
music_assistant/constants.py
music_assistant/models/player.py
music_assistant/player_manager.py
music_assistant/providers/home_assistant/__init__.py [deleted file]
music_assistant/web.py
requirements.txt

index 67a7294699fa413e22333cb6a7481f65c427c445..69f450c8dbcc33de98bc6898771212def76ba618 100755 (executable)
@@ -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"
index 63aa45fab4bd0ce8d1b8e3fc8989e8329c6d773f..f289177556508f62fe7ceb3b89f45b4250b7f0ce 100755 (executable)
@@ -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,
+        }
index dba38674d449c5134bd20d2d82b4dc85ce8a488d..3efc524b941bbe92b5662fa878021628d03414ed 100755 (executable)
@@ -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 (file)
index db96378..0000000
+++ /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},
-        )
index 04cfb03c8c33a9d1b6ab241441433c509f2ef7ff..14e7becbb4b459d6613bcd57bb8b66be50c5a553 100755 (executable)
@@ -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))
index a96be77b02bc09c6e1b723af135ee08221fe7ef7..e9611cc8bc08a909af6b5e57adc695b7b4f41a52 100755 (executable)
@@ -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