pyambeo 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pyambeo/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """Async client for Sennheiser Ambeo soundbars."""
2
+
3
+ from .const import Capability, StateKey
4
+ from .espresso import AmbeoEspresso
5
+ from .exceptions import (
6
+ AmbeoConnectionError,
7
+ AmbeoError,
8
+ AmbeoResponseError,
9
+ AmbeoTimeoutError,
10
+ AmbeoUnsupportedModelError,
11
+ )
12
+ from .models import DeviceInfo, PlayerStatus, Preset, Source
13
+ from .popcorn import AmbeoPopcorn
14
+ from .soundbar import AmbeoSoundbar
15
+
16
+ __all__ = [
17
+ "AmbeoConnectionError",
18
+ "AmbeoError",
19
+ "AmbeoEspresso",
20
+ "AmbeoPopcorn",
21
+ "AmbeoResponseError",
22
+ "AmbeoSoundbar",
23
+ "AmbeoTimeoutError",
24
+ "AmbeoUnsupportedModelError",
25
+ "Capability",
26
+ "DeviceInfo",
27
+ "PlayerStatus",
28
+ "Preset",
29
+ "Source",
30
+ "StateKey",
31
+ ]
pyambeo/const.py ADDED
@@ -0,0 +1,159 @@
1
+ """Constants for Ambeo Soundbar API."""
2
+
3
+ from enum import StrEnum
4
+ from typing import NamedTuple
5
+
6
+ DEFAULT_PORT = 80
7
+ DEFAULT_TIMEOUT = 5
8
+
9
+ # ESPRESSO: MAX
10
+ MAX_SOUNDBAR = "AMBEO Soundbar Max"
11
+ EXCLUDE_SOURCES_MAX = ["aes"]
12
+ AMBEO_MAX_VOLUME_STEP = 0.02
13
+
14
+
15
+ # POPCORN: PLUS/MINI
16
+ PLUS_SOUNDBAR = "AMBEO Soundbar Plus"
17
+ MINI_SOUNDBAR = "AMBEO Soundbar Mini"
18
+ AMBEO_POPCORN_VOLUME_STEP = 0.01
19
+
20
+
21
+ BRIGHTNESS_RANGE_DEFAULT = (0, 100)
22
+ BRIGHTNESS_RANGE_AMBEO_MAX_DISPLAY = (1, 126)
23
+ BRIGHTNESS_RANGE_AMBEO_MAX_LOGO = (1, 118)
24
+
25
+
26
+ class Capability(StrEnum):
27
+ """Device capability identifiers."""
28
+
29
+ AMBEO_LOGO = "AmbeoLogo"
30
+ DECODER_STATUS = "DecoderStatus"
31
+ AMBEO_MODE_LEVEL = "AmbeoModeLevel"
32
+ BLUETOOTH_PAIRING = "AmbeoBluetoothPairing"
33
+ CENTER_SPEAKER_LEVEL = "CenterSpeakerLevel"
34
+ CENTER_VOLUME = "CenterVolume"
35
+ CODEC_LED = "CodecLED"
36
+ ECO_MODE = "EcoMode"
37
+ LED_BAR = "LEDBar"
38
+ MAX_DISPLAY = "AmbeoMaxDisplay"
39
+ MAX_LOGO = "AmbeoMaxLogo"
40
+ RESET_EXPERT_SETTINGS = "ResetExpertSettings"
41
+ SIDE_FIRING_LEVEL = "SideFiringLevel"
42
+ STANDBY = "standby"
43
+ SUBWOOFER = "SubWoofer"
44
+ UP_FIRING_LEVEL = "UpFiringLevel"
45
+ NATIVE_VOLUME = "NativeVolume"
46
+ VOICE_ENHANCEMENT_LEVEL = "VoiceEnhancementLevel"
47
+ VOICE_ENHANCEMENT_TOGGLE = "VoiceEnhancementMode"
48
+
49
+
50
+ class StateKey(StrEnum):
51
+ """Keys of the device state returned by fetch_state() and listen()."""
52
+
53
+ VOLUME = "volume"
54
+ MUTED = "muted"
55
+ STATE = "state"
56
+ CURRENT_SOURCE = "current_source"
57
+ CURRENT_PRESET = "current_preset"
58
+ PLAYER_DATA = "player_data"
59
+ PLAY_TIME = "play_time"
60
+ LED_BAR_BRIGHTNESS = "led_bar_brightness"
61
+ CODEC_LED_BRIGHTNESS = "codec_led_brightness"
62
+ LOGO_BRIGHTNESS = "logo_brightness"
63
+ LOGO_STATE = "logo_state"
64
+ DISPLAY_BRIGHTNESS = "display_brightness"
65
+ NIGHT_MODE = "night_mode"
66
+ AMBEO_MODE = "ambeo_mode"
67
+ AMBEO_MODE_LEVEL = "ambeo_mode_level"
68
+ SOUND_FEEDBACK = "sound_feedback"
69
+ VOICE_ENHANCEMENT = "voice_enhancement"
70
+ VOICE_ENHANCEMENT_LEVEL = "voice_enhancement_level"
71
+ BLUETOOTH_PAIRING = "bluetooth_pairing"
72
+ SUBWOOFER_STATUS = "subwoofer_status"
73
+ SUBWOOFER_VOLUME = "subwoofer_volume"
74
+ CENTER_SPEAKER_LEVEL = "center_speaker_level"
75
+ SIDE_FIRING_LEVEL = "side_firing_level"
76
+ UP_FIRING_LEVEL = "up_firing_level"
77
+ CENTER_VOLUME = "center_volume"
78
+ ECO_MODE = "eco_mode"
79
+ DECODER_STATUS = "decoder_status"
80
+
81
+
82
+ class PathSub(NamedTuple):
83
+ """Describes a single event-queue subscription.
84
+
85
+ path -- Device API path to subscribe to.
86
+ data_key -- Key used in the state dict.
87
+ type_key -- JSON type key inside the event's itemValue dict.
88
+ capability -- Required device capability, or None if always subscribed.
89
+ sub_key -- If set, the final value is item_value[type_key][sub_key]
90
+ (used when the type value is itself a dict).
91
+ """
92
+
93
+ path: str
94
+ data_key: StateKey
95
+ type_key: str
96
+ capability: Capability | None = None
97
+ sub_key: str | None = None
98
+
99
+
100
+ class FeatureDef(NamedTuple):
101
+ """Optional state value fetched by fetch_state() when the capability matches."""
102
+
103
+ data_key: StateKey
104
+ api_method: str
105
+ capability: Capability | None = None
106
+
107
+
108
+ OPTIONAL_FEATURES: tuple[FeatureDef, ...] = (
109
+ FeatureDef(StateKey.PLAY_TIME, "get_play_time"),
110
+ FeatureDef(
111
+ StateKey.LED_BAR_BRIGHTNESS, "get_led_bar_brightness", Capability.LED_BAR
112
+ ),
113
+ FeatureDef(
114
+ StateKey.CODEC_LED_BRIGHTNESS, "get_codec_led_brightness", Capability.CODEC_LED
115
+ ),
116
+ FeatureDef(StateKey.LOGO_BRIGHTNESS, "get_logo_brightness", Capability.AMBEO_LOGO),
117
+ FeatureDef(StateKey.LOGO_STATE, "get_logo_state", Capability.AMBEO_LOGO),
118
+ FeatureDef(StateKey.NIGHT_MODE, "get_night_mode"),
119
+ FeatureDef(StateKey.AMBEO_MODE, "get_ambeo_mode"),
120
+ FeatureDef(StateKey.SOUND_FEEDBACK, "get_sound_feedback"),
121
+ FeatureDef(
122
+ StateKey.VOICE_ENHANCEMENT,
123
+ "get_voice_enhancement",
124
+ Capability.VOICE_ENHANCEMENT_TOGGLE,
125
+ ),
126
+ FeatureDef(
127
+ StateKey.BLUETOOTH_PAIRING,
128
+ "get_bluetooth_pairing_state",
129
+ Capability.BLUETOOTH_PAIRING,
130
+ ),
131
+ FeatureDef(StateKey.SUBWOOFER_STATUS, "get_subwoofer_status", Capability.SUBWOOFER),
132
+ FeatureDef(StateKey.SUBWOOFER_VOLUME, "get_subwoofer_volume", Capability.SUBWOOFER),
133
+ FeatureDef(
134
+ StateKey.VOICE_ENHANCEMENT_LEVEL,
135
+ "get_voice_enhancement_level",
136
+ Capability.VOICE_ENHANCEMENT_LEVEL,
137
+ ),
138
+ FeatureDef(
139
+ StateKey.CENTER_SPEAKER_LEVEL,
140
+ "get_center_speaker_level",
141
+ Capability.CENTER_SPEAKER_LEVEL,
142
+ ),
143
+ FeatureDef(
144
+ StateKey.SIDE_FIRING_LEVEL,
145
+ "get_side_firing_level",
146
+ Capability.SIDE_FIRING_LEVEL,
147
+ ),
148
+ FeatureDef(
149
+ StateKey.UP_FIRING_LEVEL, "get_up_firing_level", Capability.UP_FIRING_LEVEL
150
+ ),
151
+ FeatureDef(StateKey.CENTER_VOLUME, "get_center_volume", Capability.CENTER_VOLUME),
152
+ FeatureDef(StateKey.ECO_MODE, "get_eco_mode", Capability.ECO_MODE),
153
+ FeatureDef(
154
+ StateKey.DECODER_STATUS, "get_decoder_status", Capability.DECODER_STATUS
155
+ ),
156
+ FeatureDef(
157
+ StateKey.AMBEO_MODE_LEVEL, "get_ambeo_mode_level", Capability.AMBEO_MODE_LEVEL
158
+ ),
159
+ )
pyambeo/espresso.py ADDED
@@ -0,0 +1,415 @@
1
+ """Implementation for Ambeo Soundbar Max (Espresso)."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from typing import Any
6
+
7
+ from .const import (
8
+ AMBEO_MAX_VOLUME_STEP,
9
+ BRIGHTNESS_RANGE_AMBEO_MAX_DISPLAY,
10
+ BRIGHTNESS_RANGE_AMBEO_MAX_LOGO,
11
+ EXCLUDE_SOURCES_MAX,
12
+ MAX_SOUNDBAR,
13
+ Capability,
14
+ PathSub,
15
+ StateKey,
16
+ )
17
+ from .exceptions import AmbeoError, AmbeoResponseError
18
+ from .models import Preset, Source
19
+ from .soundbar import AmbeoSoundbar
20
+ from .transport import AmbeoTransport
21
+
22
+ _LOGGER = logging.getLogger(__name__)
23
+
24
+
25
+ class AmbeoEspresso(AmbeoSoundbar):
26
+ """Ambeo Soundbar Max."""
27
+
28
+ models = (MAX_SOUNDBAR,)
29
+
30
+ capabilities = frozenset(
31
+ {
32
+ Capability.AMBEO_MODE_LEVEL,
33
+ Capability.CENTER_SPEAKER_LEVEL,
34
+ Capability.DECODER_STATUS,
35
+ Capability.MAX_DISPLAY,
36
+ Capability.MAX_LOGO,
37
+ Capability.NATIVE_VOLUME,
38
+ Capability.RESET_EXPERT_SETTINGS,
39
+ Capability.SIDE_FIRING_LEVEL,
40
+ Capability.STANDBY,
41
+ Capability.SUBWOOFER,
42
+ Capability.UP_FIRING_LEVEL,
43
+ Capability.VOICE_ENHANCEMENT_LEVEL,
44
+ }
45
+ )
46
+
47
+ # brightnessSensor is subscribed separately because it maps to two keys.
48
+ _BRIGHTNESS_PATH = "settings:/espresso/brightnessSensor"
49
+
50
+ _SUBSCRIPTIONS = (
51
+ PathSub("player:volume", StateKey.VOLUME, "i32_"),
52
+ PathSub("settings:/mediaPlayer/mute", StateKey.MUTED, "bool_"),
53
+ PathSub("espresso:nightModeUi", StateKey.NIGHT_MODE, "bool_"),
54
+ PathSub("espresso:ambeoModeUi", StateKey.AMBEO_MODE, "bool_"),
55
+ PathSub("settings:/espresso/soundFeedback", StateKey.SOUND_FEEDBACK, "bool_"),
56
+ PathSub("espresso:audioInputID", StateKey.CURRENT_SOURCE, "i32_"),
57
+ PathSub("settings:/espresso/equalizerPreset", StateKey.CURRENT_PRESET, "i32_"),
58
+ PathSub(
59
+ "ui:/mydevice/voiceEnhanceLevel",
60
+ StateKey.VOICE_ENHANCEMENT_LEVEL,
61
+ "i16_",
62
+ Capability.VOICE_ENHANCEMENT_LEVEL,
63
+ ),
64
+ PathSub(
65
+ "ui:/settings/audio/centerSettings",
66
+ StateKey.CENTER_SPEAKER_LEVEL,
67
+ "i16_",
68
+ Capability.CENTER_SPEAKER_LEVEL,
69
+ ),
70
+ PathSub(
71
+ "ui:/settings/audio/widthSettings",
72
+ StateKey.SIDE_FIRING_LEVEL,
73
+ "i16_",
74
+ Capability.SIDE_FIRING_LEVEL,
75
+ ),
76
+ PathSub(
77
+ "ui:/settings/audio/heightSettings",
78
+ StateKey.UP_FIRING_LEVEL,
79
+ "i16_",
80
+ Capability.UP_FIRING_LEVEL,
81
+ ),
82
+ PathSub(
83
+ "settings:/espresso/ambeoMode",
84
+ StateKey.AMBEO_MODE_LEVEL,
85
+ "i32_",
86
+ Capability.AMBEO_MODE_LEVEL,
87
+ ),
88
+ PathSub(
89
+ "ui:/settings/subwoofer/enabled",
90
+ StateKey.SUBWOOFER_STATUS,
91
+ "bool_",
92
+ Capability.SUBWOOFER,
93
+ ),
94
+ PathSub(
95
+ "ui:/settings/subwoofer/volume",
96
+ StateKey.SUBWOOFER_VOLUME,
97
+ "i16_",
98
+ Capability.SUBWOOFER,
99
+ ),
100
+ PathSub(
101
+ "settings:/espresso/decoderStatus",
102
+ StateKey.DECODER_STATUS,
103
+ "espressoDecoderStatus",
104
+ Capability.DECODER_STATUS,
105
+ ),
106
+ )
107
+
108
+ _PRESETS = (
109
+ Preset(0, "Neutral"),
110
+ Preset(1, "Movies"),
111
+ Preset(2, "Sport"),
112
+ Preset(3, "News"),
113
+ Preset(4, "Music"),
114
+ )
115
+
116
+ def __init__(self, transport: AmbeoTransport, *args, **kwargs) -> None:
117
+ """Initialize and set up instance variables."""
118
+ super().__init__(transport, *args, **kwargs)
119
+ self._has_subwoofer: bool | None = None
120
+ # Logo and display brightness share one path: serialize read-modify-write.
121
+ self._brightness_lock = asyncio.Lock()
122
+
123
+ def get_volume_max(self) -> int:
124
+ """Get the maximum native volume value."""
125
+ return 50
126
+
127
+ def get_volume_step(self) -> float:
128
+ """Get the volume step size."""
129
+ return AMBEO_MAX_VOLUME_STEP
130
+
131
+ def get_subwoofer_min_value(self) -> int:
132
+ """Get the subwoofer minimum value."""
133
+ return -12
134
+
135
+ def get_subwoofer_max_value(self) -> int:
136
+ """Get the subwoofer maximum value."""
137
+ return 12
138
+
139
+ def get_logo_brightness_range(self) -> tuple[int, int]:
140
+ """Get the Ambeo Max logo brightness range."""
141
+ return BRIGHTNESS_RANGE_AMBEO_MAX_LOGO
142
+
143
+ def get_display_brightness_range(self) -> tuple[int, int]:
144
+ """Get the Ambeo Max display brightness range."""
145
+ return BRIGHTNESS_RANGE_AMBEO_MAX_DISPLAY
146
+
147
+ async def stand_by(self) -> None:
148
+ """Put the device into standby mode."""
149
+ await self._transport.set_value("espresso:appRequestedStandby", "bool_", True)
150
+
151
+ async def wake(self) -> None:
152
+ """Wake the device from standby."""
153
+ await self._transport.set_value("espresso:appRequestedOnline", "bool_", True)
154
+
155
+ async def get_night_mode(self) -> bool | None:
156
+ """Get the night mode state."""
157
+ return await self._transport.get_value("espresso:nightModeUi", "bool_")
158
+
159
+ async def set_night_mode(self, night_mode: bool) -> None:
160
+ """Set the night mode state."""
161
+ await self._transport.set_value("espresso:nightModeUi", "bool_", night_mode)
162
+
163
+ async def get_ambeo_mode(self) -> bool | None:
164
+ """Get the Ambeo mode state."""
165
+ return await self._transport.get_value("espresso:ambeoModeUi", "bool_")
166
+
167
+ async def set_ambeo_mode(self, ambeo_mode: bool) -> None:
168
+ """Set the Ambeo mode state."""
169
+ await self._transport.set_value("espresso:ambeoModeUi", "bool_", ambeo_mode)
170
+
171
+ async def get_sound_feedback(self) -> bool | None:
172
+ """Get the sound feedback state."""
173
+ return await self._transport.get_value(
174
+ "settings:/espresso/soundFeedback", "bool_"
175
+ )
176
+
177
+ async def set_sound_feedback(self, state: bool) -> None:
178
+ """Set the sound feedback state."""
179
+ await self._transport.set_value(
180
+ "settings:/espresso/soundFeedback", "bool_", state
181
+ )
182
+
183
+ async def get_current_source(self) -> int | None:
184
+ """Get the current audio source ID."""
185
+ return await self._transport.get_value("espresso:audioInputID", "i32_")
186
+
187
+ async def fetch_sources(self) -> list[Source]:
188
+ """Fetch all available audio sources."""
189
+ input_names, inputs = await asyncio.gather(
190
+ self._transport.get_rows("settings:/espresso/inputNames", 0, 20),
191
+ self._transport.get_rows("espresso:", 0, 20),
192
+ )
193
+ input_index_map = {
194
+ row["title"].lower(): index
195
+ for index, row in enumerate(inputs)
196
+ if isinstance(row.get("title"), str)
197
+ }
198
+ sources = []
199
+ for name in input_names:
200
+ title = name.get("title")
201
+ value = name.get("value")
202
+ label = value.get("string_") if isinstance(value, dict) else None
203
+ if not isinstance(title, str) or label is None:
204
+ continue
205
+ key = title.lower()
206
+ if key in EXCLUDE_SOURCES_MAX or key not in input_index_map:
207
+ continue
208
+ sources.append(Source(input_index_map[key], label))
209
+ return sources
210
+
211
+ async def set_source(self, source_id: int) -> None:
212
+ """Set the audio source."""
213
+ await self._transport.set_value("espresso:audioInputID", "i32_", source_id)
214
+
215
+ async def get_current_preset(self) -> int | None:
216
+ """Get the current audio preset ID."""
217
+ return await self._transport.get_value(
218
+ "settings:/espresso/equalizerPreset", "i32_"
219
+ )
220
+
221
+ async def set_preset(self, preset: int) -> None:
222
+ """Set the audio preset."""
223
+ await self._transport.set_value(
224
+ "settings:/espresso/equalizerPreset", "i32_", preset
225
+ )
226
+
227
+ async def fetch_presets(self) -> list[Preset]:
228
+ """Return the audio presets (fixed on this model)."""
229
+ return list(self._PRESETS)
230
+
231
+ async def fetch_state(self, max_concurrency: int = 3) -> dict[StateKey, Any]:
232
+ """Fetch the full device state.
233
+
234
+ Logo and display brightness share one path, read once per fetch.
235
+ """
236
+ state, brightness = await asyncio.gather(
237
+ super().fetch_state(max_concurrency), self._fetch_brightness_state()
238
+ )
239
+ state.update(brightness)
240
+ return state
241
+
242
+ async def _fetch_brightness_state(self) -> dict[StateKey, Any]:
243
+ """Return the logo and display brightness state values."""
244
+ try:
245
+ brightness = await self._get_brightness()
246
+ except AmbeoError as e:
247
+ _LOGGER.debug("Brightness not available: %s", e)
248
+ return {}
249
+ values = {
250
+ StateKey.LOGO_BRIGHTNESS: brightness.get("ambeologo"),
251
+ StateKey.DISPLAY_BRIGHTNESS: brightness.get("display"),
252
+ }
253
+ return {key: value for key, value in values.items() if value is not None}
254
+
255
+ async def _get_brightness(self) -> dict:
256
+ """Get the logo and display brightness values."""
257
+ brightness = await self._transport.get_value(
258
+ self._BRIGHTNESS_PATH, "espressoBrightness"
259
+ )
260
+ return brightness if isinstance(brightness, dict) else {}
261
+
262
+ async def _set_brightness(self, key: str, brightness: int) -> None:
263
+ """Set one brightness value, keeping the other one unchanged."""
264
+ async with self._brightness_lock:
265
+ current = await self._get_brightness()
266
+ value = {
267
+ "ambeologo": current.get("ambeologo"),
268
+ "display": current.get("display"),
269
+ key: brightness,
270
+ }
271
+ if None in value.values():
272
+ raise AmbeoResponseError(
273
+ f"Missing brightness value for path: {self._BRIGHTNESS_PATH}"
274
+ )
275
+ await self._transport.set_value(
276
+ self._BRIGHTNESS_PATH, "espressoBrightness", value
277
+ )
278
+
279
+ async def get_display_brightness(self) -> int | None:
280
+ """Get the display brightness."""
281
+ return (await self._get_brightness()).get("display")
282
+
283
+ async def set_display_brightness(self, brightness: int) -> None:
284
+ """Set the display brightness."""
285
+ await self._set_brightness("display", brightness)
286
+
287
+ async def get_logo_brightness(self) -> int | None:
288
+ """Get the Ambeo logo brightness."""
289
+ return (await self._get_brightness()).get("ambeologo")
290
+
291
+ async def set_logo_brightness(self, brightness: int) -> None:
292
+ """Set the Ambeo logo brightness."""
293
+ await self._set_brightness("ambeologo", brightness)
294
+
295
+ async def get_voice_enhancement_level(self) -> int | None:
296
+ """Get the voice enhancement level."""
297
+ return await self._transport.get_value("ui:/mydevice/voiceEnhanceLevel", "i16_")
298
+
299
+ async def set_voice_enhancement_level(self, level: int) -> None:
300
+ """Set the voice enhancement level."""
301
+ await self._transport.set_value("ui:/mydevice/voiceEnhanceLevel", "i16_", level)
302
+
303
+ async def get_center_speaker_level(self) -> int | None:
304
+ """Get the center speaker level."""
305
+ return await self._transport.get_value(
306
+ "ui:/settings/audio/centerSettings", "i16_"
307
+ )
308
+
309
+ async def set_center_speaker_level(self, level: int) -> None:
310
+ """Set the center speaker level."""
311
+ await self._transport.set_value(
312
+ "ui:/settings/audio/centerSettings", "i16_", level
313
+ )
314
+
315
+ async def get_side_firing_level(self) -> int | None:
316
+ """Get the side firing speaker level."""
317
+ return await self._transport.get_value(
318
+ "ui:/settings/audio/widthSettings", "i16_"
319
+ )
320
+
321
+ async def set_side_firing_level(self, level: int) -> None:
322
+ """Set the side firing speaker level."""
323
+ await self._transport.set_value(
324
+ "ui:/settings/audio/widthSettings", "i16_", level
325
+ )
326
+
327
+ async def get_up_firing_level(self) -> int | None:
328
+ """Get the up firing speaker level."""
329
+ return await self._transport.get_value(
330
+ "ui:/settings/audio/heightSettings", "i16_"
331
+ )
332
+
333
+ async def set_up_firing_level(self, level: int) -> None:
334
+ """Set the up firing speaker level."""
335
+ await self._transport.set_value(
336
+ "ui:/settings/audio/heightSettings", "i16_", level
337
+ )
338
+
339
+ async def get_ambeo_mode_level(self) -> int | None:
340
+ """Get the Ambeo mode level (1=Light, 2=Regular, 3=Boost)."""
341
+ return await self._transport.get_value("settings:/espresso/ambeoMode", "i32_")
342
+
343
+ async def set_ambeo_mode_level(self, level: int) -> None:
344
+ """Set the Ambeo mode level (1=Light, 2=Regular, 3=Boost)."""
345
+ await self._transport.set_value("settings:/espresso/ambeoMode", "i32_", level)
346
+
347
+ async def get_decoder_status(self) -> dict | None:
348
+ """Get the current audio decoder status."""
349
+ return await self._transport.get_value(
350
+ "settings:/espresso/decoderStatus", "espressoDecoderStatus"
351
+ )
352
+
353
+ async def reset_expert_settings(self) -> None:
354
+ """Reset expert audio settings to defaults."""
355
+ await self._transport.activate(
356
+ "ui:/settings/audio/resetExpertSettings", {"type": "bool_", "bool_": True}
357
+ )
358
+
359
+ # Subwoofer.
360
+ async def has_subwoofer(self) -> bool:
361
+ """Check if a subwoofer is connected."""
362
+ if self._has_subwoofer is None:
363
+ try:
364
+ data = await self._transport.get_data("ui:/settings/subwoofer/enabled")
365
+ except AmbeoResponseError:
366
+ data = None
367
+ self._has_subwoofer = (
368
+ bool(data.get("modifiable", False)) if isinstance(data, dict) else False
369
+ )
370
+ return self._has_subwoofer
371
+
372
+ async def get_subwoofer_volume(self) -> float | None:
373
+ """Get the subwoofer volume."""
374
+ return await self._transport.get_value("ui:/settings/subwoofer/volume", "i16_")
375
+
376
+ async def set_subwoofer_volume(self, volume: float) -> None:
377
+ """Set the subwoofer volume."""
378
+ await self._transport.set_value(
379
+ "ui:/settings/subwoofer/volume", "i16_", int(volume)
380
+ )
381
+
382
+ async def get_subwoofer_status(self) -> bool | None:
383
+ """Get the subwoofer enabled status."""
384
+ return await self._transport.get_value(
385
+ "ui:/settings/subwoofer/enabled", "bool_"
386
+ )
387
+
388
+ async def set_subwoofer_status(self, status: bool) -> None:
389
+ """Set the subwoofer enabled status."""
390
+ await self._transport.set_value(
391
+ "ui:/settings/subwoofer/enabled", "bool_", status
392
+ )
393
+
394
+ def subscribed_paths(self) -> list[str]:
395
+ """Return paths to subscribe to, filtered by device capabilities."""
396
+ paths = super().subscribed_paths()
397
+ if self.has_capability(Capability.MAX_DISPLAY) or self.has_capability(
398
+ Capability.MAX_LOGO
399
+ ):
400
+ paths.append(self._BRIGHTNESS_PATH)
401
+ return paths
402
+
403
+ def process_event(self, path: str, item_value: dict) -> dict[StateKey, Any]:
404
+ """Map an event path + itemValue to state updates."""
405
+ if path == self._BRIGHTNESS_PATH:
406
+ brightness = item_value.get("espressoBrightness")
407
+ if not isinstance(brightness, dict):
408
+ return {}
409
+ updates: dict[StateKey, Any] = {}
410
+ if "ambeologo" in brightness:
411
+ updates[StateKey.LOGO_BRIGHTNESS] = brightness["ambeologo"]
412
+ if "display" in brightness:
413
+ updates[StateKey.DISPLAY_BRIGHTNESS] = brightness["display"]
414
+ return updates
415
+ return super().process_event(path, item_value)
pyambeo/exceptions.py ADDED
@@ -0,0 +1,31 @@
1
+ """Exceptions for the Ambeo Soundbar API."""
2
+
3
+
4
+ class AmbeoError(Exception):
5
+ """Base class for all Ambeo Soundbar API errors."""
6
+
7
+
8
+ class AmbeoConnectionError(AmbeoError):
9
+ """Raised when the device cannot be reached."""
10
+
11
+
12
+ class AmbeoTimeoutError(AmbeoConnectionError):
13
+ """Raised when a request to the device times out."""
14
+
15
+
16
+ class AmbeoResponseError(AmbeoError):
17
+ """Raised when the device answers with an unexpected response."""
18
+
19
+ def __init__(self, message: str, status: int | None = None) -> None:
20
+ """Initialize with an optional HTTP status code."""
21
+ super().__init__(message)
22
+ self.status = status
23
+
24
+
25
+ class AmbeoUnsupportedModelError(AmbeoError):
26
+ """Raised when the device model is not supported."""
27
+
28
+ def __init__(self, model: str | None) -> None:
29
+ """Initialize with the unsupported model name."""
30
+ super().__init__(f"Unsupported model: {model}")
31
+ self.model = model
pyambeo/models.py ADDED
@@ -0,0 +1,40 @@
1
+ """Data models for the Ambeo Soundbar API."""
2
+
3
+ from dataclasses import dataclass
4
+ from enum import StrEnum
5
+
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class DeviceInfo:
9
+ """Static information about a soundbar."""
10
+
11
+ model: str
12
+ name: str | None
13
+ serial: str | None
14
+ firmware_version: str | None
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class Source:
19
+ """An audio input (HDMI, Optical, …)."""
20
+
21
+ id: str | int
22
+ title: str
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class Preset:
27
+ """An audio preset / sound mode (Movies, Music, …)."""
28
+
29
+ id: str | int
30
+ title: str
31
+
32
+
33
+ class PlayerStatus(StrEnum):
34
+ """Effective playback status derived from the device state."""
35
+
36
+ PLAYING = "playing"
37
+ PAUSED = "paused"
38
+ IDLE = "idle"
39
+ ON = "on"
40
+ STANDBY = "standby"