Compare commits

...

39 Commits
203 ... 210

Author SHA1 Message Date
Pascal Vizeli
fa783a0d2c Merge pull request #1602 from home-assistant/dev
Release 210
2020-03-27 17:46:51 +01:00
Pascal Vizeli
96c0fbaf10 Cli rebrand (#1601)
* Rebrand CLI

* forward

* Fix startup command

* add cli api

* Add token handling

* Fix security check

* fix repair

* fix lint

* Update for new cli

* Add watchdog

* rename

* use s6
2020-03-27 17:37:48 +01:00
Pascal Vizeli
24f7801ddc Fix wrong function for set profiles (#1600) 2020-03-27 12:32:14 +01:00
Pascal Vizeli
8e83e007e9 DNS loop protection (#1599)
* DNS loop protection

* Update supervisor/dns.py

Co-Authored-By: Franck Nijhof <git@frenck.dev>

* cleanup not needed code

* Fix

Co-authored-by: Franck Nijhof <git@frenck.dev>
2020-03-27 11:54:32 +01:00
Pascal Vizeli
d0db466e67 Use DoT as fallback (#1597)
* Use DoT as fallback / add cache

* Stage

* merge

* fix lint

* Fallback server

* use fallback

* add nxdomain

* Address comments
2020-03-27 00:38:54 +01:00
Franck Nijhof
3010bd4eb6 Remove Home Panel Discovery (#1594)
* Remove Home Panel Discovery

* Remove related tests
2020-03-23 10:32:56 +01:00
Phill (pssc)
069bed8815 Rasie limit on container shutdown for addons usecase tmpfs based mariadb for recorder taking over 2 mins for dump (#1595) 2020-03-23 09:15:35 +01:00
Pascal Vizeli
d2088ae5f8 Update azure-pipelines-wheels.yml for Azure Pipelines 2020-03-21 09:13:13 +01:00
dependabot-preview[bot]
0ca5a241bb Bump cchardet from 2.1.5 to 2.1.6 (#1593)
Bumps [cchardet](https://github.com/PyYoshi/cChardet) from 2.1.5 to 2.1.6.
- [Release notes](https://github.com/PyYoshi/cChardet/releases)
- [Changelog](https://github.com/PyYoshi/cChardet/blob/master/CHANGES.rst)
- [Commits](https://github.com/PyYoshi/cChardet/compare/2.1.5...2.1.6)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>

Co-authored-by: dependabot-preview[bot] <27856297+dependabot-preview[bot]@users.noreply.github.com>
2020-03-18 15:21:12 +01:00
dependabot-preview[bot]
dff32a8e84 Bump pytest from 5.3.5 to 5.4.1 (#1591)
Bumps [pytest](https://github.com/pytest-dev/pytest) from 5.3.5 to 5.4.1.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/5.3.5...5.4.1)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>

Co-authored-by: dependabot-preview[bot] <27856297+dependabot-preview[bot]@users.noreply.github.com>
2020-03-16 14:36:11 +01:00
Pascal Vizeli
4a20344652 Log config check (#1583)
* Add more log for config check to debug

* Convert to ascii

* fix comment
2020-03-12 15:16:40 +01:00
Pascal Vizeli
98b969ef06 Bump version to 210 2020-03-06 12:43:11 +01:00
Pascal Vizeli
c8cb8aecf7 Merge pull request #1574 from home-assistant/dev
Release 209
2020-03-06 12:41:56 +01:00
Pascal Vizeli
73e8875018 Fix Issue with pulse folder (#1573)
* Fix Issue with pulse folder

* Fix config check
2020-03-06 12:32:29 +01:00
Pascal Vizeli
02aed9c084 Enforce Pulse client (#1572) 2020-03-05 15:54:23 +01:00
Pascal Vizeli
89148f8fff Bump packaging from 20.1 to 20.3 (#1570)
Bumps [packaging](https://github.com/pypa/packaging) from 20.1 to 20.3.
- [Release notes](https://github.com/pypa/packaging/releases)
- [Changelog](https://github.com/pypa/packaging/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/pypa/packaging/compare/20.1...20.3)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-03-05 13:44:10 +01:00
Pascal Vizeli
6bde527f5c Bump version to 209 2020-03-04 19:09:20 +01:00
Pascal Vizeli
d62aabc01b Merge pull request #1567 from home-assistant/dev
Release 208
2020-03-04 19:08:12 +01:00
Pascal Vizeli
82299a3799 Fix udev error without privileged (#1566)
* Fix udev error without privileged

* Fix udev

* Remove context

* Update supervisor/hwmon.py

Co-Authored-By: Paulus Schoutsen <balloob@gmail.com>

* Update supervisor/hwmon.py

Co-Authored-By: Paulus Schoutsen <balloob@gmail.com>

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
2020-03-04 18:55:07 +01:00
Pascal Vizeli
c02f30dd7e Enforce env check (#1565) 2020-03-04 18:15:10 +01:00
Pascal Vizeli
e91983adb4 Watchdog check in_progress for audio/dns (#1555) 2020-03-03 15:06:09 +01:00
Pascal Vizeli
ff88359429 Bump version to 208 2020-03-02 11:32:01 +01:00
Pascal Vizeli
5a60d5cbe8 Merge pull request #1554 from home-assistant/dev
Release 207
2020-03-02 11:31:26 +01:00
Pascal Vizeli
2b41ffe019 Bump version to 207 2020-03-01 17:52:35 +01:00
Pascal Vizeli
1c23e26f93 Check if SND is loaded (#1552)
* Check if SND is loaded

* add warning
2020-02-29 23:45:37 +01:00
Pascal Vizeli
3d555f951d Fix supervisor update flow with apparmor (#1551) 2020-02-29 23:07:34 +01:00
Bram Kragten
6d39b4d7cd Update audio.py (#1548) 2020-02-29 19:28:52 +01:00
Pascal Vizeli
4fe5d09f01 Bump version to 206 2020-02-29 12:19:42 +01:00
Pascal Vizeli
e52af3bfb4 Merge pull request #1547 from home-assistant/dev
Release 205
2020-02-29 12:18:51 +01:00
Pascal Vizeli
0467b33cd5 Core support audio settings (#1546) 2020-02-29 12:14:03 +01:00
Pascal Vizeli
14167f6e13 Merge pull request #1544 from home-assistant/dev
Release 204
2020-02-29 00:30:16 +01:00
Pascal Vizeli
7a1aba6f81 Fix old alsa format settings (#1543) 2020-02-29 00:25:11 +01:00
Pascal Vizeli
920f7f2ece Support for own init on image (#1542)
* Support for own init on image

* fix params
2020-02-28 23:15:46 +01:00
Pascal Vizeli
06fadbd70f fix lint 2020-02-28 19:25:15 +00:00
Pascal Vizeli
d4f486864f Make audio socket RO and aware of restarts (#1545) 2020-02-29 11:23:36 +01:00
Pascal Vizeli
d3a21303d9 Bump version to 205 2020-02-29 00:31:21 +01:00
Pascal Vizeli
e1cbfdd84b Support mute + applications from pulse (#1541)
* Support mute + applications from pulse

* Fix lint

* Fix application parser

* Fix type

* Add application endpoints

* error handling

* Fix
2020-02-28 17:52:12 +01:00
Pascal Vizeli
87170a4497 Restart add-ons attach to audio with update pulse (#1540) 2020-02-28 14:05:31 +01:00
Pascal Vizeli
ae6f8bd345 Bump version to 203 2020-02-28 10:57:05 +01:00
49 changed files with 1018 additions and 526 deletions

180
.vscode/tasks.json vendored
View File

@@ -1,92 +1,90 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Run Testenv",
"type": "shell",
"command": "./scripts/test_env.sh",
"group": {
"kind": "test",
"isDefault": true,
},
"presentation": {
"reveal": "always",
"panel": "new"
},
"problemMatcher": []
},
{
"label": "Run Testenv CLI",
"type": "shell",
"command": "docker run --rm -ti -v /etc/machine-id:/etc/machine-id --network=hassio --add-host hassio:172.30.32.2 homeassistant/amd64-hassio-cli:dev",
"group": {
"kind": "test",
"isDefault": true,
},
"presentation": {
"reveal": "always",
"panel": "new"
},
"problemMatcher": []
},
{
"label": "Update UI",
"type": "shell",
"command": "./scripts/update-frontend.sh",
"group": {
"kind": "build",
"isDefault": true
},
"presentation": {
"reveal": "always",
"panel": "new"
},
"problemMatcher": []
},
{
"label": "Pytest",
"type": "shell",
"command": "pytest --timeout=10 tests",
"group": {
"kind": "test",
"isDefault": true,
},
"presentation": {
"reveal": "always",
"panel": "new"
},
"problemMatcher": []
},
{
"label": "Flake8",
"type": "shell",
"command": "flake8 hassio tests",
"group": {
"kind": "test",
"isDefault": true,
},
"presentation": {
"reveal": "always",
"panel": "new"
},
"problemMatcher": []
},
{
"label": "Pylint",
"type": "shell",
"command": "pylint hassio",
"dependsOn": [
"Install all Requirements"
],
"group": {
"kind": "test",
"isDefault": true,
},
"presentation": {
"reveal": "always",
"panel": "new"
},
"problemMatcher": []
}
]
}
"version": "2.0.0",
"tasks": [
{
"label": "Run Testenv",
"type": "shell",
"command": "./scripts/test_env.sh",
"group": {
"kind": "test",
"isDefault": true
},
"presentation": {
"reveal": "always",
"panel": "new"
},
"problemMatcher": []
},
{
"label": "Run Testenv CLI",
"type": "shell",
"command": "docker exec -ti hassio_cli /usr/bin/cli.sh",
"group": {
"kind": "test",
"isDefault": true
},
"presentation": {
"reveal": "always",
"panel": "new"
},
"problemMatcher": []
},
{
"label": "Update UI",
"type": "shell",
"command": "./scripts/update-frontend.sh",
"group": {
"kind": "build",
"isDefault": true
},
"presentation": {
"reveal": "always",
"panel": "new"
},
"problemMatcher": []
},
{
"label": "Pytest",
"type": "shell",
"command": "pytest --timeout=10 tests",
"group": {
"kind": "test",
"isDefault": true
},
"presentation": {
"reveal": "always",
"panel": "new"
},
"problemMatcher": []
},
{
"label": "Flake8",
"type": "shell",
"command": "flake8 hassio tests",
"group": {
"kind": "test",
"isDefault": true
},
"presentation": {
"reveal": "always",
"panel": "new"
},
"problemMatcher": []
},
{
"label": "Pylint",
"type": "shell",
"command": "pylint hassio",
"dependsOn": ["Install all Requirements"],
"group": {
"kind": "test",
"isDefault": true
},
"presentation": {
"reveal": "always",
"panel": "new"
},
"problemMatcher": []
}
]
}

114
API.md
View File

@@ -291,9 +291,7 @@ return:
```json
{
"version": "2.3",
"version_cli": "7",
"version_latest": "2.4",
"version_cli_latest": "8",
"board": "ova|rpi",
"boot": "rauc boot slot"
}
@@ -307,14 +305,6 @@ return:
}
```
- POST `/os/update/cli`
```json
{
"version": "optional"
}
```
- POST `/os/config/sync`
Load host configs from a USB stick.
@@ -379,7 +369,9 @@ Trigger an udev reload
"port": 8123,
"ssl": "bool",
"watchdog": "bool",
"wait_boot": 600
"wait_boot": 600,
"audio_input": "null|profile",
"audio_output": "null|profile"
}
```
@@ -413,7 +405,9 @@ Output is the raw Docker log.
"ssl": "bool",
"refresh_token": "",
"watchdog": "bool",
"wait_boot": 600
"wait_boot": 600,
"audio_input": "null|profile",
"audio_output": "null|profile"
}
```
@@ -853,50 +847,29 @@ return:
}
```
### Audio
### DNS
- GET `/audio/info`
- GET `/dns/info`
```json
{
"host": "ip-address",
"version": "1",
"latest_version": "2",
"audio": {
"card": [
{
"name": "...",
"driver": "...",
"profiles": [
{
"name": "...",
"description": "...",
"active": false
}
]
}
],
"input": [
{
"name": "...",
"description": "...",
"volume": 0.3,
"default": false
}
],
"output": [
{
"name": "...",
"description": "...",
"volume": 0.3,
"default": false
}
]
}
"servers": ["dns://8.8.8.8"],
"locals": ["dns://xy"]
}
```
- POST `/audio/update`
- POST `/dns/options`
```json
{
"servers": ["dns://8.8.8.8"]
}
```
- POST `/dns/update`
```json
{
@@ -904,56 +877,47 @@ return:
}
```
- POST `/audio/restart`
- POST `/dns/restart`
- POST `/audio/reload`
- POST `/dns/reset`
- GET `/audio/logs`
- GET `/dns/logs`
- POST `/audio/volume/input`
- GET `/dns/stats`
```json
{
"name": "...",
"volume": 0.5
"cpu_percent": 0.0,
"memory_usage": 283123,
"memory_limit": 329392,
"memory_percent": 1.4,
"network_tx": 0,
"network_rx": 0,
"blk_read": 0,
"blk_write": 0
}
```
- POST `/audio/volume/output`
### CLI
- GET `/cli/info`
```json
{
"name": "...",
"volume": 0.5
"version": "1",
"version_latest": "2"
}
```
- POST `/audio/default/input`
- POST `/cli/update`
```json
{
"name": "..."
"version": "VERSION"
}
```
- POST `/audio/default/output`
```json
{
"name": "..."
}
```
- POST `/audio/profile`
```json
{
"card": "...",
"name": "..."
}
```
- GET `/audio/stats`
- GET `/cli/stats`
```json
{

View File

@@ -8,53 +8,19 @@ trigger:
pr: none
variables:
- name: versionWheels
value: "1.6-3.7-alpine3.11"
- group: wheels
value: '1.6.1-3.7-alpine3.11'
resources:
repositories:
- repository: azure
type: github
name: 'home-assistant/ci-azure'
endpoint: 'home-assistant'
jobs:
- job: "Wheels"
timeoutInMinutes: 360
pool:
vmImage: "ubuntu-latest"
strategy:
maxParallel: 5
matrix:
amd64:
buildArch: "amd64"
i386:
buildArch: "i386"
armhf:
buildArch: "armhf"
armv7:
buildArch: "armv7"
aarch64:
buildArch: "aarch64"
steps:
- script: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
qemu-user-static \
binfmt-support \
curl
sudo mount binfmt_misc -t binfmt_misc /proc/sys/fs/binfmt_misc
sudo update-binfmts --enable qemu-arm
sudo update-binfmts --enable qemu-aarch64
displayName: "Initial cross build"
- script: |
mkdir -p .ssh
echo -e "-----BEGIN RSA PRIVATE KEY-----\n$(wheelsSSH)\n-----END RSA PRIVATE KEY-----" >> .ssh/id_rsa
ssh-keyscan -H $(wheelsHost) >> .ssh/known_hosts
chmod 600 .ssh/*
displayName: "Install ssh key"
- script: sudo docker pull homeassistant/$(buildArch)-wheels:$(versionWheels)
displayName: "Install wheels builder"
- script: |
sudo docker run --rm -v $(pwd):/data:ro -v $(pwd)/.ssh:/root/.ssh:rw \
homeassistant/$(buildArch)-wheels:$(versionWheels) \
--apk "build-base;libffi-dev;openssl-dev" \
--index $(wheelsIndex) \
--requirement requirements.txt \
--upload rsync \
--remote wheels@$(wheelsHost):/opt/wheels
displayName: "Run wheels build"
- template: templates/azp-job-wheels.yaml@azure
parameters:
builderVersion: '$(versionWheels)'
builderApk: 'build-base;libffi-dev;openssl-dev'
builderPip: 'Cython'
wheelsRequirement: 'requirements.txt'

View File

@@ -1,14 +1,14 @@
aiohttp==3.6.1
async_timeout==3.0.1
attrs==19.3.0
cchardet==2.1.5
cchardet==2.1.6
colorlog==4.1.0
cpe==1.2.1
cryptography==2.8
docker==4.2.0
gitpython==3.1.0
jinja2==2.11.1
packaging==20.1
packaging==20.3
ptvsd==4.3.2
pulsectl==20.2.4
pytz==2019.3

View File

@@ -1,6 +1,6 @@
flake8==3.7.9
pylint==2.4.4
pytest==5.3.5
pytest==5.4.1
pytest-timeout==1.3.4
pytest-aiohttp==0.3.0
black==19.10b0

View File

@@ -2,4 +2,6 @@
# ==============================================================================
# Start Service service
# ==============================================================================
export LD_PRELOAD="/usr/local/lib/libjemalloc.so.2"
exec python3 -m supervisor

View File

@@ -81,11 +81,6 @@ function cleanup_docker() {
}
function install_cli() {
docker pull homeassistant/amd64-hassio-cli:dev
}
function setup_test_env() {
mkdir -p /workspaces/test_supervisor
@@ -131,7 +126,6 @@ start_docker
trap "stop_docker" ERR
build_supervisor
install_cli
cleanup_lastboot
cleanup_docker
init_dbus

View File

@@ -30,8 +30,7 @@ if __name__ == "__main__":
loop = initialize_event_loop()
# Check if all information are available to setup Supervisor
if not bootstrap.check_environment():
sys.exit(1)
bootstrap.check_environment()
# init executor pool
executor = ThreadPoolExecutor(thread_name_prefix="SyncWorker")

View File

@@ -59,7 +59,7 @@ class AddonManager(CoreSysAttributes):
def from_token(self, token: str) -> Optional[Addon]:
"""Return an add-on from Supervisor token."""
for addon in self.installed:
if token == addon.hassio_token:
if token == addon.supervisor_token:
return addon
return None

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."""
@@ -162,7 +164,7 @@ class Addon(AddonModel):
return self.persist[ATTR_UUID]
@property
def hassio_token(self) -> Optional[str]:
def supervisor_token(self) -> Optional[str]:
"""Return access token for Supervisor API."""
return self.persist.get(ATTR_ACCESS_TOKEN)
@@ -282,30 +284,36 @@ 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]):
"""Set/reset audio output profile settings."""
if value is None:
self.persist.pop(ATTR_AUDIO_OUTPUT, None)
else:
self.persist[ATTR_AUDIO_OUTPUT] = value
"""Set audio output profile settings."""
self.persist[ATTR_AUDIO_OUTPUT] = value
@property
def audio_input(self) -> Optional[str]:
"""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]):
"""Set/reset audio input settings."""
if value is None:
self.persist.pop(ATTR_AUDIO_INPUT, None)
else:
self.persist[ATTR_AUDIO_INPUT] = value
"""Set audio input settings."""
self.persist[ATTR_AUDIO_INPUT] = value
@property
def image(self):
@@ -385,6 +393,11 @@ class Addon(AddonModel):
input_profile=self.audio_input, output_profile=self.audio_output
)
# Cleanup wrong maps
if self.path_pulse.is_dir():
shutil.rmtree(self.path_pulse, ignore_errors=True)
# Write pulse config
try:
with self.path_pulse.open("w") as config_file:
config_file.write(pulse_config)
@@ -392,11 +405,10 @@ class Addon(AddonModel):
_LOGGER.error(
"Add-on %s can't write pulse/client.config: %s", self.slug, err
)
raise AddonsError()
_LOGGER.debug(
"Add-on %s write pulse/client.config: %s", self.slug, self.path_pulse
)
else:
_LOGGER.debug(
"Add-on %s write pulse/client.config: %s", self.slug, self.path_pulse
)
async def install_apparmor(self) -> None:
"""Install or Update AppArmor profile for Add-on."""

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,
@@ -136,7 +137,7 @@ class AddonModel(CoreSysAttributes):
return None
@property
def hassio_token(self) -> Optional[str]:
def supervisor_token(self) -> Optional[str]:
"""Return access token for Supervisor API."""
return None
@@ -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,
@@ -259,7 +261,7 @@ SCHEMA_ADDON_CONFIG = vol.Schema(
),
vol.Optional(ATTR_IMAGE): vol.Match(RE_DOCKER_IMAGE),
vol.Optional(ATTR_TIMEOUT, default=10): vol.All(
vol.Coerce(int), vol.Range(min=10, max=120)
vol.Coerce(int), vol.Range(min=10, max=300)
),
},
extra=vol.REMOVE_EXTRA,

View File

@@ -7,11 +7,13 @@ from aiohttp import web
from ..coresys import CoreSys, CoreSysAttributes
from .addons import APIAddons
from .audio import APIAudio
from .auth import APIAuth
from .cli import APICli
from .discovery import APIDiscovery
from .dns import APICoreDNS
from .hardware import APIHardware
from .hassos import APIHassOS
from .os import APIOS
from .homeassistant import APIHomeAssistant
from .host import APIHost
from .info import APIInfo
@@ -21,7 +23,6 @@ from .security import SecurityMiddleware
from .services import APIServices
from .snapshots import APISnapshots
from .supervisor import APISupervisor
from .audio import APIAudio
_LOGGER: logging.Logger = logging.getLogger(__name__)
@@ -49,7 +50,8 @@ class RestAPI(CoreSysAttributes):
"""Register REST API Calls."""
self._register_supervisor()
self._register_host()
self._register_hassos()
self._register_os()
self._register_cli()
self._register_hardware()
self._register_homeassistant()
self._register_proxy()
@@ -84,22 +86,29 @@ class RestAPI(CoreSysAttributes):
]
)
def _register_hassos(self) -> None:
"""Register HassOS functions."""
api_hassos = APIHassOS()
api_hassos.coresys = self.coresys
def _register_os(self) -> None:
"""Register OS functions."""
api_os = APIOS()
api_os.coresys = self.coresys
self.webapp.add_routes(
[
web.get("/os/info", api_hassos.info),
web.post("/os/update", api_hassos.update),
web.post("/os/update/cli", api_hassos.update_cli),
web.post("/os/config/sync", api_hassos.config_sync),
# Remove with old Supervisor fallback
web.get("/hassos/info", api_hassos.info),
web.post("/hassos/update", api_hassos.update),
web.post("/hassos/update/cli", api_hassos.update_cli),
web.post("/hassos/config/sync", api_hassos.config_sync),
web.get("/os/info", api_os.info),
web.post("/os/update", api_os.update),
web.post("/os/config/sync", api_os.config_sync),
]
)
def _register_cli(self) -> None:
"""Register HA cli functions."""
api_cli = APICli()
api_cli.coresys = self.coresys
self.webapp.add_routes(
[
web.get("/cli/info", api_cli.info),
web.get("/cli/stats", api_cli.stats),
web.post("/cli/update", api_cli.update),
]
)
@@ -330,7 +339,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
@@ -133,8 +163,8 @@ class APIAudio(CoreSysAttributes):
@api_process
async def set_profile(self, request: web.Request) -> None:
"""Set audio default sources."""
body = await api_validate(SCHEMA_DEFAULT, request)
body = await api_validate(SCHEMA_PROFILE, request)
await asyncio.shield(
self.sys_host.sound.set_profile(body[ATTR_CARD], body[ATTR_NAME])
self.sys_host.sound.ativate_profile(body[ATTR_CARD], body[ATTR_NAME])
)

62
supervisor/api/cli.py Normal file
View File

@@ -0,0 +1,62 @@
"""Init file for Supervisor HA cli RESTful API."""
import asyncio
import logging
from typing import Any, Dict
from aiohttp import web
import voluptuous as vol
from ..const import (
ATTR_VERSION,
ATTR_VERSION_LATEST,
ATTR_BLK_READ,
ATTR_BLK_WRITE,
ATTR_CPU_PERCENT,
ATTR_MEMORY_LIMIT,
ATTR_MEMORY_PERCENT,
ATTR_MEMORY_USAGE,
ATTR_NETWORK_RX,
ATTR_NETWORK_TX,
)
from ..coresys import CoreSysAttributes
from .utils import api_process, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__)
SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): vol.Coerce(str)})
class APICli(CoreSysAttributes):
"""Handle RESTful API for HA Cli functions."""
@api_process
async def info(self, request: web.Request) -> Dict[str, Any]:
"""Return HA cli information."""
return {
ATTR_VERSION: self.sys_cli.version,
ATTR_VERSION_LATEST: self.sys_cli.latest_version,
}
@api_process
async def stats(self, request: web.Request) -> Dict[str, Any]:
"""Return resource information."""
stats = await self.sys_cli.stats()
return {
ATTR_CPU_PERCENT: stats.cpu_percent,
ATTR_MEMORY_USAGE: stats.memory_usage,
ATTR_MEMORY_LIMIT: stats.memory_limit,
ATTR_MEMORY_PERCENT: stats.memory_percent,
ATTR_NETWORK_RX: stats.network_rx,
ATTR_NETWORK_TX: stats.network_tx,
ATTR_BLK_READ: stats.blk_read,
ATTR_BLK_WRITE: stats.blk_write,
}
@api_process
async def update(self, request: web.Request) -> None:
"""Update HA CLI."""
body = await api_validate(SCHEMA_VERSION, request)
version = body.get(ATTR_VERSION, self.sys_cli.latest_version)
await asyncio.shield(self.sys_cli.update(version))

View File

@@ -1,24 +1,27 @@
"""Init file for Supervisor Home Assistant RESTful API."""
import asyncio
import logging
from typing import Coroutine, Dict, Any
from typing import Any, Coroutine, Dict
import voluptuous as vol
from aiohttp import web
import voluptuous as vol
from ..const import (
ATTR_ARCH,
ATTR_AUDIO_INPUT,
ATTR_AUDIO_OUTPUT,
ATTR_BLK_READ,
ATTR_BLK_WRITE,
ATTR_BOOT,
ATTR_CPU_PERCENT,
ATTR_CUSTOM,
ATTR_IMAGE,
ATTR_IP_ADDRESS,
ATTR_LAST_VERSION,
ATTR_MACHINE,
ATTR_MEMORY_LIMIT,
ATTR_MEMORY_USAGE,
ATTR_MEMORY_PERCENT,
ATTR_MEMORY_USAGE,
ATTR_NETWORK_RX,
ATTR_NETWORK_TX,
ATTR_PORT,
@@ -27,7 +30,6 @@ from ..const import (
ATTR_VERSION,
ATTR_WAIT_BOOT,
ATTR_WATCHDOG,
ATTR_IP_ADDRESS,
CONTENT_TYPE_BINARY,
)
from ..coresys import CoreSysAttributes
@@ -48,6 +50,8 @@ SCHEMA_OPTIONS = vol.Schema(
vol.Optional(ATTR_WATCHDOG): vol.Boolean(),
vol.Optional(ATTR_WAIT_BOOT): vol.All(vol.Coerce(int), vol.Range(min=60)),
vol.Optional(ATTR_REFRESH_TOKEN): vol.Maybe(vol.Coerce(str)),
vol.Optional(ATTR_AUDIO_OUTPUT): vol.Maybe(vol.Coerce(str)),
vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(vol.Coerce(str)),
}
)
@@ -73,6 +77,8 @@ class APIHomeAssistant(CoreSysAttributes):
ATTR_SSL: self.sys_homeassistant.api_ssl,
ATTR_WATCHDOG: self.sys_homeassistant.watchdog,
ATTR_WAIT_BOOT: self.sys_homeassistant.wait_boot,
ATTR_AUDIO_INPUT: self.sys_homeassistant.audio_input,
ATTR_AUDIO_OUTPUT: self.sys_homeassistant.audio_output,
}
@api_process
@@ -102,6 +108,12 @@ class APIHomeAssistant(CoreSysAttributes):
if ATTR_REFRESH_TOKEN in body:
self.sys_homeassistant.refresh_token = body[ATTR_REFRESH_TOKEN]
if ATTR_AUDIO_INPUT in body:
self.sys_homeassistant.audio_input = body[ATTR_AUDIO_INPUT]
if ATTR_AUDIO_OUTPUT in body:
self.sys_homeassistant.audio_output = body[ATTR_AUDIO_OUTPUT]
self.sys_homeassistant.save_data()
@api_process

View File

@@ -10,8 +10,6 @@ from ..const import (
ATTR_BOARD,
ATTR_BOOT,
ATTR_VERSION,
ATTR_VERSION_CLI,
ATTR_VERSION_CLI_LATEST,
ATTR_VERSION_LATEST,
)
from ..coresys import CoreSysAttributes
@@ -22,38 +20,28 @@ _LOGGER: logging.Logger = logging.getLogger(__name__)
SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): vol.Coerce(str)})
class APIHassOS(CoreSysAttributes):
"""Handle RESTful API for HassOS functions."""
class APIOS(CoreSysAttributes):
"""Handle RESTful API for OS functions."""
@api_process
async def info(self, request: web.Request) -> Dict[str, Any]:
"""Return HassOS information."""
"""Return OS information."""
return {
ATTR_VERSION: self.sys_hassos.version,
ATTR_VERSION_CLI: self.sys_hassos.version_cli,
ATTR_VERSION_LATEST: self.sys_hassos.version_latest,
ATTR_VERSION_CLI_LATEST: self.sys_hassos.version_cli_latest,
ATTR_VERSION_LATEST: self.sys_hassos.latest_version,
ATTR_BOARD: self.sys_hassos.board,
ATTR_BOOT: self.sys_dbus.rauc.boot_slot,
}
@api_process
async def update(self, request: web.Request) -> None:
"""Update HassOS."""
"""Update OS."""
body = await api_validate(SCHEMA_VERSION, request)
version = body.get(ATTR_VERSION, self.sys_hassos.version_latest)
version = body.get(ATTR_VERSION, self.sys_hassos.latest_version)
await asyncio.shield(self.sys_hassos.update(version))
@api_process
async def update_cli(self, request: web.Request) -> None:
"""Update HassOS CLI."""
body = await api_validate(SCHEMA_VERSION, request)
version = body.get(ATTR_VERSION, self.sys_hassos.version_cli_latest)
await asyncio.shield(self.sys_hassos.update_cli(version))
@api_process
def config_sync(self, request: web.Request) -> Awaitable[None]:
"""Trigger config reload on HassOS."""
"""Trigger config reload on OS."""
return asyncio.shield(self.sys_hassos.config_sync())

View File

@@ -26,11 +26,11 @@ class APIProxy(CoreSysAttributes):
"""Check the Supervisor token."""
if AUTHORIZATION in request.headers:
bearer = request.headers[AUTHORIZATION]
hassio_token = bearer.split(" ")[-1]
supervisor_token = bearer.split(" ")[-1]
else:
hassio_token = request.headers.get(HEADER_HA_ACCESS)
supervisor_token = request.headers.get(HEADER_HA_ACCESS)
addon = self.sys_addons.from_token(hassio_token)
addon = self.sys_addons.from_token(supervisor_token)
if not addon:
_LOGGER.warning("Unknown Home Assistant API access!")
elif not addon.access_homeassistant_api:
@@ -177,8 +177,10 @@ class APIProxy(CoreSysAttributes):
# Check API access
response = await server.receive_json()
hassio_token = response.get("api_password") or response.get("access_token")
addon = self.sys_addons.from_token(hassio_token)
supervisor_token = response.get("api_password") or response.get(
"access_token"
)
addon = self.sys_addons.from_token(supervisor_token)
if not addon or not addon.access_homeassistant_api:
_LOGGER.warning("Unauthorized WebSocket access!")

View File

@@ -75,6 +75,7 @@ ADDONS_ROLE_ACCESS = {
r"^(?:"
r"|/audio/.*"
r"|/dns/.*"
r"|/cli/.*"
r"|/core/.+"
r"|/homeassistant/.+"
r"|/host/.+"
@@ -123,12 +124,13 @@ class SecurityMiddleware(CoreSysAttributes):
raise HTTPUnauthorized()
# Home-Assistant
if supervisor_token == self.sys_homeassistant.hassio_token:
if supervisor_token == self.sys_homeassistant.supervisor_token:
_LOGGER.debug("%s access from Home Assistant", request.path)
request_from = self.sys_homeassistant
# Host
if supervisor_token == self.sys_machine_id:
# Remove machine_id handling later if all use new CLI
if supervisor_token in (self.sys_machine_id, self.sys_cli.supervisor_token):
_LOGGER.debug("%s access from Host", request.path)
request_from = self.sys_host

View File

@@ -3,8 +3,8 @@ import asyncio
from contextlib import suppress
import logging
from pathlib import Path
from typing import Awaitable, Optional
import shutil
from typing import Awaitable, Optional
import jinja2
@@ -35,7 +35,7 @@ class Audio(JsonConfig, CoreSysAttributes):
@property
def path_extern_pulse(self) -> Path:
"""Return path of pulse socket file."""
return self.sys_config.path_extern_audio.joinpath("external/pulse.sock")
return self.sys_config.path_extern_audio.joinpath("external")
@property
def path_extern_asound(self) -> Path:
@@ -49,7 +49,7 @@ class Audio(JsonConfig, CoreSysAttributes):
@version.setter
def version(self, value: str) -> None:
"""Return current version of Audio."""
"""Set current version of Audio."""
self._data[ATTR_VERSION] = value
@property
@@ -188,13 +188,6 @@ class Audio(JsonConfig, CoreSysAttributes):
"""
return self.instance.is_running()
def is_fails(self) -> Awaitable[bool]:
"""Return True if a Docker container is fails state.
Return a coroutine.
"""
return self.instance.is_fails()
async def repair(self) -> None:
"""Repair CoreDNS plugin."""
if await self.instance.exists():

View File

@@ -14,6 +14,7 @@ from .auth import Auth
from .audio import Audio
from .const import SOCKET_DOCKER, UpdateChannels
from .core import Core
from .cli import HaCli
from .coresys import CoreSys
from .dbus import DBusManager
from .discovery import Discovery
@@ -67,6 +68,7 @@ async def initialize_coresys():
coresys.dbus = DBusManager(coresys)
coresys.hassos = HassOS(coresys)
coresys.secrets = SecretsManager(coresys)
coresys.cli = HaCli(coresys)
# bootstrap config
initialize_system_data(coresys)
@@ -197,7 +199,7 @@ def initialize_logging():
)
def check_environment():
def check_environment() -> None:
"""Check if all environment are exists."""
# check environment variables
for key in (ENV_SHARE, ENV_NAME, ENV_REPO):
@@ -205,24 +207,18 @@ def check_environment():
os.environ[key]
except KeyError:
_LOGGER.fatal("Can't find %s in env!", key)
return False
# check docker socket
if not SOCKET_DOCKER.is_socket():
_LOGGER.fatal("Can't find Docker socket!")
return False
# check socat exec
if not shutil.which("socat"):
_LOGGER.fatal("Can't find socat!")
return False
# check socat exec
if not shutil.which("gdbus"):
_LOGGER.fatal("Can't find gdbus!")
return False
return True
def reg_signal(loop):

156
supervisor/cli.py Normal file
View File

@@ -0,0 +1,156 @@
"""CLI support on supervisor."""
import asyncio
from contextlib import suppress
import logging
import secrets
from typing import Awaitable, Optional
from .const import ATTR_ACCESS_TOKEN, ATTR_VERSION, FILE_HASSIO_CLI
from .coresys import CoreSys, CoreSysAttributes
from .docker.cli import DockerCli
from .docker.stats import DockerStats
from .exceptions import CliError, CliUpdateError, DockerAPIError
from .utils.json import JsonConfig
from .validate import SCHEMA_CLI_CONFIG
_LOGGER: logging.Logger = logging.getLogger(__name__)
class HaCli(CoreSysAttributes, JsonConfig):
"""HA cli interface inside supervisor."""
def __init__(self, coresys: CoreSys):
"""Initialize cli handler."""
super().__init__(FILE_HASSIO_CLI, SCHEMA_CLI_CONFIG)
self.coresys: CoreSys = coresys
self.instance: DockerCli = DockerCli(coresys)
@property
def version(self) -> Optional[str]:
"""Return version of cli."""
return self._data.get(ATTR_VERSION)
@version.setter
def version(self, value: str) -> None:
"""Set current version of cli."""
self._data[ATTR_VERSION] = value
@property
def latest_version(self) -> str:
"""Return version of latest cli."""
return self.sys_updater.version_cli
@property
def need_update(self) -> bool:
"""Return true if a cli update is available."""
return self.version != self.latest_version
@property
def supervisor_token(self) -> str:
"""Return an access token for the Supervisor API."""
return self._data.get(ATTR_ACCESS_TOKEN)
@property
def in_progress(self) -> bool:
"""Return True if a task is in progress."""
return self.instance.in_progress
async def load(self) -> None:
"""Load cli setup."""
# Check cli state
try:
# Evaluate Version if we lost this information
if not self.version:
self.version = await self.instance.get_latest_version(key=int)
await self.instance.attach(tag=self.version)
except DockerAPIError:
_LOGGER.info("No Audio plugin Docker image %s found.", self.instance.image)
# Install cli
with suppress(CliError):
await self.install()
else:
self.version = self.instance.version
self.save_data()
# Run PulseAudio
with suppress(CliError):
if not await self.instance.is_running():
await self.start()
async def install(self) -> None:
"""Install cli."""
_LOGGER.info("Setup cli plugin")
while True:
# read audio tag and install it
if not self.latest_version:
await self.sys_updater.reload()
if self.latest_version:
with suppress(DockerAPIError):
await self.instance.install(self.latest_version)
break
_LOGGER.warning("Error on install cli plugin. Retry in 30sec")
await asyncio.sleep(30)
_LOGGER.info("cli plugin now installed")
self.version = self.instance.version
self.save_data()
async def update(self, version: Optional[str] = None) -> None:
"""Update local HA cli."""
version = version or self.latest_version
if version == self.version:
_LOGGER.warning("Version %s is already installed for cli", version)
return
try:
await self.instance.update(version, latest=True)
except DockerAPIError:
_LOGGER.error("HA cli update fails")
raise CliUpdateError() from None
else:
# Cleanup
with suppress(DockerAPIError):
await self.instance.cleanup()
async def start(self) -> None:
"""Run cli."""
# Create new API token
self._data[ATTR_ACCESS_TOKEN] = secrets.token_hex(56)
self.save_data()
# Start Instance
_LOGGER.info("Start cli plugin")
try:
await self.instance.run()
except DockerAPIError:
_LOGGER.error("Can't start cli plugin")
raise CliError() from None
async def stats(self) -> DockerStats:
"""Return stats of cli."""
try:
return await self.instance.stats()
except DockerAPIError:
raise CliError() from None
def is_running(self) -> Awaitable[bool]:
"""Return True if Docker container is running.
Return a coroutine.
"""
return self.instance.is_running()
async def repair(self) -> None:
"""Repair cli container."""
if await self.instance.exists():
return
_LOGGER.info("Repair HA cli %s", self.version)
try:
await self.instance.install(self.version, latest=True)
except DockerAPIError:
_LOGGER.error("Repairing of HA cli fails")

View File

@@ -3,7 +3,7 @@ from enum import Enum
from ipaddress import ip_network
from pathlib import Path
SUPERVISOR_VERSION = "203"
SUPERVISOR_VERSION = "210"
URL_HASSIO_ADDONS = "https://github.com/home-assistant/hassio-addons"
@@ -27,14 +27,14 @@ FILE_HASSIO_DISCOVERY = Path(SUPERVISOR_DATA, "discovery.json")
FILE_HASSIO_INGRESS = Path(SUPERVISOR_DATA, "ingress.json")
FILE_HASSIO_DNS = Path(SUPERVISOR_DATA, "dns.json")
FILE_HASSIO_AUDIO = Path(SUPERVISOR_DATA, "audio.json")
FILE_HASSIO_CLI = Path(SUPERVISOR_DATA, "cli.json")
SOCKET_DOCKER = Path("/var/run/docker.sock")
SOCKET_DOCKER = Path("/run/docker.sock")
DOCKER_NETWORK = "hassio"
DOCKER_NETWORK_MASK = ip_network("172.30.32.0/23")
DOCKER_NETWORK_RANGE = ip_network("172.30.33.0/24")
DNS_SERVERS = ["dns://1.1.1.1", "dns://9.9.9.9"]
DNS_SUFFIX = "local.hass.io"
LABEL_VERSION = "io.hass.version"
@@ -190,9 +190,6 @@ ATTR_DEVICETREE = "devicetree"
ATTR_CPE = "cpe"
ATTR_BOARD = "board"
ATTR_HASSOS = "hassos"
ATTR_HASSOS_CLI = "hassos_cli"
ATTR_VERSION_CLI = "version_cli"
ATTR_VERSION_CLI_LATEST = "version_cli_latest"
ATTR_REFRESH_TOKEN = "refresh_token"
ATTR_ACCESS_TOKEN = "access_token"
ATTR_DOCKER_API = "docker_api"
@@ -234,6 +231,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

@@ -41,7 +41,9 @@ class Core(CoreSysAttributes):
await self.sys_host.load()
# Load Plugins container
await asyncio.wait([self.sys_dns.load(), self.sys_audio.load()])
await asyncio.wait(
[self.sys_dns.load(), self.sys_audio.load(), self.sys_cli.load()]
)
# Load Home Assistant
await self.sys_homeassistant.load()
@@ -203,13 +205,11 @@ class Core(CoreSysAttributes):
# Restore core functionality
await self.sys_dns.repair()
await self.sys_audio.repair()
await self.sys_cli.repair()
await self.sys_addons.repair()
await self.sys_homeassistant.repair()
# Fix HassOS specific
if self.sys_hassos.available:
await self.sys_hassos.repair_cli()
# Tag version for latest
await self.sys_supervisor.repair()
_LOGGER.info("Finished repairing of Supervisor Environment")

View File

@@ -18,6 +18,7 @@ if TYPE_CHECKING:
from .audio import Audio
from .auth import Auth
from .core import Core
from .cli import HaCli
from .dbus import DBusManager
from .discovery import Discovery
from .dns import CoreDNS
@@ -62,6 +63,7 @@ class CoreSys:
self._audio: Optional[Audio] = None
self._auth: Optional[Auth] = None
self._dns: Optional[CoreDNS] = None
self._cli: Optional[HaCli] = None
self._homeassistant: Optional[HomeAssistant] = None
self._supervisor: Optional[Supervisor] = None
self._addons: Optional[AddonManager] = None
@@ -143,6 +145,18 @@ class CoreSys:
raise RuntimeError("Core already set!")
self._core = value
@property
def cli(self) -> HaCli:
"""Return HaCli object."""
return self._cli
@cli.setter
def cli(self, value: HaCli):
"""Set a HaCli object."""
if self._cli:
raise RuntimeError("HaCli already set!")
self._cli = value
@property
def arch(self) -> CpuArch:
"""Return CpuArch object."""
@@ -449,6 +463,11 @@ class CoreSysAttributes:
"""Return core object."""
return self.coresys.core
@property
def sys_cli(self) -> HaCli:
"""Return HaCli object."""
return self.coresys.cli
@property
def sys_arch(self) -> CpuArch:
"""Return CpuArch object."""

View File

@@ -1,15 +1,31 @@
.:53 {
log
errors
loop
hosts /config/hosts {
fallthrough
}
template ANY AAAA local.hass.io hassio {
rcode NOERROR
}
forward . $servers {
forward . {{ locals | join(" ") }} dns://127.0.0.1:5353 {
except local.hass.io
policy sequential
health_check 5s
}
fallback REFUSED . dns://127.0.0.1:5353
fallback SERVFAIL . dns://127.0.0.1:5353
fallback NXDOMAIN . dns://127.0.0.1:5353
cache 10
}
.:5353 {
log
errors
forward . tls://1.1.1.1 tls://1.0.0.1 {
tls_servername cloudflare-dns.com
except local.hass.io
health_check 10s
}
cache 30
}

View File

@@ -20,7 +20,7 @@
{% if default_sink %}default-sink = {{ default_sink }}{% endif %}
{% if default_source %}default-source = {{ default_source }}{% endif %}
default-server = unix://run/pulse.sock
default-server = unix://run/audio/pulse.sock
; default-dbus-server =
autospawn = no

View File

@@ -1,11 +0,0 @@
"""Discovery service for Home Panel."""
import voluptuous as vol
from supervisor.validate import network_port
from ..const import ATTR_HOST, ATTR_PORT
SCHEMA = vol.Schema(
{vol.Required(ATTR_HOST): vol.Coerce(str), vol.Required(ATTR_PORT): network_port}
)

View File

@@ -4,13 +4,13 @@ from contextlib import suppress
from ipaddress import IPv4Address
import logging
from pathlib import Path
from string import Template
from typing import Awaitable, List, Optional
import attr
import jinja2
import voluptuous as vol
from .const import ATTR_SERVERS, ATTR_VERSION, DNS_SERVERS, DNS_SUFFIX, FILE_HASSIO_DNS
from .const import ATTR_SERVERS, ATTR_VERSION, DNS_SUFFIX, FILE_HASSIO_DNS
from .coresys import CoreSys, CoreSysAttributes
from .docker.dns import DockerDNS
from .docker.stats import DockerStats
@@ -42,8 +42,10 @@ class CoreDNS(JsonConfig, CoreSysAttributes):
self.coresys: CoreSys = coresys
self.instance: DockerDNS = DockerDNS(coresys)
self.forwarder: DNSForward = DNSForward()
self.coredns_template: Optional[jinja2.Template] = None
self._hosts: List[HostEntry] = []
self._loop: bool = False
@property
def corefile(self) -> Path:
@@ -116,6 +118,12 @@ class CoreDNS(JsonConfig, CoreSysAttributes):
# Start DNS forwarder
self.sys_create_task(self.forwarder.start(self.sys_docker.network.dns))
# Initialize CoreDNS Template
try:
self.coredns_template = jinja2.Template(COREDNS_TMPL.read_text())
except OSError as err:
_LOGGER.error("Can't read coredns.tmpl: %s", err)
# Run CoreDNS
with suppress(CoreDNSError):
if await self.instance.is_running():
@@ -202,30 +210,43 @@ class CoreDNS(JsonConfig, CoreSysAttributes):
self.hosts.unlink()
self._init_hosts()
# Reset loop protection
self._loop = False
await self.sys_addons.sync_dns()
async def loop_detection(self) -> None:
"""Check if there was a loop found."""
log = await self.instance.logs()
# Check the log for loop plugin output
if b"plugin/loop: Loop" in log:
_LOGGER.error("Detect a DNS loop in local Network!")
self._loop = True
else:
self._loop = False
def _write_corefile(self) -> None:
"""Write CoreDNS config."""
dns_servers: List[str] = []
# Load Template
try:
corefile_template: Template = Template(COREDNS_TMPL.read_text())
except OSError as err:
_LOGGER.error("Can't read coredns template file: %s", err)
raise CoreDNSError() from None
local_dns: List[str] = []
servers: List[str] = []
# Prepare DNS serverlist: Prio 1 Manual, Prio 2 Local, Prio 3 Fallback
local_dns: List[str] = self.sys_host.network.dns_servers or ["dns://127.0.0.11"]
servers: List[str] = self.servers + local_dns + DNS_SERVERS
if not self._loop:
local_dns = self.sys_host.network.dns_servers or ["dns://127.0.0.11"]
servers = self.servers + local_dns
else:
_LOGGER.warning("Ignore user DNS settings because of loop")
# Print some usefully debug data
_LOGGER.debug(
"config-dns = %s, local-dns = %s , backup-dns = %s",
"config-dns = %s, local-dns = %s , backup-dns = CloudFlare DoT",
self.servers,
local_dns,
DNS_SERVERS,
)
# Make sure, they are valid
for server in servers:
try:
dns_url(server)
@@ -235,7 +256,7 @@ class CoreDNS(JsonConfig, CoreSysAttributes):
_LOGGER.warning("Ignore invalid DNS Server: %s", server)
# Generate config file
data = corefile_template.safe_substitute(servers=" ".join(dns_servers))
data = self.coredns_template.render(locals=dns_servers)
try:
self.corefile.write_text(data)
@@ -339,7 +360,6 @@ class CoreDNS(JsonConfig, CoreSysAttributes):
def is_fails(self) -> Awaitable[bool]:
"""Return True if a Docker container is fails state.
Return a coroutine.
"""
return self.instance.is_fails()

View File

@@ -117,8 +117,8 @@ class DockerAddon(DockerInterface):
return {
**addon_env,
ENV_TIME: self.sys_timezone,
ENV_TOKEN: self.addon.hassio_token,
ENV_TOKEN_OLD: self.addon.hassio_token,
ENV_TOKEN: self.addon.supervisor_token,
ENV_TOKEN_OLD: self.addon.supervisor_token,
}
@property
@@ -213,10 +213,10 @@ class DockerAddon(DockerInterface):
@property
def volumes(self) -> Dict[str, Dict[str, str]]:
"""Generate volumes for mappings."""
volumes = {str(self.addon.path_extern_data): {"bind": "/data", "mode": "rw"}}
addon_mapping = self.addon.map_volumes
volumes = {str(self.addon.path_extern_data): {"bind": "/data", "mode": "rw"}}
# setup config mappings
if MAP_CONFIG in addon_mapping:
volumes.update(
@@ -298,7 +298,7 @@ class DockerAddon(DockerInterface):
# Host D-Bus system
if self.addon.host_dbus:
volumes.update({"/run/dbus": {"bind": "/run/dbus", "mode": "rw"}})
volumes.update({"/run/dbus": {"bind": "/run/dbus", "mode": "ro"}})
# Configuration Audio
if self.addon.with_audio:
@@ -309,8 +309,8 @@ class DockerAddon(DockerInterface):
"mode": "ro",
},
str(self.sys_audio.path_extern_pulse): {
"bind": "/run/pulse.sock",
"mode": "rw",
"bind": "/run/audio",
"mode": "ro",
},
str(self.sys_audio.path_extern_asound): {
"bind": "/etc/asound.conf",
@@ -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

@@ -1,6 +1,8 @@
"""Audio docker object."""
from contextlib import suppress
import logging
from pathlib import Path
from typing import Dict
from ..const import ENV_TIME
from ..coresys import CoreSysAttributes
@@ -25,6 +27,22 @@ class DockerAudio(DockerInterface, CoreSysAttributes):
"""Return name of Docker container."""
return AUDIO_DOCKER_NAME
@property
def volumes(self) -> Dict[str, Dict[str, str]]:
"""Return Volumes for the mount."""
volumes = {
str(self.sys_config.path_extern_audio): {"bind": "/data", "mode": "rw"},
"/etc/group": {"bind": "/host/group", "mode": "ro"},
}
# SND support
if Path("/dev/snd").exists():
volumes.update({"/dev/snd": {"bind": "/dev/snd", "mode": "rw"}})
else:
_LOGGER.warning("Kernel have no audio support in")
return volumes
def _run(self) -> None:
"""Run Docker image.
@@ -41,20 +59,14 @@ 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("_", "-"),
detach=True,
privileged=True,
environment={ENV_TIME: self.sys_timezone},
volumes={
str(self.sys_config.path_extern_audio): {
"bind": "/data",
"mode": "rw",
},
"/dev/snd": {"bind": "/dev/snd", "mode": "rw"},
"/etc/group": {"bind": "/host/group", "mode": "ro"},
},
volumes=self.volumes,
)
self._meta = docker_container.attrs

64
supervisor/docker/cli.py Normal file
View File

@@ -0,0 +1,64 @@
"""HA Cli docker object."""
from contextlib import suppress
import logging
from ..coresys import CoreSysAttributes
from ..exceptions import DockerAPIError
from .interface import DockerInterface
from ..const import ENV_TIME, ENV_TOKEN
_LOGGER: logging.Logger = logging.getLogger(__name__)
CLI_DOCKER_NAME: str = "hassio_cli"
class DockerCli(DockerInterface, CoreSysAttributes):
"""Docker Supervisor wrapper for HA cli."""
@property
def image(self):
"""Return name of HA cli image."""
return f"homeassistant/{self.sys_arch.supervisor}-hassio-cli"
@property
def name(self) -> str:
"""Return name of Docker container."""
return CLI_DOCKER_NAME
def _run(self) -> None:
"""Run Docker image.
Need run inside executor.
"""
if self._is_running():
return
# Cleanup
with suppress(DockerAPIError):
self._stop()
# Create & Run container
docker_container = self.sys_docker.run(
self.image,
entrypoint=["/init"],
command=["/bin/bash", "-c", "sleep infinity"],
version=self.sys_cli.version,
init=False,
ipv4=self.sys_docker.network.cli,
name=self.name,
hostname=self.name.replace("_", "-"),
detach=True,
extra_hosts={"supervisor": self.sys_docker.network.supervisor},
environment={
ENV_TIME: self.sys_timezone,
ENV_TOKEN: self.sys_cli.supervisor_token,
},
)
self._meta = docker_container.attrs
_LOGGER.info(
"Start CLI %s with version %s - %s",
self.image,
self.version,
self.sys_docker.network.audio,
)

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

@@ -1,38 +0,0 @@
"""HassOS Cli docker object."""
import logging
import docker
from ..coresys import CoreSysAttributes
from .interface import DockerInterface
_LOGGER: logging.Logger = logging.getLogger(__name__)
class DockerHassOSCli(DockerInterface, CoreSysAttributes):
"""Docker Supervisor wrapper for HassOS Cli."""
@property
def image(self):
"""Return name of HassOS CLI image."""
return f"homeassistant/{self.sys_arch.supervisor}-hassio-cli"
def _stop(self, remove_container=True):
"""Don't need stop."""
return True
def _attach(self, tag: str):
"""Attach to running Docker container.
Need run inside executor.
"""
try:
image = self.sys_docker.images.get(f"{self.image}:{tag}")
except docker.errors.DockerException:
_LOGGER.warning("Can't find a HassOS CLI %s", self.image)
else:
self._meta = image.attrs
_LOGGER.info(
"Found HassOS CLI %s with version %s", self.image, self.version
)

View File

@@ -2,7 +2,7 @@
from contextlib import suppress
from ipaddress import IPv4Address
import logging
from typing import Awaitable, Optional
from typing import Awaitable, Dict, Optional
import docker
@@ -45,6 +45,46 @@ class DockerHomeAssistant(DockerInterface):
"""Return IP address of this container."""
return self.sys_docker.network.gateway
@property
def volumes(self) -> Dict[str, Dict[str, str]]:
"""Return Volumes for the mount."""
volumes = {}
# Add folders
volumes.update(
{
str(self.sys_config.path_extern_homeassistant): {
"bind": "/config",
"mode": "rw",
},
str(self.sys_config.path_extern_ssl): {"bind": "/ssl", "mode": "ro"},
str(self.sys_config.path_extern_share): {
"bind": "/share",
"mode": "rw",
},
}
)
# Configuration Audio
volumes.update(
{
str(self.sys_homeassistant.path_extern_pulse): {
"bind": "/etc/pulse/client.conf",
"mode": "ro",
},
str(self.sys_audio.path_extern_pulse): {
"bind": "/run/audio",
"mode": "ro",
},
str(self.sys_audio.path_extern_asound): {
"bind": "/etc/asound.conf",
"mode": "ro",
},
}
)
return volumes
def _run(self) -> None:
"""Run Docker image.
@@ -67,23 +107,13 @@ class DockerHomeAssistant(DockerInterface):
privileged=True,
init=False,
network_mode="host",
volumes=self.volumes,
environment={
"HASSIO": self.sys_docker.network.supervisor,
"SUPERVISOR": self.sys_docker.network.supervisor,
ENV_TIME: self.sys_timezone,
ENV_TOKEN: self.sys_homeassistant.hassio_token,
ENV_TOKEN_OLD: self.sys_homeassistant.hassio_token,
},
volumes={
str(self.sys_config.path_extern_homeassistant): {
"bind": "/config",
"mode": "rw",
},
str(self.sys_config.path_extern_ssl): {"bind": "/ssl", "mode": "ro"},
str(self.sys_config.path_extern_share): {
"bind": "/share",
"mode": "rw",
},
ENV_TOKEN: self.sys_homeassistant.supervisor_token,
ENV_TOKEN_OLD: self.sys_homeassistant.supervisor_token,
},
)
@@ -105,7 +135,6 @@ class DockerHomeAssistant(DockerInterface):
detach=True,
stdout=True,
stderr=True,
environment={ENV_TIME: self.sys_timezone},
volumes={
str(self.sys_config.path_extern_homeassistant): {
"bind": "/config",
@@ -117,6 +146,7 @@ class DockerHomeAssistant(DockerInterface):
"mode": "ro",
},
},
environment={ENV_TIME: self.sys_timezone},
)
def is_initialize(self) -> Awaitable[bool]:

View File

@@ -53,6 +53,11 @@ class DockerNetwork:
"""Return audio of the network."""
return DOCKER_NETWORK_MASK[4]
@property
def cli(self) -> IPv4Address:
"""Return cli of the network."""
return DOCKER_NETWORK_MASK[5]
def _get_network(self) -> docker.models.networks.Network:
"""Get supervisor network."""
try:

View File

@@ -54,6 +54,17 @@ class HassOSNotSupportedError(HassioNotSupportedError):
"""Function not supported by HassOS."""
# HaCli
class CliError(HassioError):
"""HA cli exception."""
class CliUpdateError(HassOSError):
"""Error on update of a HA cli."""
# DNS

View File

@@ -1,6 +1,5 @@
"""HassOS support on supervisor."""
import asyncio
from contextlib import suppress
import logging
from pathlib import Path
from typing import Awaitable, Optional
@@ -10,12 +9,10 @@ from cpe import CPE
from .const import URL_HASSOS_OTA
from .coresys import CoreSysAttributes, CoreSys
from .docker.hassos_cli import DockerHassOSCli
from .exceptions import (
DBusError,
HassOSNotSupportedError,
HassOSUpdateError,
DockerAPIError,
)
from .dbus.rauc import RaucState
@@ -28,7 +25,6 @@ class HassOS(CoreSysAttributes):
def __init__(self, coresys: CoreSys):
"""Initialize HassOS handler."""
self.coresys: CoreSys = coresys
self.instance: DockerHassOSCli = DockerHassOSCli(coresys)
self._available: bool = False
self._version: Optional[str] = None
self._board: Optional[str] = None
@@ -44,29 +40,14 @@ class HassOS(CoreSysAttributes):
return self._version
@property
def version_cli(self) -> Optional[str]:
"""Return version of HassOS cli."""
return self.instance.version
@property
def version_latest(self) -> str:
def latest_version(self) -> str:
"""Return version of HassOS."""
return self.sys_updater.version_hassos
@property
def version_cli_latest(self) -> str:
"""Return version of HassOS."""
return self.sys_updater.version_cli
@property
def need_update(self) -> bool:
"""Return true if a HassOS update is available."""
return self.version != self.version_latest
@property
def need_cli_update(self) -> bool:
"""Return true if a HassOS cli update is available."""
return self.version_cli != self.version_cli_latest
return self.version != self.latest_version
@property
def board(self) -> Optional[str]:
@@ -134,8 +115,6 @@ class HassOS(CoreSysAttributes):
_LOGGER.info(
"Detect HassOS %s / BootSlot %s", self.version, self.sys_dbus.rauc.boot_slot
)
with suppress(DockerAPIError):
await self.instance.attach(tag="latest")
def config_sync(self) -> Awaitable[None]:
"""Trigger a host config reload from usb.
@@ -149,7 +128,7 @@ class HassOS(CoreSysAttributes):
async def update(self, version: Optional[str] = None) -> None:
"""Update HassOS system."""
version = version or self.version_latest
version = version or self.latest_version
# Check installed version
self._check_host()
@@ -183,35 +162,6 @@ class HassOS(CoreSysAttributes):
_LOGGER.error("HassOS update fails with: %s", self.sys_dbus.rauc.last_error)
raise HassOSUpdateError()
async def update_cli(self, version: Optional[str] = None) -> None:
"""Update local HassOS cli."""
version = version or self.version_cli_latest
if version == self.version_cli:
_LOGGER.warning("Version %s is already installed for CLI", version)
return
try:
await self.instance.update(version, latest=True)
except DockerAPIError:
_LOGGER.error("HassOS CLI update fails")
raise HassOSUpdateError() from None
else:
# Cleanup
with suppress(DockerAPIError):
await self.instance.cleanup()
async def repair_cli(self) -> None:
"""Repair CLI container."""
if await self.instance.exists():
return
_LOGGER.info("Repair HassOS CLI %s", self.version_cli)
try:
await self.instance.install(self.version_cli, latest=True)
except DockerAPIError:
_LOGGER.error("Repairing of HassOS CLI fails")
async def mark_healthy(self):
"""Set booted partition as good for rauc."""
try:

View File

@@ -8,6 +8,7 @@ import os
from pathlib import Path
import re
import secrets
import shutil
import time
from typing import Any, AsyncContextManager, Awaitable, Dict, Optional
from uuid import UUID
@@ -19,6 +20,8 @@ from packaging import version as pkg_version
from .const import (
ATTR_ACCESS_TOKEN,
ATTR_AUDIO_INPUT,
ATTR_AUDIO_OUTPUT,
ATTR_BOOT,
ATTR_IMAGE,
ATTR_LAST_VERSION,
@@ -218,7 +221,7 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
return self._data[ATTR_UUID]
@property
def hassio_token(self) -> str:
def supervisor_token(self) -> str:
"""Return an access token for the Supervisor API."""
return self._data.get(ATTR_ACCESS_TOKEN)
@@ -232,6 +235,36 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
"""Set Home Assistant refresh_token."""
self._data[ATTR_REFRESH_TOKEN] = value
@property
def path_pulse(self):
"""Return path to asound config."""
return Path(self.sys_config.path_tmp, "homeassistant_pulse")
@property
def path_extern_pulse(self):
"""Return path to asound config for Docker."""
return Path(self.sys_config.path_extern_tmp, "homeassistant_pulse")
@property
def audio_output(self) -> Optional[str]:
"""Return a pulse profile for output or None."""
return self._data[ATTR_AUDIO_OUTPUT]
@audio_output.setter
def audio_output(self, value: Optional[str]):
"""Set audio output profile settings."""
self._data[ATTR_AUDIO_OUTPUT] = value
@property
def audio_input(self) -> Optional[str]:
"""Return pulse profile for input or None."""
return self._data[ATTR_AUDIO_INPUT]
@audio_input.setter
def audio_input(self, value: Optional[str]):
"""Set audio input settings."""
self._data[ATTR_AUDIO_INPUT] = value
@process_lock
async def install_landingpage(self) -> None:
"""Install a landing page."""
@@ -334,6 +367,9 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
self._data[ATTR_ACCESS_TOKEN] = secrets.token_hex(56)
self.save_data()
# Write audio settings
self.write_pulse()
try:
await self.instance.run()
except DockerAPIError:
@@ -429,13 +465,16 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
"python3 -m homeassistant -c /config --script check_config"
)
# if not valid
# If not valid
if result.exit_code is None:
_LOGGER.error("Fatal error on config check!")
raise HomeAssistantError()
# parse output
# Convert output
log = convert_to_ascii(result.output)
_LOGGER.debug("Result config check: %s", log.strip())
# Parse output
if result.exit_code != 0 or RE_YAML_ERROR.search(log):
_LOGGER.error("Invalid Home Assistant config found!")
return ConfigResult(False, log)
@@ -602,3 +641,22 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
await self.instance.install(self.version)
except DockerAPIError:
_LOGGER.error("Repairing of Home Assistant fails")
def write_pulse(self):
"""Write asound config to file and return True on success."""
pulse_config = self.sys_audio.pulse_client(
input_profile=self.audio_input, output_profile=self.audio_output
)
# Cleanup wrong maps
if self.path_pulse.is_dir():
shutil.rmtree(self.path_pulse, ignore_errors=True)
# Write pulse config
try:
with self.path_pulse.open("w") as config_file:
config_file.write(pulse_config)
except OSError as err:
_LOGGER.error("Home Assistant can't write pulse/client.config: %s", err)
else:
_LOGGER.info("Update pulse/client.config: %s", self.path_pulse)

View File

@@ -73,7 +73,7 @@ class AppArmorControl(CoreSysAttributes):
# Copy to AppArmor folder
dest_profile = Path(self.sys_config.path_apparmor, profile_name)
try:
shutil.copy(profile_file, dest_profile)
shutil.copyfile(profile_file, dest_profile)
except OSError as err:
_LOGGER.error("Can't copy %s: %s", profile_file, err)
raise HostAppArmorError() from None

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:
stream = pulse.source_info(index)
else:
# Get sink and set it as default
stream = pulse.get_sink_by_name(name)
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:

View File

@@ -19,20 +19,25 @@ class HwMonitor(CoreSysAttributes):
"""Initialize Hardware Monitor object."""
self.coresys: CoreSys = coresys
self.context = pyudev.Context()
self.monitor = pyudev.Monitor.from_netlink(self.context)
self.monitor: Optional[pyudev.Monitor] = None
self.observer: Optional[pyudev.MonitorObserver] = None
async def load(self) -> None:
"""Start hardware monitor."""
self.observer = pyudev.MonitorObserver(self.monitor, self._udev_events)
self.observer.start()
_LOGGER.info("Start Supervisor hardware monitor")
try:
self.monitor = pyudev.Monitor.from_netlink(self.context)
self.observer = pyudev.MonitorObserver(self.monitor, self._udev_events)
except OSError:
_LOGGER.fatal("Not privileged to run udev monitor!")
else:
self.observer.start()
_LOGGER.info("Started Supervisor hardware monitor")
async def unload(self) -> None:
"""Shutdown sessions."""
if self.observer is None:
return
self.observer.stop()
_LOGGER.info("Stop Supervisor hardware monitor")

View File

@@ -16,6 +16,8 @@ from voluptuous.humanize import humanize_error
from ..const import (
ATTR_ADDONS,
ATTR_AUDIO_INPUT,
ATTR_AUDIO_OUTPUT,
ATTR_BOOT,
ATTR_CRYPTO,
ATTR_DATE,
@@ -443,6 +445,10 @@ class Snapshot(CoreSysAttributes):
self.sys_homeassistant.refresh_token
)
# Audio
self.homeassistant[ATTR_AUDIO_INPUT] = self.sys_homeassistant.audio_input
self.homeassistant[ATTR_AUDIO_OUTPUT] = self.sys_homeassistant.audio_output
def restore_homeassistant(self):
"""Write all data to the Home Assistant object."""
self.sys_homeassistant.watchdog = self.homeassistant[ATTR_WATCHDOG]
@@ -463,6 +469,10 @@ class Snapshot(CoreSysAttributes):
self.homeassistant[ATTR_REFRESH_TOKEN]
)
# Audio
self.sys_homeassistant.audio_input = self.homeassistant[ATTR_AUDIO_INPUT]
self.sys_homeassistant.audio_output = self.homeassistant[ATTR_AUDIO_OUTPUT]
# save
self.sys_homeassistant.save_data()

View File

@@ -3,6 +3,8 @@ import voluptuous as vol
from ..const import (
ATTR_ADDONS,
ATTR_AUDIO_INPUT,
ATTR_AUDIO_OUTPUT,
ATTR_BOOT,
ATTR_CRYPTO,
ATTR_DATE,
@@ -68,6 +70,12 @@ SCHEMA_SNAPSHOT = vol.Schema(
vol.Optional(ATTR_WAIT_BOOT, default=600): vol.All(
vol.Coerce(int), vol.Range(min=60)
),
vol.Optional(ATTR_AUDIO_OUTPUT, default=None): vol.Maybe(
vol.Coerce(str)
),
vol.Optional(ATTR_AUDIO_INPUT, default=None): vol.Maybe(
vol.Coerce(str)
),
},
extra=vol.REMOVE_EXTRA,
),

View File

@@ -115,7 +115,7 @@ class Supervisor(CoreSysAttributes):
_LOGGER.info("Update Supervisor to version %s", version)
try:
await self.instance.update(version, latest=True)
await self.instance.install(version, image=None, latest=True)
except DockerAPIError:
_LOGGER.error("Update of Supervisor fails!")
raise SupervisorUpdateError() from None

View File

@@ -25,7 +25,8 @@ RUN_WATCHDOG_HOMEASSISTANT_DOCKER = 15
RUN_WATCHDOG_HOMEASSISTANT_API = 300
RUN_WATCHDOG_DNS_DOCKER = 20
RUN_WATCHDOG_AUDIO_DOCKER = 20
RUN_WATCHDOG_AUDIO_DOCKER = 30
RUN_WATCHDOG_CLI_DOCKER = 40
class Tasks(CoreSysAttributes):
@@ -102,6 +103,11 @@ class Tasks(CoreSysAttributes):
self._watchdog_audio_docker, RUN_WATCHDOG_AUDIO_DOCKER
)
)
self.jobs.add(
self.sys_scheduler.register_task(
self._watchdog_cli_docker, RUN_WATCHDOG_CLI_DOCKER
)
)
_LOGGER.info("All core tasks are scheduled")
@@ -202,12 +208,12 @@ class Tasks(CoreSysAttributes):
self._cache[HASS_WATCHDOG_API] = 0
async def _update_cli(self):
"""Check and run update of CLI."""
if not self.sys_hassos.need_cli_update:
"""Check and run update of cli."""
if not self.sys_cli.need_update:
return
_LOGGER.info("Found new CLI version")
await self.sys_hassos.update_cli()
_LOGGER.info("Found new cli version")
await self.sys_cli.update()
async def _update_dns(self):
"""Check and run update of CoreDNS plugin."""
@@ -228,13 +234,15 @@ class Tasks(CoreSysAttributes):
async def _watchdog_dns_docker(self):
"""Check running state of Docker and start if they is close."""
# if CoreDNS is active
if await self.sys_dns.is_running():
if await self.sys_dns.is_running() or self.sys_dns.in_progress:
return
_LOGGER.warning("Watchdog found a problem with CoreDNS plugin!")
# Reset of fails
if await self.sys_dns.is_fails():
_LOGGER.warning("CoreDNS plugin is in fails state / Reset config")
_LOGGER.error("CoreDNS plugin is in fails state / Reset config")
await self.sys_dns.reset()
await self.sys_dns.loop_detection()
try:
await self.sys_dns.start()
@@ -244,7 +252,7 @@ class Tasks(CoreSysAttributes):
async def _watchdog_audio_docker(self):
"""Check running state of Docker and start if they is close."""
# if PulseAudio plugin is active
if await self.sys_audio.is_running():
if await self.sys_audio.is_running() or self.sys_audio.in_progress:
return
_LOGGER.warning("Watchdog found a problem with PulseAudio plugin!")
@@ -252,3 +260,15 @@ class Tasks(CoreSysAttributes):
await self.sys_audio.start()
except CoreDNSError:
_LOGGER.error("Watchdog PulseAudio reanimation fails!")
async def _watchdog_cli_docker(self):
"""Check running state of Docker and start if they is close."""
# if cli plugin is active
if await self.sys_cli.is_running() or self.sys_cli.in_progress:
return
_LOGGER.warning("Watchdog found a problem with cli plugin!")
try:
await self.sys_cli.start()
except CoreDNSError:
_LOGGER.error("Watchdog cli reanimation fails!")

View File

@@ -208,7 +208,7 @@ class DBus:
raise exception()
# General
_LOGGER.error("DBus return error: %s", error)
_LOGGER.error("DBus return error: %s", error.strip())
raise DBusFatalError()
def attach_signals(self, filters=None):

View File

@@ -9,6 +9,8 @@ from .const import (
ATTR_ACCESS_TOKEN,
ATTR_ADDONS_CUSTOM_LIST,
ATTR_AUDIO,
ATTR_AUDIO_INPUT,
ATTR_AUDIO_OUTPUT,
ATTR_BOOT,
ATTR_CHANNEL,
ATTR_CLI,
@@ -111,6 +113,8 @@ SCHEMA_HASS_CONFIG = vol.Schema(
vol.Optional(ATTR_WAIT_BOOT, default=600): vol.All(
vol.Coerce(int), vol.Range(min=60)
),
vol.Optional(ATTR_AUDIO_OUTPUT, default=None): vol.Maybe(vol.Coerce(str)),
vol.Optional(ATTR_AUDIO_INPUT, default=None): vol.Maybe(vol.Coerce(str)),
},
extra=vol.REMOVE_EXTRA,
)
@@ -178,3 +182,12 @@ SCHEMA_DNS_CONFIG = vol.Schema(
SCHEMA_AUDIO_CONFIG = vol.Schema(
{vol.Optional(ATTR_VERSION): vol.Maybe(vol.Coerce(str))}, extra=vol.REMOVE_EXTRA,
)
SCHEMA_CLI_CONFIG = vol.Schema(
{
vol.Optional(ATTR_VERSION): vol.Maybe(vol.Coerce(str)),
vol.Optional(ATTR_ACCESS_TOKEN): token,
},
extra=vol.REMOVE_EXTRA,
)

View File

@@ -1,19 +0,0 @@
"""Test adguard discovery."""
import voluptuous as vol
import pytest
from supervisor.discovery.validate import valid_discovery_config
def test_good_config():
"""Test good deconz config."""
valid_discovery_config("home_panel", {"host": "test", "port": 3812})
def test_bad_config():
"""Test good adguard config."""
with pytest.raises(vol.Invalid):
valid_discovery_config("home_panel", {"host": "test"})