Support mute + applications from pulse (#1541)

* Support mute + applications from pulse

* Fix lint

* Fix application parser

* Fix type

* Add application endpoints

* error handling

* Fix
This commit is contained in:
Pascal Vizeli 2020-02-28 17:52:12 +01:00 committed by GitHub
parent 87170a4497
commit e1cbfdd84b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 253 additions and 20 deletions

84
API.md
View File

@ -866,6 +866,7 @@ return:
"card": [ "card": [
{ {
"name": "...", "name": "...",
"index": 1,
"driver": "...", "driver": "...",
"profiles": [ "profiles": [
{ {
@ -879,17 +880,56 @@ return:
"input": [ "input": [
{ {
"name": "...", "name": "...",
"index": 0,
"description": "...", "description": "...",
"volume": 0.3, "volume": 0.3,
"default": false "mute": false,
"default": false,
"card": "null|int",
"applications": [
{
"name": "...",
"index": 0,
"stream_index": 0,
"stream_type": "INPUT",
"volume": 0.3,
"mute": false,
"addon": ""
}
]
} }
], ],
"output": [ "output": [
{ {
"name": "...", "name": "...",
"index": 0,
"description": "...", "description": "...",
"volume": 0.3, "volume": 0.3,
"default": false "mute": false,
"default": false,
"card": "null|int",
"applications": [
{
"name": "...",
"index": 0,
"stream_index": 0,
"stream_type": "OUTPUT",
"volume": 0.3,
"mute": false,
"addon": ""
}
]
}
],
"application": [
{
"name": "...",
"index": 0,
"stream_index": 0,
"stream_type": "OUTPUT",
"volume": 0.3,
"mute": false,
"addon": ""
} }
] ]
} }
@ -914,7 +954,7 @@ return:
```json ```json
{ {
"name": "...", "index": "...",
"volume": 0.5 "volume": 0.5
} }
``` ```
@ -923,11 +963,47 @@ return:
```json ```json
{ {
"name": "...", "index": "...",
"volume": 0.5 "volume": 0.5
} }
``` ```
- POST `/audio/volume/{output|input}/application`
```json
{
"index": "...",
"volume": 0.5
}
```
- POST `/audio/mute/input`
```json
{
"index": "...",
"active": false
}
```
- POST `/audio/mute/output`
```json
{
"index": "...",
"active": false
}
```
- POST `/audio/mute/{output|input}/application`
```json
{
"index": "...",
"active": false
}
```
- POST `/audio/default/input` - POST `/audio/default/input`
```json ```json

View File

@ -330,7 +330,10 @@ class RestAPI(CoreSysAttributes):
web.post("/audio/restart", api_audio.restart), web.post("/audio/restart", api_audio.restart),
web.post("/audio/reload", api_audio.reload), web.post("/audio/reload", api_audio.reload),
web.post("/audio/profile", api_audio.set_profile), web.post("/audio/profile", api_audio.set_profile),
web.post("/audio/volume/{source}/application", api_audio.set_volume),
web.post("/audio/volume/{source}", api_audio.set_volume), web.post("/audio/volume/{source}", api_audio.set_volume),
web.post("/audio/mute/{source}/application", api_audio.set_mute),
web.post("/audio/mute/{source}", api_audio.set_mute),
web.post("/audio/default/{source}", api_audio.set_default), web.post("/audio/default/{source}", api_audio.set_default),
] ]
) )

View File

@ -8,12 +8,15 @@ import attr
import voluptuous as vol import voluptuous as vol
from ..const import ( from ..const import (
ATTR_ACTIVE,
ATTR_APPLICATION,
ATTR_AUDIO, ATTR_AUDIO,
ATTR_BLK_READ, ATTR_BLK_READ,
ATTR_BLK_WRITE, ATTR_BLK_WRITE,
ATTR_CARD, ATTR_CARD,
ATTR_CPU_PERCENT, ATTR_CPU_PERCENT,
ATTR_HOST, ATTR_HOST,
ATTR_INDEX,
ATTR_INPUT, ATTR_INPUT,
ATTR_LATEST_VERSION, ATTR_LATEST_VERSION,
ATTR_MEMORY_LIMIT, ATTR_MEMORY_LIMIT,
@ -38,11 +41,19 @@ SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): vol.Coerce(str)})
SCHEMA_VOLUME = vol.Schema( SCHEMA_VOLUME = vol.Schema(
{ {
vol.Required(ATTR_NAME): vol.Coerce(str), vol.Required(ATTR_INDEX): vol.Coerce(int),
vol.Required(ATTR_VOLUME): vol.Coerce(float), vol.Required(ATTR_VOLUME): vol.Coerce(float),
} }
) )
# pylint: disable=no-value-for-parameter
SCHEMA_MUTE = vol.Schema(
{
vol.Required(ATTR_INDEX): vol.Coerce(int),
vol.Required(ATTR_ACTIVE): vol.Boolean(),
}
)
SCHEMA_DEFAULT = vol.Schema({vol.Required(ATTR_NAME): vol.Coerce(str)}) SCHEMA_DEFAULT = vol.Schema({vol.Required(ATTR_NAME): vol.Coerce(str)})
SCHEMA_PROFILE = vol.Schema( SCHEMA_PROFILE = vol.Schema(
@ -68,6 +79,9 @@ class APIAudio(CoreSysAttributes):
ATTR_OUTPUT: [ ATTR_OUTPUT: [
attr.asdict(stream) for stream in self.sys_host.sound.outputs attr.asdict(stream) for stream in self.sys_host.sound.outputs
], ],
ATTR_APPLICATION: [
attr.asdict(stream) for stream in self.sys_host.sound.applications
],
}, },
} }
@ -116,10 +130,26 @@ class APIAudio(CoreSysAttributes):
async def set_volume(self, request: web.Request) -> None: async def set_volume(self, request: web.Request) -> None:
"""Set audio volume on stream.""" """Set audio volume on stream."""
source: StreamType = StreamType(request.match_info.get("source")) source: StreamType = StreamType(request.match_info.get("source"))
application: bool = request.path.endswith("application")
body = await api_validate(SCHEMA_VOLUME, request) body = await api_validate(SCHEMA_VOLUME, request)
await asyncio.shield( await asyncio.shield(
self.sys_host.sound.set_volume(source, body[ATTR_NAME], body[ATTR_VOLUME]) self.sys_host.sound.set_volume(
source, body[ATTR_INDEX], body[ATTR_VOLUME], application
)
)
@api_process
async def set_mute(self, request: web.Request) -> None:
"""Mute audio volume on stream."""
source: StreamType = StreamType(request.match_info.get("source"))
application: bool = request.path.endswith("application")
body = await api_validate(SCHEMA_MUTE, request)
await asyncio.shield(
self.sys_host.sound.set_mute(
source, body[ATTR_INDEX], body[ATTR_ACTIVE], application
)
) )
@api_process @api_process

