Merge pull request #1544 from home-assistant/dev

Release 204
This commit is contained in:
Pascal Vizeli 2020-02-29 00:30:16 +01:00 committed by GitHub
commit 14167f6e13
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 307 additions and 26 deletions

84
API.md
View File

@ -866,6 +866,7 @@ return:
"card": [
{
"name": "...",
"index": 1,
"driver": "...",
"profiles": [
{
@ -879,17 +880,56 @@ return:
"input": [
{
"name": "...",
"index": 0,
"description": "...",
"volume": 0.3,
"default": false
"mute": false,
"default": false,
"card": "null|int",
"applications": [
{
"name": "...",
"index": 0,
"stream_index": 0,
"stream_type": "INPUT",
"volume": 0.3,
"mute": false,
"addon": ""
}
]
}
],
"output": [
{
"name": "...",
"index": 0,
"description": "...",
"volume": 0.3,
"default": false
"mute": false,
"default": false,
"card": "null|int",
"applications": [
{
"name": "...",
"index": 0,
"stream_index": 0,
"stream_type": "OUTPUT",
"volume": 0.3,
"mute": false,
"addon": ""
}
]
}
],
"application": [
{
"name": "...",
"index": 0,
"stream_index": 0,
"stream_type": "OUTPUT",
"volume": 0.3,
"mute": false,
"addon": ""
}
]
}
@ -914,7 +954,7 @@ return:
```json
{
"name": "...",
"index": "...",
"volume": 0.5
}
```
@ -923,11 +963,47 @@ return:
```json
{
"name": "...",
"index": "...",
"volume": 0.5
}
```
- POST `/audio/volume/{output|input}/application`
```json
{
"index": "...",
"volume": 0.5
}
```
- POST `/audio/mute/input`
```json
{
"index": "...",
"active": false
}
```
- POST `/audio/mute/output`
```json
{
"index": "...",
"active": false
}
```
- POST `/audio/mute/{output|input}/application`
```json
{
"index": "...",
"active": false
}
```
- POST `/audio/default/input`
```json

View File

@ -63,6 +63,8 @@ RE_WEBUI = re.compile(
r":\/\/\[HOST\]:\[PORT:(?P<t_port>\d+)\](?P<s_suffix>.*)$"
)
RE_OLD_AUDIO = re.compile(r"\d+,\d+")
class Addon(AddonModel):
"""Hold data for add-on inside Supervisor."""
@ -282,7 +284,13 @@ class Addon(AddonModel):
"""Return a pulse profile for output or None."""
if not self.with_audio:
return None
return self.persist.get(ATTR_AUDIO_OUTPUT)
# Fallback with old audio settings
# Remove after 210
output_data = self.persist.get(ATTR_AUDIO_OUTPUT)
if output_data and RE_OLD_AUDIO.fullmatch(output_data):
return None
return output_data
@audio_output.setter
def audio_output(self, value: Optional[str]):
@ -297,7 +305,13 @@ class Addon(AddonModel):
"""Return pulse profile for input or None."""
if not self.with_audio:
return None
return self.persist.get(ATTR_AUDIO_INPUT)
# Fallback with old audio settings
# Remove after 210
input_data = self.persist.get(ATTR_AUDIO_INPUT)
if input_data and RE_OLD_AUDIO.fullmatch(input_data):
return None
return input_data
@audio_input.setter
def audio_input(self, value: Optional[str]):

View File

@ -31,6 +31,7 @@ from ..const import (
ATTR_HOST_PID,
ATTR_IMAGE,
ATTR_INGRESS,
ATTR_INIT,
ATTR_KERNEL_MODULES,
ATTR_LEGACY,
ATTR_LOCATON,
@ -344,6 +345,11 @@ class AddonModel(CoreSysAttributes):
"""Return Exclude list for snapshot."""
return self.data.get(ATTR_SNAPSHOT_EXCLUDE, [])
@property
def default_init(self) -> bool:
"""Return True if the add-on have no own init."""
return self.data[ATTR_INIT]
@property
def with_stdin(self) -> bool:
"""Return True if the add-on access use stdin input."""

View File

@ -44,6 +44,7 @@ from ..const import (
ATTR_INGRESS_PANEL,
ATTR_INGRESS_PORT,
ATTR_INGRESS_TOKEN,
ATTR_INIT,
ATTR_KERNEL_MODULES,
ATTR_LEGACY,
ATTR_LOCATON,
@ -189,6 +190,7 @@ SCHEMA_ADDON_CONFIG = vol.Schema(
vol.Optional(ATTR_URL): vol.Url(),
vol.Required(ATTR_STARTUP): vol.All(_simple_startup, vol.In(STARTUP_ALL)),
vol.Required(ATTR_BOOT): vol.In([BOOT_AUTO, BOOT_MANUAL]),
vol.Optional(ATTR_INIT, default=True): vol.Boolean(),
vol.Optional(ATTR_ADVANCED, default=False): vol.Boolean(),
vol.Optional(ATTR_STAGE, default=AddonStages.STABLE): vol.Coerce(AddonStages),
vol.Optional(ATTR_PORTS): DOCKER_PORTS,

View File

@ -330,7 +330,10 @@ class RestAPI(CoreSysAttributes):
web.post("/audio/restart", api_audio.restart),
web.post("/audio/reload", api_audio.reload),
web.post("/audio/profile", api_audio.set_profile),
web.post("/audio/volume/{source}/application", api_audio.set_volume),
web.post("/audio/volume/{source}", api_audio.set_volume),
web.post("/audio/mute/{source}/application", api_audio.set_mute),
web.post("/audio/mute/{source}", api_audio.set_mute),
web.post("/audio/default/{source}", api_audio.set_default),
]
)

View File

@ -8,12 +8,15 @@ import attr
import voluptuous as vol
from ..const import (
ATTR_ACTIVE,
ATTR_APPLICATION,
ATTR_AUDIO,
ATTR_BLK_READ,
ATTR_BLK_WRITE,
ATTR_CARD,
ATTR_CPU_PERCENT,
ATTR_HOST,
ATTR_INDEX,
ATTR_INPUT,
ATTR_LATEST_VERSION,
ATTR_MEMORY_LIMIT,
@ -38,11 +41,19 @@ SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): vol.Coerce(str)})
SCHEMA_VOLUME = vol.Schema(
{
vol.Required(ATTR_NAME): vol.Coerce(str),
vol.Required(ATTR_INDEX): vol.Coerce(int),
vol.Required(ATTR_VOLUME): vol.Coerce(float),
}
)
# pylint: disable=no-value-for-parameter
SCHEMA_MUTE = vol.Schema(
{
vol.Required(ATTR_INDEX): vol.Coerce(int),
vol.Required(ATTR_ACTIVE): vol.Boolean(),
}
)
SCHEMA_DEFAULT = vol.Schema({vol.Required(ATTR_NAME): vol.Coerce(str)})
SCHEMA_PROFILE = vol.Schema(
@ -68,6 +79,9 @@ class APIAudio(CoreSysAttributes):
ATTR_OUTPUT: [
attr.asdict(stream) for stream in self.sys_host.sound.outputs
],
ATTR_APPLICATION: [
attr.asdict(stream) for stream in self.sys_host.sound.applications
],
},
}
@ -116,10 +130,26 @@ class APIAudio(CoreSysAttributes):
async def set_volume(self, request: web.Request) -> None:
"""Set audio volume on stream."""
source: StreamType = StreamType(request.match_info.get("source"))
application: bool = request.path.endswith("application")
body = await api_validate(SCHEMA_VOLUME, request)
await asyncio.shield(
self.sys_host.sound.set_volume(source, body[ATTR_NAME], body[ATTR_VOLUME])
self.sys_host.sound.set_volume(
source, body[ATTR_INDEX], body[ATTR_VOLUME], application
)
)
@api_process
async def set_mute(self, request: web.Request) -> None:
"""Mute audio volume on stream."""
source: StreamType = StreamType(request.match_info.get("source"))
application: bool = request.path.endswith("application")
body = await api_validate(SCHEMA_MUTE, request)
await asyncio.shield(
self.sys_host.sound.set_mute(
source, body[ATTR_INDEX], body[ATTR_ACTIVE], application
)
)
@api_process

View File

@ -3,12 +3,12 @@ import asyncio
from contextlib import suppress
import logging
from pathlib import Path
from typing import Awaitable, Optional
import shutil
from typing import Awaitable, List, Optional
import jinja2
from .const import ATTR_VERSION, FILE_HASSIO_AUDIO
from .const import ATTR_VERSION, FILE_HASSIO_AUDIO, STATE_STARTED
from .coresys import CoreSys, CoreSysAttributes
from .docker.audio import DockerAudio
from .docker.stats import DockerStats
@ -157,6 +157,8 @@ class Audio(JsonConfig, CoreSysAttributes):
_LOGGER.error("Can't start Audio plugin")
raise AudioError() from None
await self._restart_audio_addons()
async def start(self) -> None:
"""Run CoreDNS."""
# Start Instance
@ -167,6 +169,8 @@ class Audio(JsonConfig, CoreSysAttributes):
_LOGGER.error("Can't start Audio plugin")
raise AudioError() from None
await self._restart_audio_addons()
def logs(self) -> Awaitable[bytes]:
"""Get CoreDNS docker logs.
@ -217,3 +221,22 @@ class Audio(JsonConfig, CoreSysAttributes):
default_source=input_profile,
default_sink=output_profile,
)
async def _restart_audio_addons(self):
"""Restart all Add-ons they can be connect to unix socket."""
tasks: List[Awaitable[None]] = []
# Find all Add-ons running with audio
for addon in self.sys_addons.installed:
if not addon.with_audio:
continue
if await addon.state() != STATE_STARTED:
continue
tasks.append(addon.restart())
if not tasks:
return
# restart
_LOGGER.info("Restart all Add-ons attach to pulse server: %d", len(tasks))
await asyncio.wait(tasks)

View File

@ -3,7 +3,7 @@ from enum import Enum
from ipaddress import ip_network
from pathlib import Path
SUPERVISOR_VERSION = "203"
SUPERVISOR_VERSION = "204"
URL_HASSIO_ADDONS = "https://github.com/home-assistant/hassio-addons"
@ -234,6 +234,10 @@ ATTR_CLI = "cli"
ATTR_DEFAULT = "default"
ATTR_VOLUME = "volume"
ATTR_CARD = "card"
ATTR_INDEX = "index"
ATTR_ACTIVE = "active"
ATTR_APPLICATION = "application"
ATTR_INIT = "init"
PROVIDE_SERVICE = "provide"
NEED_SERVICE = "need"

View File

@ -344,7 +344,7 @@ class DockerAddon(DockerInterface):
name=self.name,
hostname=self.addon.hostname,
detach=True,
init=True,
init=self.addon.default_init,
privileged=self.full_access,
ipc_mode=self.ipc,
stdin_open=self.addon.with_stdin,

View File

@ -41,6 +41,7 @@ class DockerAudio(DockerInterface, CoreSysAttributes):
docker_container = self.sys_docker.run(
self.image,
version=self.sys_audio.version,
init=False,
ipv4=self.sys_docker.network.audio,
name=self.name,
hostname=self.name.replace("_", "-"),

View File

@ -41,6 +41,7 @@ class DockerDNS(DockerInterface, CoreSysAttributes):
docker_container = self.sys_docker.run(
self.image,
version=self.sys_dns.version,
init=False,
dns=False,
ipv4=self.sys_docker.network.dns,
name=self.name,

View File

@ -2,7 +2,7 @@
from datetime import timedelta
from enum import Enum
import logging
from typing import List
from typing import List, Optional
import attr
from pulsectl import Pulse, PulseError, PulseIndexError, PulseOperationFailed
@ -24,13 +24,30 @@ class StreamType(str, Enum):
@attr.s(frozen=True)
class AudioStream:
"""Represent a input/output profile."""
class AudioApplication:
"""Represent a application on the stream."""
name: str = attr.ib()
index: int = attr.ib()
stream_index: str = attr.ib()
stream_type: StreamType = attr.ib()
volume: float = attr.ib()
mute: bool = attr.ib()
addon: str = attr.ib()
@attr.s(frozen=True)
class AudioStream:
"""Represent a input/output stream."""
name: str = attr.ib()
index: int = attr.ib()
description: str = attr.ib()
volume: float = attr.ib()
mute: bool = attr.ib()
default: bool = attr.ib()
card: Optional[int] = attr.ib()
applications: List[AudioApplication] = attr.ib()
@attr.s(frozen=True)
@ -47,6 +64,7 @@ class SoundCard:
"""Represent a Sound Card."""
name: str = attr.ib()
index: int = attr.ib()
driver: str = attr.ib()
profiles: List[SoundProfile] = attr.ib()
@ -60,6 +78,7 @@ class SoundControl(CoreSysAttributes):
self._cards: List[SoundCard] = []
self._inputs: List[AudioStream] = []
self._outputs: List[AudioStream] = []
self._applications: List[AudioApplication] = []
@property
def cards(self) -> List[SoundCard]:
@ -76,6 +95,11 @@ class SoundControl(CoreSysAttributes):
"""Return a list of available output streams."""
return self._outputs
@property
def applications(self) -> List[AudioApplication]:
"""Return a list of available application streams."""
return self._applications
async def set_default(self, stream_type: StreamType, name: str) -> None:
"""Set a stream to default input/output."""
@ -90,11 +114,12 @@ class SoundControl(CoreSysAttributes):
# Get sink and set it as default
sink = pulse.get_sink_by_name(name)
pulse.sink_default_set(sink)
except PulseIndexError:
_LOGGER.error("Can't find %s profile %s", source, name)
_LOGGER.error("Can't find %s stream %s", source, name)
raise PulseAudioError() from None
except PulseError as err:
_LOGGER.error("Can't set %s as default: %s", name, err)
_LOGGER.error("Can't set %s as stream: %s", name, err)
raise PulseAudioError() from None
# Run and Reload data
@ -102,32 +127,73 @@ class SoundControl(CoreSysAttributes):
await self.update()
async def set_volume(
self, stream_type: StreamType, name: str, volume: float
self, stream_type: StreamType, index: int, volume: float, application: bool
) -> None:
"""Set a stream to volume input/output."""
"""Set a stream to volume input/output/application."""
def _set_volume():
try:
with Pulse(PULSE_NAME) as pulse:
if stream_type == StreamType.INPUT:
# Get source and set it as default
stream = pulse.get_source_by_name(name)
if application:
stream = pulse.source_output_info(index)
else:
# Get sink and set it as default
stream = pulse.get_sink_by_name(name)
stream = pulse.source_info(index)
else:
if application:
stream = pulse.sink_input_info(index)
else:
stream = pulse.sink_info(index)
# Set volume
pulse.volume_set_all_chans(stream, volume)
except PulseIndexError:
_LOGGER.error("Can't find %s profile %s", stream_type, name)
_LOGGER.error(
"Can't find %s stream %d (App: %s)", stream_type, index, application
)
raise PulseAudioError() from None
except PulseError as err:
_LOGGER.error("Can't set %s volume: %s", name, err)
_LOGGER.error("Can't set %d volume: %s", index, err)
raise PulseAudioError() from None
# Run and Reload data
await self.sys_run_in_executor(_set_volume)
await self.update()
async def set_mute(
self, stream_type: StreamType, index: int, mute: bool, application: bool
) -> None:
"""Set a stream to mute input/output/application."""
def _set_mute():
try:
with Pulse(PULSE_NAME) as pulse:
if stream_type == StreamType.INPUT:
if application:
stream = pulse.source_output_info(index)
else:
stream = pulse.source_info(index)
else:
if application:
stream = pulse.sink_input_info(index)
else:
stream = pulse.sink_info(index)
# Mute stream
pulse.mute(stream, mute)
except PulseIndexError:
_LOGGER.error(
"Can't find %s stream %d (App: %s)", stream_type, index, application
)
raise PulseAudioError() from None
except PulseError as err:
_LOGGER.error("Can't set %d volume: %s", index, err)
raise PulseAudioError() from None
# Run and Reload data
await self.sys_run_in_executor(_set_mute)
await self.update()
async def ativate_profile(self, card_name: str, profile_name: str) -> None:
"""Set a profile to volume input/output."""
@ -160,15 +226,59 @@ class SoundControl(CoreSysAttributes):
with Pulse(PULSE_NAME) as pulse:
server = pulse.server_info()
# Update applications
self._applications.clear()
for application in pulse.sink_input_list():
self._applications.append(
AudioApplication(
application.proplist.get(
"application.name", application.name
),
application.index,
application.sink,
StreamType.OUTPUT,
application.volume.value_flat,
bool(application.mute),
application.proplist.get(
"application.process.machine_id", ""
).replace("-", "_"),
)
)
for application in pulse.source_output_list():
self._applications.append(
AudioApplication(
application.proplist.get(
"application.name", application.name
),
application.index,
application.source,
StreamType.INPUT,
application.volume.value_flat,
bool(application.mute),
application.proplist.get(
"application.process.machine_id", ""
).replace("-", "_"),
)
)
# Update output
self._outputs.clear()
for sink in pulse.sink_list():
self._outputs.append(
AudioStream(
sink.name,
sink.index,
sink.description,
sink.volume.value_flat,
bool(sink.mute),
sink.name == server.default_sink_name,
sink.card if sink.card != 0xFFFFFFFF else None,
[
application
for application in self._applications
if application.stream_index == sink.index
and application.stream_type == StreamType.OUTPUT
],
)
)
@ -181,9 +291,18 @@ class SoundControl(CoreSysAttributes):
self._inputs.append(
AudioStream(
source.name,
source.index,
source.description,
source.volume.value_flat,
bool(source.mute),
source.name == server.default_source_name,
source.card if source.card != 0xFFFFFFFF else None,
[
application
for application in self._applications
if application.stream_index == source.index
and application.stream_type == StreamType.INPUT
],
)
)
@ -205,7 +324,9 @@ class SoundControl(CoreSysAttributes):
)
self._cards.append(
SoundCard(card.name, card.driver, sound_profiles)
SoundCard(
card.name, card.index, card.driver, sound_profiles
)
)
except PulseOperationFailed as err: