<filename>soco_plugin/gateway.py
import asyncio
import logging
import requests

import home
import soco
from soco_plugin.message import Description, Trigger, Msg
from soco_plugin import command, trigger
from typing import Callable, Iterable


class Gateway(home.protocol.Gateway):

    PROTOCOL = Description.PROTOCOL
    SEND_MESSAGE_TIMEOUT = 1
    POLL_TIMEOUT = 0.05

    def __init__(self):
        self._players = {}
        self._loop = asyncio.get_event_loop()

        # lock events
        # generated by our commands
        self._players_locked = {}

        self.logger = logging.getLogger(__name__)
        self._waiting_for_response = dict()
        self._waiting_for_response_type = dict()

    async def unlock_events(self, player):
        self.logger.error("Unlock events for player {}".format(player))
        await asyncio.sleep(self.LOCK_NOTIFICATION_TIMEOUT)
        if self._players_locked[player]:
            self._players_locked[player] -= 1

    async def lock_events(self, player):
        self.logger.error("Lock events for player {}".format(player))
        if self._players_locked[player]:
            self._players_locked[player] += 1

    def is_locked(self, player):
        return self._players_locked[player] == 0

    async def disconnect(self) -> None:
        for (player, sub_rendering, sub_avtransport) in self._players:
            player.renderingControl.unsubscribe()
            player.avTransport.unsubscribe()

    async def _associate(
        self, descriptions: Iterable["soco_plugin.Description"]
    ) -> None:
        for description in descriptions:
            for name in description.msg["addresses"]:
                if name not in self._players:
                    try:
                        player = await self._loop.run_in_executor(
                            None, lambda: soco.discovery.by_name(name)
                        )
                    except TypeError as e:
                        self.logger.error(e)
                        player = None
                    except ConnectionError as e:
                        self.logger.error(e)
                        player = None
                    except requests.exceptions.ReadTimeout as e:
                        self.logger.error(e)
                        player = None
                    self.logger.info("Player %s: %s" % (name, str(player)))
                    if player:
                        sub_rendering = player.renderingControl.subscribe()
                        sub_avtransport = player.avTransport.subscribe()
                        self._players[name] = (player, sub_rendering, sub_avtransport)
                        self._waiting_for_response[player] = False
                        self._waiting_for_response_type[player] = None

    async def associate_commands(
        self, descriptions: Iterable["soco_plugin.Command"]
    ) -> None:
        await self._associate(descriptions)

    async def associate_triggers(
        self, descriptions: Iterable["soco_plugin.Trigger"]
    ) -> None:
        await self._associate(descriptions)

    def build_msg(
        self, address: str, action: str, fields: dict = None
    ) -> Description.Msg:
        msg = Msg()
        msg["type"] = Description.PROTOCOL
        msg["name"] = action
        msg["addresses"] = [address]
        msg["fields"] = fields if fields else {}
        return msg

    def build_msgs_from_bus(
        self, player: soco.SoCo, event
    ) -> Iterable["soco_plugin.Description"]:
        msgs = list()
        if "transport_state" in event.variables:
            transport_state = event.variables["transport_state"]
            # if "PAUSED_PLAYBACK" in transport_state:
            #    msgs.append(self.build_msg(player.player_name,
            #                               trigger.pause.Trigger.ACTION))
            if "PLAYING" in transport_state:
                msgs.append(
                    self.build_msg(player.player_name, trigger.play.Trigger.ACTION)
                )
            elif "PAUSED" in transport_state:
                msgs.append(
                    self.build_msg(player.player_name, trigger.pause.Trigger.ACTION)
                )
            elif "STOPPED" in transport_state:
                msgs.append(
                    self.build_msg(player.player_name, trigger.stop.Trigger.ACTION)
                )
        elif "volume" in event.variables:
            msgs.append(
                self.build_msg(
                    player.player_name,
                    trigger.volume.Trigger.ACTION,
                    {"volume": event.variables["volume"]["Master"]},
                )
            )
        return msgs

    async def _wait_for_event(self, player: soco.SoCo, channel, tasks) -> None:
        print("hallo!")
        while True:
            await asyncio.sleep(0.01)  # avoid potential starvation
            try:
                event = await self._loop.run_in_executor(
                    None, lambda: channel.events.get(block=True)
                )
                self.logger.error("Soco event %s" % (str(event)))
                msgs = self.build_msgs_from_bus(player, event)
                for task in tasks:
                    for msg in msgs:
                        if self._waiting_for_response[player]:
                            if self._waiting_for_response_type[player]:
                                if (
                                    msg["name"]
                                    == self._waiting_for_response_type[player]
                                ):
                                    self.logger.error(
                                        "processed msg {} of waited type".format(msg)
                                    )
                                    self._loop.create_task(task(msg))
                                else:
                                    self.logger.error(
                                        "waiting for response type {} received response of type {}".format(
                                            self._waiting_for_response_type[player],
                                            msg["name"],
                                        )
                                    )
                            else:
                                self.logger.error(
                                    "waiting for response type {} received response of type {}".format(
                                        self._waiting_for_response_type[player],
                                        msg["name"],
                                    )
                                )
                        else:
                            self.logger.error(
                                "processed msg {} no waited response".format(msg)
                            )
                            self._loop.create_task(task(msg))
            except Exception as e:
                self.logger.error(e)

    @staticmethod
    def get_action(player: soco.SoCo, msg: "soco_plugin.message.Command") -> Callable:
        """
        >>> import home
        >>> import soco_plugin
        >>> class Player:
        ...     def play(self):
        ...         print("play")
        ...     def pause(self):
        ...         print("pause")
        ...     def stop(self):
        ...         print("stop")
        ...     @property
        ...     def volume(self):
        ...         return 1
        ...     @property
        ...     def play_mode(self):
        ...         return "booo"
        ...     @volume.setter
        ...     def volume(self, value):
        ...         print("volume is {}".format(value))
        ...     @play_mode.setter
        ...     def play_mode(self, mode):
        ...         print("play mode is {}".format(mode))
        ...     def get_sonos_playlist_by_attr(self, attr, title):
        ...         print ("playlist title is {}".format(title))
        ...         return {"uri": "a uri"}
        ...     def clear_queue(self):
        ...         pass
        ...     def add_uri_to_queue(self, uri):
        ...         pass
        ...     def play_from_queue(self, index):
        ...         pass
        ...     def ramp_to_volume(self, volume, ramp_type):
        ...         print("ramp to {} {}".format(volume, ramp_type))

        >>> old_state = home.appliance.sound.player.state.off.State()
        >>> new_state = old_state.next(home.appliance.sound.player.event.forced.Event.On)
        >>> new_state = new_state.next(home.event.sleepiness.Event.Awake)
        >>> msgs = soco_plugin.command.play.Command.make(["Bath"]).make_msgs_from(old_state, new_state)
        >>> action = soco_plugin.Gateway.get_action(Player(), msgs[0])
        >>> action()
        play
        >>> msgs = soco_plugin.command.volume.absolute.Command.make(["Bath"]).make_msgs_from(old_state, new_state)
        >>> action = soco_plugin.Gateway.get_action(Player(), msgs[0])
        >>> action()
        volume is 20
        >>> msgs = soco_plugin.command.volume.relative.Command.make(["Bath"]).make_msgs_from(old_state, new_state)
        >>> action = soco_plugin.Gateway.get_action(Player(), msgs[0])
        >>> action()
        volume is 11
        >>> msgs = soco_plugin.command.playlist.Command.make(["Bath"]).make_msgs_from(old_state, new_state)
        >>> action = soco_plugin.Gateway.get_action(Player(), msgs[0])
        >>> action()
        playlist title is Common
        >>> old_state = home.appliance.sound.player.state.off.State()
        >>> old_state = old_state.next(home.event.presence.Event.On)
        >>> old_state = old_state.next(home.event.sleepiness.Event.Asleep)
        >>> new_state = old_state.next(home.event.sleepiness.Event.Awake)
        >>> msgs = soco_plugin.command.volume.ramp.Command.make(["Bath"]).make_msgs_from(old_state, new_state)
        >>> action = soco_plugin.Gateway.get_action(Player(), msgs[0])
        >>> action()
        ramp to 30 SLEEP_TIMER_RAMP_TYPE
        >>> msgs = soco_plugin.command.mode.Command.make(["Bath"]).make_msgs_from(old_state, new_state)
        >>> action = soco_plugin.Gateway.get_action(Player(), msgs[0])
        >>> action()
        play mode is SHUFFLE

        :param msg: a command message to be executed
        :param player: a player where the command will be applied
        :return: a callable to execute
        """
        name = msg["name"]
        fields = msg["fields"]
        action = None
        if name == command.play.Command.ACTION:
            action = lambda: command.play.action(player)  # noqa
        elif name == command.stop.Command.ACTION:
            action = lambda: command.stop.action(player)  # noqa
        elif name == command.pause.Command.ACTION:
            action = lambda: command.pause.action(player)  # noqa
        elif name == command.volume.relative.Command.ACTION:
            action = lambda: command.volume.relative.action(player, **fields)  # noqa
        elif name == command.volume.absolute.Command.ACTION:
            action = lambda: command.volume.absolute.action(player, **fields)  # noqa
        elif name == command.volume.ramp.Command.ACTION:
            action = lambda: command.volume.ramp.action(player, **fields)  # noqa
        elif name == command.playlist.Command.ACTION:
            action = lambda: command.playlist.action(player, **fields)  # noqa
        elif name == command.mode.Command.ACTION:
            action = lambda: command.mode.action(player, **fields)  # noqa
        return action

    async def send_msg(
        self, msg: "soco_plugin.message.Command", player: soco.SoCo
    ) -> None:
        self.logger.error("send msg {} to player {}".format(msg, player))
        if msg:
            action = self.get_action(player, msg)
            if action:
                try:
                    await self._loop.run_in_executor(None, action)
                except soco.exceptions.SoCoUPnPException as e:
                    self.logger.error(e)
                except Exception as e:
                    self.logger.error(e)

    async def _cancel_waiting_on_response(self, player, timeout):
        await asyncio.sleep(timeout)
        if self._waiting_for_response[player]:
            self.logger.error(
                "waiting for response of type {} timed out".format(
                    self._waiting_for_response_type[player]
                )
            )
            self._waiting_for_response[player] = False
            self._waiting_for_response_type[player] = None

    def get_response_type(self, msg: "soco_plugin.message.Command"):
        res = None
        name = msg["name"]
        if name == command.play.Command.ACTION:
            res = trigger.play.Trigger.ACTION
        elif name == command.stop.Command.ACTION:
            res = trigger.stop.Trigger.ACTION
        elif name == command.pause.Command.ACTION:
            res = trigger.pause.Trigger.ACTION
        elif name in (
            command.volume.relative.Command.ACTION,
            command.volume.absolute.Command.ACTION,
            command.volume.ramp.Command.ACTION,
        ):
            res = trigger.volume.Trigger.ACTION
        return res

    async def writer(
        self, msgs: Iterable["soco_plugin.message.Command"], *args
    ) -> None:
        for msg in msgs:
            if isinstance(msg, Msg):
                for address in msg["addresses"]:
                    if address in self._players:
                        (player, _, _) = self._players[address]
                        while True:
                            if not self._waiting_for_response[player]:
                                self._waiting_for_response[player] = True
                                self._waiting_for_response_type[
                                    player
                                ] = self.get_response_type(msg)
                                self._loop.create_task(
                                    self._cancel_waiting_on_response(
                                        player, self.SEND_MESSAGE_TIMEOUT
                                    )
                                )

                                self.logger.error("send message {}".format(msg))
                                await self.send_msg(msg, player)
                                break
                            else:
                                await asyncio.sleep(self.POLL_TIMEOUT)

    @staticmethod
    def make_trigger(msg: "soco_plugin.Description") -> "soco_plugin.Trigger":
        t = Trigger.make_from(msg)
        return t

    async def run(self, other_tasks: Iterable[Callable]) -> None:
        wrapped_tasks = self._wrap_tasks(other_tasks)
        for (player, sub_rendering, sub_avtransport) in self._players.values():
            self._loop.create_task(
                self._wait_for_event(player, sub_avtransport, wrapped_tasks)
            )
            self._loop.create_task(
                self._wait_for_event(player, sub_rendering, wrapped_tasks)
            )
