/
/
/
1"""Media Assistant Provider implementation."""
2
3from __future__ import annotations
4
5import asyncio
6import logging
7from typing import TYPE_CHECKING, cast
8
9from async_upnp_client.search import async_search
10from music_assistant_models.enums import IdentifierType
11from music_assistant_models.player import DeviceInfo
12from rokuecp import Roku
13
14from music_assistant.constants import CONF_ENTRY_MANUAL_DISCOVERY_IPS, VERBOSE_LOG_LEVEL
15from music_assistant.helpers.util import TaskManager
16from music_assistant.models.player_provider import PlayerProvider
17
18from .constants import CONF_AUTO_DISCOVER
19from .player import MediaAssistantPlayer
20
21if TYPE_CHECKING:
22 from async_upnp_client.utils import CaseInsensitiveDict
23 from music_assistant_models.enums import ProviderFeature
24
25SUPPORTED_FEATURES: set[ProviderFeature] = set()
26
27
28class MediaAssistantprovider(PlayerProvider):
29 """Media Assistant Player provider."""
30
31 roku_players: dict[str, MediaAssistantPlayer] = {}
32 _discovery_running: bool = False
33 lock: asyncio.Lock
34
35 @property
36 def supported_features(self) -> set[ProviderFeature]:
37 """Return the features supported by this Provider."""
38 return SUPPORTED_FEATURES
39
40 async def handle_async_init(self) -> None:
41 """Handle async initialization of the provider."""
42 self.lock = asyncio.Lock()
43 # silence the async_upnp_client logger
44 if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL):
45 logging.getLogger("async_upnp_client").setLevel(logging.DEBUG)
46 else:
47 logging.getLogger("async_upnp_client").setLevel(self.logger.level + 10)
48 # silence the rokuecp logger
49 if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL):
50 logging.getLogger("rokuecp").setLevel(logging.DEBUG)
51 else:
52 logging.getLogger("rokuecp").setLevel(self.logger.level + 10)
53
54 async def loaded_in_mass(self) -> None:
55 """Call after the provider has been loaded."""
56 manual_ip_config = cast(
57 "list[str]", self.config.get_value(CONF_ENTRY_MANUAL_DISCOVERY_IPS.key)
58 )
59
60 for ip in manual_ip_config:
61 await self._device_discovered(ip)
62
63 self.logger.info("MediaAssistantProvider loaded")
64 await self.discover_players()
65
66 async def unload(self, is_removed: bool = False) -> None:
67 """Handle unload/close of the provider."""
68 if self.roku_players is None:
69 return # type: ignore[unreachable]
70 async with TaskManager(self.mass) as tg:
71 for roku_player in self.roku_players.values():
72 tg.create_task(self._device_disconnect(roku_player))
73
74 async def discover_players(self) -> None:
75 """Discover Roku players on the network."""
76 if not self.config.get_value(CONF_AUTO_DISCOVER):
77 return
78 if self._discovery_running:
79 return
80 try:
81 self._discovery_running = True
82 self.logger.debug("Roku discovery started...")
83 discovered_devices: set[str] = set()
84
85 async def on_response(discovery_info: CaseInsensitiveDict) -> None:
86 """Process discovered device from ssdp search."""
87 ssdp_st: str | None = discovery_info.get("st")
88 if not ssdp_st:
89 return
90
91 if "roku:ecp" not in ssdp_st:
92 # we're only interested in Roku devices
93 return
94
95 ssdp_usn: str = discovery_info["usn"]
96 ssdp_udn: str | None = discovery_info.get("_udn")
97 if not ssdp_udn and ssdp_usn.startswith("uuid:"):
98 ssdp_udn = "ROKU_" + ssdp_usn.split(":")[-1]
99 elif ssdp_udn:
100 ssdp_udn = "ROKU_" + ssdp_udn.split(":")[-1]
101 else:
102 return
103
104 if ssdp_udn in discovered_devices:
105 # already processed this device
106 return
107
108 discovered_devices.add(ssdp_udn)
109
110 await self._device_discovered(discovery_info["_host"])
111
112 await async_search(on_response, search_target="roku:ecp")
113
114 finally:
115 self._discovery_running = False
116
117 def reschedule() -> None:
118 self.mass.create_task(self.discover_players())
119
120 # reschedule self once finished
121 self.mass.loop.call_later(300, reschedule)
122
123 async def _device_disconnect(self, roku_player: MediaAssistantPlayer) -> None:
124 """Destroy connections to the device."""
125 async with roku_player.lock:
126 if not roku_player.roku:
127 self.logger.debug("Disconnecting from device that's not connected")
128 return
129
130 self.logger.debug("Disconnecting from %s", roku_player.name)
131
132 old_device = roku_player.roku
133 self.roku_players.pop(roku_player.player_id)
134 await old_device.close_session()
135
136 async def _device_discovered(self, ip: str) -> None:
137 """Handle discovered Roku."""
138 async with self.lock:
139 # connecting to Roku to retrieve device Info
140 roku = Roku(ip)
141 try:
142 device = await roku.update()
143 await roku.close_session()
144 except Exception:
145 self.logger.error("Failed to retrieve device info from Roku at: %s", ip)
146 await roku.close_session()
147 return
148
149 if device.info.serial_number is None:
150 return
151
152 player_id = "ROKU_" + device.info.serial_number
153
154 if roku_player := self.roku_players.get(player_id):
155 # existing player
156 if roku_player.device_info.ip_address == ip and roku_player.available:
157 # nothing to do, device is already connected
158 return
159 # update description url to newly discovered one
160 roku_player.device_info.add_identifier(IdentifierType.IP_ADDRESS, ip)
161 else:
162 roku_player = MediaAssistantPlayer(
163 provider=self,
164 player_id=player_id,
165 roku_name=device.info.name if device.info.name is not None else "",
166 roku=Roku(ip),
167 )
168
169 roku_player._attr_device_info = DeviceInfo(
170 model=device.info.model_name if device.info.model_name is not None else "",
171 model_id=device.info.model_number,
172 manufacturer=device.info.brand,
173 )
174 roku_player._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, ip)
175 roku_player._attr_device_info.add_identifier(
176 IdentifierType.SERIAL_NUMBER, device.info.serial_number
177 )
178 if device.info.ethernet_mac:
179 roku_player._attr_device_info.add_identifier(
180 IdentifierType.MAC_ADDRESS, device.info.ethernet_mac
181 )
182 elif device.info.wifi_mac:
183 roku_player._attr_device_info.add_identifier(
184 IdentifierType.MAC_ADDRESS, device.info.wifi_mac
185 )
186
187 self.roku_players[player_id] = roku_player
188 await roku_player.setup()
189