mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-09-19 01:49:36 +00:00
Compare commits
39 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
fa783a0d2c | ||
![]() |
96c0fbaf10 | ||
![]() |
24f7801ddc | ||
![]() |
8e83e007e9 | ||
![]() |
d0db466e67 | ||
![]() |
3010bd4eb6 | ||
![]() |
069bed8815 | ||
![]() |
d2088ae5f8 | ||
![]() |
0ca5a241bb | ||
![]() |
dff32a8e84 | ||
![]() |
4a20344652 | ||
![]() |
98b969ef06 | ||
![]() |
c8cb8aecf7 | ||
![]() |
73e8875018 | ||
![]() |
02aed9c084 | ||
![]() |
89148f8fff | ||
![]() |
6bde527f5c | ||
![]() |
d62aabc01b | ||
![]() |
82299a3799 | ||
![]() |
c02f30dd7e | ||
![]() |
e91983adb4 | ||
![]() |
ff88359429 | ||
![]() |
5a60d5cbe8 | ||
![]() |
2b41ffe019 | ||
![]() |
1c23e26f93 | ||
![]() |
3d555f951d | ||
![]() |
6d39b4d7cd | ||
![]() |
4fe5d09f01 | ||
![]() |
e52af3bfb4 | ||
![]() |
0467b33cd5 | ||
![]() |
14167f6e13 | ||
![]() |
7a1aba6f81 | ||
![]() |
920f7f2ece | ||
![]() |
06fadbd70f | ||
![]() |
d4f486864f | ||
![]() |
d3a21303d9 | ||
![]() |
e1cbfdd84b | ||
![]() |
87170a4497 | ||
![]() |
ae6f8bd345 |
180
.vscode/tasks.json
vendored
180
.vscode/tasks.json
vendored
@@ -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
114
API.md
@@ -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
|
||||
{
|
||||
|
@@ -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'
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -2,4 +2,6 @@
|
||||
# ==============================================================================
|
||||
# Start Service service
|
||||
# ==============================================================================
|
||||
export LD_PRELOAD="/usr/local/lib/libjemalloc.so.2"
|
||||
|
||||
exec python3 -m supervisor
|
@@ -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
|
||||
|
@@ -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")
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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."""
|
||||
|
@@ -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."""
|
||||
|
@@ -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,
|
||||
|
@@ -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),
|
||||
]
|
||||
)
|
||||
|
@@ -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
62
supervisor/api/cli.py
Normal 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))
|
@@ -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
|
||||
|
@@ -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())
|
@@ -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!")
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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():
|
||||
|
@@ -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
156
supervisor/cli.py
Normal 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")
|
@@ -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"
|
||||
|
@@ -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")
|
||||
|
@@ -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."""
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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
|
||||
|
@@ -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}
|
||||
)
|
@@ -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()
|
||||
|
@@ -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,
|
||||
|
@@ -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
64
supervisor/docker/cli.py
Normal 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,
|
||||
)
|
@@ -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,
|
||||
|
@@ -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
|
||||
)
|
@@ -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]:
|
||||
|
@@ -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:
|
||||
|
@@ -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
|
||||
|
||||
|
||||
|
@@ -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:
|
||||
|
@@ -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)
|
||||
|
@@ -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
|
||||
|
@@ -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:
|
||||
|
@@ -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")
|
||||
|
||||
|
@@ -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()
|
||||
|
||||
|
@@ -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,
|
||||
),
|
||||
|
@@ -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
|
||||
|
@@ -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!")
|
||||
|
@@ -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):
|
||||
|
@@ -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,
|
||||
)
|
||||
|
@@ -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"})
|
Reference in New Issue
Block a user