Add Pulse audio control basics (#1525)

* Add Pulse audio control basics

* add functionality

* Fix handling

* Give access to all

* Fix latest issues

* revert docker

* Fix pipeline
This commit is contained in:
Pascal Vizeli 2020-02-26 11:48:11 +01:00 committed by GitHub
parent ae8ddca040
commit 2495cda5ec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 334 additions and 24 deletions

View File

@ -38,6 +38,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
jq \
dbus \
network-manager \
libpulse0 \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies from requirements.txt if it exists

56
API.md
View File

@ -861,7 +861,25 @@ return:
{
"host": "ip-address",
"version": "1",
"latest_version": "2"
"latest_version": "2",
"audio": {
"input": [
{
"name": "...",
"description": "...",
"volume": 0.3,
"default": false
}
],
"output": [
{
"name": "...",
"description": "...",
"volume": 0.3,
"default": false
}
]
}
}
```
@ -875,8 +893,44 @@ return:
- POST `/audio/restart`
- POST `/audio/reload`
- GET `/audio/logs`
- POST `/audio/volume/input`
```json
{
"name": "...",
"volume": 0.5
}
```
- POST `/audio/volume/output`
```json
{
"name": "...",
"volume": 0.5
}
```
- POST `/audio/default/input`
```json
{
"name": "..."
}
```
- POST `/audio/default/output`
```json
{
"name": "..."
}
```
- GET `/audio/stats`
```json

View File

@ -3,14 +3,15 @@ FROM $BUILD_FROM
# Install base
RUN apk add --no-cache \
openssl \
libffi \
musl \
git \
socat \
glib \
eudev \
eudev-libs
eudev-libs \
git \
glib \
libffi \
libpulse \
musl \
openssl \
socat
ARG BUILD_ARCH
WORKDIR /usr/src
@ -18,7 +19,7 @@ WORKDIR /usr/src
# Install requirements
COPY requirements.txt .
RUN export MAKEFLAGS="-j$(nproc)" \
&& pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links \
&& pip3 install --no-cache-dir --no-index --only-binary=:all: \
"https://wheels.home-assistant.io/alpine-$(cut -d '.' -f 1-2 < /etc/alpine-release)/${BUILD_ARCH}/" \
-r ./requirements.txt \
&& rm -f requirements.txt

View File

@ -17,6 +17,10 @@ jobs:
pool:
vmImage: "ubuntu-latest"
steps:
- script: |
sudo apt-get update
sudo apt-get install -y libpulse0 libudev1
displayName: "Install Host library"
- task: UsePythonVersion@0
displayName: "Use Python 3.7"
inputs:

View File

@ -8,9 +8,10 @@ cryptography==2.8
docker==4.2.0
gitpython==3.1.0
packaging==20.1
ptvsd==4.3.2
pulsectl==20.2.2
pytz==2019.3
pyudev==0.22.0
ruamel.yaml==0.15.100
uvloop==0.14.0
voluptuous==0.11.7
ptvsd==4.3.2

View File

@ -0,0 +1,35 @@
# This file is part of PulseAudio.
#
# PulseAudio is free software; you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# PulseAudio is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with PulseAudio; if not, see <http://www.gnu.org/licenses/>.
## Configuration file for PulseAudio clients. See pulse-client.conf(5) for
## more information. Default values are commented out. Use either ; or # for
## commenting.
; default-sink =
; default-source =
default-server = unix://data/audio/external/pulse.sock
; default-dbus-server =
autospawn = no
; daemon-binary = /usr/bin/pulseaudio
; extra-arguments = --log-target=syslog
; cookie-file =
; enable-shm = yes
; shm-size-bytes = 0 # setting this 0 will use the system-default, usually 64 MiB
; auto-connect-localhost = no
; auto-connect-display = no

View File

@ -62,6 +62,7 @@ class RestAPI(CoreSysAttributes):
self._register_info()
self._register_auth()
self._register_dns()
self._register_audio()
def _register_host(self) -> None:
"""Register hostcontrol functions."""
@ -327,6 +328,9 @@ class RestAPI(CoreSysAttributes):
web.get("/audio/logs", api_audio.logs),
web.post("/audio/update", api_audio.update),
web.post("/audio/restart", api_audio.restart),
web.post("/audio/reload", api_audio.reload),
web.post("/audio/volume/{source}", api_audio.set_volume),
web.post("/audio/default/{source}", api_audio.set_default),
]
)

View File

