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:
parent
ae8ddca040
commit
2495cda5ec
|
@ -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
56
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
|
||||
|
|
17
Dockerfile
17
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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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),
|
||||
]
|
||||
)
|
||||
|
||||
|
|
|
@ -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]))
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -73,6 +73,7 @@ ADDONS_ROLE_ACCESS = {
|
|||
),
|
||||
ROLE_MANAGER: re.compile(
|
||||
r"^(?:"
|
||||
r"|/audio/.*"
|
||||
r"|/dns/.*"
|
||||
r"|/core/.+"
|
||||
r"|/homeassistant/.+"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
|
@ -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"]
|
||||
|
|
Loading…
Reference in New Issue