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