From 2495cda5ecb51350a0defd848ef8c4fb2fe84f9d Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 26 Feb 2020 11:48:11 +0100 Subject: [PATCH] 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 --- .devcontainer/Dockerfile | 1 + API.md | 56 ++++++++++++++- Dockerfile | 17 ++--- azure-pipelines-ci.yml | 4 ++ requirements.txt | 3 +- rootfs/etc/pulse/client.conf | 35 ++++++++++ supervisor/api/__init__.py | 4 ++ supervisor/api/audio.py | 49 +++++++++++++ supervisor/api/hardware.py | 13 +++- supervisor/api/security.py | 1 + supervisor/const.py | 2 + supervisor/exceptions.py | 7 ++ supervisor/host/__init__.py | 30 +++++--- supervisor/host/sound.py | 131 +++++++++++++++++++++++++++++++++++ supervisor/updater.py | 5 +- 15 files changed, 334 insertions(+), 24 deletions(-) create mode 100644 rootfs/etc/pulse/client.conf create mode 100644 supervisor/host/sound.py diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 614cd1570..959c5477b 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -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 diff --git a/API.md b/API.md index d2875611c..21470355c 100644 --- a/API.md +++ b/API.md @@ -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 diff --git a/Dockerfile b/Dockerfile index 291513258..39089fc00 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml index 6cee89ace..03e97dcc4 100644 --- a/azure-pipelines-ci.yml +++ b/azure-pipelines-ci.yml @@ -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: diff --git a/requirements.txt b/requirements.txt index d2a4a1a57..3c8914863 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/rootfs/etc/pulse/client.conf b/rootfs/etc/pulse/client.conf new file mode 100644 index 000000000..307143277 --- /dev/null +++ b/rootfs/etc/pulse/client.conf @@ -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 . + +## 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 diff --git a/supervisor/api/__init__.py b/supervisor/api/__init__.py index e595c59ac..bbf1ebecd 100644 --- a/supervisor/api/__init__.py +++ b/supervisor/api/__init__.py @@ -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), ] ) diff --git a/supervisor/api/audio.py b/supervisor/api/audio.py index 06d1b81a0..b8e9357a8 100644 --- a/supervisor/api/audio.py +++ b/supervisor/api/audio.py @@ -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])) diff --git a/supervisor/api/hardware.py b/supervisor/api/hardware.py index 7a5f4b7bd..a2bd1e2e8 100644 --- a/supervisor/api/hardware.py +++ b/supervisor/api/hardware.py @@ -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: diff --git a/supervisor/api/security.py b/supervisor/api/security.py index 9ce4661dd..e338600be 100644 --- a/supervisor/api/security.py +++ b/supervisor/api/security.py @@ -73,6 +73,7 @@ ADDONS_ROLE_ACCESS = { ), ROLE_MANAGER: re.compile( r"^(?:" + r"|/audio/.*" r"|/dns/.*" r"|/core/.+" r"|/homeassistant/.+" diff --git a/supervisor/const.py b/supervisor/const.py index e65312d7c..e00b2c1fb 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -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" diff --git a/supervisor/exceptions.py b/supervisor/exceptions.py index a89ea85fb..30b35c11e 100644 --- a/supervisor/exceptions.py +++ b/supervisor/exceptions.py @@ -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.""" diff --git a/supervisor/host/__init__.py b/supervisor/host/__init__.py index ac1127840..c88ec8598 100644 --- a/supervisor/host/__init__.py +++ b/supervisor/host/__init__.py @@ -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): diff --git a/supervisor/host/sound.py b/supervisor/host/sound.py new file mode 100644 index 000000000..5d97ce3df --- /dev/null +++ b/supervisor/host/sound.py @@ -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) diff --git a/supervisor/updater.py b/supervisor/updater.py index 3765c2b05..47ac77001 100644 --- a/supervisor/updater.py +++ b/supervisor/updater.py @@ -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"]