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": [ "card": [
{ {
"name": "...", "name": "...",
"index": 1,
"driver": "...", "driver": "...",
"profiles": [ "profiles": [
{ {
@ -879,17 +880,56 @@ return:
"input": [ "input": [
{ {
"name": "...", "name": "...",
"index": 0,
"description": "...", "description": "...",
"volume": 0.3, "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": [ "output": [
{ {
"name": "...", "name": "...",
"index": 0,
"description": "...", "description": "...",
"volume": 0.3, "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 ```json
{ {
"name": "...", "index": "...",
"volume": 0.5 "volume": 0.5
} }
``` ```
@ -923,11 +963,47 @@ return:
```json ```json
{ {
"name": "...", "index": "...",
"volume": 0.5 "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` - POST `/audio/default/input`
```json ```json

View File

@ -63,6 +63,8 @@ RE_WEBUI = re.compile(
r":\/\/\[HOST\]:\[PORT:(?P<t_port>\d+)\](?P<s_suffix>.*)$" r":\/\/\[HOST\]:\[PORT:(?P<t_port>\d+)\](?P<s_suffix>.*)$"
) )
RE_OLD_AUDIO = re.compile(r"\d+,\d+")
class Addon(AddonModel): class Addon(AddonModel):
"""Hold data for add-on inside Supervisor.""" """Hold data for add-on inside Supervisor."""
@ -282,7 +284,13 @@ class Addon(AddonModel):
"""Return a pulse profile for output or None.""" """Return a pulse profile for output or None."""
if not self.with_audio: if not self.with_audio:
return None 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 @audio_output.setter
def audio_output(self, value: Optional[str]): def audio_output(self, value: Optional[str]):
@ -297,7 +305,13 @@ class Addon(AddonModel):
"""Return pulse profile for input or None.""" """Return pulse profile for input or None."""
if not self.with_audio: if not self.with_audio:
return None 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 @audio_input.setter
def audio_input(self, value: Optional[str]): def audio_input(self, value: Optional[str]):

View File

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

View File

@ -44,6 +44,7 @@ from ..const import (
ATTR_INGRESS_PANEL, ATTR_INGRESS_PANEL,
ATTR_INGRESS_PORT, ATTR_INGRESS_PORT,
ATTR_INGRESS_TOKEN, ATTR_INGRESS_TOKEN,
ATTR_INIT,
ATTR_KERNEL_MODULES, ATTR_KERNEL_MODULES,
ATTR_LEGACY, ATTR_LEGACY,
ATTR_LOCATON, ATTR_LOCATON,
@ -189,6 +190,7 @@ SCHEMA_ADDON_CONFIG = vol.Schema(
vol.Optional(ATTR_URL): vol.Url(), vol.Optional(ATTR_URL): vol.Url(),
vol.Required(ATTR_STARTUP): vol.All(_simple_startup, vol.In(STARTUP_ALL)), vol.Required(ATTR_STARTUP): vol.All(_simple_startup, vol.In(STARTUP_ALL)),
vol.Required(ATTR_BOOT): vol.In([BOOT_AUTO, BOOT_MANUAL]), 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_ADVANCED, default=False): vol.Boolean(),
vol.Optional(ATTR_STAGE, default=AddonStages.STABLE): vol.Coerce(AddonStages), vol.Optional(ATTR_STAGE, default=AddonStages.STABLE): vol.Coerce(AddonStages),
vol.Optional(ATTR_PORTS): DOCKER_PORTS, 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/restart", api_audio.restart),
web.post("/audio/reload", api_audio.reload), web.post("/audio/reload", api_audio.reload),
web.post("/audio/profile", api_audio.set_profile), 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/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), web.post("/audio/default/{source}", api_audio.set_default),
] ]
) )

View File

@ -8,12 +8,15 @@ import attr
import voluptuous as vol import voluptuous as vol
from ..const import ( from ..const import (
ATTR_ACTIVE,
ATTR_APPLICATION,
ATTR_AUDIO, ATTR_AUDIO,
ATTR_BLK_READ, ATTR_BLK_READ,
ATTR_BLK_WRITE, ATTR_BLK_WRITE,
ATTR_CARD, ATTR_CARD,
ATTR_CPU_PERCENT, ATTR_CPU_PERCENT,
ATTR_HOST, ATTR_HOST,
ATTR_INDEX,
ATTR_INPUT, ATTR_INPUT,
ATTR_LATEST_VERSION, ATTR_LATEST_VERSION,
ATTR_MEMORY_LIMIT, ATTR_MEMORY_LIMIT,
@ -38,11 +41,19 @@ SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): vol.Coerce(str)})
SCHEMA_VOLUME = vol.Schema( 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), 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_DEFAULT = vol.Schema({vol.Required(ATTR_NAME): vol.Coerce(str)})
SCHEMA_PROFILE = vol.Schema( SCHEMA_PROFILE = vol.Schema(
@ -68,6 +79,9 @@ class APIAudio(CoreSysAttributes):
ATTR_OUTPUT: [ ATTR_OUTPUT: [
attr.asdict(stream) for stream in self.sys_host.sound.outputs 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: async def set_volume(self, request: web.Request) -> None:
"""Set audio volume on stream.""" """Set audio volume on stream."""
source: StreamType = StreamType(request.match_info.get("source")) source: StreamType = StreamType(request.match_info.get("source"))
application: bool = request.path.endswith("application")
body = await api_validate(SCHEMA_VOLUME, request) body = await api_validate(SCHEMA_VOLUME, request)
await asyncio.shield( 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 @api_process

View File

@ -3,12 +3,12 @@ import asyncio
from contextlib import suppress from contextlib import suppress
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Awaitable, Optional
import shutil import shutil
from typing import Awaitable, List, Optional
import jinja2 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 .coresys import CoreSys, CoreSysAttributes
from .docker.audio import DockerAudio from .docker.audio import DockerAudio
from .docker.stats import DockerStats from .docker.stats import DockerStats
@ -157,6 +157,8 @@ class Audio(JsonConfig, CoreSysAttributes):
_LOGGER.error("Can't start Audio plugin") _LOGGER.error("Can't start Audio plugin")
raise AudioError() from None raise AudioError() from None
await self._restart_audio_addons()
async def start(self) -> None: async def start(self) -> None:
"""Run CoreDNS.""" """Run CoreDNS."""
# Start Instance # Start Instance
@ -167,6 +169,8 @@ class Audio(JsonConfig, CoreSysAttributes):
_LOGGER.error("Can't start Audio plugin") _LOGGER.error("Can't start Audio plugin")
raise AudioError() from None raise AudioError() from None
await self._restart_audio_addons()
def logs(self) -> Awaitable[bytes]: def logs(self) -> Awaitable[bytes]:
"""Get CoreDNS docker logs. """Get CoreDNS docker logs.
@ -217,3 +221,22 @@ class Audio(JsonConfig, CoreSysAttributes):
default_source=input_profile, default_source=input_profile,
default_sink=output_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 ipaddress import ip_network
from pathlib import Path from pathlib import Path
SUPERVISOR_VERSION = "203" SUPERVISOR_VERSION = "204"
URL_HASSIO_ADDONS = "https://github.com/home-assistant/hassio-addons" URL_HASSIO_ADDONS = "https://github.com/home-assistant/hassio-addons"
@ -234,6 +234,10 @@ ATTR_CLI = "cli"
ATTR_DEFAULT = "default" ATTR_DEFAULT = "default"
ATTR_VOLUME = "volume" ATTR_VOLUME = "volume"
ATTR_CARD = "card" ATTR_CARD = "card"
ATTR_INDEX = "index"
ATTR_ACTIVE = "active"
ATTR_APPLICATION = "application"
ATTR_INIT = "init"
PROVIDE_SERVICE = "provide" PROVIDE_SERVICE = "provide"
NEED_SERVICE = "need" NEED_SERVICE = "need"

View File

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

View File

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

View File

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

View File

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