From 0212d027fb10f6c408c0a2636e3922ac33b2750a Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Tue, 25 Feb 2020 18:37:06 +0100 Subject: [PATCH] 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 --- .devcontainer/Dockerfile | 7 + .dockerignore | 8 +- API.md | 39 +++++ Dockerfile | 14 +- MANIFEST.in | 2 +- README.md | 2 - azure-pipelines-release.yml | 7 +- build.json | 13 ++ misc/hassio.png | Bin 37464 -> 0 bytes misc/hassio.xml | 1 - rootfs/etc/cont-init.d/udev.sh | 9 ++ rootfs/etc/services.d/supervisor/finish | 5 + rootfs/etc/services.d/supervisor/run | 5 + scripts/test_env.sh | 23 ++- setup.py | 1 + supervisor/addons/__init__.py | 4 +- supervisor/addons/addon.py | 38 +++-- supervisor/addons/validate.py | 5 +- supervisor/api/__init__.py | 16 ++ supervisor/api/addons.py | 8 +- supervisor/api/audio.py | 78 ++++++++++ supervisor/api/hardware.py | 9 +- supervisor/audio.py | 199 ++++++++++++++++++++++++ supervisor/bootstrap.py | 21 ++- supervisor/config.py | 37 +++-- supervisor/const.py | 22 +-- supervisor/core.py | 4 +- supervisor/coresys.py | 19 +++ supervisor/data/asound.tmpl | 17 -- supervisor/data/audiodb.json | 18 --- supervisor/data/pulse-client.tmpl | 35 +++++ supervisor/docker/addon.py | 21 ++- supervisor/docker/audio.py | 66 ++++++++ supervisor/docker/dns.py | 3 +- supervisor/docker/network.py | 5 + supervisor/exceptions.py | 11 ++ supervisor/hassos.py | 2 +- supervisor/host/__init__.py | 7 - supervisor/host/alsa.py | 138 ---------------- supervisor/misc/hardware.py | 4 +- supervisor/tasks.py | 54 ++++--- supervisor/updater.py | 31 ++-- supervisor/utils/__init__.py | 17 +- supervisor/validate.py | 15 +- 44 files changed, 715 insertions(+), 325 deletions(-) create mode 100644 build.json delete mode 100644 misc/hassio.png delete mode 100644 misc/hassio.xml create mode 100644 rootfs/etc/cont-init.d/udev.sh create mode 100644 rootfs/etc/services.d/supervisor/finish create mode 100644 rootfs/etc/services.d/supervisor/run create mode 100644 supervisor/api/audio.py create mode 100644 supervisor/audio.py delete mode 100644 supervisor/data/asound.tmpl delete mode 100644 supervisor/data/audiodb.json create mode 100644 supervisor/data/pulse-client.tmpl create mode 100644 supervisor/docker/audio.py delete mode 100644 supervisor/host/alsa.py diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index d48100d8d..614cd1570 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -33,6 +33,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ containerd.io \ && 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 COPY requirements.txt requirements_tests.txt ./ RUN pip3 install -r requirements.txt -r requirements_tests.txt \ diff --git a/.dockerignore b/.dockerignore index 0001411df..50bda0041 100644 --- a/.dockerignore +++ b/.dockerignore @@ -14,10 +14,10 @@ # virtualenv venv/ -# HA -home-assistant-polymer/* -misc/* -script/* +# Data +home-assistant-polymer/ +script/ +tests/ # Test ENV data/ diff --git a/API.md b/API.md index 0a1fbd328..d2875611c 100644 --- a/API.md +++ b/API.md @@ -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 You can use the user system on homeassistant. We handle this auth system on diff --git a/Dockerfile b/Dockerfile index dbe182ec8..291513258 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,15 +23,11 @@ RUN export MAKEFLAGS="-j$(nproc)" \ -r ./requirements.txt \ && rm -f requirements.txt -# Install HassIO -COPY . hassio -RUN pip3 install --no-cache-dir -e ./hassio \ - && python3 -m compileall ./hassio/hassio +# Install Home Assistant Supervisor +COPY . supervisor +RUN pip3 install --no-cache-dir -e ./supervisor \ + && python3 -m compileall ./supervisor/supervisor -# Initialize udev daemon, handle CMD -COPY entry.sh /bin/ -ENTRYPOINT ["/bin/entry.sh"] - WORKDIR / -CMD [ "python3", "-m", "supervisor" ] +COPY rootfs / diff --git a/MANIFEST.in b/MANIFEST.in index 34c9c023d..8d25cd1ce 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,3 @@ include LICENSE.md -graft hassio +graft supervisor recursive-exclude * *.py[co] diff --git a/README.md b/README.md index ee2afd5a5..c6fddb5f1 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,6 @@ communicates with the Supervisor. The Supervisor provides an API to manage the installation. This includes changing network settings or installing and updating software. -![](misc/hassio.png?raw=true) - ## Installation Installation instructions can be found at . diff --git a/azure-pipelines-release.yml b/azure-pipelines-release.yml index 69b0e94ac..ee44522f1 100644 --- a/azure-pipelines-release.yml +++ b/azure-pipelines-release.yml @@ -10,10 +10,8 @@ trigger: - "*" pr: none variables: - - name: basePythonTag - value: "3.7-alpine3.11" - name: versionBuilder - value: "6.9" + value: "7.0" - group: docker jobs: @@ -51,6 +49,5 @@ jobs: -v ~/.docker:/root/.docker \ -v /run/docker.sock:/run/docker.sock:rw -v $(pwd):/data:ro \ homeassistant/amd64-builder:$(versionBuilder) \ - --supervisor $(basePythonTag) --version $(Build.SourceBranchName) \ - --all -t /data --docker-hub homeassistant + --generic $(Build.SourceBranchName) --all -t /data displayName: "Build Release" diff --git a/build.json b/build.json new file mode 100644 index 000000000..5dca29692 --- /dev/null +++ b/build.json @@ -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" + } +} diff --git a/misc/hassio.png b/misc/hassio.png deleted file mode 100644 index 3ccbf7ae941c874face5f55e87019f7d25efc1a0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 37464 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 diff --git a/misc/hassio.xml b/misc/hassio.xml deleted file mode 100644 index 685610ae1..000000000 --- a/misc/hassio.xml +++ /dev/null @@ -1 +0,0 @@ -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 \ No newline at end of file diff --git a/rootfs/etc/cont-init.d/udev.sh b/rootfs/etc/cont-init.d/udev.sh new file mode 100644 index 000000000..58ea286ea --- /dev/null +++ b/rootfs/etc/cont-init.d/udev.sh @@ -0,0 +1,9 @@ +#!/usr/bin/with-contenv bashio +# ============================================================================== +# Start udev service +# ============================================================================== +udevd --daemon + +bashio::log.info "Update udev informations" +udevadm trigger +udevadm settle \ No newline at end of file diff --git a/rootfs/etc/services.d/supervisor/finish b/rootfs/etc/services.d/supervisor/finish new file mode 100644 index 000000000..709660c5a --- /dev/null +++ b/rootfs/etc/services.d/supervisor/finish @@ -0,0 +1,5 @@ +#!/usr/bin/execlineb -S0 +# ============================================================================== +# Take down the S6 supervision tree when Supervisor fails +# ============================================================================== +s6-svscanctl -t /var/run/s6/services \ No newline at end of file diff --git a/rootfs/etc/services.d/supervisor/run b/rootfs/etc/services.d/supervisor/run new file mode 100644 index 000000000..4450c0086 --- /dev/null +++ b/rootfs/etc/services.d/supervisor/run @@ -0,0 +1,5 @@ +#!/usr/bin/with-contenv bashio +# ============================================================================== +# Start Service service +# ============================================================================== +exec python3 -m supervisor \ No newline at end of file diff --git a/scripts/test_env.sh b/scripts/test_env.sh index c40df18a9..35c09e32b 100755 --- a/scripts/test_env.sh +++ b/scripts/test_env.sh @@ -61,9 +61,7 @@ function build_supervisor() { docker run --rm --privileged \ -v /run/docker.sock:/run/docker.sock -v "$(pwd):/data" \ homeassistant/amd64-builder:dev \ - --supervisor 3.7-alpine3.11 --version dev \ - -t /data --test --amd64 \ - --no-cache --docker-hub homeassistant + --generic dev -t /data --test --amd64 --no-cache } @@ -79,7 +77,7 @@ function cleanup_lastboot() { function cleanup_docker() { 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" start_docker @@ -117,5 +131,6 @@ build_supervisor install_cli cleanup_lastboot cleanup_docker +init_dbus setup_test_env stop_docker diff --git a/setup.py b/setup.py index 1e8ce20b2..85159ee8e 100644 --- a/setup.py +++ b/setup.py @@ -1,3 +1,4 @@ +"""Home Assistant Supervisor setup.""" from setuptools import setup from supervisor.const import SUPERVISOR_VERSION diff --git a/supervisor/addons/__init__.py b/supervisor/addons/__init__.py index 46103bd4c..be2139830 100644 --- a/supervisor/addons/__init__.py +++ b/supervisor/addons/__init__.py @@ -152,9 +152,9 @@ class AddonManager(CoreSysAttributes): await addon.remove_data() # Cleanup audio settings - if addon.path_asound.exists(): + if addon.path_pulse.exists(): with suppress(OSError): - addon.path_asound.unlink() + addon.path_pulse.unlink() # Cleanup AppArmor profile with suppress(HostAppArmorError): diff --git a/supervisor/addons/addon.py b/supervisor/addons/addon.py index c1d840174..da0f4ac30 100644 --- a/supervisor/addons/addon.py +++ b/supervisor/addons/addon.py @@ -279,14 +279,14 @@ class Addon(AddonModel): @property 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: 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 def audio_output(self, value: Optional[str]): - """Set/reset audio output settings.""" + """Set/reset audio output profile settings.""" if value is None: self.persist.pop(ATTR_AUDIO_OUTPUT, None) else: @@ -294,10 +294,10 @@ class Addon(AddonModel): @property 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: 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 def audio_input(self, value: Optional[str]): @@ -333,14 +333,14 @@ class Addon(AddonModel): return Path(self.path_data, "options.json") @property - def path_asound(self): + def path_pulse(self): """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 - def path_extern_asound(self): + def path_extern_pulse(self): """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): """Save data of add-on.""" @@ -379,20 +379,24 @@ class Addon(AddonModel): _LOGGER.info("Remove add-on data folder %s", 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.""" - asound_config = self.sys_host.alsa.asound( - alsa_input=self.audio_input, alsa_output=self.audio_output + pulse_config = self.sys_audio.pulse_client( + input_profile=self.audio_input, output_profile=self.audio_output ) try: - with self.path_asound.open("w") as config_file: - config_file.write(asound_config) + with self.path_pulse.open("w") as config_file: + config_file.write(pulse_config) 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() - _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: """Install or Update AppArmor profile for Add-on.""" @@ -468,7 +472,7 @@ class Addon(AddonModel): # Sound if self.with_audio: - self.write_asound() + self.write_pulse() # Start Add-on try: diff --git a/supervisor/addons/validate.py b/supervisor/addons/validate.py index ec42060f7..bda65bd84 100644 --- a/supervisor/addons/validate.py +++ b/supervisor/addons/validate.py @@ -96,7 +96,6 @@ from ..discovery.validate import valid_discovery_service from ..validate import ( DOCKER_PORTS, DOCKER_PORTS_DESCRIPTION, - alsa_device, network_port, token, uuid_match, @@ -296,8 +295,8 @@ SCHEMA_ADDON_USER = vol.Schema( vol.Optional(ATTR_AUTO_UPDATE, default=False): vol.Boolean(), vol.Optional(ATTR_BOOT): vol.In([BOOT_AUTO, BOOT_MANUAL]), vol.Optional(ATTR_NETWORK): DOCKER_PORTS, - vol.Optional(ATTR_AUDIO_OUTPUT): alsa_device, - vol.Optional(ATTR_AUDIO_INPUT): alsa_device, + vol.Optional(ATTR_AUDIO_OUTPUT): vol.Maybe(vol.Coerce(str)), + vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(vol.Coerce(str)), vol.Optional(ATTR_PROTECTED, default=True): vol.Boolean(), vol.Optional(ATTR_INGRESS_PANEL, default=False): vol.Boolean(), }, diff --git a/supervisor/api/__init__.py b/supervisor/api/__init__.py index 88b015e24..e595c59ac 100644 --- a/supervisor/api/__init__.py +++ b/supervisor/api/__init__.py @@ -21,6 +21,7 @@ from .security import SecurityMiddleware from .services import APIServices from .snapshots import APISnapshots from .supervisor import APISupervisor +from .audio import APIAudio _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: """Register panel for Home Assistant.""" panel_dir = Path(__file__).parent.joinpath("panel") diff --git a/supervisor/api/addons.py b/supervisor/api/addons.py index d15f04040..8a11509cf 100644 --- a/supervisor/api/addons.py +++ b/supervisor/api/addons.py @@ -96,7 +96,7 @@ from ..const import ( from ..coresys import CoreSysAttributes from ..docker.stats import DockerStats 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 _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( { 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_AUDIO_OUTPUT): alsa_device, - vol.Optional(ATTR_AUDIO_INPUT): alsa_device, + vol.Optional(ATTR_AUDIO_OUTPUT): vol.Maybe(vol.Coerce(str)), + vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(vol.Coerce(str)), vol.Optional(ATTR_INGRESS_PANEL): vol.Boolean(), } ) diff --git a/supervisor/api/audio.py b/supervisor/api/audio.py new file mode 100644 index 000000000..06d1b81a0 --- /dev/null +++ b/supervisor/api/audio.py @@ -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()) diff --git a/supervisor/api/hardware.py b/supervisor/api/hardware.py index a26608028..7a5f4b7bd 100644 --- a/supervisor/api/hardware.py +++ b/supervisor/api/hardware.py @@ -37,13 +37,8 @@ class APIHardware(CoreSysAttributes): @api_process async def audio(self, request: web.Request) -> Dict[str, Any]: - """Show ALSA audio devices.""" - return { - ATTR_AUDIO: { - ATTR_INPUT: self.sys_host.alsa.input_devices, - ATTR_OUTPUT: self.sys_host.alsa.output_devices, - } - } + """Show pulse audio profiles.""" + return {ATTR_AUDIO: {ATTR_INPUT: [], ATTR_OUTPUT: []}} @api_process def trigger(self, request: web.Request) -> None: diff --git a/supervisor/audio.py b/supervisor/audio.py new file mode 100644 index 000000000..853f2ebe5 --- /dev/null +++ b/supervisor/audio.py @@ -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 + ) diff --git a/supervisor/bootstrap.py b/supervisor/bootstrap.py index aaabfbde3..4a47dc78b 100644 --- a/supervisor/bootstrap.py +++ b/supervisor/bootstrap.py @@ -11,6 +11,7 @@ from .addons import AddonManager from .api import RestAPI from .arch import CpuArch from .auth import Auth +from .audio import Audio from .const import SOCKET_DOCKER, UpdateChannels from .core import Core from .coresys import CoreSys @@ -47,6 +48,7 @@ async def initialize_coresys(): coresys.core = Core(coresys) coresys.dns = CoreDNS(coresys) coresys.arch = CpuArch(coresys) + coresys.audio = Audio(coresys) coresys.auth = Auth(coresys) coresys.updater = Updater(coresys) coresys.api = RestAPI(coresys) @@ -89,12 +91,12 @@ def initialize_system_data(coresys: CoreSys): ) config.path_homeassistant.mkdir() - # supervisor ssl folder + # Supervisor ssl folder if not config.path_ssl.is_dir(): _LOGGER.info("Create Supervisor SSL/TLS folder %s", config.path_ssl) config.path_ssl.mkdir() - # supervisor addon data folder + # Supervisor addon data folder if not config.path_addons_data.is_dir(): _LOGGER.info("Create Supervisor Add-on data folder %s", config.path_addons_data) config.path_addons_data.mkdir(parents=True) @@ -113,31 +115,36 @@ def initialize_system_data(coresys: CoreSys): ) config.path_addons_git.mkdir(parents=True) - # supervisor tmp folder + # Supervisor tmp folder if not config.path_tmp.is_dir(): _LOGGER.info("Create Supervisor temp folder %s", config.path_tmp) config.path_tmp.mkdir(parents=True) - # supervisor backup folder + # Supervisor backup folder if not config.path_backup.is_dir(): _LOGGER.info("Create Supervisor backup folder %s", config.path_backup) config.path_backup.mkdir() - # share folder + # Share folder if not config.path_share.is_dir(): _LOGGER.info("Create Supervisor share folder %s", config.path_share) config.path_share.mkdir() - # apparmor folder + # Apparmor folder if not config.path_apparmor.is_dir(): _LOGGER.info("Create Supervisor Apparmor folder %s", config.path_apparmor) config.path_apparmor.mkdir() - # dns folder + # DNS folder if not config.path_dns.is_dir(): _LOGGER.info("Create Supervisor DNS folder %s", config.path_dns) 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 coresys.config.modify_log_level() diff --git a/supervisor/config.py b/supervisor/config.py index c70b0ccc0..754e029ba 100644 --- a/supervisor/config.py +++ b/supervisor/config.py @@ -13,7 +13,7 @@ from .const import ( ATTR_TIMEZONE, ATTR_WAIT_BOOT, FILE_HASSIO_CONFIG, - HASSIO_DATA, + SUPERVISOR_DATA, ) from .utils.dt import parse_datetime from .utils.json import JsonConfig @@ -35,6 +35,7 @@ SHARE_DATA = PurePath("share") TMP_DATA = PurePath("tmp") APPARMOR_DATA = PurePath("apparmor") DNS_DATA = PurePath("dns") +AUDIO_DATA = PurePath("audio") DEFAULT_BOOT_TIME = datetime.utcfromtimestamp(0).isoformat() @@ -120,7 +121,7 @@ class CoreConfig(JsonConfig): @property def path_hassio(self): """Return Supervisor data path.""" - return HASSIO_DATA + return SUPERVISOR_DATA @property def path_extern_hassio(self): @@ -135,7 +136,7 @@ class CoreConfig(JsonConfig): @property def path_homeassistant(self): """Return config path inside supervisor.""" - return Path(HASSIO_DATA, HOMEASSISTANT_CONFIG) + return Path(SUPERVISOR_DATA, HOMEASSISTANT_CONFIG) @property def path_extern_ssl(self): @@ -145,22 +146,22 @@ class CoreConfig(JsonConfig): @property def path_ssl(self): """Return SSL path inside supervisor.""" - return Path(HASSIO_DATA, HASSIO_SSL) + return Path(SUPERVISOR_DATA, HASSIO_SSL) @property def path_addons_core(self): """Return git path for core Add-ons.""" - return Path(HASSIO_DATA, ADDONS_CORE) + return Path(SUPERVISOR_DATA, ADDONS_CORE) @property def path_addons_git(self): """Return path for Git Add-on.""" - return Path(HASSIO_DATA, ADDONS_GIT) + return Path(SUPERVISOR_DATA, ADDONS_GIT) @property def path_addons_local(self): """Return path for custom Add-ons.""" - return Path(HASSIO_DATA, ADDONS_LOCAL) + return Path(SUPERVISOR_DATA, ADDONS_LOCAL) @property def path_extern_addons_local(self): @@ -170,17 +171,27 @@ class CoreConfig(JsonConfig): @property def path_addons_data(self): """Return root Add-on data folder.""" - return Path(HASSIO_DATA, ADDONS_DATA) + return Path(SUPERVISOR_DATA, ADDONS_DATA) @property def path_extern_addons_data(self): """Return root add-on data folder external for Docker.""" 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 def path_tmp(self): """Return Supervisor temp folder.""" - return Path(HASSIO_DATA, TMP_DATA) + return Path(SUPERVISOR_DATA, TMP_DATA) @property def path_extern_tmp(self): @@ -190,7 +201,7 @@ class CoreConfig(JsonConfig): @property def path_backup(self): """Return root backup data folder.""" - return Path(HASSIO_DATA, BACKUP_DATA) + return Path(SUPERVISOR_DATA, BACKUP_DATA) @property def path_extern_backup(self): @@ -200,12 +211,12 @@ class CoreConfig(JsonConfig): @property def path_share(self): """Return root share data folder.""" - return Path(HASSIO_DATA, SHARE_DATA) + return Path(SUPERVISOR_DATA, SHARE_DATA) @property def path_apparmor(self): """Return root Apparmor profile folder.""" - return Path(HASSIO_DATA, APPARMOR_DATA) + return Path(SUPERVISOR_DATA, APPARMOR_DATA) @property def path_extern_share(self): @@ -220,7 +231,7 @@ class CoreConfig(JsonConfig): @property def path_dns(self): """Return dns path inside supervisor.""" - return Path(HASSIO_DATA, DNS_DATA) + return Path(SUPERVISOR_DATA, DNS_DATA) @property def addons_repositories(self): diff --git a/supervisor/const.py b/supervisor/const.py index d6766a98c..e65312d7c 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -15,17 +15,18 @@ URL_HASSOS_OTA = ( "{version}/hassos_{board}-{version}.raucb" ) -HASSIO_DATA = Path("/data") +SUPERVISOR_DATA = Path("/data") -FILE_HASSIO_AUTH = Path(HASSIO_DATA, "auth.json") -FILE_HASSIO_ADDONS = Path(HASSIO_DATA, "addons.json") -FILE_HASSIO_CONFIG = Path(HASSIO_DATA, "config.json") -FILE_HASSIO_HOMEASSISTANT = Path(HASSIO_DATA, "homeassistant.json") -FILE_HASSIO_UPDATER = Path(HASSIO_DATA, "updater.json") -FILE_HASSIO_SERVICES = Path(HASSIO_DATA, "services.json") -FILE_HASSIO_DISCOVERY = Path(HASSIO_DATA, "discovery.json") -FILE_HASSIO_INGRESS = Path(HASSIO_DATA, "ingress.json") -FILE_HASSIO_DNS = Path(HASSIO_DATA, "dns.json") +FILE_HASSIO_AUTH = Path(SUPERVISOR_DATA, "auth.json") +FILE_HASSIO_ADDONS = Path(SUPERVISOR_DATA, "addons.json") +FILE_HASSIO_CONFIG = Path(SUPERVISOR_DATA, "config.json") +FILE_HASSIO_HOMEASSISTANT = Path(SUPERVISOR_DATA, "homeassistant.json") +FILE_HASSIO_UPDATER = Path(SUPERVISOR_DATA, "updater.json") +FILE_HASSIO_SERVICES = Path(SUPERVISOR_DATA, "services.json") +FILE_HASSIO_DISCOVERY = Path(SUPERVISOR_DATA, "discovery.json") +FILE_HASSIO_INGRESS = Path(SUPERVISOR_DATA, "ingress.json") +FILE_HASSIO_DNS = Path(SUPERVISOR_DATA, "dns.json") +FILE_HASSIO_AUDIO = Path(SUPERVISOR_DATA, "audio.json") SOCKET_DOCKER = Path("/var/run/docker.sock") @@ -229,6 +230,7 @@ ATTR_SNAPSHOT_EXCLUDE = "snapshot_exclude" ATTR_DOCUMENTATION = "documentation" ATTR_ADVANCED = "advanced" ATTR_STAGE = "stage" +ATTR_CLI = "cli" PROVIDE_SERVICE = "provide" NEED_SERVICE = "need" diff --git a/supervisor/core.py b/supervisor/core.py index e3e0c1c77..1d1328406 100644 --- a/supervisor/core.py +++ b/supervisor/core.py @@ -40,8 +40,8 @@ class Core(CoreSysAttributes): # Load Host await self.sys_host.load() - # Load CoreDNS - await self.sys_dns.load() + # Load Plugins container + await asyncio.wait([self.sys_dns.load(), self.sys_audio.load()]) # Load Home Assistant await self.sys_homeassistant.load() diff --git a/supervisor/coresys.py b/supervisor/coresys.py index 9ab44182f..ecfb058f2 100644 --- a/supervisor/coresys.py +++ b/supervisor/coresys.py @@ -15,6 +15,7 @@ if TYPE_CHECKING: from .addons import AddonManager from .api import RestAPI from .arch import CpuArch + from .audio import Audio from .auth import Auth from .core import Core from .dbus import DBusManager @@ -57,6 +58,7 @@ class CoreSys: # Internal objects pointers self._core: Optional[Core] = None self._arch: Optional[CpuArch] = None + self._audio: Optional[Audio] = None self._auth: Optional[Auth] = None self._dns: Optional[CoreDNS] = None self._homeassistant: Optional[HomeAssistant] = None @@ -163,6 +165,18 @@ class CoreSys: raise RuntimeError("Auth already set!") 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 def homeassistant(self) -> HomeAssistant: """Return Home Assistant object.""" @@ -431,6 +445,11 @@ class CoreSysAttributes: """Return Auth object.""" return self.coresys.auth + @property + def sys_audio(self) -> Audio: + """Return Audio object.""" + return self.coresys.audio + @property def sys_homeassistant(self) -> HomeAssistant: """Return Home Assistant object.""" diff --git a/supervisor/data/asound.tmpl b/supervisor/data/asound.tmpl deleted file mode 100644 index dc64186fd..000000000 --- a/supervisor/data/asound.tmpl +++ /dev/null @@ -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" - } -} diff --git a/supervisor/data/audiodb.json b/supervisor/data/audiodb.json deleted file mode 100644 index f6cccd456..000000000 --- a/supervisor/data/audiodb.json +++ /dev/null @@ -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 - } -} diff --git a/supervisor/data/pulse-client.tmpl b/supervisor/data/pulse-client.tmpl new file mode 100644 index 000000000..1729d927f --- /dev/null +++ b/supervisor/data/pulse-client.tmpl @@ -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://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 diff --git a/supervisor/docker/addon.py b/supervisor/docker/addon.py index a72a26df6..f1509e720 100644 --- a/supervisor/docker/addon.py +++ b/supervisor/docker/addon.py @@ -35,7 +35,6 @@ if TYPE_CHECKING: _LOGGER: logging.Logger = logging.getLogger(__name__) -AUDIO_DEVICE = "/dev/snd:/dev/snd:rwm" NO_ADDDRESS = ip_address("0.0.0.0") @@ -131,10 +130,6 @@ class DockerAddon(DockerInterface): if 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 if self.addon.auto_uart: if self.addon.with_udev: @@ -298,21 +293,25 @@ class DockerAddon(DockerInterface): # Docker API support if not self.addon.protected and self.addon.access_docker_api: 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 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: volumes.update( { - str(self.addon.path_extern_asound): { - "bind": "/etc/asound.conf", + str(self.addon.path_extern_pulse): { + "bind": "/etc/pulse/client.conf", "mode": "ro", - } + }, + str(self.sys_audio.path_extern_data.joinpath("pulse.sock")): { + "bind": "/run/pulse.sock", + "mode": "rw", + }, } ) diff --git a/supervisor/docker/audio.py b/supervisor/docker/audio.py new file mode 100644 index 000000000..bf9e62a90 --- /dev/null +++ b/supervisor/docker/audio.py @@ -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, + ) diff --git a/supervisor/docker/dns.py b/supervisor/docker/dns.py index de172d506..258cc6796 100644 --- a/supervisor/docker/dns.py +++ b/supervisor/docker/dns.py @@ -1,4 +1,4 @@ -"""HassOS Cli docker object.""" +"""DNS docker object.""" from contextlib import suppress import logging @@ -46,7 +46,6 @@ class DockerDNS(DockerInterface, CoreSysAttributes): name=self.name, hostname=self.name.replace("_", "-"), detach=True, - init=True, environment={ENV_TIME: self.sys_timezone}, volumes={ str(self.sys_config.path_extern_dns): {"bind": "/config", "mode": "ro"} diff --git a/supervisor/docker/network.py b/supervisor/docker/network.py index 1ed09d440..1a0fbcf4e 100644 --- a/supervisor/docker/network.py +++ b/supervisor/docker/network.py @@ -48,6 +48,11 @@ class DockerNetwork: """Return dns of the network.""" 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: """Get supervisor network.""" try: diff --git a/supervisor/exceptions.py b/supervisor/exceptions.py index aa14db239..a89ea85fb 100644 --- a/supervisor/exceptions.py +++ b/supervisor/exceptions.py @@ -65,6 +65,17 @@ class CoreDNSUpdateError(CoreDNSError): """Error on update of a CoreDNS.""" +# DNS + + +class AudioError(HassioError): + """PulseAudio exception.""" + + +class AudioUpdateError(AudioError): + """Error on update of a Audio.""" + + # Addons diff --git a/supervisor/hassos.py b/supervisor/hassos.py index fbdf754b0..a7f745251 100644 --- a/supervisor/hassos.py +++ b/supervisor/hassos.py @@ -56,7 +56,7 @@ class HassOS(CoreSysAttributes): @property def version_cli_latest(self) -> str: """Return version of HassOS.""" - return self.sys_updater.version_hassos_cli + return self.sys_updater.version_cli @property def need_update(self) -> bool: diff --git a/supervisor/host/__init__.py b/supervisor/host/__init__.py index 8fe2b5404..ac1127840 100644 --- a/supervisor/host/__init__.py +++ b/supervisor/host/__init__.py @@ -2,7 +2,6 @@ from contextlib import suppress import logging -from .alsa import AlsaAudio from .apparmor import AppArmorControl from .control import SystemControl from .info import InfoCenter @@ -28,18 +27,12 @@ class HostManager(CoreSysAttributes): """Initialize Host manager.""" self.coresys: CoreSys = coresys - self._alsa: AlsaAudio = AlsaAudio(coresys) self._apparmor: AppArmorControl = AppArmorControl(coresys) self._control: SystemControl = SystemControl(coresys) self._info: InfoCenter = InfoCenter(coresys) self._services: ServiceManager = ServiceManager(coresys) self._network: NetworkManager = NetworkManager(coresys) - @property - def alsa(self) -> AlsaAudio: - """Return host ALSA handler.""" - return self._alsa - @property def apparmor(self) -> AppArmorControl: """Return host AppArmor handler.""" diff --git a/supervisor/host/alsa.py b/supervisor/host/alsa.py deleted file mode 100644 index e17082364..000000000 --- a/supervisor/host/alsa.py +++ /dev/null @@ -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) diff --git a/supervisor/misc/hardware.py b/supervisor/misc/hardware.py index 7aa49f42b..7d6919c6d 100644 --- a/supervisor/misc/hardware.py +++ b/supervisor/misc/hardware.py @@ -199,7 +199,9 @@ class Hardware: async def udev_trigger(self) -> None: """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() if proc.returncode == 0: diff --git a/supervisor/tasks.py b/supervisor/tasks.py index 80388bacb..e39d4afe9 100644 --- a/supervisor/tasks.py +++ b/supervisor/tasks.py @@ -11,8 +11,9 @@ HASS_WATCHDOG_API = "HASS_WATCHDOG_API" RUN_UPDATE_SUPERVISOR = 29100 RUN_UPDATE_ADDONS = 57600 -RUN_UPDATE_HASSOSCLI = 28100 +RUN_UPDATE_CLI = 28100 RUN_UPDATE_DNS = 30100 +RUN_UPDATE_AUDIO = 30200 RUN_RELOAD_ADDONS = 10800 RUN_RELOAD_SNAPSHOTS = 72000 @@ -24,6 +25,7 @@ RUN_WATCHDOG_HOMEASSISTANT_DOCKER = 15 RUN_WATCHDOG_HOMEASSISTANT_API = 300 RUN_WATCHDOG_DNS_DOCKER = 20 +RUN_WATCHDOG_AUDIO_DOCKER = 20 class Tasks(CoreSysAttributes): @@ -47,13 +49,14 @@ class Tasks(CoreSysAttributes): ) ) self.jobs.add( - self.sys_scheduler.register_task( - self._update_hassos_cli, RUN_UPDATE_HASSOSCLI - ) + self.sys_scheduler.register_task(self._update_cli, RUN_UPDATE_CLI) ) self.jobs.add( 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 self.jobs.add( @@ -94,6 +97,11 @@ class Tasks(CoreSysAttributes): 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") @@ -193,17 +201,12 @@ class Tasks(CoreSysAttributes): finally: self._cache[HASS_WATCHDOG_API] = 0 - async def _update_hassos_cli(self): - """Check and run update of HassOS CLI.""" + async def _update_cli(self): + """Check and run update of CLI.""" if not self.sys_hassos.need_cli_update: return - # don't perform an update on dev channel - if self.sys_dev: - _LOGGER.warning("Ignore HassOS CLI update on dev channel!") - return - - _LOGGER.info("Found new HassOS CLI version") + _LOGGER.info("Found new CLI version") await self.sys_hassos.update_cli() async def _update_dns(self): @@ -211,17 +214,20 @@ class Tasks(CoreSysAttributes): if not self.sys_dns.need_update: 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") 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): """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(): return _LOGGER.warning("Watchdog found a problem with CoreDNS plugin!") @@ -234,3 +240,15 @@ class Tasks(CoreSysAttributes): await self.sys_dns.start() except CoreDNSError: _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!") diff --git a/supervisor/updater.py b/supervisor/updater.py index 79ee549bc..3765c2b05 100644 --- a/supervisor/updater.py +++ b/supervisor/updater.py @@ -9,11 +9,12 @@ from typing import Optional import aiohttp from .const import ( + ATTR_AUDIO, ATTR_CHANNEL, + ATTR_CLI, ATTR_DNS, ATTR_HASSIO, ATTR_HASSOS, - ATTR_HASSOS_CLI, ATTR_HOMEASSISTANT, FILE_HASSIO_UPDATER, URL_HASSIO_VERSION, @@ -62,15 +63,20 @@ class Updater(JsonConfig, CoreSysAttributes): return self._data.get(ATTR_HASSOS) @property - def version_hassos_cli(self) -> Optional[str]: - """Return latest version of HassOS cli.""" - return self._data.get(ATTR_HASSOS_CLI) + def version_cli(self) -> Optional[str]: + """Return latest version of CLI.""" + return self._data.get(ATTR_CLI) @property def version_dns(self) -> Optional[str]: - """Return latest version of Supervisor DNS.""" + """Return latest version of 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 def channel(self) -> UpdateChannels: """Return upstream channel of Supervisor instance.""" @@ -81,7 +87,7 @@ class Updater(JsonConfig, CoreSysAttributes): """Set upstream mode.""" self._data[ATTR_CHANNEL] = value - @AsyncThrottle(timedelta(seconds=60)) + @AsyncThrottle(timedelta(seconds=30)) async def fetch_data(self): """Fetch current versions from Github. @@ -110,17 +116,20 @@ class Updater(JsonConfig, CoreSysAttributes): raise HassioUpdaterError() from None try: - # update supervisor version + # Update supervisor version 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] - # update hassos version + # Update HassOS version if self.sys_hassos.available and 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: _LOGGER.warning("Can't process version data: %s", err) diff --git a/supervisor/utils/__init__.py b/supervisor/utils/__init__.py index b6b29f008..f3c0d6349 100644 --- a/supervisor/utils/__init__.py +++ b/supervisor/utils/__init__.py @@ -1,9 +1,11 @@ """Tools file for Supervisor.""" +import asyncio from datetime import datetime from ipaddress import IPv4Address import logging import re import socket +from typing import Optional _LOGGER: logging.Logger = logging.getLogger(__name__) RE_STRING = re.compile(r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))") @@ -41,18 +43,23 @@ class AsyncThrottle: """Initialize async throttle.""" self.throttle_period = delta self.time_of_last_call = datetime.min + self.synchronize: Optional[asyncio.Lock] = None def __call__(self, method): """Throttle function""" async def wrapper(*args, **kwargs): """Throttle function wrapper""" - now = datetime.now() - time_since_last_call = now - self.time_of_last_call + if not self.synchronize: + self.synchronize = asyncio.Lock() - if time_since_last_call > self.throttle_period: - self.time_of_last_call = now - return await method(*args, **kwargs) + async with self.synchronize: + now = datetime.now() + 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 diff --git a/supervisor/validate.py b/supervisor/validate.py index 785b6fff9..766a32d5e 100644 --- a/supervisor/validate.py +++ b/supervisor/validate.py @@ -1,21 +1,22 @@ """Validate functions.""" +import ipaddress import re import uuid -import ipaddress import voluptuous as vol from .const import ( ATTR_ACCESS_TOKEN, ATTR_ADDONS_CUSTOM_LIST, + ATTR_AUDIO, ATTR_BOOT, ATTR_CHANNEL, + ATTR_CLI, ATTR_DEBUG, ATTR_DEBUG_BLOCK, ATTR_DNS, ATTR_HASSIO, ATTR_HASSOS, - ATTR_HASSOS_CLI, ATTR_HOMEASSISTANT, ATTR_IMAGE, ATTR_LAST_BOOT, @@ -36,7 +37,6 @@ from .const import ( ) from .utils.validate import validate_timezone - RE_REPOSITORY = re.compile(r"^(?P[^#]+)(?:#(?P[\w\-]+))?$") # pylint: disable=no-value-for-parameter @@ -44,7 +44,6 @@ RE_REPOSITORY = re.compile(r"^(?P[^#]+)(?:#(?P[\w\-]+))?$") 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)) 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}$") sha256 = vol.Match(r"^[0-9a-f]{64}$") 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_HASSIO): 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_AUDIO): vol.Coerce(str), }, extra=vol.REMOVE_EXTRA, ) @@ -173,3 +173,8 @@ SCHEMA_DNS_CONFIG = vol.Schema( }, extra=vol.REMOVE_EXTRA, ) + + +SCHEMA_AUDIO_CONFIG = vol.Schema( + {vol.Optional(ATTR_VERSION): vol.Maybe(vol.Coerce(str))}, extra=vol.REMOVE_EXTRA, +)