/
/
/
1"""FullyKiosk Player implementation."""
2
3from __future__ import annotations
4
5import asyncio
6import re
7import time
8from dataclasses import dataclass
9from typing import TYPE_CHECKING, Any, cast
10
11from aiohttp import ClientError, ClientSession, Fingerprint
12from fullykiosk import FullyKiosk
13from fullykiosk.exceptions import FullyKioskError
14from music_assistant_models.config_entries import ConfigEntry
15from music_assistant_models.enums import (
16 ConfigEntryType,
17 IdentifierType,
18 PlaybackState,
19 PlayerFeature,
20)
21from music_assistant_models.errors import PlayerCommandFailed
22
23from music_assistant.constants import (
24 CONF_ENTRY_OUTPUT_CODEC_DEFAULT_AAC,
25 CONF_PASSWORD,
26 CONF_SSL_FINGERPRINT,
27 CONF_USE_SSL,
28 CONF_VERIFY_SSL,
29)
30from music_assistant.models.player import DeviceInfo, Player, PlayerMedia
31from music_assistant.models.setup_flow import SetupFlowError
32
33if TYPE_CHECKING:
34 from music_assistant.models.setup_flow import SetupSession
35
36 from .dashboard import FullyKioskDashboards
37 from .provider import FullyKioskProvider
38
39AUDIOMANAGER_STREAM_MUSIC = 3
40DEFAULT_PORT = 2323
41
42
43@dataclass
44class _FingerprintSessionWrapper:
45 """Proxy ClientSession that enforces a TLS fingerprint."""
46
47 session: ClientSession
48 fingerprint: Fingerprint
49
50 def get(self, *args: Any, **kwargs: Any) -> Any:
51 """Call the wrapped session.get while injecting the fingerprint."""
52 kwargs.setdefault("ssl", self.fingerprint)
53 return self.session.get(*args, **kwargs)
54
55 def __getattr__(self, name: str) -> Any:
56 """Delegate attribute access to the wrapped session."""
57 return getattr(self.session, name)
58
59
60def _build_fingerprint(value: str) -> Fingerprint:
61 """Parse a fingerprint string (sha256 hex) into an aiohttp Fingerprint."""
62 normalized = re.sub(r"[^0-9a-fA-F]", "", value).lower()
63 if not normalized:
64 msg = "Empty fingerprint provided."
65 raise ValueError(msg)
66 if len(normalized) % 2 != 0:
67 msg = "Fingerprint must contain an even number of hex characters."
68 raise ValueError(msg)
69 digest = bytes.fromhex(normalized)
70 return Fingerprint(digest)
71
72
73class FullyKioskPlayer(Player):
74 """FullyKiosk Player implementation."""
75
76 _attr_poll_interval = 10
77
78 def __init__(
79 self,
80 provider: FullyKioskProvider,
81 player_id: str,
82 host: str,
83 port: int = DEFAULT_PORT,
84 ) -> None:
85 """Initialize the FullyKiosk Player."""
86 super().__init__(provider, player_id)
87 self.host = host
88 self.port = port
89 self.fully_kiosk: FullyKiosk | None = None
90 self._attr_needs_setup = True
91 self._attr_setup_reason = "password_required"
92 self._attr_available = False
93 self._attr_name = f"Fully Kiosk ({host})"
94 self._attr_supported_features = {PlayerFeature.PLAY_MEDIA, PlayerFeature.VOLUME_SET}
95
96 @property
97 def needs_poll(self) -> bool:
98 """Poll only once a password has been configured."""
99 return not self._attr_needs_setup
100
101 @property
102 def requires_flow_mode(self) -> bool:
103 """Return if the player requires flow mode."""
104 return True
105
106 async def get_config_entries(self) -> list[ConfigEntry]:
107 """Return all (provider/player specific) Config Entries for the given player (if any)."""
108 return [
109 ConfigEntry(
110 key=CONF_USE_SSL,
111 type=ConfigEntryType.BOOLEAN,
112 default_value=False,
113 advanced=True,
114 ),
115 ConfigEntry(
116 key=CONF_VERIFY_SSL,
117 type=ConfigEntryType.BOOLEAN,
118 default_value=True,
119 advanced=True,
120 ),
121 ConfigEntry(
122 key=CONF_SSL_FINGERPRINT,
123 type=ConfigEntryType.STRING,
124 required=False,
125 advanced=True,
126 ),
127 # Fully Kiosk aborts a long lived MP3 stream after roughly 8m45s and re-requests
128 # the url, so radio and long queues never play through. AAC is not affected.
129 CONF_ENTRY_OUTPUT_CODEC_DEFAULT_AAC,
130 ]
131
132 async def on_config_updated(self) -> None:
133 """Reconnect to the Fully Kiosk device when player configuration changes."""
134 password = cast("str | None", self.get_setup_value(CONF_PASSWORD) or None)
135 if not password:
136 self.fully_kiosk = None
137 self._attr_needs_setup = True
138 self._attr_setup_reason = "password_required"
139 self._attr_available = False
140 self._dashboards.unregister(self.player_id)
141 self.update_state()
142 return
143 await self._connect()
144
145 async def run_setup_flow(self, session: SetupSession) -> None:
146 """Run the setup flow: collect the Fully Kiosk API password."""
147 entries = [
148 ConfigEntry(key=CONF_PASSWORD, type=ConfigEntryType.SECURE_STRING, required=True)
149 ]
150 errors: dict[str, str] | None = None
151 while True:
152 values = await session.form(entries, step_id="user", errors=errors, last_step=True)
153 try:
154 await session.finish({CONF_PASSWORD: str(values[CONF_PASSWORD])})
155 return
156 except SetupFlowError as err:
157 errors = {"base": err.translation_key or str(err)}
158
159 async def poll(self) -> None:
160 """Poll player for state updates."""
161 if self.fully_kiosk is None:
162 await self._connect()
163 return
164 try:
165 async with asyncio.timeout(15):
166 await self.fully_kiosk.getDeviceInfo()
167 except (TimeoutError, ClientError, FullyKioskError) as err:
168 # do not log err: its repr may include the request URL with the password in the query string
169 self.logger.warning(
170 "Fully Kiosk device %s unavailable (%s) â will retry",
171 self.host,
172 type(err).__name__,
173 )
174 self._attr_available = False
175 self.fully_kiosk = None
176 self._dashboards.unregister(self.player_id)
177 self.update_state()
178 return
179 self._sync_state()
180 self.update_state()
181
182 async def volume_set(self, volume_level: int) -> None:
183 """Send VOLUME_SET command to given player."""
184 if self.fully_kiosk is None:
185 return
186 await self.fully_kiosk.setAudioVolume(volume_level, AUDIOMANAGER_STREAM_MUSIC)
187 self._attr_volume_level = volume_level
188 self.update_state()
189
190 async def stop(self) -> None:
191 """Send STOP command to given player."""
192 if self.fully_kiosk is None:
193 return
194 await self.fully_kiosk.stopSound()
195 self._attr_playback_state = PlaybackState.IDLE
196 self._attr_current_media = None
197 self.update_state()
198
199 async def play(self) -> None:
200 """Handle PLAY command on the player."""
201 raise PlayerCommandFailed("Playback can not be resumed.")
202
203 async def play_media(self, media: PlayerMedia) -> None:
204 """Handle PLAY MEDIA on given player."""
205 if self.fully_kiosk is None:
206 raise PlayerCommandFailed("Fully Kiosk device is not connected.")
207 url = await self.mass.streams.resolve_stream_url(self.player_id, media)
208 await self.fully_kiosk.playSound(url, AUDIOMANAGER_STREAM_MUSIC)
209 self._attr_current_media = media
210 self._attr_elapsed_time = 0
211 self._attr_elapsed_time_last_updated = time.time()
212 self._attr_playback_state = PlaybackState.PLAYING
213 self.update_state()
214
215 async def on_unload(self) -> None:
216 """Handle logic when the player is unloaded from the Player controller."""
217 await super().on_unload()
218 self._dashboards.unregister(self.player_id)
219
220 async def _connect(self) -> None:
221 """Establish a connection to the Fully Kiosk device."""
222 password = cast("str | None", self.get_setup_value(CONF_PASSWORD) or None)
223 if not password:
224 self._attr_needs_setup = True
225 self._attr_setup_reason = "password_required"
226 self._attr_available = False
227 self.fully_kiosk = None
228 self._dashboards.unregister(self.player_id)
229 self.update_state()
230 return
231
232 session_info = self._resolve_http_session()
233 if session_info is None:
234 self._attr_available = False
235 self._dashboards.unregister(self.player_id)
236 self.update_state()
237 return
238 http_session, use_ssl, verify_ssl = session_info
239
240 client = FullyKiosk(
241 http_session,
242 self.host,
243 self.port,
244 password,
245 use_ssl=use_ssl,
246 verify_ssl=verify_ssl,
247 )
248 try:
249 async with asyncio.timeout(15):
250 await client.getDeviceInfo()
251 except (TimeoutError, ClientError, FullyKioskError) as err:
252 # do not log err: its repr may include the request URL with the password in the query string
253 self.logger.warning(
254 "Failed to connect to Fully Kiosk at %s:%s (%s)",
255 self.host,
256 self.port,
257 type(err).__name__,
258 )
259 self.fully_kiosk = None
260 self._attr_available = False
261 self._dashboards.unregister(self.player_id)
262 self.update_state()
263 return
264
265 self.fully_kiosk = client
266 scheme = "https" if use_ssl else "http"
267 address = f"{scheme}://{self.host}:{self.port}"
268 self._attr_device_info = DeviceInfo(
269 model=client.deviceInfo.get("deviceModel", ""),
270 manufacturer=client.deviceInfo.get("deviceManufacturer", ""),
271 )
272 self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, address)
273 self._attr_needs_setup = False
274 self._attr_setup_reason = None
275 self._attr_available = True
276 self._sync_state()
277 self._dashboards.register(self)
278 self.update_state()
279
280 def _resolve_http_session(
281 self,
282 ) -> tuple[ClientSession | _FingerprintSessionWrapper, bool, bool] | None:
283 """Resolve (http_session, use_ssl, verify_ssl) from config, or None if misconfigured."""
284 use_ssl = bool(self.config.get_value(CONF_USE_SSL))
285 fingerprint_value = self.config.get_value(CONF_SSL_FINGERPRINT)
286 fingerprint_raw = fingerprint_value.strip() if isinstance(fingerprint_value, str) else ""
287 if fingerprint_raw and not use_ssl:
288 self.logger.warning(
289 "Fully Kiosk %s: fingerprint validation requires HTTPS to be enabled",
290 self.host,
291 )
292 return None
293 verify_ssl = bool(self.config.get_value(CONF_VERIFY_SSL)) if use_ssl else False
294 if not use_ssl:
295 return self.mass.http_session_no_ssl, use_ssl, verify_ssl
296 if fingerprint_raw:
297 try:
298 fingerprint = _build_fingerprint(fingerprint_raw)
299 except ValueError as err:
300 self.logger.warning(
301 "Fully Kiosk %s: invalid TLS fingerprint configured: %s", self.host, err
302 )
303 return None
304 return _FingerprintSessionWrapper(self.mass.http_session, fingerprint), use_ssl, True
305 session = self.mass.http_session if verify_ssl else self.mass.http_session_no_ssl
306 return session, use_ssl, verify_ssl
307
308 def _sync_state(self) -> None:
309 """Refresh player attributes from the latest deviceInfo."""
310 if self.fully_kiosk is None:
311 return
312 device_info = self.fully_kiosk.deviceInfo
313 if name := device_info.get("deviceName"):
314 self._attr_name = name
315 for volume_dict in device_info.get("audioVolumes", []):
316 if str(AUDIOMANAGER_STREAM_MUSIC) in volume_dict:
317 self._attr_volume_level = volume_dict[str(AUDIOMANAGER_STREAM_MUSIC)]
318 break
319 if not device_info.get("soundUrlPlaying"):
320 self._attr_playback_state = PlaybackState.IDLE
321
322 @property
323 def _dashboards(self) -> FullyKioskDashboards:
324 """Return the owning provider's dashboard adapter."""
325 return cast("FullyKioskProvider", self.provider).dashboards
326