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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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