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 +31 -0
- pyambeo/const.py +159 -0
- pyambeo/espresso.py +415 -0
- pyambeo/exceptions.py +31 -0
- pyambeo/models.py +40 -0
- pyambeo/popcorn.py +367 -0
- pyambeo/py.typed +0 -0
- pyambeo/soundbar.py +589 -0
- pyambeo/transport.py +176 -0
- pyambeo-0.1.0.dist-info/METADATA +92 -0
- pyambeo-0.1.0.dist-info/RECORD +13 -0
- pyambeo-0.1.0.dist-info/WHEEL +4 -0
- pyambeo-0.1.0.dist-info/licenses/LICENSE +21 -0
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"
|