View File

@ -234,6 +234,9 @@ ATTR_CLI = "cli"
ATTR_DEFAULT = "default" ATTR_DEFAULT = "default"
ATTR_VOLUME = "volume" ATTR_VOLUME = "volume"
ATTR_CARD = "card" ATTR_CARD = "card"
ATTR_INDEX = "index"
ATTR_ACTIVE = "active"
ATTR_APPLICATION = "application"
PROVIDE_SERVICE = "provide" PROVIDE_SERVICE = "provide"
NEED_SERVICE = "need" NEED_SERVICE = "need"

View File

@ -2,7 +2,7 @@
from datetime import timedelta from datetime import timedelta
from enum import Enum from enum import Enum
import logging import logging
from typing import List from typing import List, Optional
import attr import attr
from pulsectl import Pulse, PulseError, PulseIndexError, PulseOperationFailed from pulsectl import Pulse, PulseError, PulseIndexError, PulseOperationFailed
@ -24,13 +24,30 @@ class StreamType(str, Enum):
@attr.s(frozen=True) @attr.s(frozen=True)
class AudioStream: class AudioApplication:
"""Represent a input/output profile.""" """Represent a application on the stream."""
name: str = attr.ib() name: str = attr.ib()
index: int = attr.ib()
stream_index: str = attr.ib()
stream_type: StreamType = attr.ib()
volume: float = attr.ib()
mute: bool = attr.ib()
addon: str = attr.ib()
@attr.s(frozen=True)
class AudioStream:
"""Represent a input/output stream."""
name: str = attr.ib()
index: int = attr.ib()
description: str = attr.ib() description: str = attr.ib()
volume: float = attr.ib() volume: float = attr.ib()
mute: bool = attr.ib()
default: bool = attr.ib() default: bool = attr.ib()
card: Optional[int] = attr.ib()
applications: List[AudioApplication] = attr.ib()
@attr.s(frozen=True) @attr.s(frozen=True)
@ -47,6 +64,7 @@ class SoundCard:
"""Represent a Sound Card.""" """Represent a Sound Card."""
name: str = attr.ib() name: str = attr.ib()
index: int = attr.ib()
driver: str = attr.ib() driver: str = attr.ib()
profiles: List[SoundProfile] = attr.ib() profiles: List[SoundProfile] = attr.ib()
@ -60,6 +78,7 @@ class SoundControl(CoreSysAttributes):
self._cards: List[SoundCard] = [] self._cards: List[SoundCard] = []
self._inputs: List[AudioStream] = [] self._inputs: List[AudioStream] = []
self._outputs: List[AudioStream] = [] self._outputs: List[AudioStream] = []
self._applications: List[AudioApplication] = []
@property @property
def cards(self) -> List[SoundCard]: def cards(self) -> List[SoundCard]:
@ -76,6 +95,11 @@ class SoundControl(CoreSysAttributes):
"""Return a list of available output streams.""" """Return a list of available output streams."""
return self._outputs return self._outputs
@property
def applications(self) -> List[AudioApplication]:
"""Return a list of available application streams."""
return self._applications
async def set_default(self, stream_type: StreamType, name: str) -> None: async def set_default(self, stream_type: StreamType, name: str) -> None:
"""Set a stream to default input/output.""" """Set a stream to default input/output."""
@ -90,11 +114,12 @@ class SoundControl(CoreSysAttributes):
# Get sink and set it as default # Get sink and set it as default
sink = pulse.get_sink_by_name(name) sink = pulse.get_sink_by_name(name)
pulse.sink_default_set(sink) pulse.sink_default_set(sink)
except PulseIndexError: except PulseIndexError:
_LOGGER.error("Can't find %s profile %s", source, name) _LOGGER.error("Can't find %s stream %s", source, name)
raise PulseAudioError() from None raise PulseAudioError() from None
except PulseError as err: except PulseError as err:
_LOGGER.error("Can't set %s as default: %s", name, err) _LOGGER.error("Can't set %s as stream: %s", name, err)
raise PulseAudioError() from None raise PulseAudioError() from None
# Run and Reload data # Run and Reload data
@ -102,32 +127,73 @@ class SoundControl(CoreSysAttributes):
await self.update() await self.update()
async def set_volume( async def set_volume(
self, stream_type: StreamType, name: str, volume: float self, stream_type: StreamType, index: int, volume: float, application: bool
) -> None: ) -> None:
"""Set a stream to volume input/output.""" """Set a stream to volume input/output/application."""
def _set_volume(): def _set_volume():
try: try:
with Pulse(PULSE_NAME) as pulse: with Pulse(PULSE_NAME) as pulse:
if stream_type == StreamType.INPUT: if stream_type == StreamType.INPUT:
# Get source and set it as default if application:
stream = pulse.get_source_by_name(name) stream = pulse.source_output_info(index)
else:
stream = pulse.source_info(index)
else: else:
# Get sink and set it as default if application:
stream = pulse.get_sink_by_name(name) stream = pulse.sink_input_info(index)
else:
stream = pulse.sink_info(index)
# Set volume
pulse.volume_set_all_chans(stream, volume) pulse.volume_set_all_chans(stream, volume)
except PulseIndexError: except PulseIndexError:
_LOGGER.error("Can't find %s profile %s", stream_type, name) _LOGGER.error(
"Can't find %s stream %d (App: %s)", stream_type, index, application
)
raise PulseAudioError() from None raise PulseAudioError() from None
except PulseError as err: except PulseError as err:
_LOGGER.error("Can't set %s volume: %s", name, err) _LOGGER.error("Can't set %d volume: %s", index, err)
raise PulseAudioError() from None raise PulseAudioError() from None
# Run and Reload data # Run and Reload data
await self.sys_run_in_executor(_set_volume) await self.sys_run_in_executor(_set_volume)
await self.update() await self.update()
async def set_mute(
self, stream_type: StreamType, index: int, mute: bool, application: bool
) -> None:
"""Set a stream to mute input/output/application."""
def _set_mute():
try:
with Pulse(PULSE_NAME) as pulse:
if stream_type == StreamType.INPUT:
if application:
stream = pulse.source_output_info(index)
else:
stream = pulse.source_info(index)
else:
if application:
stream = pulse.sink_input_info(index)
else:
stream = pulse.sink_info(index)
# Mute stream
pulse.mute(stream, mute)
except PulseIndexError:
_LOGGER.error(
"Can't find %s stream %d (App: %s)", stream_type, index, application
)
raise PulseAudioError() from None
except PulseError as err:
_LOGGER.error("Can't set %d volume: %s", index, err)
raise PulseAudioError() from None
# Run and Reload data
await self.sys_run_in_executor(_set_mute)
await self.update()
async def ativate_profile(self, card_name: str, profile_name: str) -> None: async def ativate_profile(self, card_name: str, profile_name: str) -> None:
"""Set a profile to volume input/output.""" """Set a profile to volume input/output."""
@ -160,15 +226,59 @@ class SoundControl(CoreSysAttributes):
with Pulse(PULSE_NAME) as pulse: with Pulse(PULSE_NAME) as pulse:
server = pulse.server_info() server = pulse.server_info()
# Update applications
self._applications.clear()
for application in pulse.sink_input_list():
self._applications.append(
AudioApplication(
application.proplist.get(
"application.name", application.name
),
application.index,
application.sink,
StreamType.OUTPUT,
application.volume.value_flat,
bool(application.mute),
application.proplist.get(
"application.process.machine_id", ""
).replace("-", "_"),
)
)
for application in pulse.source_output_list():
self._applications.append(
AudioApplication(
application.proplist.get(
"application.name", application.name
),
application.index,
application.source,
StreamType.INPUT,
application.volume.value_flat,
bool(application.mute),
application.proplist.get(
"application.process.machine_id", ""
).replace("-", "_"),
)
)
# Update output # Update output
self._outputs.clear() self._outputs.clear()
for sink in pulse.sink_list(): for sink in pulse.sink_list():
self._outputs.append( self._outputs.append(
AudioStream( AudioStream(
sink.name, sink.name,
sink.index,
sink.description, sink.description,
sink.volume.value_flat, sink.volume.value_flat,
bool(sink.mute),
sink.name == server.default_sink_name, sink.name == server.default_sink_name,
sink.card if sink.card != 0xFFFFFFFF else None,
[
application
for application in self._applications
if application.stream_index == sink.index
and application.stream_type == StreamType.OUTPUT
],
) )
) )
@ -181,9 +291,18 @@ class SoundControl(CoreSysAttributes):
self._inputs.append( self._inputs.append(
AudioStream( AudioStream(
source.name, source.name,
source.index,
source.description, source.description,
source.volume.value_flat, source.volume.value_flat,
bool(source.mute),
source.name == server.default_source_name, source.name == server.default_source_name,
source.card if source.card != 0xFFFFFFFF else None,
[
application
for application in self._applications
if application.stream_index == source.index
and application.stream_type == StreamType.INPUT
],
) )
) )
@ -205,7 +324,9 @@ class SoundControl(CoreSysAttributes):
) )
self._cards.append( self._cards.append(
SoundCard(card.name, card.driver, sound_profiles) SoundCard(
card.name, card.index, card.driver, sound_profiles
)
) )
except PulseOperationFailed as err: except PulseOperationFailed as err: