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 3ccbf7ae9..000000000 Binary files a/misc/hassio.png and /dev/null differ 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, +)