@ -4,30 +4,46 @@ import logging
from typing import Any, Awaitable, Dict
from aiohttp import web
import attr
import voluptuous as vol
from ..const import (
ATTR_AUDIO,
ATTR_BLK_READ,
ATTR_BLK_WRITE,
ATTR_CPU_PERCENT,
ATTR_HOST,
ATTR_INPUT,
ATTR_LATEST_VERSION,
ATTR_MEMORY_LIMIT,
ATTR_MEMORY_PERCENT,
ATTR_MEMORY_USAGE,
ATTR_NAME,
ATTR_NETWORK_RX,
ATTR_NETWORK_TX,
ATTR_OUTPUT,
ATTR_VERSION,
ATTR_VOLUME,
CONTENT_TYPE_BINARY,
)
from ..coresys import CoreSysAttributes
from ..exceptions import APIError
from ..host.sound import SourceType
from .utils import api_process, api_process_raw, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__)
SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): vol.Coerce(str)})
SCHEMA_VOLUME = vol.Schema(
{
vol.Required(ATTR_NAME): vol.Coerce(str),
vol.Required(ATTR_VOLUME): vol.Coerce(float),
}
)
SCHEMA_DEFAULT = vol.Schema({vol.Required(ATTR_NAME): vol.Coerce(str)})
class APIAudio(CoreSysAttributes):
"""Handle RESTful API for Audio functions."""
@ -39,6 +55,16 @@ class APIAudio(CoreSysAttributes):
ATTR_VERSION: self.sys_audio.version,
ATTR_LATEST_VERSION: self.sys_audio.latest_version,
ATTR_HOST: str(self.sys_docker.network.audio),
ATTR_AUDIO: {
ATTR_INPUT: [
attr.asdict(profile)
for profile in self.sys_host.sound.input_profiles
],
ATTR_OUTPUT: [
attr.asdict(profile)
for profile in self.sys_host.sound.output_profiles
],
},
}
@api_process
@ -76,3 +102,26 @@ class APIAudio(CoreSysAttributes):
def restart(self, request: web.Request) -> Awaitable[None]:
"""Restart Audio plugin."""
return asyncio.shield(self.sys_audio.restart())
@api_process
def reload(self, request: web.Request) -> Awaitable[None]:
"""Reload Audio information."""
return asyncio.shield(self.sys_host.sound.update())
@api_process
async def set_volume(self, request: web.Request) -> None:
"""Set Audio information."""
source: SourceType = SourceType(request.match_info.get("source"))
body = await api_validate(SCHEMA_VOLUME, request)
await asyncio.shield(
self.sys_host.sound.set_volume(source, body[ATTR_NAME], body[ATTR_VOLUME])
)
@api_process
async def set_default(self, request: web.Request) -> None:
"""Set Audio default sources."""
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]))

View File

@ -38,7 +38,18 @@ class APIHardware(CoreSysAttributes):
@api_process
async def audio(self, request: web.Request) -> Dict[str, Any]:
"""Show pulse audio profiles."""
return {ATTR_AUDIO: {ATTR_INPUT: [], ATTR_OUTPUT: []}}
return {
ATTR_AUDIO: {
ATTR_INPUT: {
profile.name: profile.description
for profile in self.sys_host.sound.input_profiles
},
ATTR_OUTPUT: {
profile.name: profile.description
for profile in self.sys_host.sound.output_profiles
},
}
}
@api_process
def trigger(self, request: web.Request) -> None:

View File

