Add Audio layer / PulseAudio (#1523)

* Improve alsa handling

* use default from image

* create alsa folder

* Map config into addon

* Add Audio object

* Fix dbus

* add host group file

* Fix persistent file

* Use new template

* fix lint

* Fix lint

* add API

* Update new base image / build system

* Add audio container

* extend new audio settings

* provide pulse client config

* Adjust files

* Use without auth

* reset did not exists now

* cleanup old alsa layer

* fix tasks

* fix black

* fix lint

* Add dbus support

* add dbus adjustments

* Fixups
This commit is contained in:
Pascal Vizeli 2020-02-25 18:37:06 +01:00 committed by GitHub
parent a3096153ab
commit 0212d027fb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 715 additions and 325 deletions

View File

@ -33,6 +33,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
containerd.io \ containerd.io \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install tools
RUN apt-get update && apt-get install -y --no-install-recommends \
jq \
dbus \
network-manager \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies from requirements.txt if it exists # Install Python dependencies from requirements.txt if it exists
COPY requirements.txt requirements_tests.txt ./ COPY requirements.txt requirements_tests.txt ./
RUN pip3 install -r requirements.txt -r requirements_tests.txt \ RUN pip3 install -r requirements.txt -r requirements_tests.txt \

View File

@ -14,10 +14,10 @@
# virtualenv # virtualenv
venv/ venv/
# HA # Data
home-assistant-polymer/* home-assistant-polymer/
misc/* script/
script/* tests/
# Test ENV # Test ENV
data/ data/

39
API.md
View File

@ -853,6 +853,45 @@ return:
} }
``` ```
### Audio
- GET `/audio/info`
```json
{
"host": "ip-address",
"version": "1",
"latest_version": "2"
}
```
- POST `/audio/update`
```json
{
"version": "VERSION"
}
```
- POST `/audio/restart`
- GET `/audio/logs`
- GET `/audio/stats`
```json
{
"cpu_percent": 0.0,
"memory_usage": 283123,
"memory_limit": 329392,
"memory_percent": 1.4,
"network_tx": 0,
"network_rx": 0,
"blk_read": 0,
"blk_write": 0
}
```
### Auth / SSO API ### Auth / SSO API
You can use the user system on homeassistant. We handle this auth system on You can use the user system on homeassistant. We handle this auth system on

View File

@ -23,15 +23,11 @@ RUN export MAKEFLAGS="-j$(nproc)" \
-r ./requirements.txt \ -r ./requirements.txt \
&& rm -f requirements.txt && rm -f requirements.txt
# Install HassIO # Install Home Assistant Supervisor
COPY . hassio COPY . supervisor
RUN pip3 install --no-cache-dir -e ./hassio \ RUN pip3 install --no-cache-dir -e ./supervisor \
&& python3 -m compileall ./hassio/hassio && python3 -m compileall ./supervisor/supervisor
# Initialize udev daemon, handle CMD
COPY entry.sh /bin/
ENTRYPOINT ["/bin/entry.sh"]
WORKDIR / WORKDIR /
CMD [ "python3", "-m", "supervisor" ] COPY rootfs /

View File

@ -1,3 +1,3 @@
include LICENSE.md include LICENSE.md
graft hassio graft supervisor
recursive-exclude * *.py[co] recursive-exclude * *.py[co]

View File

@ -10,8 +10,6 @@ communicates with the Supervisor. The Supervisor provides an API to manage the
installation. This includes changing network settings or installing installation. This includes changing network settings or installing
and updating software. and updating software.
![](misc/hassio.png?raw=true)
## Installation ## Installation
Installation instructions can be found at <https://home-assistant.io/hassio>. Installation instructions can be found at <https://home-assistant.io/hassio>.

View File

@ -10,10 +10,8 @@ trigger:
- "*" - "*"
pr: none pr: none
variables: variables:
- name: basePythonTag
value: "3.7-alpine3.11"
- name: versionBuilder - name: versionBuilder
value: "6.9" value: "7.0"
- group: docker - group: docker
jobs: jobs:
@ -51,6 +49,5 @@ jobs:
-v ~/.docker:/root/.docker \ -v ~/.docker:/root/.docker \
-v /run/docker.sock:/run/docker.sock:rw -v $(pwd):/data:ro \ -v /run/docker.sock:/run/docker.sock:rw -v $(pwd):/data:ro \
homeassistant/amd64-builder:$(versionBuilder) \ homeassistant/amd64-builder:$(versionBuilder) \
--supervisor $(basePythonTag) --version $(Build.SourceBranchName) \ --generic $(Build.SourceBranchName) --all -t /data
--all -t /data --docker-hub homeassistant
displayName: "Build Release" displayName: "Build Release"

13
build.json Normal file
View File

@ -0,0 +1,13 @@
{
"image": "homeassistant/{arch}-hassio-supervisor",
"build_from": {
"aarch64": "homeassistant/aarch64-base-python:3.7-alpine3.11",
"armhf": "homeassistant/armhf-base-python:3.7-alpine3.11",
"armv7": "homeassistant/armv7-base-python:3.7-alpine3.11",
"amd64": "homeassistant/amd64-base-python:3.7-alpine3.11",
"i386": "homeassistant/i386-base-python:3.7-alpine3.11"
},
"labels": {
"io.hass.type": "supervisor"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

View File

@ -1 +0,0 @@
<mxfile userAgent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36" version="7.9.5" editor="www.draw.io" type="device"><diagram name="Page-1" id="535f6c39-9b73-04c2-941c-82630de90f1a">5VrLcqM4FP0aLzsFiOcycefRVTPVqfFippdYKLYmMvII4cd8/QiQDEKQ4Bicnmp7Yevqybk691zJnoH55vDI4u36d5ogMnOs5DADX2eOY1vAER+F5VhZ3DCoDCuGE9moNizwv0j1lNYcJyjTGnJKCcdb3QhpmiLINVvMGN3rzV4o0WfdxitkGBYwJqb1T5zwtbTaflRXPCG8WsupQ8evKpYxfF0xmqdyvpkDXspXVb2J1VjyQbN1nNB9wwTuZ2DOKOXVt81hjkiBrYKt6vfQU3taN0MpH9JB+mkXkxypFftEdL17oWIEsUB+lKD4/+RUVXzJSpfdigZitoP4EBUl0KJuL4EpalPKNjGpO4tvq+Lz+0LNI9ZWTVVVSFhOszr7NeZosY1hUd6L7SYarfmGiJJdrAYTMqeEsrK1ABv5EAp7xhl9RY0aq3zJ9S/k+B14SdMOMY4ODZPE7xHRDeLsKJqo2ghUXeRe9yLp2329c1wF9LqxaXzZLpabdXUaunaY+CJ91u0/YPjvW4oLvy2OGUebC9GECVqGyy40gQ8ikJz8NS6AwUAAnREAdA0Av1L4itilyEHkCdJfGznXRM7pQg6MgJxvIPc0N1ArQyEqehTUO5PLIUTdXF6GnutZ42Do+p6OoW9i6FkmhN4IEEYGXigROiSLlPE1XdE0Jve19U5HtIEeOmD+V2G+CTxZ/KGqUrGwqs5TxR9yhL8R50epwHHOqTDVE/9G6VaO0Qt1RnMG5fKlyvOYrRDXtknxYG+6gyESc7zTBfgScFUuMTa6zhvoRiLxaeFbFp4Rw+IBELsS6O5ngR705hPLWuHPSzBsv0gw2gnEIt8itsOZCAlqAqbqnuIs+/a9N8E4mZe9SUe9Dez3w5YRnuZz369SDT2gJR4KE3ecsAU8PWyBjqzDDjvilj2GatrOFNyyG8RSUezELY1XZRgbSqJMMIPfFqcCYYBEbA4MlfkBE7WKQVyz1WmkQbbgs8gGpolwmhd0J7Tkoy62A9xAzIe6EKWJOZgwNobqTPjn80sc64Sfpl0qHjSSKzHKl1vx6ALDIppdJ2LFKHyBYyWresRyOtL8U3DS0nx3jIjlX5kr9o2l5wI3dhhemg8MpFWDLilNkcaVN9NmjRHAZITal9dnhDuJ4kifNZK5kRAe7tC+awqYs92Jzx922Kdpk2veTHzAgRoIvd4832d9InK52zrx/rjrrqE1pqduk4SmmeGvbB1vi69bRiHKsvd1RhelwarzIF6lcleHAMFSy/EDEDnA90InDC0XTJRFd2mSY3umJkUjSJK6vJsypNWltuRcmtTJsNck2Sgn2/FClez6THF50JQuV2ei9rlJjVDRUnZyGjfnZ45TUdkYp9wUp6cZtk9Ck6CQU/OKUvEz35CqAbgrqIChQD5eIvJMM8wxTUWTJeWcbkQDUlTcnX610K7Sy98t6jFuCV4VfTk9j+b1zXv7rl5OMAKRW5d4oOMSD3SklqNcwZs0HkBSK9BY6r7HUtvk6BA6XkXzztTxQYqofkH8KZIZtZgGA/f7vRm9CcHbrHSDZCIkNE8u1smrECjS45lrdZzOgqnuk8DbN+Fyc3/gOHYmRybK5RtaW58Bq0U6vWo7jCauSRO1WydXUre1ZdrRdDwJBP0/01lP+bJXCWHMLqefX7466OcV73HoF4FWOtFFv67r3FEULJiIfc19H4yZZU5P2WHs867BvsFu9AySPGK+npoefeqE7MRDwTT0cNWh9Sr0CH8VcYp8naPBZdrk/xraZP4R4g+0LY5alGHUf4vy/yWfusifgHyiWP/5rXJG/Q9DcP8f</diagram></mxfile>

View File

@ -0,0 +1,9 @@
#!/usr/bin/with-contenv bashio
# ==============================================================================
# Start udev service
# ==============================================================================
udevd --daemon
bashio::log.info "Update udev informations"
udevadm trigger
udevadm settle

View File

@ -0,0 +1,5 @@
#!/usr/bin/execlineb -S0
# ==============================================================================
# Take down the S6 supervision tree when Supervisor fails
# ==============================================================================
s6-svscanctl -t /var/run/s6/services

View File

@ -0,0 +1,5 @@
#!/usr/bin/with-contenv bashio
# ==============================================================================
# Start Service service
# ==============================================================================
exec python3 -m supervisor

View File

@ -61,9 +61,7 @@ function build_supervisor() {
docker run --rm --privileged \ docker run --rm --privileged \
-v /run/docker.sock:/run/docker.sock -v "$(pwd):/data" \ -v /run/docker.sock:/run/docker.sock -v "$(pwd):/data" \
homeassistant/amd64-builder:dev \ homeassistant/amd64-builder:dev \
--supervisor 3.7-alpine3.11 --version dev \ --generic dev -t /data --test --amd64 --no-cache
-t /data --test --amd64 \
--no-cache --docker-hub homeassistant
} }
@ -79,7 +77,7 @@ function cleanup_lastboot() {
function cleanup_docker() { function cleanup_docker() {
echo "Cleaning up stopped containers..." echo "Cleaning up stopped containers..."
docker rm $(docker ps -a -q) docker rm $(docker ps -a -q) || true
} }
@ -108,6 +106,22 @@ function setup_test_env() {
} }
function init_dbus() {
if pgrep dbus-daemon; then
echo "Dbus is running"
return 0
fi
echo "Startup dbus"
mkdir -p /var/lib/dbus
cp -f /etc/machine-id /var/lib/dbus/machine-id
# run
mkdir -p /run/dbus
dbus-daemon --system --print-address
}
echo "Start Test-Env" echo "Start Test-Env"
start_docker start_docker
@ -117,5 +131,6 @@ build_supervisor
install_cli install_cli
cleanup_lastboot cleanup_lastboot
cleanup_docker cleanup_docker
init_dbus
setup_test_env setup_test_env
stop_docker stop_docker

View File

@ -1,3 +1,4 @@
"""Home Assistant Supervisor setup."""
from setuptools import setup from setuptools import setup
from supervisor.const import SUPERVISOR_VERSION from supervisor.const import SUPERVISOR_VERSION

View File

@ -152,9 +152,9 @@ class AddonManager(CoreSysAttributes):
await addon.remove_data() await addon.remove_data()
# Cleanup audio settings # Cleanup audio settings
if addon.path_asound.exists(): if addon.path_pulse.exists():
with suppress(OSError): with suppress(OSError):
addon.path_asound.unlink() addon.path_pulse.unlink()
# Cleanup AppArmor profile # Cleanup AppArmor profile
with suppress(HostAppArmorError): with suppress(HostAppArmorError):

View File

@ -279,14 +279,14 @@ class Addon(AddonModel):
@property @property
def audio_output(self) -> Optional[str]: def audio_output(self) -> Optional[str]:
"""Return ALSA config for output or None.""" """Return a pulse profile for output or None."""
if not self.with_audio: if not self.with_audio:
return None return None
return self.persist.get(ATTR_AUDIO_OUTPUT, self.sys_host.alsa.default.output) return self.persist.get(ATTR_AUDIO_OUTPUT)
@audio_output.setter @audio_output.setter
def audio_output(self, value: Optional[str]): def audio_output(self, value: Optional[str]):
"""Set/reset audio output settings.""" """Set/reset audio output profile settings."""
if value is None: if value is None:
self.persist.pop(ATTR_AUDIO_OUTPUT, None) self.persist.pop(ATTR_AUDIO_OUTPUT, None)
else: else:
@ -294,10 +294,10 @@ class Addon(AddonModel):
@property @property
def audio_input(self) -> Optional[str]: def audio_input(self) -> Optional[str]:
"""Return ALSA config for input or None.""" """Return pulse profile for input or None."""
if not self.with_audio: if not self.with_audio:
return None return None
return self.persist.get(ATTR_AUDIO_INPUT, self.sys_host.alsa.default.input) return self.persist.get(ATTR_AUDIO_INPUT)
@audio_input.setter @audio_input.setter
def audio_input(self, value: Optional[str]): def audio_input(self, value: Optional[str]):
@ -333,14 +333,14 @@ class Addon(AddonModel):
return Path(self.path_data, "options.json") return Path(self.path_data, "options.json")
@property @property
def path_asound(self): def path_pulse(self):
"""Return path to asound config.""" """Return path to asound config."""
return Path(self.sys_config.path_tmp, f"{self.slug}_asound") return Path(self.sys_config.path_tmp, f"{self.slug}_pulse")
@property @property
def path_extern_asound(self): def path_extern_pulse(self):
"""Return path to asound config for Docker.""" """Return path to asound config for Docker."""
return Path(self.sys_config.path_extern_tmp, f"{self.slug}_asound") return Path(self.sys_config.path_extern_tmp, f"{self.slug}_pulse")
def save_persist(self): def save_persist(self):
"""Save data of add-on.""" """Save data of add-on."""
@ -379,20 +379,24 @@ class Addon(AddonModel):
_LOGGER.info("Remove add-on data folder %s", self.path_data) _LOGGER.info("Remove add-on data folder %s", self.path_data)
await remove_data(self.path_data) await remove_data(self.path_data)
def write_asound(self): def write_pulse(self):
"""Write asound config to file and return True on success.""" """Write asound config to file and return True on success."""
asound_config = self.sys_host.alsa.asound( pulse_config = self.sys_audio.pulse_client(
alsa_input=self.audio_input, alsa_output=self.audio_output input_profile=self.audio_input, output_profile=self.audio_output
) )
try: try:
with self.path_asound.open("w") as config_file: with self.path_pulse.open("w") as config_file:
config_file.write(asound_config) config_file.write(pulse_config)
except OSError as err: except OSError as err:
_LOGGER.error("Add-on %s can't write asound: %s", self.slug, err) _LOGGER.error(
"Add-on %s can't write pulse/client.config: %s", self.slug, err
)
raise AddonsError() raise AddonsError()
_LOGGER.debug("Add-on %s write asound: %s", self.slug, self.path_asound) _LOGGER.debug(
"Add-on %s write pulse/client.config: %s", self.slug, self.path_pulse
)
async def install_apparmor(self) -> None: async def install_apparmor(self) -> None:
"""Install or Update AppArmor profile for Add-on.""" """Install or Update AppArmor profile for Add-on."""
@ -468,7 +472,7 @@ class Addon(AddonModel):
# Sound # Sound
if self.with_audio: if self.with_audio:
self.write_asound() self.write_pulse()
# Start Add-on # Start Add-on
try: try:

View File

@ -96,7 +96,6 @@ from ..discovery.validate import valid_discovery_service
from ..validate import ( from ..validate import (
DOCKER_PORTS, DOCKER_PORTS,
DOCKER_PORTS_DESCRIPTION, DOCKER_PORTS_DESCRIPTION,
alsa_device,
network_port, network_port,
token, token,
uuid_match, uuid_match,
@ -296,8 +295,8 @@ SCHEMA_ADDON_USER = vol.Schema(
vol.Optional(ATTR_AUTO_UPDATE, default=False): vol.Boolean(), vol.Optional(ATTR_AUTO_UPDATE, default=False): vol.Boolean(),
vol.Optional(ATTR_BOOT): vol.In([BOOT_AUTO, BOOT_MANUAL]), vol.Optional(ATTR_BOOT): vol.In([BOOT_AUTO, BOOT_MANUAL]),
vol.Optional(ATTR_NETWORK): DOCKER_PORTS, vol.Optional(ATTR_NETWORK): DOCKER_PORTS,
vol.Optional(ATTR_AUDIO_OUTPUT): alsa_device, vol.Optional(ATTR_AUDIO_OUTPUT): vol.Maybe(vol.Coerce(str)),
vol.Optional(ATTR_AUDIO_INPUT): alsa_device, vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(vol.Coerce(str)),
vol.Optional(ATTR_PROTECTED, default=True): vol.Boolean(), vol.Optional(ATTR_PROTECTED, default=True): vol.Boolean(),
vol.Optional(ATTR_INGRESS_PANEL, default=False): vol.Boolean(), vol.Optional(ATTR_INGRESS_PANEL, default=False): vol.Boolean(),
}, },

View File

@ -21,6 +21,7 @@ from .security import SecurityMiddleware
from .services import APIServices from .services import APIServices
from .snapshots import APISnapshots from .snapshots import APISnapshots
from .supervisor import APISupervisor from .supervisor import APISupervisor
from .audio import APIAudio
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
@ -314,6 +315,21 @@ class RestAPI(CoreSysAttributes):
] ]
) )
def _register_audio(self) -> None:
"""Register Audio functions."""
api_audio = APIAudio()
api_audio.coresys = self.coresys
self.webapp.add_routes(
[
web.get("/audio/info", api_audio.info),
web.get("/audio/stats", api_audio.stats),
web.get("/audio/logs", api_audio.logs),
web.post("/audio/update", api_audio.update),
web.post("/audio/restart", api_audio.restart),
]
)
def _register_panel(self) -> None: def _register_panel(self) -> None:
"""Register panel for Home Assistant.""" """Register panel for Home Assistant."""
panel_dir = Path(__file__).parent.joinpath("panel") panel_dir = Path(__file__).parent.joinpath("panel")

View File

@ -96,7 +96,7 @@ from ..const import (
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..docker.stats import DockerStats from ..docker.stats import DockerStats
from ..exceptions import APIError from ..exceptions import APIError
from ..validate import DOCKER_PORTS, alsa_device from ..validate import DOCKER_PORTS
from .utils import api_process, api_process_raw, api_validate from .utils import api_process, api_process_raw, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
@ -107,10 +107,10 @@ SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): vol.Coerce(str)})
SCHEMA_OPTIONS = vol.Schema( SCHEMA_OPTIONS = vol.Schema(
{ {
vol.Optional(ATTR_BOOT): vol.In([BOOT_AUTO, BOOT_MANUAL]), vol.Optional(ATTR_BOOT): vol.In([BOOT_AUTO, BOOT_MANUAL]),
vol.Optional(ATTR_NETWORK): vol.Any(None, DOCKER_PORTS), vol.Optional(ATTR_NETWORK): vol.Maybe(DOCKER_PORTS),
vol.Optional(ATTR_AUTO_UPDATE): vol.Boolean(), vol.Optional(ATTR_AUTO_UPDATE): vol.Boolean(),
vol.Optional(ATTR_AUDIO_OUTPUT): alsa_device, vol.Optional(ATTR_AUDIO_OUTPUT): vol.Maybe(vol.Coerce(str)),
vol.Optional(ATTR_AUDIO_INPUT): alsa_device, vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(vol.Coerce(str)),
vol.Optional(ATTR_INGRESS_PANEL): vol.Boolean(), vol.Optional(ATTR_INGRESS_PANEL): vol.Boolean(),
} }
) )

78
supervisor/api/audio.py Normal file
View File

@ -0,0 +1,78 @@
"""Init file for Supervisor Audio RESTful API."""
import asyncio
import logging
from typing import Any, Awaitable, Dict
from aiohttp import web
import voluptuous as vol
from ..const import (
ATTR_BLK_READ,
ATTR_BLK_WRITE,
ATTR_CPU_PERCENT,
ATTR_HOST,
ATTR_LATEST_VERSION,
ATTR_MEMORY_LIMIT,
ATTR_MEMORY_PERCENT,
ATTR_MEMORY_USAGE,
ATTR_NETWORK_RX,
ATTR_NETWORK_TX,
ATTR_VERSION,
CONTENT_TYPE_BINARY,
)
from ..coresys import CoreSysAttributes
from ..exceptions import APIError
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)})
class APIAudio(CoreSysAttributes):
"""Handle RESTful API for Audio functions."""
@api_process
async def info(self, request: web.Request) -> Dict[str, Any]:
"""Return Audio information."""
return {
ATTR_VERSION: self.sys_audio.version,
ATTR_LATEST_VERSION: self.sys_audio.latest_version,
ATTR_HOST: str(self.sys_docker.network.audio),
}
@api_process
async def stats(self, request: web.Request) -> Dict[str, Any]:
"""Return resource information."""
stats = await self.sys_audio.stats()
return {
ATTR_CPU_PERCENT: stats.cpu_percent,
ATTR_MEMORY_USAGE: stats.memory_usage,
ATTR_MEMORY_LIMIT: stats.memory_limit,
ATTR_MEMORY_PERCENT: stats.memory_percent,
ATTR_NETWORK_RX: stats.network_rx,
ATTR_NETWORK_TX: stats.network_tx,
ATTR_BLK_READ: stats.blk_read,
ATTR_BLK_WRITE: stats.blk_write,
}
@api_process
async def update(self, request: web.Request) -> None:
"""Update Audio plugin."""
body = await api_validate(SCHEMA_VERSION, request)
version = body.get(ATTR_VERSION, self.sys_audio.latest_version)
if version == self.sys_audio.version:
raise APIError("Version {} is already in use".format(version))
await asyncio.shield(self.sys_audio.update(version))
@api_process_raw(CONTENT_TYPE_BINARY)
def logs(self, request: web.Request) -> Awaitable[bytes]:
"""Return Audio Docker logs."""
return self.sys_audio.logs()
@api_process
def restart(self, request: web.Request) -> Awaitable[None]:
"""Restart Audio plugin."""
return asyncio.shield(self.sys_audio.restart())

View File

@ -37,13 +37,8 @@ class APIHardware(CoreSysAttributes):
@api_process @api_process
async def audio(self, request: web.Request) -> Dict[str, Any]: async def audio(self, request: web.Request) -> Dict[str, Any]:
"""Show ALSA audio devices.""" """Show pulse audio profiles."""
return { return {ATTR_AUDIO: {ATTR_INPUT: [], ATTR_OUTPUT: []}}
ATTR_AUDIO: {
ATTR_INPUT: self.sys_host.alsa.input_devices,
ATTR_OUTPUT: self.sys_host.alsa.output_devices,
}
}
@api_process @api_process
def trigger(self, request: web.Request) -> None: def trigger(self, request: web.Request) -> None:

199
supervisor/audio.py Normal file
View File

@ -0,0 +1,199 @@
"""Home Assistant control object."""
import asyncio
from contextlib import suppress
import logging
from pathlib import Path
from string import Template
from typing import Awaitable, Optional
from .const import ATTR_VERSION, FILE_HASSIO_AUDIO
from .coresys import CoreSys, CoreSysAttributes
from .docker.audio import DockerAudio
from .docker.stats import DockerStats
from .exceptions import AudioError, AudioUpdateError, DockerAPIError
from .utils.json import JsonConfig
from .validate import SCHEMA_AUDIO_CONFIG
_LOGGER: logging.Logger = logging.getLogger(__name__)
PULSE_CLIENT_TMPL: Path = Path(__file__).parents[0].joinpath("data/pulse-client.tmpl")
class Audio(JsonConfig, CoreSysAttributes):
"""Home Assistant core object for handle audio."""
def __init__(self, coresys: CoreSys):
"""Initialize hass object."""
super().__init__(FILE_HASSIO_AUDIO, SCHEMA_AUDIO_CONFIG)
self.coresys: CoreSys = coresys
self.instance: DockerAudio = DockerAudio(coresys)
@property
def path_extern_data(self) -> Path:
"""Return path of pulse cookie file."""
return self.sys_config.path_extern_audio.joinpath("external")
@property
def version(self) -> Optional[str]:
"""Return current version of Audio."""
return self._data.get(ATTR_VERSION)
@version.setter
def version(self, value: str) -> None:
"""Return current version of Audio."""
self._data[ATTR_VERSION] = value
@property
def latest_version(self) -> Optional[str]:
"""Return latest version of Audio."""
return self.sys_updater.version_audio
@property
def in_progress(self) -> bool:
"""Return True if a task is in progress."""
return self.instance.in_progress
@property
def need_update(self) -> bool:
"""Return True if an update is available."""
return self.version != self.latest_version
async def load(self) -> None:
"""Load Audio setup."""
# Check Audio state
try:
# Evaluate Version if we lost this information
if not self.version:
self.version = await self.instance.get_latest_version(key=int)
await self.instance.attach(tag=self.version)
except DockerAPIError:
_LOGGER.info("No Audio plugin Docker image %s found.", self.instance.image)
# Install CoreDNS
with suppress(AudioError):
await self.install()
else:
self.version = self.instance.version
self.save_data()
# Run CoreDNS
with suppress(AudioError):
if await self.instance.is_running():
await self.restart()
else:
await self.start()
async def install(self) -> None:
"""Install Audio."""
_LOGGER.info("Setup Audio plugin")
while True:
# read audio tag and install it
if not self.latest_version:
await self.sys_updater.reload()
if self.latest_version:
with suppress(DockerAPIError):
await self.instance.install(self.latest_version)
break
_LOGGER.warning("Error on install Audio plugin. Retry in 30sec")
await asyncio.sleep(30)
_LOGGER.info("Audio plugin now installed")
self.version = self.instance.version
self.save_data()
async def update(self, version: Optional[str] = None) -> None:
"""Update Audio plugin."""
version = version or self.latest_version
if version == self.version:
_LOGGER.warning("Version %s is already installed for Audio", version)
return
try:
await self.instance.update(version)
except DockerAPIError:
_LOGGER.error("Audio update fails")
raise AudioUpdateError() from None
else:
# Cleanup
with suppress(DockerAPIError):
await self.instance.cleanup()
self.version = version
self.save_data()
# Start Audio
await self.start()
async def restart(self) -> None:
"""Restart Audio plugin."""
with suppress(DockerAPIError):
await self.instance.restart()
async def start(self) -> None:
"""Run CoreDNS."""
# Start Instance
_LOGGER.info("Start Audio plugin")
try:
await self.instance.run()
except DockerAPIError:
_LOGGER.error("Can't start Audio plugin")
raise AudioError() from None
def logs(self) -> Awaitable[bytes]:
"""Get CoreDNS docker logs.
Return Coroutine.
"""
return self.instance.logs()
async def stats(self) -> DockerStats:
"""Return stats of CoreDNS."""
try:
return await self.instance.stats()
except DockerAPIError:
raise AudioError() from None
def is_running(self) -> Awaitable[bool]:
"""Return True if Docker container is running.
Return a coroutine.
"""
return self.instance.is_running()
def is_fails(self) -> Awaitable[bool]:
"""Return True if a Docker container is fails state.
Return a coroutine.
"""
return self.instance.is_fails()
async def repair(self) -> None:
"""Repair CoreDNS plugin."""
if await self.instance.exists():
return
_LOGGER.info("Repair Audio %s", self.version)
try:
await self.instance.install(self.version)
except DockerAPIError:
_LOGGER.error("Repairing of Audio fails")
def pulse_client(self, input_profile=None, output_profile=None) -> str:
"""Generate an /etc/pulse/client.conf data."""
# Read Template
try:
config_data = PULSE_CLIENT_TMPL.read_text()
except OSError as err:
_LOGGER.error("Can't read pulse-client.tmpl: %s", err)
return ""
# Process Template
config_template = Template(config_data)
return config_template.safe_substitute(
audio_address=self.sys_docker.network.audio
)

View File

@ -11,6 +11,7 @@ from .addons import AddonManager
from .api import RestAPI from .api import RestAPI
from .arch import CpuArch from .arch import CpuArch
from .auth import Auth from .auth import Auth
from .audio import Audio
from .const import SOCKET_DOCKER, UpdateChannels from .const import SOCKET_DOCKER, UpdateChannels
from .core import Core from .core import Core
from .coresys import CoreSys from .coresys import CoreSys
@ -47,6 +48,7 @@ async def initialize_coresys():
coresys.core = Core(coresys) coresys.core = Core(coresys)
coresys.dns = CoreDNS(coresys) coresys.dns = CoreDNS(coresys)
coresys.arch = CpuArch(coresys) coresys.arch = CpuArch(coresys)
coresys.audio = Audio(coresys)
coresys.auth = Auth(coresys) coresys.auth = Auth(coresys)
coresys.updater = Updater(coresys) coresys.updater = Updater(coresys)
coresys.api = RestAPI(coresys) coresys.api = RestAPI(coresys)
@ -89,12 +91,12 @@ def initialize_system_data(coresys: CoreSys):
) )
config.path_homeassistant.mkdir() config.path_homeassistant.mkdir()
# supervisor ssl folder # Supervisor ssl folder
if not config.path_ssl.is_dir(): if not config.path_ssl.is_dir():
_LOGGER.info("Create Supervisor SSL/TLS folder %s", config.path_ssl) _LOGGER.info("Create Supervisor SSL/TLS folder %s", config.path_ssl)
config.path_ssl.mkdir() config.path_ssl.mkdir()
# supervisor addon data folder # Supervisor addon data folder
if not config.path_addons_data.is_dir(): if not config.path_addons_data.is_dir():
_LOGGER.info("Create Supervisor Add-on data folder %s", config.path_addons_data) _LOGGER.info("Create Supervisor Add-on data folder %s", config.path_addons_data)
config.path_addons_data.mkdir(parents=True) config.path_addons_data.mkdir(parents=True)
@ -113,31 +115,36 @@ def initialize_system_data(coresys: CoreSys):
) )
config.path_addons_git.mkdir(parents=True) config.path_addons_git.mkdir(parents=True)
# supervisor tmp folder # Supervisor tmp folder
if not config.path_tmp.is_dir(): if not config.path_tmp.is_dir():
_LOGGER.info("Create Supervisor temp folder %s", config.path_tmp) _LOGGER.info("Create Supervisor temp folder %s", config.path_tmp)
config.path_tmp.mkdir(parents=True) config.path_tmp.mkdir(parents=True)
# supervisor backup folder # Supervisor backup folder
if not config.path_backup.is_dir(): if not config.path_backup.is_dir():
_LOGGER.info("Create Supervisor backup folder %s", config.path_backup) _LOGGER.info("Create Supervisor backup folder %s", config.path_backup)
config.path_backup.mkdir() config.path_backup.mkdir()
# share folder # Share folder
if not config.path_share.is_dir(): if not config.path_share.is_dir():
_LOGGER.info("Create Supervisor share folder %s", config.path_share) _LOGGER.info("Create Supervisor share folder %s", config.path_share)
config.path_share.mkdir() config.path_share.mkdir()
# apparmor folder # Apparmor folder
if not config.path_apparmor.is_dir(): if not config.path_apparmor.is_dir():
_LOGGER.info("Create Supervisor Apparmor folder %s", config.path_apparmor) _LOGGER.info("Create Supervisor Apparmor folder %s", config.path_apparmor)
config.path_apparmor.mkdir() config.path_apparmor.mkdir()
# dns folder # DNS folder
if not config.path_dns.is_dir(): if not config.path_dns.is_dir():
_LOGGER.info("Create Supervisor DNS folder %s", config.path_dns) _LOGGER.info("Create Supervisor DNS folder %s", config.path_dns)
config.path_dns.mkdir() config.path_dns.mkdir()
# Audio folder
if not config.path_audio.is_dir():
_LOGGER.info("Create Supervisor audio folder %s", config.path_audio)
config.path_audio.mkdir()
# Update log level # Update log level
coresys.config.modify_log_level() coresys.config.modify_log_level()

View File

@ -13,7 +13,7 @@ from .const import (
ATTR_TIMEZONE, ATTR_TIMEZONE,
ATTR_WAIT_BOOT, ATTR_WAIT_BOOT,
FILE_HASSIO_CONFIG, FILE_HASSIO_CONFIG,
HASSIO_DATA, SUPERVISOR_DATA,
) )
from .utils.dt import parse_datetime from .utils.dt import parse_datetime
from .utils.json import JsonConfig from .utils.json import JsonConfig
@ -35,6 +35,7 @@ SHARE_DATA = PurePath("share")
TMP_DATA = PurePath("tmp") TMP_DATA = PurePath("tmp")
APPARMOR_DATA = PurePath("apparmor") APPARMOR_DATA = PurePath("apparmor")
DNS_DATA = PurePath("dns") DNS_DATA = PurePath("dns")
AUDIO_DATA = PurePath("audio")
DEFAULT_BOOT_TIME = datetime.utcfromtimestamp(0).isoformat() DEFAULT_BOOT_TIME = datetime.utcfromtimestamp(0).isoformat()
@ -120,7 +121,7 @@ class CoreConfig(JsonConfig):
@property @property
def path_hassio(self): def path_hassio(self):
"""Return Supervisor data path.""" """Return Supervisor data path."""
return HASSIO_DATA return SUPERVISOR_DATA
@property @property
def path_extern_hassio(self): def path_extern_hassio(self):
@ -135,7 +136,7 @@ class CoreConfig(JsonConfig):
@property @property
def path_homeassistant(self): def path_homeassistant(self):
"""Return config path inside supervisor.""" """Return config path inside supervisor."""
return Path(HASSIO_DATA, HOMEASSISTANT_CONFIG) return Path(SUPERVISOR_DATA, HOMEASSISTANT_CONFIG)
@property @property
def path_extern_ssl(self): def path_extern_ssl(self):
@ -145,22 +146,22 @@ class CoreConfig(JsonConfig):
@property @property
def path_ssl(self): def path_ssl(self):
"""Return SSL path inside supervisor.""" """Return SSL path inside supervisor."""
return Path(HASSIO_DATA, HASSIO_SSL) return Path(SUPERVISOR_DATA, HASSIO_SSL)
@property @property
def path_addons_core(self): def path_addons_core(self):
"""Return git path for core Add-ons.""" """Return git path for core Add-ons."""
return Path(HASSIO_DATA, ADDONS_CORE) return Path(SUPERVISOR_DATA, ADDONS_CORE)
@property @property
def path_addons_git(self): def path_addons_git(self):
"""Return path for Git Add-on.""" """Return path for Git Add-on."""
return Path(HASSIO_DATA, ADDONS_GIT) return Path(SUPERVISOR_DATA, ADDONS_GIT)
@property @property
def path_addons_local(self): def path_addons_local(self):
"""Return path for custom Add-ons.""" """Return path for custom Add-ons."""
return Path(HASSIO_DATA, ADDONS_LOCAL) return Path(SUPERVISOR_DATA, ADDONS_LOCAL)
@property @property
def path_extern_addons_local(self): def path_extern_addons_local(self):
@ -170,17 +171,27 @@ class CoreConfig(JsonConfig):
@property @property
def path_addons_data(self): def path_addons_data(self):
"""Return root Add-on data folder.""" """Return root Add-on data folder."""
return Path(HASSIO_DATA, ADDONS_DATA) return Path(SUPERVISOR_DATA, ADDONS_DATA)
@property @property
def path_extern_addons_data(self): def path_extern_addons_data(self):
"""Return root add-on data folder external for Docker.""" """Return root add-on data folder external for Docker."""
return PurePath(self.path_extern_hassio, ADDONS_DATA) return PurePath(self.path_extern_hassio, ADDONS_DATA)
@property
def path_audio(self):
"""Return root audio data folder."""
return Path(SUPERVISOR_DATA, AUDIO_DATA)
@property
def path_extern_audio(self):
"""Return root audio data folder external for Docker."""
return PurePath(self.path_extern_hassio, AUDIO_DATA)
@property @property
def path_tmp(self): def path_tmp(self):
"""Return Supervisor temp folder.""" """Return Supervisor temp folder."""
return Path(HASSIO_DATA, TMP_DATA) return Path(SUPERVISOR_DATA, TMP_DATA)
@property @property
def path_extern_tmp(self): def path_extern_tmp(self):
@ -190,7 +201,7 @@ class CoreConfig(JsonConfig):
@property @property
def path_backup(self): def path_backup(self):
"""Return root backup data folder.""" """Return root backup data folder."""
return Path(HASSIO_DATA, BACKUP_DATA) return Path(SUPERVISOR_DATA, BACKUP_DATA)
@property @property
def path_extern_backup(self): def path_extern_backup(self):
@ -200,12 +211,12 @@ class CoreConfig(JsonConfig):
@property @property
def path_share(self): def path_share(self):
"""Return root share data folder.""" """Return root share data folder."""
return Path(HASSIO_DATA, SHARE_DATA) return Path(SUPERVISOR_DATA, SHARE_DATA)
@property @property
def path_apparmor(self): def path_apparmor(self):
"""Return root Apparmor profile folder.""" """Return root Apparmor profile folder."""
return Path(HASSIO_DATA, APPARMOR_DATA) return Path(SUPERVISOR_DATA, APPARMOR_DATA)
@property @property
def path_extern_share(self): def path_extern_share(self):
@ -220,7 +231,7 @@ class CoreConfig(JsonConfig):
@property @property
def path_dns(self): def path_dns(self):
"""Return dns path inside supervisor.""" """Return dns path inside supervisor."""
return Path(HASSIO_DATA, DNS_DATA) return Path(SUPERVISOR_DATA, DNS_DATA)
@property @property
def addons_repositories(self): def addons_repositories(self):

View File

@ -15,17 +15,18 @@ URL_HASSOS_OTA = (
"{version}/hassos_{board}-{version}.raucb" "{version}/hassos_{board}-{version}.raucb"
) )
HASSIO_DATA = Path("/data") SUPERVISOR_DATA = Path("/data")
FILE_HASSIO_AUTH = Path(HASSIO_DATA, "auth.json") FILE_HASSIO_AUTH = Path(SUPERVISOR_DATA, "auth.json")
FILE_HASSIO_ADDONS = Path(HASSIO_DATA, "addons.json") FILE_HASSIO_ADDONS = Path(SUPERVISOR_DATA, "addons.json")
FILE_HASSIO_CONFIG = Path(HASSIO_DATA, "config.json") FILE_HASSIO_CONFIG = Path(SUPERVISOR_DATA, "config.json")
FILE_HASSIO_HOMEASSISTANT = Path(HASSIO_DATA, "homeassistant.json") FILE_HASSIO_HOMEASSISTANT = Path(SUPERVISOR_DATA, "homeassistant.json")
FILE_HASSIO_UPDATER = Path(HASSIO_DATA, "updater.json") FILE_HASSIO_UPDATER = Path(SUPERVISOR_DATA, "updater.json")
FILE_HASSIO_SERVICES = Path(HASSIO_DATA, "services.json") FILE_HASSIO_SERVICES = Path(SUPERVISOR_DATA, "services.json")
FILE_HASSIO_DISCOVERY = Path(HASSIO_DATA, "discovery.json") FILE_HASSIO_DISCOVERY = Path(SUPERVISOR_DATA, "discovery.json")
FILE_HASSIO_INGRESS = Path(HASSIO_DATA, "ingress.json") FILE_HASSIO_INGRESS = Path(SUPERVISOR_DATA, "ingress.json")
FILE_HASSIO_DNS = Path(HASSIO_DATA, "dns.json") FILE_HASSIO_DNS = Path(SUPERVISOR_DATA, "dns.json")
FILE_HASSIO_AUDIO = Path(SUPERVISOR_DATA, "audio.json")
SOCKET_DOCKER = Path("/var/run/docker.sock") SOCKET_DOCKER = Path("/var/run/docker.sock")
@ -229,6 +230,7 @@ ATTR_SNAPSHOT_EXCLUDE = "snapshot_exclude"
ATTR_DOCUMENTATION = "documentation" ATTR_DOCUMENTATION = "documentation"
ATTR_ADVANCED = "advanced" ATTR_ADVANCED = "advanced"
ATTR_STAGE = "stage" ATTR_STAGE = "stage"
ATTR_CLI = "cli"
PROVIDE_SERVICE = "provide" PROVIDE_SERVICE = "provide"
NEED_SERVICE = "need" NEED_SERVICE = "need"

View File

@ -40,8 +40,8 @@ class Core(CoreSysAttributes):
# Load Host # Load Host
await self.sys_host.load() await self.sys_host.load()
# Load CoreDNS # Load Plugins container
await self.sys_dns.load() await asyncio.wait([self.sys_dns.load(), self.sys_audio.load()])
# Load Home Assistant # Load Home Assistant
await self.sys_homeassistant.load() await self.sys_homeassistant.load()

View File

@ -15,6 +15,7 @@ if TYPE_CHECKING:
from .addons import AddonManager from .addons import AddonManager
from .api import RestAPI from .api import RestAPI
from .arch import CpuArch from .arch import CpuArch
from .audio import Audio
from .auth import Auth from .auth import Auth
from .core import Core from .core import Core
from .dbus import DBusManager from .dbus import DBusManager
@ -57,6 +58,7 @@ class CoreSys:
# Internal objects pointers # Internal objects pointers
self._core: Optional[Core] = None self._core: Optional[Core] = None
self._arch: Optional[CpuArch] = None self._arch: Optional[CpuArch] = None
self._audio: Optional[Audio] = None
self._auth: Optional[Auth] = None self._auth: Optional[Auth] = None
self._dns: Optional[CoreDNS] = None self._dns: Optional[CoreDNS] = None
self._homeassistant: Optional[HomeAssistant] = None self._homeassistant: Optional[HomeAssistant] = None
@ -163,6 +165,18 @@ class CoreSys:
raise RuntimeError("Auth already set!") raise RuntimeError("Auth already set!")
self._auth = value self._auth = value
@property
def audio(self) -> Audio:
"""Return Audio object."""
return self._audio
@audio.setter
def audio(self, value: Audio):
"""Set a Audio object."""
if self._audio:
raise RuntimeError("Audio already set!")
self._audio = value
@property @property
def homeassistant(self) -> HomeAssistant: def homeassistant(self) -> HomeAssistant:
"""Return Home Assistant object.""" """Return Home Assistant object."""
@ -431,6 +445,11 @@ class CoreSysAttributes:
"""Return Auth object.""" """Return Auth object."""
return self.coresys.auth return self.coresys.auth
@property
def sys_audio(self) -> Audio:
"""Return Audio object."""
return self.coresys.audio
@property @property
def sys_homeassistant(self) -> HomeAssistant: def sys_homeassistant(self) -> HomeAssistant:
"""Return Home Assistant object.""" """Return Home Assistant object."""

View File

@ -1,17 +0,0 @@
pcm.!default {
type asym
capture.pcm "mic"
playback.pcm "speaker"
}
pcm.mic {
type plug
slave {
pcm "hw:$input"
}
}
pcm.speaker {
type plug
slave {
pcm "hw:$output"
}
}

View File

@ -1,18 +0,0 @@
{
"raspberrypi3": {
"bcm2835 - bcm2835 ALSA": {
"0,0": "Raspberry Jack",
"0,1": "Raspberry HDMI"
},
"output": "0,0",
"input": null
},
"raspberrypi2": {
"output": "0,0",
"input": null
},
"raspberrypi": {
"output": "0,0",
"input": null
}
}

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://run/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

@ -35,7 +35,6 @@ if TYPE_CHECKING:
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
AUDIO_DEVICE = "/dev/snd:/dev/snd:rwm"
NO_ADDDRESS = ip_address("0.0.0.0") NO_ADDDRESS = ip_address("0.0.0.0")
@ -131,10 +130,6 @@ class DockerAddon(DockerInterface):
if self.addon.devices: if self.addon.devices:
devices.extend(self.addon.devices) devices.extend(self.addon.devices)
# Use audio devices
if self.addon.with_audio and self.sys_hardware.support_audio:
devices.append(AUDIO_DEVICE)
# Auto mapping UART devices # Auto mapping UART devices
if self.addon.auto_uart: if self.addon.auto_uart:
if self.addon.with_udev: if self.addon.with_udev:
@ -298,21 +293,25 @@ class DockerAddon(DockerInterface):
# Docker API support # Docker API support
if not self.addon.protected and self.addon.access_docker_api: if not self.addon.protected and self.addon.access_docker_api:
volumes.update( volumes.update(
{"/var/run/docker.sock": {"bind": "/var/run/docker.sock", "mode": "ro"}} {"/run/docker.sock": {"bind": "/run/docker.sock", "mode": "ro"}}
) )
# Host D-Bus system # Host D-Bus system
if self.addon.host_dbus: if self.addon.host_dbus:
volumes.update({"/var/run/dbus": {"bind": "/var/run/dbus", "mode": "rw"}}) volumes.update({"/run/dbus": {"bind": "/run/dbus", "mode": "rw"}})
# ALSA configuration # Configuration Audio
if self.addon.with_audio: if self.addon.with_audio:
volumes.update( volumes.update(
{ {
str(self.addon.path_extern_asound): { str(self.addon.path_extern_pulse): {
"bind": "/etc/asound.conf", "bind": "/etc/pulse/client.conf",
"mode": "ro", "mode": "ro",
} },
str(self.sys_audio.path_extern_data.joinpath("pulse.sock")): {
"bind": "/run/pulse.sock",
"mode": "rw",
},
} }
) )

View File

@ -0,0 +1,66 @@
"""Audio docker object."""
from contextlib import suppress
import logging
from ..const import ENV_TIME
from ..coresys import CoreSysAttributes
from ..exceptions import DockerAPIError
from .interface import DockerInterface
_LOGGER: logging.Logger = logging.getLogger(__name__)
AUDIO_DOCKER_NAME: str = "hassio_audio"
class DockerAudio(DockerInterface, CoreSysAttributes):
"""Docker Supervisor wrapper for Supervisor Audio."""
@property
def image(self) -> str:
"""Return name of Supervisor Audio image."""
return f"homeassistant/{self.sys_arch.supervisor}-hassio-audio"
@property
def name(self) -> str:
"""Return name of Docker container."""
return AUDIO_DOCKER_NAME
def _run(self) -> None:
"""Run Docker image.
Need run inside executor.
"""
if self._is_running():
return
# Cleanup
with suppress(DockerAPIError):
self._stop()
# Create & Run container
docker_container = self.sys_docker.run(
self.image,
version=self.sys_audio.version,
ipv4=self.sys_docker.network.audio,
name=self.name,
hostname=self.name.replace("_", "-"),
detach=True,
privileged=True,
environment={ENV_TIME: self.sys_timezone},
volumes={
str(self.sys_config.path_extern_audio): {
"bind": "/data",
"mode": "rw",
},
"/dev/snd": {"bind": "/dev/snd", "mode": "rw"},
"/etc/group": {"bind": "/host/group", "mode": "ro"},
},
)
self._meta = docker_container.attrs
_LOGGER.info(
"Start Audio %s with version %s - %s",
self.image,
self.version,
self.sys_docker.network.audio,
)

View File

@ -1,4 +1,4 @@
"""HassOS Cli docker object.""" """DNS docker object."""
from contextlib import suppress from contextlib import suppress
import logging import logging
@ -46,7 +46,6 @@ class DockerDNS(DockerInterface, CoreSysAttributes):
name=self.name, name=self.name,
hostname=self.name.replace("_", "-"), hostname=self.name.replace("_", "-"),
detach=True, detach=True,
init=True,
environment={ENV_TIME: self.sys_timezone}, environment={ENV_TIME: self.sys_timezone},
volumes={ volumes={
str(self.sys_config.path_extern_dns): {"bind": "/config", "mode": "ro"} str(self.sys_config.path_extern_dns): {"bind": "/config", "mode": "ro"}

View File

@ -48,6 +48,11 @@ class DockerNetwork:
"""Return dns of the network.""" """Return dns of the network."""
return DOCKER_NETWORK_MASK[3] return DOCKER_NETWORK_MASK[3]
@property
def audio(self) -> IPv4Address:
"""Return audio of the network."""
return DOCKER_NETWORK_MASK[4]
def _get_network(self) -> docker.models.networks.Network: def _get_network(self) -> docker.models.networks.Network:
"""Get supervisor network.""" """Get supervisor network."""
try: try:

View File

@ -65,6 +65,17 @@ class CoreDNSUpdateError(CoreDNSError):
"""Error on update of a CoreDNS.""" """Error on update of a CoreDNS."""
# DNS
class AudioError(HassioError):
"""PulseAudio exception."""
class AudioUpdateError(AudioError):
"""Error on update of a Audio."""
# Addons # Addons

View File

@ -56,7 +56,7 @@ class HassOS(CoreSysAttributes):
@property @property
def version_cli_latest(self) -> str: def version_cli_latest(self) -> str:
"""Return version of HassOS.""" """Return version of HassOS."""
return self.sys_updater.version_hassos_cli return self.sys_updater.version_cli
@property @property
def need_update(self) -> bool: def need_update(self) -> bool:

View File

@ -2,7 +2,6 @@
from contextlib import suppress from contextlib import suppress
import logging import logging
from .alsa import AlsaAudio
from .apparmor import AppArmorControl from .apparmor import AppArmorControl
from .control import SystemControl from .control import SystemControl
from .info import InfoCenter from .info import InfoCenter
@ -28,18 +27,12 @@ class HostManager(CoreSysAttributes):
"""Initialize Host manager.""" """Initialize Host manager."""
self.coresys: CoreSys = coresys self.coresys: CoreSys = coresys
self._alsa: AlsaAudio = AlsaAudio(coresys)
self._apparmor: AppArmorControl = AppArmorControl(coresys) self._apparmor: AppArmorControl = AppArmorControl(coresys)
self._control: SystemControl = SystemControl(coresys) self._control: SystemControl = SystemControl(coresys)
self._info: InfoCenter = InfoCenter(coresys) self._info: InfoCenter = InfoCenter(coresys)
self._services: ServiceManager = ServiceManager(coresys) self._services: ServiceManager = ServiceManager(coresys)
self._network: NetworkManager = NetworkManager(coresys) self._network: NetworkManager = NetworkManager(coresys)
@property
def alsa(self) -> AlsaAudio:
"""Return host ALSA handler."""
return self._alsa
@property @property
def apparmor(self) -> AppArmorControl: def apparmor(self) -> AppArmorControl:
"""Return host AppArmor handler.""" """Return host AppArmor handler."""

View File

@ -1,138 +0,0 @@
"""Host Audio support."""
import logging
import json
from pathlib import Path
from string import Template
import attr
from ..const import ATTR_INPUT, ATTR_OUTPUT, ATTR_DEVICES, ATTR_NAME, CHAN_ID, CHAN_TYPE
from ..coresys import CoreSysAttributes
_LOGGER: logging.Logger = logging.getLogger(__name__)
@attr.s()
class DefaultConfig:
"""Default config input/output ALSA channel."""
input: str = attr.ib()
output: str = attr.ib()
AUDIODB_JSON: Path = Path(__file__).parents[1].joinpath("data/audiodb.json")
ASOUND_TMPL: Path = Path(__file__).parents[1].joinpath("data/asound.tmpl")
class AlsaAudio(CoreSysAttributes):
"""Handle Audio ALSA host data."""
def __init__(self, coresys):
"""Initialize ALSA audio system."""
self.coresys = coresys
self._data = {ATTR_INPUT: {}, ATTR_OUTPUT: {}}
self._cache = 0
self._default = None
@property
def input_devices(self):
"""Return list of ALSA input devices."""
self._update_device()
return self._data[ATTR_INPUT]
@property
def output_devices(self):
"""Return list of ALSA output devices."""
self._update_device()
return self._data[ATTR_OUTPUT]
def _update_device(self):
"""Update Internal device DB."""
current_id = hash(frozenset(self.sys_hardware.audio_devices))
# Need rebuild?
if current_id == self._cache:
return
# Clean old stuff
self._data[ATTR_INPUT].clear()
self._data[ATTR_OUTPUT].clear()
# Init database
_LOGGER.info("Update ALSA device list")
database = self._audio_database()
# Process devices
for dev_id, dev_data in self.sys_hardware.audio_devices.items():
for chan_info in dev_data[ATTR_DEVICES]:
chan_id = chan_info[CHAN_ID]
chan_type = chan_info[CHAN_TYPE]
alsa_id = f"{dev_id},{chan_id}"
dev_name = dev_data[ATTR_NAME]
# Lookup type
if chan_type.endswith("playback"):
key = ATTR_OUTPUT
elif chan_type.endswith("capture"):
key = ATTR_INPUT
else:
_LOGGER.warning("Unknown channel type: %s", chan_type)
continue
# Use name from DB or a generic name
self._data[key][alsa_id] = (
database.get(self.sys_machine, {})
.get(dev_name, {})
.get(alsa_id, f"{dev_name}: {chan_id}")
)
self._cache = current_id
@staticmethod
def _audio_database():
"""Read local json audio data into dict."""
try:
return json.loads(AUDIODB_JSON.read_text())
except (ValueError, OSError) as err:
_LOGGER.warning("Can't read audio DB: %s", err)
return {}
@property
def default(self):
"""Generate ALSA default setting."""
# Init defaults
if self._default is None:
database = self._audio_database()
alsa_input = database.get(self.sys_machine, {}).get(ATTR_INPUT)
alsa_output = database.get(self.sys_machine, {}).get(ATTR_OUTPUT)
self._default = DefaultConfig(alsa_input, alsa_output)
# Search exists/new output
if self._default.output is None and self.output_devices:
self._default.output = next(iter(self.output_devices))
_LOGGER.info("Detect output device %s", self._default.output)
# Search exists/new input
if self._default.input is None and self.input_devices:
self._default.input = next(iter(self.input_devices))
_LOGGER.info("Detect input device %s", self._default.input)
return self._default
def asound(self, alsa_input=None, alsa_output=None):
"""Generate an asound data."""
alsa_input = alsa_input or self.default.input
alsa_output = alsa_output or self.default.output
# Read Template
try:
asound_data = ASOUND_TMPL.read_text()
except OSError as err:
_LOGGER.error("Can't read asound.tmpl: %s", err)
return ""
# Process Template
asound_template = Template(asound_data)
return asound_template.safe_substitute(input=alsa_input, output=alsa_output)

View File

@ -199,7 +199,9 @@ class Hardware:
async def udev_trigger(self) -> None: async def udev_trigger(self) -> None:
"""Trigger a udev reload.""" """Trigger a udev reload."""
proc = await asyncio.create_subprocess_exec("udevadm", "trigger") proc = await asyncio.create_subprocess_shell(
"udevadm trigger && udevadm settle"
)
await proc.wait() await proc.wait()
if proc.returncode == 0: if proc.returncode == 0:

View File

@ -11,8 +11,9 @@ HASS_WATCHDOG_API = "HASS_WATCHDOG_API"
RUN_UPDATE_SUPERVISOR = 29100 RUN_UPDATE_SUPERVISOR = 29100
RUN_UPDATE_ADDONS = 57600 RUN_UPDATE_ADDONS = 57600
RUN_UPDATE_HASSOSCLI = 28100 RUN_UPDATE_CLI = 28100
RUN_UPDATE_DNS = 30100 RUN_UPDATE_DNS = 30100
RUN_UPDATE_AUDIO = 30200
RUN_RELOAD_ADDONS = 10800 RUN_RELOAD_ADDONS = 10800
RUN_RELOAD_SNAPSHOTS = 72000 RUN_RELOAD_SNAPSHOTS = 72000
@ -24,6 +25,7 @@ RUN_WATCHDOG_HOMEASSISTANT_DOCKER = 15
RUN_WATCHDOG_HOMEASSISTANT_API = 300 RUN_WATCHDOG_HOMEASSISTANT_API = 300
RUN_WATCHDOG_DNS_DOCKER = 20 RUN_WATCHDOG_DNS_DOCKER = 20
RUN_WATCHDOG_AUDIO_DOCKER = 20
class Tasks(CoreSysAttributes): class Tasks(CoreSysAttributes):
@ -47,13 +49,14 @@ class Tasks(CoreSysAttributes):
) )
) )
self.jobs.add( self.jobs.add(
self.sys_scheduler.register_task( self.sys_scheduler.register_task(self._update_cli, RUN_UPDATE_CLI)
self._update_hassos_cli, RUN_UPDATE_HASSOSCLI
)
) )
self.jobs.add( self.jobs.add(
self.sys_scheduler.register_task(self._update_dns, RUN_UPDATE_DNS) self.sys_scheduler.register_task(self._update_dns, RUN_UPDATE_DNS)
) )
self.jobs.add(
self.sys_scheduler.register_task(self._update_audio, RUN_UPDATE_AUDIO)
)
# Reload # Reload
self.jobs.add( self.jobs.add(
@ -94,6 +97,11 @@ class Tasks(CoreSysAttributes):
self._watchdog_dns_docker, RUN_WATCHDOG_DNS_DOCKER self._watchdog_dns_docker, RUN_WATCHDOG_DNS_DOCKER
) )
) )
self.jobs.add(
self.sys_scheduler.register_task(
self._watchdog_audio_docker, RUN_WATCHDOG_AUDIO_DOCKER
)
)
_LOGGER.info("All core tasks are scheduled") _LOGGER.info("All core tasks are scheduled")
@ -193,17 +201,12 @@ class Tasks(CoreSysAttributes):
finally: finally:
self._cache[HASS_WATCHDOG_API] = 0 self._cache[HASS_WATCHDOG_API] = 0
async def _update_hassos_cli(self): async def _update_cli(self):
"""Check and run update of HassOS CLI.""" """Check and run update of CLI."""
if not self.sys_hassos.need_cli_update: if not self.sys_hassos.need_cli_update:
return return
# don't perform an update on dev channel _LOGGER.info("Found new CLI version")
if self.sys_dev:
_LOGGER.warning("Ignore HassOS CLI update on dev channel!")
return
_LOGGER.info("Found new HassOS CLI version")
await self.sys_hassos.update_cli() await self.sys_hassos.update_cli()
async def _update_dns(self): async def _update_dns(self):
@ -211,17 +214,20 @@ class Tasks(CoreSysAttributes):
if not self.sys_dns.need_update: if not self.sys_dns.need_update:
return return
# don't perform an update on dev channel
if self.sys_dev:
_LOGGER.warning("Ignore CoreDNS update on dev channel!")
return
_LOGGER.info("Found new CoreDNS plugin version") _LOGGER.info("Found new CoreDNS plugin version")
await self.sys_dns.update() await self.sys_dns.update()
async def _update_audio(self):
"""Check and run update of PulseAudio plugin."""
if not self.sys_audio.need_update:
return
_LOGGER.info("Found new PulseAudio plugin version")
await self.sys_audio.update()
async def _watchdog_dns_docker(self): async def _watchdog_dns_docker(self):
"""Check running state of Docker and start if they is close.""" """Check running state of Docker and start if they is close."""
# if Home Assistant is active # if CoreDNS is active
if await self.sys_dns.is_running(): if await self.sys_dns.is_running():
return return
_LOGGER.warning("Watchdog found a problem with CoreDNS plugin!") _LOGGER.warning("Watchdog found a problem with CoreDNS plugin!")
@ -234,3 +240,15 @@ class Tasks(CoreSysAttributes):
await self.sys_dns.start() await self.sys_dns.start()
except CoreDNSError: except CoreDNSError:
_LOGGER.error("Watchdog CoreDNS reanimation fails!") _LOGGER.error("Watchdog CoreDNS reanimation fails!")
async def _watchdog_audio_docker(self):
"""Check running state of Docker and start if they is close."""
# if PulseAudio plugin is active
if await self.sys_audio.is_running():
return
_LOGGER.warning("Watchdog found a problem with PulseAudio plugin!")
try:
await self.sys_audio.start()
except CoreDNSError:
_LOGGER.error("Watchdog PulseAudio reanimation fails!")

View File

@ -9,11 +9,12 @@ from typing import Optional
import aiohttp import aiohttp
from .const import ( from .const import (
ATTR_AUDIO,
ATTR_CHANNEL, ATTR_CHANNEL,
ATTR_CLI,
ATTR_DNS, ATTR_DNS,
ATTR_HASSIO, ATTR_HASSIO,
ATTR_HASSOS, ATTR_HASSOS,
ATTR_HASSOS_CLI,
ATTR_HOMEASSISTANT, ATTR_HOMEASSISTANT,
FILE_HASSIO_UPDATER, FILE_HASSIO_UPDATER,
URL_HASSIO_VERSION, URL_HASSIO_VERSION,
@ -62,15 +63,20 @@ class Updater(JsonConfig, CoreSysAttributes):
return self._data.get(ATTR_HASSOS) return self._data.get(ATTR_HASSOS)
@property @property
def version_hassos_cli(self) -> Optional[str]: def version_cli(self) -> Optional[str]:
"""Return latest version of HassOS cli.""" """Return latest version of CLI."""
return self._data.get(ATTR_HASSOS_CLI) return self._data.get(ATTR_CLI)
@property @property
def version_dns(self) -> Optional[str]: def version_dns(self) -> Optional[str]:
"""Return latest version of Supervisor DNS.""" """Return latest version of DNS."""
return self._data.get(ATTR_DNS) return self._data.get(ATTR_DNS)
@property
def version_audio(self) -> Optional[str]:
"""Return latest version of Audio."""
return self._data.get(ATTR_AUDIO)
@property @property
def channel(self) -> UpdateChannels: def channel(self) -> UpdateChannels:
"""Return upstream channel of Supervisor instance.""" """Return upstream channel of Supervisor instance."""
@ -81,7 +87,7 @@ class Updater(JsonConfig, CoreSysAttributes):
"""Set upstream mode.""" """Set upstream mode."""
self._data[ATTR_CHANNEL] = value self._data[ATTR_CHANNEL] = value
@AsyncThrottle(timedelta(seconds=60)) @AsyncThrottle(timedelta(seconds=30))
async def fetch_data(self): async def fetch_data(self):
"""Fetch current versions from Github. """Fetch current versions from Github.
@ -110,17 +116,20 @@ class Updater(JsonConfig, CoreSysAttributes):
raise HassioUpdaterError() from None raise HassioUpdaterError() from None
try: try:
# update supervisor version # Update supervisor version
self._data[ATTR_HASSIO] = data["supervisor"] self._data[ATTR_HASSIO] = data["supervisor"]
self._data[ATTR_DNS] = data["dns"]
# update Home Assistant version # Update Home Assistant core version
self._data[ATTR_HOMEASSISTANT] = data["homeassistant"][machine] self._data[ATTR_HOMEASSISTANT] = data["homeassistant"][machine]
# update hassos version # Update HassOS version
if self.sys_hassos.available and board: if self.sys_hassos.available and board:
self._data[ATTR_HASSOS] = data["hassos"][board] self._data[ATTR_HASSOS] = data["hassos"][board]
self._data[ATTR_HASSOS_CLI] = data["hassos-cli"]
# Update Home Assistant services
self._data[ATTR_CLI] = data["cli"]
self._data[ATTR_DNS] = data["dns"]
self._data[ATTR_AUDIO] = data["audio"]
except KeyError as err: except KeyError as err:
_LOGGER.warning("Can't process version data: %s", err) _LOGGER.warning("Can't process version data: %s", err)

View File

@ -1,9 +1,11 @@
"""Tools file for Supervisor.""" """Tools file for Supervisor."""
import asyncio
from datetime import datetime from datetime import datetime
from ipaddress import IPv4Address from ipaddress import IPv4Address
import logging import logging
import re import re
import socket import socket
from typing import Optional
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
RE_STRING = re.compile(r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))") RE_STRING = re.compile(r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))")
@ -41,18 +43,23 @@ class AsyncThrottle:
"""Initialize async throttle.""" """Initialize async throttle."""
self.throttle_period = delta self.throttle_period = delta
self.time_of_last_call = datetime.min self.time_of_last_call = datetime.min
self.synchronize: Optional[asyncio.Lock] = None
def __call__(self, method): def __call__(self, method):
"""Throttle function""" """Throttle function"""
async def wrapper(*args, **kwargs): async def wrapper(*args, **kwargs):
"""Throttle function wrapper""" """Throttle function wrapper"""
now = datetime.now() if not self.synchronize:
time_since_last_call = now - self.time_of_last_call self.synchronize = asyncio.Lock()
if time_since_last_call > self.throttle_period: async with self.synchronize:
self.time_of_last_call = now now = datetime.now()
return await method(*args, **kwargs) time_since_last_call = now - self.time_of_last_call
if time_since_last_call > self.throttle_period:
self.time_of_last_call = now
return await method(*args, **kwargs)
return wrapper return wrapper

View File

@ -1,21 +1,22 @@
"""Validate functions.""" """Validate functions."""
import ipaddress
import re import re
import uuid import uuid
import ipaddress
import voluptuous as vol import voluptuous as vol
from .const import ( from .const import (
ATTR_ACCESS_TOKEN, ATTR_ACCESS_TOKEN,
ATTR_ADDONS_CUSTOM_LIST, ATTR_ADDONS_CUSTOM_LIST,
ATTR_AUDIO,
ATTR_BOOT, ATTR_BOOT,
ATTR_CHANNEL, ATTR_CHANNEL,
ATTR_CLI,
ATTR_DEBUG, ATTR_DEBUG,
ATTR_DEBUG_BLOCK, ATTR_DEBUG_BLOCK,
ATTR_DNS, ATTR_DNS,
ATTR_HASSIO, ATTR_HASSIO,
ATTR_HASSOS, ATTR_HASSOS,
ATTR_HASSOS_CLI,
ATTR_HOMEASSISTANT, ATTR_HOMEASSISTANT,
ATTR_IMAGE, ATTR_IMAGE,
ATTR_LAST_BOOT, ATTR_LAST_BOOT,
@ -36,7 +37,6 @@ from .const import (
) )
from .utils.validate import validate_timezone from .utils.validate import validate_timezone
RE_REPOSITORY = re.compile(r"^(?P<url>[^#]+)(?:#(?P<branch>[\w\-]+))?$") RE_REPOSITORY = re.compile(r"^(?P<url>[^#]+)(?:#(?P<branch>[\w\-]+))?$")
# pylint: disable=no-value-for-parameter # pylint: disable=no-value-for-parameter
@ -44,7 +44,6 @@ RE_REPOSITORY = re.compile(r"^(?P<url>[^#]+)(?:#(?P<branch>[\w\-]+))?$")
network_port = vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)) network_port = vol.All(vol.Coerce(int), vol.Range(min=1, max=65535))
wait_boot = vol.All(vol.Coerce(int), vol.Range(min=1, max=60)) wait_boot = vol.All(vol.Coerce(int), vol.Range(min=1, max=60))
docker_image = vol.Match(r"^[\w{}]+/[\-\w{}]+$") docker_image = vol.Match(r"^[\w{}]+/[\-\w{}]+$")
alsa_device = vol.Maybe(vol.Match(r"\d+,\d+"))
uuid_match = vol.Match(r"^[0-9a-f]{32}$") uuid_match = vol.Match(r"^[0-9a-f]{32}$")
sha256 = vol.Match(r"^[0-9a-f]{64}$") sha256 = vol.Match(r"^[0-9a-f]{64}$")
token = vol.Match(r"^[0-9a-f]{32,256}$") token = vol.Match(r"^[0-9a-f]{32,256}$")
@ -125,8 +124,9 @@ SCHEMA_UPDATER_CONFIG = vol.Schema(
vol.Optional(ATTR_HOMEASSISTANT): vol.Coerce(str), vol.Optional(ATTR_HOMEASSISTANT): vol.Coerce(str),
vol.Optional(ATTR_HASSIO): vol.Coerce(str), vol.Optional(ATTR_HASSIO): vol.Coerce(str),
vol.Optional(ATTR_HASSOS): vol.Coerce(str), vol.Optional(ATTR_HASSOS): vol.Coerce(str),
vol.Optional(ATTR_HASSOS_CLI): vol.Coerce(str), vol.Optional(ATTR_CLI): vol.Coerce(str),
vol.Optional(ATTR_DNS): vol.Coerce(str), vol.Optional(ATTR_DNS): vol.Coerce(str),
vol.Optional(ATTR_AUDIO): vol.Coerce(str),
}, },
extra=vol.REMOVE_EXTRA, extra=vol.REMOVE_EXTRA,
) )
@ -173,3 +173,8 @@ SCHEMA_DNS_CONFIG = vol.Schema(
}, },
extra=vol.REMOVE_EXTRA, extra=vol.REMOVE_EXTRA,
) )
SCHEMA_AUDIO_CONFIG = vol.Schema(
{vol.Optional(ATTR_VERSION): vol.Maybe(vol.Coerce(str))}, extra=vol.REMOVE_EXTRA,
)