Extend Audio support

This commit is contained in:
Pascal Vizeli 2018-04-11 23:53:30 +02:00
parent 7d02bb2fe9
commit a2789ac540
13 changed files with 265 additions and 40 deletions

21
API.md
View File

@ -255,7 +255,11 @@ Optional:
} }
``` ```
- GET `/host/hardware` - POST `/host/reload`
### Hardware
- GET `/hardware/info`
```json ```json
{ {
"serial": ["/dev/xy"], "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 ### Network

View File

@ -1,4 +1,5 @@
"""Init file for HassIO addons.""" """Init file for HassIO addons."""
from contextlib import suppress
from copy import deepcopy from copy import deepcopy
import logging import logging
import json import json
@ -372,15 +373,14 @@ class Addon(CoreSysAttributes):
if not self.with_audio: if not self.with_audio:
return None return None
setting = self._config.audio_output
if self.is_installed and \ if self.is_installed and \
ATTR_AUDIO_OUTPUT in self._data.user[self._id]: ATTR_AUDIO_OUTPUT in self._data.user[self._id]:
setting = self._data.user[self._id][ATTR_AUDIO_OUTPUT] return self._data.user[self._id][ATTR_AUDIO_OUTPUT]
return setting return self._audio.default.output
@audio_output.setter @audio_output.setter
def audio_output(self, value): def audio_output(self, value):
"""Set/remove custom audio output settings.""" """Set/reset audio output settings."""
if value is None: if value is None:
self._data.user[self._id].pop(ATTR_AUDIO_OUTPUT, None) self._data.user[self._id].pop(ATTR_AUDIO_OUTPUT, None)
else: else:
@ -392,14 +392,13 @@ class Addon(CoreSysAttributes):
if not self.with_audio: if not self.with_audio:
return None return None
setting = self._config.audio_input
if self.is_installed and ATTR_AUDIO_INPUT in self._data.user[self._id]: if self.is_installed and ATTR_AUDIO_INPUT in self._data.user[self._id]:
setting = self._data.user[self._id][ATTR_AUDIO_INPUT] return self._data.user[self._id][ATTR_AUDIO_INPUT]
return setting return self._audio.default.input
@audio_input.setter @audio_input.setter
def audio_input(self, value): def audio_input(self, value):
"""Set/remove custom audio input settings.""" """Set/reset audio input settings."""
if value is None: if value is None:
self._data.user[self._id].pop(ATTR_AUDIO_INPUT, None) self._data.user[self._id].pop(ATTR_AUDIO_INPUT, None)
else: else:
@ -504,6 +503,16 @@ class Addon(CoreSysAttributes):
"""Return path to custom AppArmor profile.""" """Return path to custom AppArmor profile."""
return Path(self.path_location, 'apparmor') 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): def save_data(self):
"""Save data of addon.""" """Save data of addon."""
self._addons.data.save_data() self._addons.data.save_data()
@ -526,6 +535,20 @@ class Addon(CoreSysAttributes):
return False 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 @property
def schema(self): def schema(self):
"""Create a schema for addon options.""" """Create a schema for addon options."""
@ -613,18 +636,24 @@ class Addon(CoreSysAttributes):
@check_installed @check_installed
async def start(self): async def start(self):
"""Set options and start addon.""" """Set options and start addon."""
# Options
if not self.write_options(): if not self.write_options():
return False return False
# Sound
if self.with_audio and not self.write_asound():
return False
return await self.instance.run() return await self.instance.run()
@check_installed @check_installed
def stop(self): async def stop(self):
"""Stop addon. """Stop addon."""
try:
Return a coroutine. return self.instance.stop()
""" finally:
return self.instance.stop() with suppress(OSError):
self.path_asound.unlink()
@check_installed @check_installed
async def update(self): async def update(self):

View File

@ -7,6 +7,7 @@ from pathlib import Path
from colorlog import ColoredFormatter from colorlog import ColoredFormatter
from .audio import AlsaAudio
from .addons import AddonManager from .addons import AddonManager
from .api import RestAPI from .api import RestAPI
from .const import SOCKET_DOCKER from .const import SOCKET_DOCKER
@ -28,6 +29,7 @@ def initialize_coresys(loop):
# Initialize core objects # Initialize core objects
coresys.updater = Updater(coresys) coresys.updater = Updater(coresys)
coresys.api = RestAPI(coresys) coresys.api = RestAPI(coresys)
coresys.audio = AlsaAudio(coresys)
coresys.supervisor = Supervisor(coresys) coresys.supervisor = Supervisor(coresys)
coresys.homeassistant = HomeAssistant(coresys) coresys.homeassistant = HomeAssistant(coresys)
coresys.addons = AddonManager(coresys) coresys.addons = AddonManager(coresys)

View File

@ -6,7 +6,7 @@ from pathlib import Path, PurePath
from .const import ( from .const import (
FILE_HASSIO_CONFIG, HASSIO_DATA, ATTR_TIMEZONE, ATTR_ADDONS_CUSTOM_LIST, 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.dt import parse_datetime
from .utils.json import JsonConfig from .utils.json import JsonConfig
from .validate import SCHEMA_HASSIO_CONFIG from .validate import SCHEMA_HASSIO_CONFIG
@ -136,6 +136,11 @@ class CoreConfig(JsonConfig):
"""Return hass.io temp folder.""" """Return hass.io temp folder."""
return Path(HASSIO_DATA, TMP_DATA) 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 @property
def path_backup(self): def path_backup(self):
"""Return root backup data folder.""" """Return root backup data folder."""
@ -174,23 +179,3 @@ class CoreConfig(JsonConfig):
return return
self._data[ATTR_ADDONS_CUSTOM_LIST].remove(repo) 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

View File

@ -27,6 +27,7 @@ DOCKER_NETWORK_RANGE = ip_network('172.30.33.0/24')
LABEL_VERSION = 'io.hass.version' LABEL_VERSION = 'io.hass.version'
LABEL_ARCH = 'io.hass.arch' LABEL_ARCH = 'io.hass.arch'
LABEL_TYPE = 'io.hass.type' LABEL_TYPE = 'io.hass.type'
LABEL_MACHINE = 'io.hass.machine'
META_ADDON = 'addon' META_ADDON = 'addon'
META_SUPERVISOR = 'supervisor' META_SUPERVISOR = 'supervisor'
@ -161,6 +162,8 @@ ATTR_CRYPTO = 'crypto'
ATTR_BRANCH = 'branch' ATTR_BRANCH = 'branch'
ATTR_SECCOMP = 'seccomp' ATTR_SECCOMP = 'seccomp'
ATTR_APPARMOR = 'apparmor' ATTR_APPARMOR = 'apparmor'
ATTR_CACHE = 'cache'
ATTR_DEFAULT = 'default'
SERVICE_MQTT = 'mqtt' SERVICE_MQTT = 'mqtt'

View File

@ -42,6 +42,7 @@ class CoreSys(object):
self._snapshots = None self._snapshots = None
self._tasks = None self._tasks = None
self._services = None self._services = None
self._audio = None
@property @property
def arch(self): def arch(self):
@ -50,6 +51,13 @@ class CoreSys(object):
return self._supervisor.arch return self._supervisor.arch
return None return None
@property
def machine(self):
"""Return running machine type of hass.io system."""
if self._homeassistant:
return self._homeassistant.machine
return None
@property @property
def dev(self): def dev(self):
"""Return True if we run dev modus.""" """Return True if we run dev modus."""
@ -196,6 +204,18 @@ class CoreSys(object):
raise RuntimeError("Services already set!") raise RuntimeError("Services already set!")
self._services = value 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): class CoreSysAttributes(object):
"""Inheret basic CoreSysAttributes.""" """Inheret basic CoreSysAttributes."""

View File

@ -201,7 +201,7 @@ class DockerAddon(DockerInterface):
'bind': "/share", 'mode': addon_mapping[MAP_SHARE] 'bind': "/share", 'mode': addon_mapping[MAP_SHARE]
}}) }})
# init other hardware mappings # Init other hardware mappings
if self.addon.with_gpio: if self.addon.with_gpio:
volumes.update({ volumes.update({
"/sys/class/gpio": { "/sys/class/gpio": {
@ -212,13 +212,20 @@ class DockerAddon(DockerInterface):
}, },
}) })
# host dbus system # Host dbus system
if self.addon.host_dbus: if self.addon.host_dbus:
volumes.update({ volumes.update({
"/var/run/dbus": { "/var/run/dbus": {
'bind': "/var/run/dbus", 'mode': 'rw' '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 return volumes
def _run(self): def _run(self):

View File

@ -4,7 +4,7 @@ import logging
import docker import docker
from .interface import DockerInterface from .interface import DockerInterface
from ..const import ENV_TOKEN, ENV_TIME from ..const import ENV_TOKEN, ENV_TIME, LABEL_MACHINE
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -14,6 +14,13 @@ HASS_DOCKER_NAME = 'homeassistant'
class DockerHomeAssistant(DockerInterface): class DockerHomeAssistant(DockerInterface):
"""Docker hassio wrapper for HomeAssistant.""" """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 @property
def image(self): def image(self):
"""Return name of docker image.""" """Return name of docker image."""

View File

@ -45,6 +45,11 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
_LOGGER.info("No HomeAssistant docker %s found.", self.image) _LOGGER.info("No HomeAssistant docker %s found.", self.image)
await self.install_landingpage() await self.install_landingpage()
@property
def machine(self):
"""Return System Machines."""
return self._docker.machine
@property @property
def api_ip(self): def api_ip(self):
"""Return IP of HomeAssistant instance.""" """Return IP of HomeAssistant instance."""

1
hassio/host/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Host function like audio/dbus/systemd."""

17
hassio/host/asound.tmpl Normal file
View File

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

114
hassio/host/audio.py Normal file
View File

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

18
hassio/host/audiodb.json Normal file
View File

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