Merge pull request #441 from home-assistant/new_audio_system

Extend Audio support
This commit is contained in:
Pascal Vizeli 2018-04-14 00:44:37 +02:00 committed by GitHub
commit 35b3f364c9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 348 additions and 92 deletions

30
API.md
View File

@ -236,15 +236,6 @@ return:
}
```
- POST `/host/options`
```json
{
"audio_input": "0,0",
"audio_output": "0,0"
}
```
- POST `/host/update`
Optional:
@ -255,7 +246,11 @@ Optional:
}
```
- GET `/host/hardware`
- POST `/host/reload`
### Hardware
- GET `/hardware/info`
```json
{
"serial": ["/dev/xy"],
@ -274,7 +269,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._alsa.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._alsa.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._alsa.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."""
@ -598,6 +621,11 @@ class Addon(CoreSysAttributes):
"Remove Home-Assistant addon data folder %s", self.path_data)
shutil.rmtree(str(self.path_data))
# Cleanup audio settings
if self.path_asound.exists():
with suppress(OSError):
self.path_asound.unlink()
self._set_uninstall()
return True
@ -613,9 +641,14 @@ 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

View File

@ -19,7 +19,7 @@ from ..const import (
ATTR_ARGS, ATTR_GPIO, ATTR_HOMEASSISTANT_API, ATTR_STDIN, ATTR_LEGACY,
ATTR_HOST_DBUS, ATTR_AUTO_UART, ATTR_SERVICES, ATTR_DISCOVERY,
ATTR_SECCOMP, ATTR_APPARMOR)
from ..validate import NETWORK_PORT, DOCKER_PORTS, ALSA_CHANNEL
from ..validate import NETWORK_PORT, DOCKER_PORTS, ALSA_DEVICE
_LOGGER = logging.getLogger(__name__)
@ -165,8 +165,8 @@ SCHEMA_ADDON_USER = vol.Schema({
vol.Optional(ATTR_BOOT):
vol.In([BOOT_AUTO, BOOT_MANUAL]),
vol.Optional(ATTR_NETWORK): DOCKER_PORTS,
vol.Optional(ATTR_AUDIO_OUTPUT): ALSA_CHANNEL,
vol.Optional(ATTR_AUDIO_INPUT): ALSA_CHANNEL,
vol.Optional(ATTR_AUDIO_OUTPUT): ALSA_DEVICE,
vol.Optional(ATTR_AUDIO_INPUT): ALSA_DEVICE,
}, extra=vol.REMOVE_EXTRA)

View File

@ -7,6 +7,7 @@ from aiohttp import web
from .addons import APIAddons
from .discovery import APIDiscovery
from .homeassistant import APIHomeAssistant
from .hardware import APIHardware
from .host import APIHost
from .network import APINetwork
from .proxy import APIProxy
@ -37,6 +38,7 @@ class RestAPI(CoreSysAttributes):
"""Register REST API Calls."""
self._register_supervisor()
self._register_host()
self._register_hardware()
self._register_homeassistant()
self._register_proxy()
self._register_panel()
@ -53,11 +55,9 @@ class RestAPI(CoreSysAttributes):
self.webapp.add_routes([
web.get('/host/info', api_host.info),
web.get('/host/hardware', api_host.hardware),
web.post('/host/reboot', api_host.reboot),
web.post('/host/shutdown', api_host.shutdown),
web.post('/host/update', api_host.update),
web.post('/host/options', api_host.options),
web.post('/host/reload', api_host.reload),
])
@ -71,6 +71,16 @@ class RestAPI(CoreSysAttributes):
web.post('/network/options', api_net.options),
])
def _register_hardware(self):
"""Register hardware function."""
api_hardware = APIHardware()
api_hardware.coresys = self.coresys
self.webapp.add_routes([
web.get('/hardware/info', api_hardware.info),
web.get('/hardware/audio', api_hardware.audio),
])
def _register_supervisor(self):
"""Register supervisor function."""
api_supervisor = APISupervisor()

View File

@ -20,7 +20,7 @@ from ..const import (
ATTR_DISCOVERY, ATTR_SECCOMP, ATTR_APPARMOR,
CONTENT_TYPE_PNG, CONTENT_TYPE_BINARY, CONTENT_TYPE_TEXT)
from ..coresys import CoreSysAttributes
from ..validate import DOCKER_PORTS
from ..validate import DOCKER_PORTS, ALSA_DEVICE
_LOGGER = logging.getLogger(__name__)
@ -33,6 +33,8 @@ 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_AUTO_UPDATE): vol.Boolean(),
vol.Optional(ATTR_AUDIO_OUTPUT): ALSA_DEVICE,
vol.Optional(ATTR_AUDIO_INPUT): ALSA_DEVICE,
})

34
hassio/api/hardware.py Normal file
View File

@ -0,0 +1,34 @@
"""Init file for HassIO hardware rest api."""
import logging
from .utils import api_process
from ..const import (
ATTR_SERIAL, ATTR_DISK, ATTR_GPIO, ATTR_AUDIO, ATTR_INPUT, ATTR_OUTPUT)
from ..coresys import CoreSysAttributes
_LOGGER = logging.getLogger(__name__)
class APIHardware(CoreSysAttributes):
"""Handle rest api for hardware functions."""
@api_process
async def info(self, request):
"""Show hardware info."""
return {
ATTR_SERIAL: list(self._hardware.serial_devices),
ATTR_INPUT: list(self._hardware.input_devices),
ATTR_DISK: list(self._hardware.disk_devices),
ATTR_GPIO: list(self._hardware.gpio_devices),
ATTR_AUDIO: self._hardware.audio_devices,
}
@api_process
async def audio(self, request):
"""Show ALSA audio devices."""
return {
ATTR_AUDIO: {
ATTR_INPUT: self._alsa.input_devices,
ATTR_OUTPUT: self._alsa.output_devices,
}
}

View File

@ -7,10 +7,8 @@ import voluptuous as vol
from .utils import api_process_hostcontrol, api_process, api_validate
from ..const import (
ATTR_VERSION, ATTR_LAST_VERSION, ATTR_TYPE, ATTR_HOSTNAME, ATTR_FEATURES,
ATTR_OS, ATTR_SERIAL, ATTR_INPUT, ATTR_DISK, ATTR_AUDIO, ATTR_AUDIO_INPUT,
ATTR_AUDIO_OUTPUT, ATTR_GPIO)
ATTR_OS)
from ..coresys import CoreSysAttributes
from ..validate import ALSA_CHANNEL
_LOGGER = logging.getLogger(__name__)
@ -18,11 +16,6 @@ SCHEMA_VERSION = vol.Schema({
vol.Optional(ATTR_VERSION): vol.Coerce(str),
})
SCHEMA_OPTIONS = vol.Schema({
vol.Optional(ATTR_AUDIO_OUTPUT): ALSA_CHANNEL,
vol.Optional(ATTR_AUDIO_INPUT): ALSA_CHANNEL,
})
class APIHost(CoreSysAttributes):
"""Handle rest api for host functions."""
@ -39,19 +32,6 @@ class APIHost(CoreSysAttributes):
ATTR_OS: self._host_control.os_info,
}
@api_process
async def options(self, request):
"""Process host options."""
body = await api_validate(SCHEMA_OPTIONS, request)
if ATTR_AUDIO_OUTPUT in body:
self._config.audio_output = body[ATTR_AUDIO_OUTPUT]
if ATTR_AUDIO_INPUT in body:
self._config.audio_input = body[ATTR_AUDIO_INPUT]
self._config.save_data()
return True
@api_process_hostcontrol
def reboot(self, request):
"""Reboot host."""
@ -79,14 +59,3 @@ class APIHost(CoreSysAttributes):
return await asyncio.shield(
self._host_control.update(version=version), loop=self._loop)
@api_process
async def hardware(self, request):
"""Return local hardware infos."""
return {
ATTR_SERIAL: list(self._hardware.serial_devices),
ATTR_INPUT: list(self._hardware.input_devices),
ATTR_DISK: list(self._hardware.disk_devices),
ATTR_GPIO: list(self._hardware.gpio_devices),
ATTR_AUDIO: self._hardware.audio_devices,
}

View File

@ -17,6 +17,7 @@ from .snapshots import SnapshotManager
from .tasks import Tasks
from .updater import Updater
from .services import ServiceManager
from .host import AlsaAudio
_LOGGER = logging.getLogger(__name__)
@ -28,6 +29,7 @@ def initialize_coresys(loop):
# Initialize core objects
coresys.updater = Updater(coresys)
coresys.api = RestAPI(coresys)
coresys.alsa = 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'

View File

@ -42,6 +42,7 @@ class CoreSys(object):
self._snapshots = None
self._tasks = None
self._services = None
self._alsa = 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 alsa(self):
"""Return ALSA Audio object."""
return self._alsa
@alsa.setter
def alsa(self, value):
"""Set a ALSA Audio object."""
if self._alsa:
raise RuntimeError("ALSA already set!")
self._alsa = value
class CoreSysAttributes(object):
"""Inheret basic CoreSysAttributes."""

View File

@ -1,8 +1,8 @@
"""Init file for HassIO docker object."""
from contextlib import suppress
from collections import namedtuple
import logging
import attr
import docker
from .network import DockerNetwork
@ -10,7 +10,8 @@ from ..const import SOCKET_DOCKER
_LOGGER = logging.getLogger(__name__)
CommandReturn = namedtuple('CommandReturn', ['exit_code', 'output'])
# pylint: disable=invalid-name
CommandReturn = attr.make_class('CommandReturn', ['exit_code', 'output'])
class DockerAPI(object):

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

@ -1,6 +1,5 @@
"""HomeAssistant control object."""
import asyncio
from collections import namedtuple
import logging
import os
import re
@ -9,6 +8,7 @@ import time
import aiohttp
from aiohttp.hdrs import CONTENT_TYPE
import attr
from .const import (
FILE_HASSIO_HOMEASSISTANT, ATTR_IMAGE, ATTR_LAST_VERSION, ATTR_UUID,
@ -24,7 +24,8 @@ _LOGGER = logging.getLogger(__name__)
RE_YAML_ERROR = re.compile(r"homeassistant\.util\.yaml")
ConfigResult = namedtuple('ConfigResult', ['valid', 'log'])
# pylint: disable=invalid-name
ConfigResult = attr.make_class('ConfigResult', ['valid', 'log'])
class HomeAssistant(JsonConfig, CoreSysAttributes):
@ -45,6 +46,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.instance.machine
@property
def api_ip(self):
"""Return IP of HomeAssistant instance."""

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

@ -0,0 +1,2 @@
"""Host function like audio/dbus/systemd."""
from .alsa import AlsaAudio # noqa

137
hassio/host/alsa.py Normal file
View File

@ -0,0 +1,137 @@
"""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
from ..coresys import CoreSysAttributes
_LOGGER = logging.getLogger(__name__)
# pylint: disable=invalid-name
DefaultConfig = attr.make_class('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_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._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._hardware.audio_devices.items():
for chan_id, chan_type in dev_data[ATTR_DEVICES].items():
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._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."""
json_file = Path(__file__).parent.joinpath('audiodb.json')
try:
# pylint: disable=no-member
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."""
# Init defaults
if self._default is None:
database = self._audio_database()
alsa_input = database.get(self._machine, {}).get(ATTR_INPUT)
alsa_output = database.get(self._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 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:
# pylint: disable=no-member
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
)

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

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

@ -0,0 +1,18 @@
{
"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

@ -7,18 +7,17 @@ import pytz
from .const import (
ATTR_IMAGE, ATTR_LAST_VERSION, ATTR_CHANNEL, ATTR_TIMEZONE,
ATTR_ADDONS_CUSTOM_LIST, ATTR_AUDIO_OUTPUT, ATTR_AUDIO_INPUT,
ATTR_PASSWORD, ATTR_HOMEASSISTANT, ATTR_HASSIO, ATTR_BOOT, ATTR_LAST_BOOT,
ATTR_SSL, ATTR_PORT, ATTR_WATCHDOG, ATTR_WAIT_BOOT, ATTR_UUID,
CHANNEL_STABLE, CHANNEL_BETA, CHANNEL_DEV)
ATTR_ADDONS_CUSTOM_LIST, ATTR_PASSWORD, ATTR_HOMEASSISTANT, ATTR_HASSIO,
ATTR_BOOT, ATTR_LAST_BOOT, ATTR_SSL, ATTR_PORT, ATTR_WATCHDOG,
ATTR_WAIT_BOOT, ATTR_UUID, CHANNEL_STABLE, CHANNEL_BETA, CHANNEL_DEV)
RE_REPOSITORY = re.compile(r"^(?P<url>[^#]+)(?:#(?P<branch>[\w\-]+))?$")
NETWORK_PORT = vol.All(vol.Coerce(int), vol.Range(min=1, max=65535))
ALSA_CHANNEL = vol.Match(r"\d+,\d+")
WAIT_BOOT = vol.All(vol.Coerce(int), vol.Range(min=1, max=60))
DOCKER_IMAGE = vol.Match(r"^[\w{}]+/[\-\w{}]+$")
ALSA_DEVICE = vol.Any(None, vol.Match(r"\d+,\d+"))
CHANNELS = vol.In([CHANNEL_STABLE, CHANNEL_BETA, CHANNEL_DEV])
@ -110,7 +109,5 @@ SCHEMA_HASSIO_CONFIG = vol.Schema({
vol.Optional(ATTR_ADDONS_CUSTOM_LIST, default=[
"https://github.com/hassio-addons/repository",
]): REPOSITORIES,
vol.Optional(ATTR_AUDIO_OUTPUT): ALSA_CHANNEL,
vol.Optional(ATTR_AUDIO_INPUT): ALSA_CHANNEL,
vol.Optional(ATTR_WAIT_BOOT, default=5): WAIT_BOOT,
}, extra=vol.REMOVE_EXTRA)