diff --git a/API.md b/API.md index 21470355c..ef3dd67ee 100644 --- a/API.md +++ b/API.md @@ -863,6 +863,19 @@ return: "version": "1", "latest_version": "2", "audio": { + "card": [ + { + "name": "...", + "description": "...", + "profiles": [ + { + "name": "...", + "description": "...", + "active": false + } + ] + } + ], "input": [ { "name": "...", @@ -931,6 +944,15 @@ return: } ``` +- POST `/audio/profile` + +```json +{ + "card": "...", + "name": "..." +} +``` + - GET `/audio/stats` ```json diff --git a/supervisor/api/__init__.py b/supervisor/api/__init__.py index bbf1ebecd..300b8b94c 100644 --- a/supervisor/api/__init__.py +++ b/supervisor/api/__init__.py @@ -329,6 +329,7 @@ class RestAPI(CoreSysAttributes): web.post("/audio/update", api_audio.update), web.post("/audio/restart", api_audio.restart), web.post("/audio/reload", api_audio.reload), + web.post("/audio/profile", api_audio.set_profile), web.post("/audio/volume/{source}", api_audio.set_volume), web.post("/audio/default/{source}", api_audio.set_default), ] diff --git a/supervisor/api/audio.py b/supervisor/api/audio.py index b8e9357a8..36feab636 100644 --- a/supervisor/api/audio.py +++ b/supervisor/api/audio.py @@ -11,6 +11,7 @@ from ..const import ( ATTR_AUDIO, ATTR_BLK_READ, ATTR_BLK_WRITE, + ATTR_CARD, ATTR_CPU_PERCENT, ATTR_HOST, ATTR_INPUT, @@ -44,6 +45,10 @@ SCHEMA_VOLUME = vol.Schema( SCHEMA_DEFAULT = vol.Schema({vol.Required(ATTR_NAME): vol.Coerce(str)}) +SCHEMA_PROFILE = vol.Schema( + {vol.Required(ATTR_CARD): vol.Coerce(str), vol.Required(ATTR_NAME): vol.Coerce(str)} +) + class APIAudio(CoreSysAttributes): """Handle RESTful API for Audio functions.""" @@ -56,13 +61,12 @@ class APIAudio(CoreSysAttributes): ATTR_LATEST_VERSION: self.sys_audio.latest_version, ATTR_HOST: str(self.sys_docker.network.audio), ATTR_AUDIO: { + ATTR_CARD: [attr.asdict(card) for card in self.sys_host.sound.cards], ATTR_INPUT: [ - attr.asdict(profile) - for profile in self.sys_host.sound.input_profiles + attr.asdict(stream) for stream in self.sys_host.sound.inputs ], ATTR_OUTPUT: [ - attr.asdict(profile) - for profile in self.sys_host.sound.output_profiles + attr.asdict(stream) for stream in self.sys_host.sound.outputs ], }, } @@ -110,7 +114,7 @@ class APIAudio(CoreSysAttributes): @api_process async def set_volume(self, request: web.Request) -> None: - """Set Audio information.""" + """Set audio volume on stream.""" source: SourceType = SourceType(request.match_info.get("source")) body = await api_validate(SCHEMA_VOLUME, request) @@ -120,8 +124,17 @@ class APIAudio(CoreSysAttributes): @api_process async def set_default(self, request: web.Request) -> None: - """Set Audio default sources.""" + """Set audio default stream.""" source: SourceType = SourceType(request.match_info.get("source")) body = await api_validate(SCHEMA_DEFAULT, request) await asyncio.shield(self.sys_host.sound.set_default(source, body[ATTR_NAME])) + + @api_process + async def set_profile(self, request: web.Request) -> None: + """Set audio default sources.""" + body = await api_validate(SCHEMA_DEFAULT, request) + + await asyncio.shield( + self.sys_host.sound.set_profile(body[ATTR_CARD], body[ATTR_NAME]) + ) diff --git a/supervisor/api/hardware.py b/supervisor/api/hardware.py index a2bd1e2e8..26880947d 100644 --- a/supervisor/api/hardware.py +++ b/supervisor/api/hardware.py @@ -42,11 +42,11 @@ class APIHardware(CoreSysAttributes): ATTR_AUDIO: { ATTR_INPUT: { profile.name: profile.description - for profile in self.sys_host.sound.input_profiles + for profile in self.sys_host.sound.inputs }, ATTR_OUTPUT: { profile.name: profile.description - for profile in self.sys_host.sound.output_profiles + for profile in self.sys_host.sound.outputs }, } } diff --git a/supervisor/const.py b/supervisor/const.py index 0fefbb811..1692ea301 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -233,6 +233,7 @@ ATTR_STAGE = "stage" ATTR_CLI = "cli" ATTR_DEFAULT = "default" ATTR_VOLUME = "volume" +ATTR_CARD = "card" PROVIDE_SERVICE = "provide" NEED_SERVICE = "need" diff --git a/supervisor/host/sound.py b/supervisor/host/sound.py index 7138594ce..1f8dc7855 100644 --- a/supervisor/host/sound.py +++ b/supervisor/host/sound.py @@ -22,7 +22,7 @@ class SourceType(str, Enum): @attr.s(frozen=True) -class AudioProfile: +class AudioStream: """Represent a input/output profile.""" name: str = attr.ib() @@ -31,27 +31,51 @@ class AudioProfile: default: bool = attr.ib() +@attr.s(frozen=True) +class SoundProfile: + """Represent a Sound Card profile.""" + + name: str = attr.ib() + description: str = attr.ib() + active: bool = attr.ib() + + +@attr.s(frozen=True) +class SoundCard: + """Represent a Sound Card.""" + + name: str = attr.ib() + description: str = attr.ib() + profiles: List[SoundProfile] = attr.ib() + + class SoundControl(CoreSysAttributes): """Pulse control from Host.""" def __init__(self, coresys: CoreSys) -> None: """Initialize PulseAudio sound control.""" self.coresys: CoreSys = coresys - self._input: List[AudioProfile] = [] - self._output: List[AudioProfile] = [] + self._cards: List[SoundCard] = [] + self._inputs: List[AudioStream] = [] + self._outputs: List[AudioStream] = [] @property - def input_profiles(self) -> List[AudioProfile]: - """Return a list of available input profiles.""" - return self._input + def cards(self) -> List[SoundCard]: + """Return a list of available sound cards and profiles.""" + return self._cards @property - def output_profiles(self) -> List[AudioProfile]: - """Return a list of available output profiles.""" - return self._output + def inputs(self) -> List[AudioStream]: + """Return a list of available input streams.""" + return self._inputs + + @property + def outputs(self) -> List[AudioStream]: + """Return a list of available output streams.""" + return self._outputs async def set_default(self, source: SourceType, name: str) -> None: - """Set a profile to default input/output.""" + """Set a stream to default input/output.""" try: with Pulse(PULSE_NAME) as pulse: if source == SourceType.OUTPUT: @@ -73,7 +97,7 @@ class SoundControl(CoreSysAttributes): await self.update() async def set_volume(self, source: SourceType, name: str, volume: float) -> None: - """Set a profile to volume input/output.""" + """Set a stream to volume input/output.""" try: with Pulse(PULSE_NAME) as pulse: if source == SourceType.OUTPUT: @@ -94,6 +118,37 @@ class SoundControl(CoreSysAttributes): # Reload data await self.update() + async def ativate_profile(self, card_name: str, profile_name: str) -> None: + """Set a profile to volume input/output.""" + try: + with Pulse(PULSE_NAME) as pulse: + + # Get card + select_card = None + for card in pulse.card_list(): + if card.name != card_name: + continue + select_card = card + break + + if not select_card: + raise PulseIndexError() + + # set profile + pulse.card_profile_set(select_card, profile_name) + + except PulseIndexError: + _LOGGER.error("Can't find %s profile %s", card_name, profile_name) + raise PulseAudioError() from None + except PulseError as err: + _LOGGER.error( + "Can't activate %s profile %s: %s", card_name, profile_name, err + ) + raise PulseAudioError() from None + + # Reload data + await self.update() + async def update(self): """Update properties over dbus.""" _LOGGER.info("Update PulseAudio information") @@ -102,10 +157,10 @@ class SoundControl(CoreSysAttributes): server = pulse.server_info() # Update output - self._output.clear() + self._outputs.clear() for sink in pulse.sink_list(): - self._output.append( - AudioProfile( + self._outputs.append( + AudioStream( sink.name, sink.description, sink.volume.value_flat, @@ -114,19 +169,41 @@ class SoundControl(CoreSysAttributes): ) # Update input - self._input.clear() + self._inputs.clear() for source in pulse.source_list(): # Filter monitor devices out because we did not use it now if source.name.endswith(".monitor"): continue - self._input.append( - AudioProfile( + self._inputs.append( + AudioStream( source.name, source.description, source.volume.value_flat, source.name == server.default_source_name, ) ) + + # Update Sound Card + self._cards.clear() + for card in pulse.card_list(): + sound_profiles: List[SoundProfile] = [] + + # Generate profiles + for profile in card.profile_list: + if not profile.available: + continue + sound_profiles.append( + SoundProfile( + profile.name, + profile.description, + profile.name == card.profile_active.name, + ) + ) + + self._cards.append( + SoundCard(card.name, card.description, sound_profiles) + ) + except PulseOperationFailed as err: _LOGGER.error("Error while processing pulse update: %s", err) raise PulseAudioError() from None diff --git a/supervisor/misc/hardware.py b/supervisor/misc/hardware.py index c75e8b3ad..89f4841cc 100644 --- a/supervisor/misc/hardware.py +++ b/supervisor/misc/hardware.py @@ -12,7 +12,6 @@ import pyudev from ..const import ATTR_DEVICES, ATTR_NAME, ATTR_TYPE, CHAN_ID, CHAN_TYPE from ..exceptions import HardwareNotSupportedError - _LOGGER: logging.Logger = logging.getLogger(__name__) ASOUND_CARDS: Path = Path("/proc/asound/cards")