Expose sound cards and profiles with endpoint (#1534)

* Expose sound cards and profiles with endpoint

* Fix naming

* Fix issue

* Update API
This commit is contained in:
Pascal Vizeli 2020-02-27 16:25:04 +01:00 committed by GitHub
parent 9393521f98
commit 6721b8f265
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 139 additions and 26 deletions

22
API.md
View File

@ -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

View File

@ -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),
]

View File

@ -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])
)

View File

@ -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
},
}
}

View File

@ -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"

View File

@ -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

View File

@ -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")