/
/
/
1"""Media Assistant Provider implementation."""
2
3from __future__ import annotations
4
5import asyncio
6import logging
7from typing import TYPE_CHECKING, ClassVar, cast
8
9from music_assistant_models.config_entries import ConfigEntry
10from music_assistant_models.enums import ConfigEntryType, 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, CONF_ROKU_APP_ID
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: ClassVar[dict[str, MediaAssistantPlayer]] = {}
32 lock: asyncio.Lock
33
34 @property
35 def supported_features(self) -> set[ProviderFeature]:
36 """Return the features supported by this Provider."""
37 return SUPPORTED_FEATURES
38
39 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
40 """Return Config entries to setup this provider."""
41 return (
42 CONF_ENTRY_MANUAL_DISCOVERY_IPS,
43 ConfigEntry(
44 key=CONF_ROKU_APP_ID,
45 type=ConfigEntryType.STRING,
46 default_value="782875",
47 required=False,
48 advanced=True,
49 ),
50 ConfigEntry(
51 key=CONF_AUTO_DISCOVER,
52 type=ConfigEntryType.BOOLEAN,
53 default_value=True,
54 advanced=True,
55 ),
56 )
57
58 async def handle_async_init(self) -> None:
59 """Handle async initialization of the provider."""
60 self.lock = asyncio.Lock()
61 # silence the rokuecp logger
62 if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL):
63 logging.getLogger("rokuecp").setLevel(logging.DEBUG)
64 else:
65 logging.getLogger("rokuecp").setLevel(self.logger.level + 10)
66
67 async def loaded_in_mass(self) -> None:
68 """Call after the provider has been loaded."""
69 manual_ip_config = cast(
70 "list[str]", self.config.get_value(CONF_ENTRY_MANUAL_DISCOVERY_IPS.key)
71 )
72
73 for ip in manual_ip_config:
74 await self._device_discovered(ip)
75
76 self.logger.info("MediaAssistantProvider loaded")
77
78 async def unload(self, is_removed: bool = False) -> None:
79 """Handle unload/close of the provider."""
80 if self.roku_players is None:
81 return # type: ignore[unreachable]
82 async with TaskManager(self.mass) as tg:
83 for roku_player in self.roku_players.values():
84 tg.create_task(self._device_disconnect(roku_player))
85
86 async def on_upnp_service_discovered(
87 self, search_target: str, discovery_info: CaseInsensitiveDict
88 ) -> None:
89 """Handle SSDP discovery callbacks."""
90 del search_target
91 if not self.config.get_value(CONF_AUTO_DISCOVER):
92 return
93 ssdp_st: str | None = discovery_info.get("st")
94 if not ssdp_st or "roku:ecp" not in ssdp_st:
95 return
96 if not discovery_info.get("usn"):
97 return
98 device_ip: str | None = discovery_info.get("_host")
99 if not device_ip:
100 return
101 await self._device_discovered(device_ip)
102
103 async def _device_disconnect(self, roku_player: MediaAssistantPlayer) -> None:
104 """Destroy connections to the device."""
105 async with roku_player.lock:
106 if not roku_player.roku:
107 self.logger.debug("Disconnecting from device that's not connected")
108 return
109
110 self.logger.debug("Disconnecting from %s", roku_player.name)
111
112 old_device = roku_player.roku
113 self.roku_players.pop(roku_player.player_id)
114 await old_device.close_session()
115
116 async def _device_discovered(self, ip: str) -> None:
117 """Handle discovered Roku."""
118 async with self.lock:
119 # connecting to Roku to retrieve device Info
120 roku = Roku(ip)
121 try:
122 device = await roku.update()
123 await roku.close_session()
124 except Exception:
125 self.logger.error("Failed to retrieve device info from Roku at: %s", ip)
126 await roku.close_session()
127 return
128
129 if device.info.serial_number is None:
130 return
131
132 player_id = "ROKU_" + device.info.serial_number
133
134 if roku_player := self.roku_players.get(player_id):
135 # existing player
136 if roku_player.device_info.ip_address == ip and roku_player.available:
137 # nothing to do, device is already connected
138 return
139 # update description url to newly discovered one
140 roku_player.device_info.add_identifier(IdentifierType.IP_ADDRESS, ip)
141 else:
142 roku_player = MediaAssistantPlayer(
143 provider=self,
144 player_id=player_id,
145 roku_name=device.info.name if device.info.name is not None else "",
146 roku=Roku(ip),
147 )
148
149 roku_player._attr_device_info = DeviceInfo(
150 model=device.info.model_name if device.info.model_name is not None else "",
151 model_id=device.info.model_number,
152 manufacturer=device.info.brand,
153 )
154 roku_player._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, ip)
155 roku_player._attr_device_info.add_identifier(
156 IdentifierType.SERIAL_NUMBER, device.info.serial_number
157 )
158 if device.info.ethernet_mac:
159 roku_player._attr_device_info.add_identifier(
160 IdentifierType.MAC_ADDRESS, device.info.ethernet_mac
161 )
162 elif device.info.wifi_mac:
163 roku_player._attr_device_info.add_identifier(
164 IdentifierType.MAC_ADDRESS, device.info.wifi_mac
165 )
166
167 self.roku_players[player_id] = roku_player
168 await roku_player.setup()
169