@ -73,6 +73,7 @@ ADDONS_ROLE_ACCESS = {
),
ROLE_MANAGER: re.compile(
r"^(?:"
r"|/audio/.*"
r"|/dns/.*"
r"|/core/.+"
r"|/homeassistant/.+"

View File

@ -231,6 +231,8 @@ ATTR_DOCUMENTATION = "documentation"
ATTR_ADVANCED = "advanced"
ATTR_STAGE = "stage"
ATTR_CLI = "cli"
ATTR_DEFAULT = "default"
ATTR_VOLUME = "volume"
PROVIDE_SERVICE = "provide"
NEED_SERVICE = "need"

View File

@ -210,3 +210,10 @@ class DockerAPIError(HassioError):
class HardwareNotSupportedError(HassioNotSupportedError):
"""Raise if hardware function is not supported."""
# Pulse Audio
class PulseAudioError(HassioError):
"""Raise if an sound error is happening."""

View File

@ -2,20 +2,21 @@
from contextlib import suppress
import logging
from ..const import (
FEATURES_HASSOS,
FEATURES_HOSTNAME,
FEATURES_REBOOT,
FEATURES_SERVICES,
FEATURES_SHUTDOWN,
)
from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import HassioError, PulseAudioError
from .apparmor import AppArmorControl
from .control import SystemControl
from .info import InfoCenter
from .services import ServiceManager
from .network import NetworkManager
from ..const import (
FEATURES_REBOOT,
FEATURES_SHUTDOWN,
FEATURES_HOSTNAME,
FEATURES_SERVICES,
FEATURES_HASSOS,
)
from ..coresys import CoreSysAttributes, CoreSys
from ..exceptions import HassioError
from .services import ServiceManager
from .sound import SoundControl
_LOGGER: logging.Logger = logging.getLogger(__name__)
@ -32,6 +33,7 @@ class HostManager(CoreSysAttributes):
self._info: InfoCenter = InfoCenter(coresys)
self._services: ServiceManager = ServiceManager(coresys)
self._network: NetworkManager = NetworkManager(coresys)
self._sound: SoundControl = SoundControl(coresys)
@property
def apparmor(self) -> AppArmorControl:
@ -58,6 +60,11 @@ class HostManager(CoreSysAttributes):
"""Return host NetworkManager handler."""
return self._network
@property
def sound(self) -> SoundControl:
"""Return host PulseAudio control."""
return self._sound
@property
def supperted_features(self):
"""Return a list of supported host features."""
@ -85,6 +92,9 @@ class HostManager(CoreSysAttributes):
if self.sys_dbus.nmi_dns.is_connected:
await self.network.update()
with suppress(PulseAudioError):
await self.sound.update()
async def load(self):
"""Load host information."""
with suppress(HassioError):

131
supervisor/host/sound.py Normal file
View File

@ -0,0 +1,131 @@
"""Pulse host control."""
from enum import Enum
import logging
from typing import List
import attr
from pulsectl import Pulse, PulseError, PulseIndexError, PulseOperationFailed
from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import PulseAudioError
_LOGGER: logging.Logger = logging.getLogger(__name__)
PULSE_NAME = "supervisor"
class SourceType(str, Enum):
"""INPUT/OUTPUT type of source."""
INPUT = "input"
OUTPUT = "output"
@attr.s(frozen=True)
class AudioProfile:
"""Represent a input/output profile."""
name: str = attr.ib()
description: str = attr.ib()
volume: float = attr.ib()
default: bool = 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] = []
@property
def input_profiles(self) -> List[AudioProfile]:
"""Return a list of available input profiles."""
return self._input
@property
def output_profiles(self) -> List[AudioProfile]:
"""Return a list of available output profiles."""
return self._output
async def set_default(self, source: SourceType, name: str) -> None:
"""Set a profile to default input/output."""
try:
with Pulse(PULSE_NAME) as pulse:
if source == SourceType.OUTPUT:
# Get source and set it as default
source = pulse.get_source_by_name(name)
pulse.source_default_set(source)
else:
# Get sink and set it as default
sink = pulse.get_sink_by_name(name)
pulse.sink_default_set(sink)
except PulseIndexError:
_LOGGER.error("Can't find %s profile %s", source, name)
raise PulseAudioError() from None
except PulseError as err:
_LOGGER.error("Can't set %s as default: %s", name, err)
raise PulseAudioError() from None
# Reload data
await self.update()
async def set_volume(self, source: SourceType, name: str, volume: float) -> None:
"""Set a profile to volume input/output."""
try:
with Pulse(PULSE_NAME) as pulse:
if source == SourceType.OUTPUT:
# Get source and set it as default
source = pulse.get_source_by_name(name)
else:
# Get sink and set it as default
source = pulse.get_sink_by_name(name)
pulse.volume_set_all_chans(source, volume)
except PulseIndexError:
_LOGGER.error("Can't find %s profile %s", source, name)
raise PulseAudioError() from None
except PulseError as err:
_LOGGER.error("Can't set %s volume: %s", name, err)
raise PulseAudioError() from None
# Reload data
await self.update()
async def update(self):
"""Update properties over dbus."""
_LOGGER.info("Update PulseAudio information")
try:
with Pulse(PULSE_NAME) as pulse:
server = pulse.server_info()
# Update output
self._output.clear()
for sink in pulse.sink_list():
self._output.append(
AudioProfile(
sink.name,
sink.description,
sink.volume.value_flat,
sink.name == server.default_sink_name,
)
)
# Update input
self._input.clear()
for source in pulse.source_list():
self._input.append(
AudioProfile(
source.name,
source.description,
source.volume.value_flat,
source.name == server.default_source_name,
)
)
except PulseOperationFailed as err:
_LOGGER.error("Error while processing pulse update: %s", err)
raise PulseAudioError() from None
except PulseError as err:
_LOGGER.debug("Can't update PulseAudio data: %s", err)

View File

@ -95,7 +95,6 @@ class Updater(JsonConfig, CoreSysAttributes):
"""
url = URL_HASSIO_VERSION.format(channel=self.channel)
machine = self.sys_machine or "default"
board = self.sys_hassos.board
try:
_LOGGER.info("Fetch update data from %s", url)
@ -123,8 +122,8 @@ class Updater(JsonConfig, CoreSysAttributes):
self._data[ATTR_HOMEASSISTANT] = data["homeassistant"][machine]
# Update HassOS version
if self.sys_hassos.available and board:
self._data[ATTR_HASSOS] = data["hassos"][board]
if self.sys_hassos.board:
self._data[ATTR_HASSOS] = data["hassos"][self.sys_hassos.board]
# Update Home Assistant services
self._data[ATTR_CLI] = data["cli"]