Add Audio layer / PulseAudio (#1523)

* Improve alsa handling

* use default from image

* create alsa folder

* Map config into addon

* Add Audio object

* Fix dbus

* add host group file

* Fix persistent file

* Use new template

* fix lint

* Fix lint

* add API

* Update new base image / build system

* Add audio container

* extend new audio settings

* provide pulse client config

* Adjust files

* Use without auth

* reset did not exists now

* cleanup old alsa layer

* fix tasks

* fix black

* fix lint

* Add dbus support

* add dbus adjustments

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

View File

@ -33,6 +33,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
containerd.io \
&& 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 \

View File

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

39
API.md
View File

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

View File

@ -23,15 +23,11 @@ RUN export MAKEFLAGS="-j$(nproc)" \
-r ./requirements.txt \
&& 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 /

View File

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

View File

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

View File

@ -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"

13
build.json Normal file
View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

View File

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

View File

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

View File

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

View File

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

View File

@ -61,9 +61,7 @@ function build_supervisor() {
docker run --rm --privileged \
-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

View File

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

View File

@ -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):

View File

@ -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:

View File

@ -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(),
},

View File

@ -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")

View File

@ -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(),
}
)

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

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

View File

@ -37,13 +37,8 @@ class APIHardware(CoreSysAttributes):
@api_process
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:

199
supervisor/audio.py Normal file
View File

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

View File

@ -11,6 +11,7 @@ from .addons import AddonManager
from .api import RestAPI
from .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()

View File

@ -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):

View File

@ -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"

View File

@ -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()

View File

@ -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."""

View File

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

View File

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

View File

@ -0,0 +1,35 @@
# This file is part of PulseAudio.
#
# PulseAudio is free software; you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# PulseAudio is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with PulseAudio; if not, see <http://www.gnu.org/licenses/>.
## Configuration file for PulseAudio clients. See pulse-client.conf(5) for
## more information. Default values are commented out. Use either ; or # for
## commenting.
; default-sink =
; default-source =
default-server = unix://run/pulse.sock
; default-dbus-server =
autospawn = no
; daemon-binary = /usr/bin/pulseaudio
; extra-arguments = --log-target=syslog
; cookie-file =
; enable-shm = yes
; shm-size-bytes = 0 # setting this 0 will use the system-default, usually 64 MiB
; auto-connect-localhost = no
; auto-connect-display = no

View File

@ -35,7 +35,6 @@ if TYPE_CHECKING:
_LOGGER: logging.Logger = logging.getLogger(__name__)
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",
},
}
)

View File

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

View File

@ -1,4 +1,4 @@
"""HassOS Cli docker object."""
"""DNS docker object."""
from contextlib import suppress
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"}

View File

@ -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:

View File

@ -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

View File

@ -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:

View File

@ -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."""

View File

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

View File

@ -199,7 +199,9 @@ class Hardware:
async def udev_trigger(self) -> None:
"""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:

View File

@ -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!")

View File

@ -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)

View File

@ -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

View File

@ -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<url>[^#]+)(?:#(?P<branch>[\w\-]+))?$")
# pylint: disable=no-value-for-parameter
@ -44,7 +44,6 @@ RE_REPOSITORY = re.compile(r"^(?P<url>[^#]+)(?:#(?P<branch>[\w\-]+))?$")
network_port = vol.All(vol.Coerce(int), vol.Range(min=1, max=65535))
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,
)