diff --git a/API.md b/API.md index aac11806b..80e96c04c 100644 --- a/API.md +++ b/API.md @@ -255,7 +255,11 @@ Optional: } ``` -- GET `/host/hardware` +- POST `/host/reload` + +### Hardware + +- GET `/hardware/info` ```json { "serial": ["/dev/xy"], @@ -274,7 +278,20 @@ Optional: } ``` -- POST `/host/reload` +- GET `/hardware/audio` +```json +{ + "audio": { + "input": { + "0,0": "Mic" + }, + "output": { + "1,0": "Jack", + "1,1": "HDMI" + } + } +} +``` ### Network diff --git a/hassio/addons/addon.py b/hassio/addons/addon.py index b3d6660d8..8900b7dd6 100644 --- a/hassio/addons/addon.py +++ b/hassio/addons/addon.py @@ -1,4 +1,5 @@ """Init file for HassIO addons.""" +from contextlib import suppress from copy import deepcopy import logging import json @@ -372,15 +373,14 @@ class Addon(CoreSysAttributes): if not self.with_audio: return None - setting = self._config.audio_output if self.is_installed and \ ATTR_AUDIO_OUTPUT in self._data.user[self._id]: - setting = self._data.user[self._id][ATTR_AUDIO_OUTPUT] - return setting + return self._data.user[self._id][ATTR_AUDIO_OUTPUT] + return self._audio.default.output @audio_output.setter def audio_output(self, value): - """Set/remove custom audio output settings.""" + """Set/reset audio output settings.""" if value is None: self._data.user[self._id].pop(ATTR_AUDIO_OUTPUT, None) else: @@ -392,14 +392,13 @@ class Addon(CoreSysAttributes): if not self.with_audio: return None - setting = self._config.audio_input if self.is_installed and ATTR_AUDIO_INPUT in self._data.user[self._id]: - setting = self._data.user[self._id][ATTR_AUDIO_INPUT] - return setting + return self._data.user[self._id][ATTR_AUDIO_INPUT] + return self._audio.default.input @audio_input.setter def audio_input(self, value): - """Set/remove custom audio input settings.""" + """Set/reset audio input settings.""" if value is None: self._data.user[self._id].pop(ATTR_AUDIO_INPUT, None) else: @@ -504,6 +503,16 @@ class Addon(CoreSysAttributes): """Return path to custom AppArmor profile.""" return Path(self.path_location, 'apparmor') + @property + def path_asound(self): + """Return path to asound config.""" + return Path(self._config.path_tmp, f"{self.slug}_asound") + + @property + def path_extern_asound(self): + """Return path to asound config for docker.""" + return Path(self._config.path_extern_tmp, f"{self.slug}_asound") + def save_data(self): """Save data of addon.""" self._addons.data.save_data() @@ -526,6 +535,20 @@ class Addon(CoreSysAttributes): return False + def write_asound(self): + """Write asound config to file and return True on success.""" + asound_config = self._audio.asound( + alsa_input=self.audio_input, alsa_output=self.audio_output) + + try: + with self.path_asound.open('w') as config_file: + config_file.write(asound_config) + except OSError as err: + _LOGGER.error("Addon %s can't write asound: %s", self._id, err) + return False + + return True + @property def schema(self): """Create a schema for addon options.""" @@ -613,18 +636,24 @@ class Addon(CoreSysAttributes): @check_installed async def start(self): """Set options and start addon.""" + # Options if not self.write_options(): return False + # Sound + if self.with_audio and not self.write_asound(): + return False + return await self.instance.run() @check_installed - def stop(self): - """Stop addon. - - Return a coroutine. - """ - return self.instance.stop() + async def stop(self): + """Stop addon.""" + try: + return self.instance.stop() + finally: + with suppress(OSError): + self.path_asound.unlink() @check_installed async def update(self): diff --git a/hassio/bootstrap.py b/hassio/bootstrap.py index 82ece0f13..066cfd328 100644 --- a/hassio/bootstrap.py +++ b/hassio/bootstrap.py @@ -7,6 +7,7 @@ from pathlib import Path from colorlog import ColoredFormatter +from .audio import AlsaAudio from .addons import AddonManager from .api import RestAPI from .const import SOCKET_DOCKER @@ -28,6 +29,7 @@ def initialize_coresys(loop): # Initialize core objects coresys.updater = Updater(coresys) coresys.api = RestAPI(coresys) + coresys.audio = AlsaAudio(coresys) coresys.supervisor = Supervisor(coresys) coresys.homeassistant = HomeAssistant(coresys) coresys.addons = AddonManager(coresys) diff --git a/hassio/config.py b/hassio/config.py index baf31fb27..2f86ad5ed 100644 --- a/hassio/config.py +++ b/hassio/config.py @@ -6,7 +6,7 @@ from pathlib import Path, PurePath from .const import ( FILE_HASSIO_CONFIG, HASSIO_DATA, ATTR_TIMEZONE, ATTR_ADDONS_CUSTOM_LIST, - ATTR_AUDIO_INPUT, ATTR_AUDIO_OUTPUT, ATTR_LAST_BOOT, ATTR_WAIT_BOOT) + ATTR_LAST_BOOT, ATTR_WAIT_BOOT) from .utils.dt import parse_datetime from .utils.json import JsonConfig from .validate import SCHEMA_HASSIO_CONFIG @@ -136,6 +136,11 @@ class CoreConfig(JsonConfig): """Return hass.io temp folder.""" return Path(HASSIO_DATA, TMP_DATA) + @property + def path_extern_tmp(self): + """Return hass.io temp folder for docker.""" + return PurePath(self.path_extern_hassio, TMP_DATA) + @property def path_backup(self): """Return root backup data folder.""" @@ -174,23 +179,3 @@ class CoreConfig(JsonConfig): return self._data[ATTR_ADDONS_CUSTOM_LIST].remove(repo) - - @property - def audio_output(self): - """Return ALSA audio output card,dev.""" - return self._data.get(ATTR_AUDIO_OUTPUT) - - @audio_output.setter - def audio_output(self, value): - """Set ALSA audio output card,dev.""" - self._data[ATTR_AUDIO_OUTPUT] = value - - @property - def audio_input(self): - """Return ALSA audio input card,dev.""" - return self._data.get(ATTR_AUDIO_INPUT) - - @audio_input.setter - def audio_input(self, value): - """Set ALSA audio input card,dev.""" - self._data[ATTR_AUDIO_INPUT] = value diff --git a/hassio/const.py b/hassio/const.py index 795cbd327..ab1b3742a 100644 --- a/hassio/const.py +++ b/hassio/const.py @@ -27,6 +27,7 @@ DOCKER_NETWORK_RANGE = ip_network('172.30.33.0/24') LABEL_VERSION = 'io.hass.version' LABEL_ARCH = 'io.hass.arch' LABEL_TYPE = 'io.hass.type' +LABEL_MACHINE = 'io.hass.machine' META_ADDON = 'addon' META_SUPERVISOR = 'supervisor' @@ -161,6 +162,8 @@ ATTR_CRYPTO = 'crypto' ATTR_BRANCH = 'branch' ATTR_SECCOMP = 'seccomp' ATTR_APPARMOR = 'apparmor' +ATTR_CACHE = 'cache' +ATTR_DEFAULT = 'default' SERVICE_MQTT = 'mqtt' diff --git a/hassio/coresys.py b/hassio/coresys.py index 79a9a9047..1568567e6 100644 --- a/hassio/coresys.py +++ b/hassio/coresys.py @@ -42,6 +42,7 @@ class CoreSys(object): self._snapshots = None self._tasks = None self._services = None + self._audio = None @property def arch(self): @@ -50,6 +51,13 @@ class CoreSys(object): return self._supervisor.arch return None + @property + def machine(self): + """Return running machine type of hass.io system.""" + if self._homeassistant: + return self._homeassistant.machine + return None + @property def dev(self): """Return True if we run dev modus.""" @@ -196,6 +204,18 @@ class CoreSys(object): raise RuntimeError("Services already set!") self._services = value + @property + def audio(self): + """Return ALSA Audio object.""" + return self._audio + + @audio.setter + def audio(self, value): + """Set a ALSA Audio object.""" + if self._audio: + raise RuntimeError("Audio already set!") + self._audio = value + class CoreSysAttributes(object): """Inheret basic CoreSysAttributes.""" diff --git a/hassio/docker/addon.py b/hassio/docker/addon.py index e766be5e0..9dc363235 100644 --- a/hassio/docker/addon.py +++ b/hassio/docker/addon.py @@ -201,7 +201,7 @@ class DockerAddon(DockerInterface): 'bind': "/share", 'mode': addon_mapping[MAP_SHARE] }}) - # init other hardware mappings + # Init other hardware mappings if self.addon.with_gpio: volumes.update({ "/sys/class/gpio": { @@ -212,13 +212,20 @@ class DockerAddon(DockerInterface): }, }) - # host dbus system + # Host dbus system if self.addon.host_dbus: volumes.update({ "/var/run/dbus": { 'bind': "/var/run/dbus", 'mode': 'rw' }}) + # ALSA configuration + if self.addon.with_audio: + volumes.update({ + str(self.addon.path_extern_asound): { + 'bind': "/etc/asound.conf", 'mode': 'ro' + }}) + return volumes def _run(self): diff --git a/hassio/docker/homeassistant.py b/hassio/docker/homeassistant.py index 48d0c9aa0..1ac05b18c 100644 --- a/hassio/docker/homeassistant.py +++ b/hassio/docker/homeassistant.py @@ -4,7 +4,7 @@ import logging import docker from .interface import DockerInterface -from ..const import ENV_TOKEN, ENV_TIME +from ..const import ENV_TOKEN, ENV_TIME, LABEL_MACHINE _LOGGER = logging.getLogger(__name__) @@ -14,6 +14,13 @@ HASS_DOCKER_NAME = 'homeassistant' class DockerHomeAssistant(DockerInterface): """Docker hassio wrapper for HomeAssistant.""" + @property + def machine(self): + """Return machine of Home-Assistant docker image.""" + if self._meta and LABEL_MACHINE in self._meta['Config']['Labels']: + return self._meta['Config']['Labels'][LABEL_MACHINE] + return None + @property def image(self): """Return name of docker image.""" diff --git a/hassio/homeassistant.py b/hassio/homeassistant.py index 21916cfdc..1a22a5087 100644 --- a/hassio/homeassistant.py +++ b/hassio/homeassistant.py @@ -45,6 +45,11 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): _LOGGER.info("No HomeAssistant docker %s found.", self.image) await self.install_landingpage() + @property + def machine(self): + """Return System Machines.""" + return self._docker.machine + @property def api_ip(self): """Return IP of HomeAssistant instance.""" diff --git a/hassio/host/__init__.py b/hassio/host/__init__.py new file mode 100644 index 000000000..01fd3820e --- /dev/null +++ b/hassio/host/__init__.py @@ -0,0 +1 @@ +"""Host function like audio/dbus/systemd.""" diff --git a/hassio/host/asound.tmpl b/hassio/host/asound.tmpl new file mode 100644 index 000000000..248482a73 --- /dev/null +++ b/hassio/host/asound.tmpl @@ -0,0 +1,17 @@ +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/hassio/host/audio.py b/hassio/host/audio.py new file mode 100644 index 000000000..a1c522cb7 --- /dev/null +++ b/hassio/host/audio.py @@ -0,0 +1,114 @@ +"""Host Audio-support.""" +from collections import namedtuple +import logging +import json +from pathlib import Path +from string import Template + +from ..const import ( + ATTR_CACHE, ATTR_INPUT, ATTR_OUTPUT, ATTR_DEVICES, ATTR_NAME, ATTR_DEFAULT) +from ..coresys import CoreSysAttributes + +_LOGGER = logging.getLogger(__name__) + +DefaultConfig = namedtuple('DefaultConfig', ['input', 'output']) + + +class AlsaAudio(CoreSysAttributes): + """Handle Audio ALSA host data.""" + + def __init__(self, coresys): + """Initialize Alsa audio system.""" + self.coresys = coresys + self._data = { + ATTR_CACHE: 0, + ATTR_INPUT: {}, + ATTR_OUTPUT: {}, + } + + @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._hardware.audio_devices)) + + # Need rebuild? + if current_id == self._data[ATTR_CACHE]: + return + + # Init database + _LOGGER.info("Update ALSA device list") + database = self._audio_database() + + # Process devices + for dev_id, dev_data in self._hardware.audio_devices.items(): + for chan_id, chan_type in dev_data[ATTR_DEVICES]: + alsa_id = f"{dev_id},{chan_id}" + 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 + + self._data[key][alsa_id] = database.get(self._machine, {}).get( + alsa_id, f"{dev_data[ATTR_NAME]}: {chan_id}") + + self._data[ATTR_CACHE] = current_id + + @staticmethod + def _audio_database(): + """Read local json audio data into dict.""" + json_file = Path(__file__).parent.joinpath('audiodb.json') + + try: + with json_file.open('r') as database: + return json.loads(database.read()) + except (ValueError, OSError) as err: + _LOGGER.warning("Can't read audio DB: %s", err) + + return {} + + @property + def default(self): + """Generate ALSA default setting.""" + if ATTR_DEFAULT in self._data: + return self._data[ATTR_DEFAULT] + + database = self._audio_database() + alsa_input = database.get(self._machine, {}).get(ATTR_INPUT, "0,0") + alsa_output = database.get(self._machine, {}).get(ATTR_OUTPUT, "0,0") + + self._data[ATTR_DEFAULT] = DefaultConfig(alsa_input, alsa_output) + return self._data[ATTR_DEFAULT] + + def asound(self, alsa_input=None, alsa_output=None): + """Generate a asound data.""" + alsa_input = alsa_input or self.default.input + alsa_output = alsa_output or self.default.output + + # Read Template + asound_file = Path(__file__).parent.joinpath('asound.tmpl') + try: + with asound_file.open('r') as asound: + asound_data = asound.read() + 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/hassio/host/audiodb.json b/hassio/host/audiodb.json new file mode 100644 index 000000000..a28c1a746 --- /dev/null +++ b/hassio/host/audiodb.json @@ -0,0 +1,18 @@ +{ + "raspberrypi3": { + "bcm2835 - bcm2835 ALSA": { + "0,0": "Jack", + "0,1": "HDMI" + }, + "output": "0,0", + "input": "1,0" + }, + "raspberrypi2": { + "output": "0,0", + "input": "1,0" + }, + "raspberrypi": { + "output": "0,0", + "input": "1,0" + } +}