mirror of
https://github.com/home-assistant/core.git
synced 2025-08-02 10:08:23 +00:00
Merge branch 'dev' of https://github.com/redgtech-automacao/core into dev
This commit is contained in:
commit
784dc233b9
6
.github/workflows/builder.yml
vendored
6
.github/workflows/builder.yml
vendored
@ -32,7 +32,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
uses: actions/setup-python@v5.3.0
|
uses: actions/setup-python@v5.4.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
|
|
||||||
@ -116,7 +116,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
if: needs.init.outputs.channel == 'dev'
|
if: needs.init.outputs.channel == 'dev'
|
||||||
uses: actions/setup-python@v5.3.0
|
uses: actions/setup-python@v5.4.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
|
|
||||||
@ -454,7 +454,7 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
|
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
uses: actions/setup-python@v5.3.0
|
uses: actions/setup-python@v5.4.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
|
|
||||||
|
34
.github/workflows/ci.yaml
vendored
34
.github/workflows/ci.yaml
vendored
@ -40,7 +40,7 @@ env:
|
|||||||
CACHE_VERSION: 11
|
CACHE_VERSION: 11
|
||||||
UV_CACHE_VERSION: 1
|
UV_CACHE_VERSION: 1
|
||||||
MYPY_CACHE_VERSION: 9
|
MYPY_CACHE_VERSION: 9
|
||||||
HA_SHORT_VERSION: "2025.2"
|
HA_SHORT_VERSION: "2025.3"
|
||||||
DEFAULT_PYTHON: "3.13"
|
DEFAULT_PYTHON: "3.13"
|
||||||
ALL_PYTHON_VERSIONS: "['3.13']"
|
ALL_PYTHON_VERSIONS: "['3.13']"
|
||||||
# 10.3 is the oldest supported version
|
# 10.3 is the oldest supported version
|
||||||
@ -234,7 +234,7 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@v5.3.0
|
uses: actions/setup-python@v5.4.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
check-latest: true
|
check-latest: true
|
||||||
@ -279,7 +279,7 @@ jobs:
|
|||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
uses: actions/setup-python@v5.3.0
|
uses: actions/setup-python@v5.4.0
|
||||||
id: python
|
id: python
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
@ -319,7 +319,7 @@ jobs:
|
|||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
uses: actions/setup-python@v5.3.0
|
uses: actions/setup-python@v5.4.0
|
||||||
id: python
|
id: python
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
@ -359,7 +359,7 @@ jobs:
|
|||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
uses: actions/setup-python@v5.3.0
|
uses: actions/setup-python@v5.4.0
|
||||||
id: python
|
id: python
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
@ -469,7 +469,7 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@v5.3.0
|
uses: actions/setup-python@v5.4.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
check-latest: true
|
check-latest: true
|
||||||
@ -572,7 +572,7 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@v5.3.0
|
uses: actions/setup-python@v5.4.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
check-latest: true
|
check-latest: true
|
||||||
@ -605,7 +605,7 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@v5.3.0
|
uses: actions/setup-python@v5.4.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
check-latest: true
|
check-latest: true
|
||||||
@ -643,7 +643,7 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@v5.3.0
|
uses: actions/setup-python@v5.4.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
check-latest: true
|
check-latest: true
|
||||||
@ -686,7 +686,7 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@v5.3.0
|
uses: actions/setup-python@v5.4.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
check-latest: true
|
check-latest: true
|
||||||
@ -733,7 +733,7 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@v5.3.0
|
uses: actions/setup-python@v5.4.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
check-latest: true
|
check-latest: true
|
||||||
@ -778,7 +778,7 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@v5.3.0
|
uses: actions/setup-python@v5.4.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
check-latest: true
|
check-latest: true
|
||||||
@ -859,7 +859,7 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@v5.3.0
|
uses: actions/setup-python@v5.4.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
check-latest: true
|
check-latest: true
|
||||||
@ -923,7 +923,7 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@v5.3.0
|
uses: actions/setup-python@v5.4.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
check-latest: true
|
check-latest: true
|
||||||
@ -1044,7 +1044,7 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@v5.3.0
|
uses: actions/setup-python@v5.4.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
check-latest: true
|
check-latest: true
|
||||||
@ -1173,7 +1173,7 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@v5.3.0
|
uses: actions/setup-python@v5.4.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
check-latest: true
|
check-latest: true
|
||||||
@ -1319,7 +1319,7 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@v5.3.0
|
uses: actions/setup-python@v5.4.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@ -24,11 +24,11 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v3.28.5
|
uses: github/codeql-action/init@v3.28.8
|
||||||
with:
|
with:
|
||||||
languages: python
|
languages: python
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v3.28.5
|
uses: github/codeql-action/analyze@v3.28.8
|
||||||
with:
|
with:
|
||||||
category: "/language:python"
|
category: "/language:python"
|
||||||
|
2
.github/workflows/translations.yml
vendored
2
.github/workflows/translations.yml
vendored
@ -22,7 +22,7 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
|
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
uses: actions/setup-python@v5.3.0
|
uses: actions/setup-python@v5.4.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
|
|
||||||
|
6
.github/workflows/wheels.yml
vendored
6
.github/workflows/wheels.yml
vendored
@ -36,7 +36,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@v5.3.0
|
uses: actions/setup-python@v5.4.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
check-latest: true
|
check-latest: true
|
||||||
@ -131,7 +131,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
abi: ["cp312", "cp313"]
|
abi: ["cp313"]
|
||||||
arch: ${{ fromJson(needs.init.outputs.architectures) }}
|
arch: ${{ fromJson(needs.init.outputs.architectures) }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout the repository
|
- name: Checkout the repository
|
||||||
@ -180,7 +180,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
abi: ["cp312", "cp313"]
|
abi: ["cp313"]
|
||||||
arch: ${{ fromJson(needs.init.outputs.architectures) }}
|
arch: ${{ fromJson(needs.init.outputs.architectures) }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout the repository
|
- name: Checkout the repository
|
||||||
|
@ -217,6 +217,7 @@ homeassistant.components.goalzero.*
|
|||||||
homeassistant.components.google.*
|
homeassistant.components.google.*
|
||||||
homeassistant.components.google_assistant_sdk.*
|
homeassistant.components.google_assistant_sdk.*
|
||||||
homeassistant.components.google_cloud.*
|
homeassistant.components.google_cloud.*
|
||||||
|
homeassistant.components.google_drive.*
|
||||||
homeassistant.components.google_photos.*
|
homeassistant.components.google_photos.*
|
||||||
homeassistant.components.google_sheets.*
|
homeassistant.components.google_sheets.*
|
||||||
homeassistant.components.govee_ble.*
|
homeassistant.components.govee_ble.*
|
||||||
@ -227,6 +228,7 @@ homeassistant.components.guardian.*
|
|||||||
homeassistant.components.habitica.*
|
homeassistant.components.habitica.*
|
||||||
homeassistant.components.hardkernel.*
|
homeassistant.components.hardkernel.*
|
||||||
homeassistant.components.hardware.*
|
homeassistant.components.hardware.*
|
||||||
|
homeassistant.components.heos.*
|
||||||
homeassistant.components.here_travel_time.*
|
homeassistant.components.here_travel_time.*
|
||||||
homeassistant.components.history.*
|
homeassistant.components.history.*
|
||||||
homeassistant.components.history_stats.*
|
homeassistant.components.history_stats.*
|
||||||
@ -359,6 +361,7 @@ homeassistant.components.number.*
|
|||||||
homeassistant.components.nut.*
|
homeassistant.components.nut.*
|
||||||
homeassistant.components.onboarding.*
|
homeassistant.components.onboarding.*
|
||||||
homeassistant.components.oncue.*
|
homeassistant.components.oncue.*
|
||||||
|
homeassistant.components.onedrive.*
|
||||||
homeassistant.components.onewire.*
|
homeassistant.components.onewire.*
|
||||||
homeassistant.components.onkyo.*
|
homeassistant.components.onkyo.*
|
||||||
homeassistant.components.open_meteo.*
|
homeassistant.components.open_meteo.*
|
||||||
|
4
CODEOWNERS
generated
4
CODEOWNERS
generated
@ -566,6 +566,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/google_assistant_sdk/ @tronikos
|
/tests/components/google_assistant_sdk/ @tronikos
|
||||||
/homeassistant/components/google_cloud/ @lufton @tronikos
|
/homeassistant/components/google_cloud/ @lufton @tronikos
|
||||||
/tests/components/google_cloud/ @lufton @tronikos
|
/tests/components/google_cloud/ @lufton @tronikos
|
||||||
|
/homeassistant/components/google_drive/ @tronikos
|
||||||
|
/tests/components/google_drive/ @tronikos
|
||||||
/homeassistant/components/google_generative_ai_conversation/ @tronikos
|
/homeassistant/components/google_generative_ai_conversation/ @tronikos
|
||||||
/tests/components/google_generative_ai_conversation/ @tronikos
|
/tests/components/google_generative_ai_conversation/ @tronikos
|
||||||
/homeassistant/components/google_mail/ @tkdrob
|
/homeassistant/components/google_mail/ @tkdrob
|
||||||
@ -1071,6 +1073,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/oncue/ @bdraco @peterager
|
/tests/components/oncue/ @bdraco @peterager
|
||||||
/homeassistant/components/ondilo_ico/ @JeromeHXP
|
/homeassistant/components/ondilo_ico/ @JeromeHXP
|
||||||
/tests/components/ondilo_ico/ @JeromeHXP
|
/tests/components/ondilo_ico/ @JeromeHXP
|
||||||
|
/homeassistant/components/onedrive/ @zweckj
|
||||||
|
/tests/components/onedrive/ @zweckj
|
||||||
/homeassistant/components/onewire/ @garbled1 @epenet
|
/homeassistant/components/onewire/ @garbled1 @epenet
|
||||||
/tests/components/onewire/ @garbled1 @epenet
|
/tests/components/onewire/ @garbled1 @epenet
|
||||||
/homeassistant/components/onkyo/ @arturpragacz @eclair4151
|
/homeassistant/components/onkyo/ @arturpragacz @eclair4151
|
||||||
|
@ -21,7 +21,7 @@ import voluptuous as vol
|
|||||||
|
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.network import is_cloud_connection
|
from homeassistant.helpers.network import is_cloud_connection
|
||||||
|
|
||||||
from .. import InvalidAuthError
|
from .. import InvalidAuthError
|
||||||
|
@ -18,6 +18,7 @@ import securetar
|
|||||||
from .const import __version__ as HA_VERSION
|
from .const import __version__ as HA_VERSION
|
||||||
|
|
||||||
RESTORE_BACKUP_FILE = ".HA_RESTORE"
|
RESTORE_BACKUP_FILE = ".HA_RESTORE"
|
||||||
|
RESTORE_BACKUP_RESULT_FILE = ".HA_RESTORE_RESULT"
|
||||||
KEEP_BACKUPS = ("backups",)
|
KEEP_BACKUPS = ("backups",)
|
||||||
KEEP_DATABASE = (
|
KEEP_DATABASE = (
|
||||||
"home-assistant_v2.db",
|
"home-assistant_v2.db",
|
||||||
@ -62,7 +63,10 @@ def restore_backup_file_content(config_dir: Path) -> RestoreBackupFileContent |
|
|||||||
restore_database=instruction_content["restore_database"],
|
restore_database=instruction_content["restore_database"],
|
||||||
restore_homeassistant=instruction_content["restore_homeassistant"],
|
restore_homeassistant=instruction_content["restore_homeassistant"],
|
||||||
)
|
)
|
||||||
except (FileNotFoundError, KeyError, json.JSONDecodeError):
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
except (KeyError, json.JSONDecodeError) as err:
|
||||||
|
_write_restore_result_file(config_dir, False, err)
|
||||||
return None
|
return None
|
||||||
finally:
|
finally:
|
||||||
# Always remove the backup instruction file to prevent a boot loop
|
# Always remove the backup instruction file to prevent a boot loop
|
||||||
@ -142,6 +146,7 @@ def _extract_backup(
|
|||||||
config_dir,
|
config_dir,
|
||||||
dirs_exist_ok=True,
|
dirs_exist_ok=True,
|
||||||
ignore=shutil.ignore_patterns(*(keep)),
|
ignore=shutil.ignore_patterns(*(keep)),
|
||||||
|
ignore_dangling_symlinks=True,
|
||||||
)
|
)
|
||||||
elif restore_content.restore_database:
|
elif restore_content.restore_database:
|
||||||
for entry in KEEP_DATABASE:
|
for entry in KEEP_DATABASE:
|
||||||
@ -159,6 +164,23 @@ def _extract_backup(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_restore_result_file(
|
||||||
|
config_dir: Path, success: bool, error: Exception | None
|
||||||
|
) -> None:
|
||||||
|
"""Write the restore result file."""
|
||||||
|
result_path = config_dir.joinpath(RESTORE_BACKUP_RESULT_FILE)
|
||||||
|
result_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"success": success,
|
||||||
|
"error": str(error) if error else None,
|
||||||
|
"error_type": str(type(error).__name__) if error else None,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def restore_backup(config_dir_path: str) -> bool:
|
def restore_backup(config_dir_path: str) -> bool:
|
||||||
"""Restore the backup file if any.
|
"""Restore the backup file if any.
|
||||||
|
|
||||||
@ -177,7 +199,14 @@ def restore_backup(config_dir_path: str) -> bool:
|
|||||||
restore_content=restore_content,
|
restore_content=restore_content,
|
||||||
)
|
)
|
||||||
except FileNotFoundError as err:
|
except FileNotFoundError as err:
|
||||||
raise ValueError(f"Backup file {backup_file_path} does not exist") from err
|
file_not_found = ValueError(f"Backup file {backup_file_path} does not exist")
|
||||||
|
_write_restore_result_file(config_dir, False, file_not_found)
|
||||||
|
raise file_not_found from err
|
||||||
|
except Exception as err:
|
||||||
|
_write_restore_result_file(config_dir, False, err)
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
_write_restore_result_file(config_dir, True, None)
|
||||||
if restore_content.remove_after_restore:
|
if restore_content.remove_after_restore:
|
||||||
backup_file_path.unlink(missing_ok=True)
|
backup_file_path.unlink(missing_ok=True)
|
||||||
_LOGGER.info("Restore complete, restarting")
|
_LOGGER.info("Restore complete, restarting")
|
||||||
|
@ -5,6 +5,7 @@
|
|||||||
"google_assistant",
|
"google_assistant",
|
||||||
"google_assistant_sdk",
|
"google_assistant_sdk",
|
||||||
"google_cloud",
|
"google_cloud",
|
||||||
|
"google_drive",
|
||||||
"google_generative_ai_conversation",
|
"google_generative_ai_conversation",
|
||||||
"google_mail",
|
"google_mail",
|
||||||
"google_maps",
|
"google_maps",
|
||||||
|
@ -11,6 +11,7 @@
|
|||||||
"microsoft_face",
|
"microsoft_face",
|
||||||
"microsoft",
|
"microsoft",
|
||||||
"msteams",
|
"msteams",
|
||||||
|
"onedrive",
|
||||||
"xbox"
|
"xbox"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -12,8 +12,8 @@ import voluptuous as vol
|
|||||||
|
|
||||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||||
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
|
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
|
||||||
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
import homeassistant.helpers.config_validation as cv
|
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
@ -22,7 +22,7 @@ from homeassistant.const import (
|
|||||||
STATE_UNKNOWN,
|
STATE_UNKNOWN,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
|
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
import homeassistant.helpers.entity_registry as er
|
from homeassistant.helpers import entity_registry as er
|
||||||
|
|
||||||
from .hub import PulseHub
|
from .hub import PulseHub
|
||||||
|
|
||||||
|
@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
from .const import LEASES_REGEX
|
from .const import LEASES_REGEX
|
||||||
|
@ -12,7 +12,7 @@ from homeassistant.const import (
|
|||||||
EVENT_HOMEASSISTANT_STOP,
|
EVENT_HOMEASSISTANT_STOP,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant, ServiceCall
|
from homeassistant.core import HomeAssistant, ServiceCall
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
from .const import CONF_ADS_VAR, DATA_ADS, DOMAIN, AdsType
|
from .const import CONF_ADS_VAR, DATA_ADS, DOMAIN, AdsType
|
||||||
|
@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME
|
from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
|
|
||||||
|
@ -17,7 +17,7 @@ from homeassistant.components.cover import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME
|
from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
|
|
||||||
|
@ -15,7 +15,7 @@ from homeassistant.components.light import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import CONF_NAME
|
from homeassistant.const import CONF_NAME
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
|
|
||||||
|
@ -11,7 +11,7 @@ from homeassistant.components.select import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import CONF_NAME
|
from homeassistant.const import CONF_NAME
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
|
|
||||||
|
@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_UNIT_OF_MEASUREMENT
|
from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_UNIT_OF_MEASUREMENT
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType
|
||||||
|
|
||||||
|
@ -13,7 +13,7 @@ from homeassistant.components.switch import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import CONF_NAME
|
from homeassistant.const import CONF_NAME
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
|
|
||||||
|
@ -14,7 +14,7 @@ from homeassistant.components.valve import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME
|
from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
|
|
||||||
|
@ -7,7 +7,7 @@ from typing import Final
|
|||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
|
|
||||||
DOMAIN: Final = "aftership"
|
DOMAIN: Final = "aftership"
|
||||||
|
|
||||||
|
@ -9,7 +9,7 @@ from pyaftership import AfterShip, AfterShipException
|
|||||||
|
|
||||||
from homeassistant.components.sensor import SensorEntity
|
from homeassistant.components.sensor import SensorEntity
|
||||||
from homeassistant.core import HomeAssistant, ServiceCall
|
from homeassistant.core import HomeAssistant, ServiceCall
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.dispatcher import (
|
from homeassistant.helpers.dispatcher import (
|
||||||
async_dispatcher_connect,
|
async_dispatcher_connect,
|
||||||
async_dispatcher_send,
|
async_dispatcher_send,
|
||||||
|
@ -13,8 +13,8 @@ import voluptuous as vol
|
|||||||
|
|
||||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||||
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
|
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
|
||||||
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
import homeassistant.helpers.config_validation as cv
|
|
||||||
|
|
||||||
from .const import CONF_USE_NEAREST, DOMAIN, NO_AIRLY_SENSORS
|
from .const import CONF_USE_NEAREST, DOMAIN, NO_AIRLY_SENSORS
|
||||||
|
|
||||||
|
@ -18,8 +18,8 @@ from homeassistant.config_entries import (
|
|||||||
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS
|
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
import homeassistant.helpers.config_validation as cv
|
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
@ -24,7 +24,7 @@ from homeassistant.const import (
|
|||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import ServiceValidationError
|
from homeassistant.exceptions import ServiceValidationError
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.config_validation import make_entity_service_schema
|
from homeassistant.helpers.config_validation import make_entity_service_schema
|
||||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||||
from homeassistant.helpers.entity_component import EntityComponent
|
from homeassistant.helpers.entity_component import EntityComponent
|
||||||
|
@ -23,8 +23,7 @@ from homeassistant.const import (
|
|||||||
SERVICE_ALARM_TRIGGER,
|
SERVICE_ALARM_TRIGGER,
|
||||||
)
|
)
|
||||||
from homeassistant.core import Context, HomeAssistant
|
from homeassistant.core import Context, HomeAssistant
|
||||||
from homeassistant.helpers import entity_registry as er
|
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||||
import homeassistant.helpers.config_validation as cv
|
|
||||||
from homeassistant.helpers.entity import get_supported_features
|
from homeassistant.helpers.entity import get_supported_features
|
||||||
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
|
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
|
||||||
|
|
||||||
|
@ -12,8 +12,7 @@ from homeassistant.components.alarm_control_panel import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import ATTR_CODE
|
from homeassistant.const import ATTR_CODE
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import entity_platform
|
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||||
import homeassistant.helpers.config_validation as cv
|
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
|
@ -15,7 +15,7 @@ from homeassistant.const import (
|
|||||||
STATE_ON,
|
STATE_ON,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.entity_component import EntityComponent
|
from homeassistant.helpers.entity_component import EntityComponent
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
|
@ -50,8 +50,7 @@ from homeassistant.const import (
|
|||||||
UnitOfVolume,
|
UnitOfVolume,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant, State
|
from homeassistant.core import HomeAssistant, State
|
||||||
import homeassistant.util.color as color_util
|
from homeassistant.util import color as color_util, dt as dt_util
|
||||||
import homeassistant.util.dt as dt_util
|
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
API_TEMP_UNITS,
|
API_TEMP_UNITS,
|
||||||
|
@ -12,7 +12,7 @@ from homeassistant.const import CONF_PASSWORD
|
|||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers import template
|
from homeassistant.helpers import template
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
import homeassistant.util.dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
API_PASSWORD,
|
API_PASSWORD,
|
||||||
|
@ -24,7 +24,7 @@ from homeassistant.core import (
|
|||||||
)
|
)
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
from homeassistant.helpers.significant_change import create_checker
|
from homeassistant.helpers.significant_change import create_checker
|
||||||
import homeassistant.util.dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
from homeassistant.util.json import JsonObjectType, json_loads_object
|
from homeassistant.util.json import JsonObjectType, json_loads_object
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
|
@ -16,7 +16,7 @@ from homeassistant.components.sensor import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import CONF_API_KEY, CONF_CURRENCY, CONF_NAME
|
from homeassistant.const import CONF_API_KEY, CONF_CURRENCY, CONF_NAME
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
|
|
||||||
|
@ -22,7 +22,7 @@ from homeassistant.generated.amazon_polly import (
|
|||||||
SUPPORTED_REGIONS,
|
SUPPORTED_REGIONS,
|
||||||
SUPPORTED_VOICES,
|
SUPPORTED_VOICES,
|
||||||
)
|
)
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
|
@ -17,9 +17,8 @@ from homeassistant.const import (
|
|||||||
)
|
)
|
||||||
from homeassistant.core import Event, HomeAssistant, callback
|
from homeassistant.core import Event, HomeAssistant, callback
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
import homeassistant.helpers.device_registry as dr
|
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||||
import homeassistant.helpers.entity_registry as er
|
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
ATTR_LAST_DATA,
|
ATTR_LAST_DATA,
|
||||||
|
@ -37,8 +37,7 @@ from homeassistant.const import (
|
|||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||||
from homeassistant.exceptions import Unauthorized, UnknownUser
|
from homeassistant.exceptions import Unauthorized, UnknownUser
|
||||||
from homeassistant.helpers import discovery
|
from homeassistant.helpers import config_validation as cv, discovery
|
||||||
import homeassistant.helpers.config_validation as cv
|
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send
|
from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send
|
||||||
from homeassistant.helpers.event import async_track_time_interval
|
from homeassistant.helpers.event import async_track_time_interval
|
||||||
from homeassistant.helpers.service import async_extract_entity_ids
|
from homeassistant.helpers.service import async_extract_entity_ids
|
||||||
|
@ -14,8 +14,8 @@ from homeassistant.components.air_quality import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import CONF_NAME
|
from homeassistant.const import CONF_NAME
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
import homeassistant.helpers.config_validation as cv
|
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
from homeassistant.util import Throttle
|
from homeassistant.util import Throttle
|
||||||
|
@ -7,7 +7,7 @@ import voluptuous as vol
|
|||||||
from homeassistant.components import websocket_api
|
from homeassistant.components import websocket_api
|
||||||
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
|
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
|
||||||
from homeassistant.core import Event, HassJob, HomeAssistant, callback
|
from homeassistant.core import Event, HassJob, HomeAssistant, callback
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.event import async_call_later, async_track_time_interval
|
from homeassistant.helpers.event import async_call_later, async_track_time_interval
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
from homeassistant.util.hass_dict import HassKey
|
from homeassistant.util.hass_dict import HassKey
|
||||||
|
@ -11,6 +11,7 @@ import uuid
|
|||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
|
from homeassistant import config as conf_util
|
||||||
from homeassistant.components import hassio
|
from homeassistant.components import hassio
|
||||||
from homeassistant.components.api import ATTR_INSTALLATION_TYPE
|
from homeassistant.components.api import ATTR_INSTALLATION_TYPE
|
||||||
from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN
|
from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN
|
||||||
@ -22,13 +23,12 @@ from homeassistant.components.recorder import (
|
|||||||
DOMAIN as RECORDER_DOMAIN,
|
DOMAIN as RECORDER_DOMAIN,
|
||||||
get_instance as get_recorder_instance,
|
get_instance as get_recorder_instance,
|
||||||
)
|
)
|
||||||
import homeassistant.config as conf_util
|
|
||||||
from homeassistant.config_entries import SOURCE_IGNORE
|
from homeassistant.config_entries import SOURCE_IGNORE
|
||||||
from homeassistant.const import ATTR_DOMAIN, __version__ as HA_VERSION
|
from homeassistant.const import ATTR_DOMAIN, __version__ as HA_VERSION
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
from homeassistant.helpers import entity_registry as er
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
import homeassistant.helpers.entity_registry as er
|
|
||||||
from homeassistant.helpers.hassio import is_hassio
|
from homeassistant.helpers.hassio import is_hassio
|
||||||
from homeassistant.helpers.storage import Store
|
from homeassistant.helpers.storage import Store
|
||||||
from homeassistant.helpers.system_info import async_get_system_info
|
from homeassistant.helpers.system_info import async_get_system_info
|
||||||
|
@ -15,7 +15,7 @@ from homeassistant.components.switch import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
from homeassistant.util import Throttle
|
from homeassistant.util import Throttle
|
||||||
|
@ -12,7 +12,7 @@ import voluptuous as vol
|
|||||||
|
|
||||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||||
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_MODEL, CONF_PORT
|
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_MODEL, CONF_PORT
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.device_registry import format_mac
|
from homeassistant.helpers.device_registry import format_mac
|
||||||
|
|
||||||
from .const import DEFAULT_NAME, DEFAULT_PORT, DEVICE_TIMEOUT_SECONDS, DOMAIN
|
from .const import DEFAULT_NAME, DEFAULT_PORT, DEVICE_TIMEOUT_SECONDS, DOMAIN
|
||||||
|
@ -18,7 +18,7 @@ from homeassistant.const import (
|
|||||||
EVENT_STATE_CHANGED,
|
EVENT_STATE_CHANGED,
|
||||||
)
|
)
|
||||||
from homeassistant.core import Event, EventStateChangedData, HomeAssistant
|
from homeassistant.core import Event, EventStateChangedData, HomeAssistant
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.entityfilter import FILTER_SCHEMA, EntityFilter
|
from homeassistant.helpers.entityfilter import FILTER_SCHEMA, EntityFilter
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
from homeassistant.util import ssl as ssl_util
|
from homeassistant.util import ssl as ssl_util
|
||||||
|
@ -10,8 +10,7 @@ import voluptuous as vol
|
|||||||
|
|
||||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||||
from homeassistant.helpers import selector
|
from homeassistant.helpers import config_validation as cv, selector
|
||||||
import homeassistant.helpers.config_validation as cv
|
|
||||||
|
|
||||||
from .const import CONNECTION_TIMEOUT, DOMAIN
|
from .const import CONNECTION_TIMEOUT, DOMAIN
|
||||||
from .coordinator import APCUPSdData
|
from .coordinator import APCUPSdData
|
||||||
|
@ -11,6 +11,7 @@ from aiohttp import web
|
|||||||
from aiohttp.web_exceptions import HTTPBadRequest
|
from aiohttp.web_exceptions import HTTPBadRequest
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant import core as ha
|
||||||
from homeassistant.auth.models import User
|
from homeassistant.auth.models import User
|
||||||
from homeassistant.auth.permissions.const import POLICY_READ
|
from homeassistant.auth.permissions.const import POLICY_READ
|
||||||
from homeassistant.components.http import (
|
from homeassistant.components.http import (
|
||||||
@ -36,7 +37,6 @@ from homeassistant.const import (
|
|||||||
URL_API_STREAM,
|
URL_API_STREAM,
|
||||||
URL_API_TEMPLATE,
|
URL_API_TEMPLATE,
|
||||||
)
|
)
|
||||||
import homeassistant.core as ha
|
|
||||||
from homeassistant.core import Event, EventStateChangedData, HomeAssistant
|
from homeassistant.core import Event, EventStateChangedData, HomeAssistant
|
||||||
from homeassistant.exceptions import (
|
from homeassistant.exceptions import (
|
||||||
InvalidEntityFormatError,
|
InvalidEntityFormatError,
|
||||||
|
@ -40,7 +40,7 @@ from homeassistant.components.media_player import (
|
|||||||
from homeassistant.const import CONF_NAME
|
from homeassistant.const import CONF_NAME
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
import homeassistant.util.dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
from . import AppleTvConfigEntry, AppleTVManager
|
from . import AppleTvConfigEntry, AppleTVManager
|
||||||
from .browse_media import build_app_list
|
from .browse_media import build_app_list
|
||||||
|
@ -26,8 +26,11 @@ from homeassistant.const import (
|
|||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import collection, config_entry_oauth2_flow
|
from homeassistant.helpers import (
|
||||||
import homeassistant.helpers.config_validation as cv
|
collection,
|
||||||
|
config_entry_oauth2_flow,
|
||||||
|
config_validation as cv,
|
||||||
|
)
|
||||||
from homeassistant.helpers.storage import Store
|
from homeassistant.helpers.storage import Store
|
||||||
from homeassistant.helpers.typing import ConfigType, VolDictType
|
from homeassistant.helpers.typing import ConfigType, VolDictType
|
||||||
from homeassistant.loader import (
|
from homeassistant.loader import (
|
||||||
|
@ -17,7 +17,7 @@ from homeassistant.components.notify import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import CONF_URL
|
from homeassistant.const import CONF_URL
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
@ -10,7 +10,7 @@ import voluptuous as vol
|
|||||||
|
|
||||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.device_registry import format_mac
|
from homeassistant.helpers.device_registry import format_mac
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
|
@ -11,7 +11,7 @@ from pyaprilaire.const import MODELS, Attribute, FunctionalDomain
|
|||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||||
import homeassistant.helpers.device_registry as dr
|
from homeassistant.helpers import device_registry as dr
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
from homeassistant.helpers.update_coordinator import BaseDataUpdateCoordinatorProtocol
|
from homeassistant.helpers.update_coordinator import BaseDataUpdateCoordinatorProtocol
|
||||||
|
|
||||||
|
@ -26,7 +26,7 @@ from homeassistant.const import (
|
|||||||
EVENT_HOMEASSISTANT_STOP,
|
EVENT_HOMEASSISTANT_STOP,
|
||||||
)
|
)
|
||||||
from homeassistant.core import Event, HomeAssistant
|
from homeassistant.core import Event, HomeAssistant
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
from homeassistant.util import slugify
|
from homeassistant.util import slugify
|
||||||
|
|
||||||
|
@ -8,8 +8,8 @@ import voluptuous as vol
|
|||||||
|
|
||||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||||
from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT
|
from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT
|
||||||
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
import homeassistant.helpers.config_validation as cv
|
|
||||||
|
|
||||||
from .const import DEFAULT_PORT, DOMAIN
|
from .const import DEFAULT_PORT, DOMAIN
|
||||||
|
|
||||||
|
@ -19,7 +19,7 @@ from homeassistant.const import (
|
|||||||
UnitOfTemperature,
|
UnitOfTemperature,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
|
@ -13,7 +13,7 @@ from homeassistant.components.switch import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import CONF_MONITORED_CONDITIONS
|
from homeassistant.const import CONF_MONITORED_CONDITIONS
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
|
@ -24,7 +24,7 @@ from homeassistant.const import (
|
|||||||
CONF_USERNAME,
|
CONF_USERNAME,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
|
|
||||||
|
@ -16,7 +16,7 @@ from homeassistant.components.binary_sensor import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_PIN, CONF_RESOURCE
|
from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_PIN, CONF_RESOURCE
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
from homeassistant.util import Throttle
|
from homeassistant.util import Throttle
|
||||||
|
@ -22,7 +22,7 @@ from homeassistant.const import (
|
|||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import TemplateError
|
from homeassistant.exceptions import TemplateError
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
from homeassistant.util import Throttle
|
from homeassistant.util import Throttle
|
||||||
|
@ -15,7 +15,7 @@ from homeassistant.components.switch import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import CONF_NAME, CONF_RESOURCE
|
from homeassistant.const import CONF_NAME, CONF_RESOURCE
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
|
|
||||||
|
@ -13,8 +13,8 @@ from homeassistant.components.device_tracker import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD
|
from homeassistant.const import CONF_HOST, CONF_PASSWORD
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
import homeassistant.helpers.config_validation as cv
|
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
DEFAULT_HOST = "192.168.178.1"
|
DEFAULT_HOST = "192.168.178.1"
|
||||||
|
@ -16,7 +16,7 @@ from homeassistant.components.device_tracker import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
@ -7,7 +7,7 @@ from collections.abc import Callable
|
|||||||
from homeassistant.components.logbook import LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME
|
from homeassistant.components.logbook import LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME
|
||||||
from homeassistant.const import ATTR_DEVICE_ID
|
from homeassistant.const import ATTR_DEVICE_ID
|
||||||
from homeassistant.core import Event, HomeAssistant, callback
|
from homeassistant.core import Event, HomeAssistant, callback
|
||||||
import homeassistant.helpers.device_registry as dr
|
from homeassistant.helpers import device_registry as dr
|
||||||
|
|
||||||
from .const import DOMAIN, EVENT_RECORDING
|
from .const import DOMAIN, EVENT_RECORDING
|
||||||
|
|
||||||
|
@ -1101,11 +1101,10 @@ class PipelineRun:
|
|||||||
"speech", ""
|
"speech", ""
|
||||||
)
|
)
|
||||||
chat_session.async_add_message(
|
chat_session.async_add_message(
|
||||||
conversation.ChatMessage(
|
conversation.Content(
|
||||||
role="assistant",
|
role="assistant",
|
||||||
agent_id=agent_id,
|
agent_id=agent_id,
|
||||||
content=speech,
|
content=speech,
|
||||||
native=intent_response,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
conversation_result = conversation.ConversationResult(
|
conversation_result = conversation.ConversationResult(
|
||||||
@ -1123,6 +1122,7 @@ class PipelineRun:
|
|||||||
context=user_input.context,
|
context=user_input.context,
|
||||||
language=user_input.language,
|
language=user_input.language,
|
||||||
agent_id=user_input.agent_id,
|
agent_id=user_input.agent_id,
|
||||||
|
extra_system_prompt=user_input.extra_system_prompt,
|
||||||
)
|
)
|
||||||
speech = conversation_result.response.speech.get("plain", {}).get(
|
speech = conversation_result.response.speech.get("plain", {}).get(
|
||||||
"speech", ""
|
"speech", ""
|
||||||
|
@ -63,6 +63,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
"async_internal_announce",
|
"async_internal_announce",
|
||||||
[AssistSatelliteEntityFeature.ANNOUNCE],
|
[AssistSatelliteEntityFeature.ANNOUNCE],
|
||||||
)
|
)
|
||||||
|
component.async_register_entity_service(
|
||||||
|
"start_conversation",
|
||||||
|
vol.All(
|
||||||
|
cv.make_entity_service_schema(
|
||||||
|
{
|
||||||
|
vol.Optional("start_message"): str,
|
||||||
|
vol.Optional("start_media_id"): str,
|
||||||
|
vol.Optional("extra_system_prompt"): str,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
cv.has_at_least_one_key("start_message", "start_media_id"),
|
||||||
|
),
|
||||||
|
"async_internal_start_conversation",
|
||||||
|
[AssistSatelliteEntityFeature.START_CONVERSATION],
|
||||||
|
)
|
||||||
hass.data[CONNECTION_TEST_DATA] = {}
|
hass.data[CONNECTION_TEST_DATA] = {}
|
||||||
async_register_websocket_api(hass)
|
async_register_websocket_api(hass)
|
||||||
hass.http.register_view(ConnectionTestView())
|
hass.http.register_view(ConnectionTestView())
|
||||||
|
@ -26,3 +26,6 @@ class AssistSatelliteEntityFeature(IntFlag):
|
|||||||
|
|
||||||
ANNOUNCE = 1
|
ANNOUNCE = 1
|
||||||
"""Device supports remotely triggered announcements."""
|
"""Device supports remotely triggered announcements."""
|
||||||
|
|
||||||
|
START_CONVERSATION = 2
|
||||||
|
"""Device supports starting conversations."""
|
||||||
|
@ -10,7 +10,7 @@ import logging
|
|||||||
import time
|
import time
|
||||||
from typing import Any, Final, Literal, final
|
from typing import Any, Final, Literal, final
|
||||||
|
|
||||||
from homeassistant.components import media_source, stt, tts
|
from homeassistant.components import conversation, media_source, stt, tts
|
||||||
from homeassistant.components.assist_pipeline import (
|
from homeassistant.components.assist_pipeline import (
|
||||||
OPTION_PREFERRED,
|
OPTION_PREFERRED,
|
||||||
AudioSettings,
|
AudioSettings,
|
||||||
@ -27,6 +27,7 @@ from homeassistant.components.tts import (
|
|||||||
generate_media_source_id as tts_generate_media_source_id,
|
generate_media_source_id as tts_generate_media_source_id,
|
||||||
)
|
)
|
||||||
from homeassistant.core import Context, callback
|
from homeassistant.core import Context, callback
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import entity
|
from homeassistant.helpers import entity
|
||||||
from homeassistant.helpers.entity import EntityDescription
|
from homeassistant.helpers.entity import EntityDescription
|
||||||
|
|
||||||
@ -117,6 +118,7 @@ class AssistSatelliteEntity(entity.Entity):
|
|||||||
|
|
||||||
_run_has_tts: bool = False
|
_run_has_tts: bool = False
|
||||||
_is_announcing = False
|
_is_announcing = False
|
||||||
|
_extra_system_prompt: str | None = None
|
||||||
_wake_word_intercept_future: asyncio.Future[str | None] | None = None
|
_wake_word_intercept_future: asyncio.Future[str | None] | None = None
|
||||||
_attr_tts_options: dict[str, Any] | None = None
|
_attr_tts_options: dict[str, Any] | None = None
|
||||||
_pipeline_task: asyncio.Task | None = None
|
_pipeline_task: asyncio.Task | None = None
|
||||||
@ -216,6 +218,60 @@ class AssistSatelliteEntity(entity.Entity):
|
|||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def async_internal_start_conversation(
|
||||||
|
self,
|
||||||
|
start_message: str | None = None,
|
||||||
|
start_media_id: str | None = None,
|
||||||
|
extra_system_prompt: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Start a conversation from the satellite.
|
||||||
|
|
||||||
|
If start_media_id is not provided, message is synthesized to
|
||||||
|
audio with the selected pipeline.
|
||||||
|
|
||||||
|
If start_media_id is provided, it is played directly. It is possible
|
||||||
|
to omit the message and the satellite will not show any text.
|
||||||
|
|
||||||
|
Calls async_start_conversation.
|
||||||
|
"""
|
||||||
|
await self._cancel_running_pipeline()
|
||||||
|
|
||||||
|
# The Home Assistant built-in agent doesn't support conversations.
|
||||||
|
pipeline = async_get_pipeline(self.hass, self._resolve_pipeline())
|
||||||
|
if pipeline.conversation_engine == conversation.HOME_ASSISTANT_AGENT:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
"Built-in conversation agent does not support starting conversations"
|
||||||
|
)
|
||||||
|
|
||||||
|
if start_message is None:
|
||||||
|
start_message = ""
|
||||||
|
|
||||||
|
announcement = await self._resolve_announcement_media_id(
|
||||||
|
start_message, start_media_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._is_announcing:
|
||||||
|
raise SatelliteBusyError
|
||||||
|
|
||||||
|
self._is_announcing = True
|
||||||
|
# Provide our start info to the LLM so it understands context of incoming message
|
||||||
|
if extra_system_prompt is not None:
|
||||||
|
self._extra_system_prompt = extra_system_prompt
|
||||||
|
else:
|
||||||
|
self._extra_system_prompt = start_message or None
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.async_start_conversation(announcement)
|
||||||
|
finally:
|
||||||
|
self._is_announcing = False
|
||||||
|
self._extra_system_prompt = None
|
||||||
|
|
||||||
|
async def async_start_conversation(
|
||||||
|
self, start_announcement: AssistSatelliteAnnouncement
|
||||||
|
) -> None:
|
||||||
|
"""Start a conversation from the satellite."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
async def async_accept_pipeline_from_satellite(
|
async def async_accept_pipeline_from_satellite(
|
||||||
self,
|
self,
|
||||||
audio_stream: AsyncIterable[bytes],
|
audio_stream: AsyncIterable[bytes],
|
||||||
@ -302,6 +358,7 @@ class AssistSatelliteEntity(entity.Entity):
|
|||||||
),
|
),
|
||||||
start_stage=start_stage,
|
start_stage=start_stage,
|
||||||
end_stage=end_stage,
|
end_stage=end_stage,
|
||||||
|
conversation_extra_system_prompt=self._extra_system_prompt,
|
||||||
),
|
),
|
||||||
f"{self.entity_id}_pipeline",
|
f"{self.entity_id}_pipeline",
|
||||||
)
|
)
|
||||||
|
@ -7,6 +7,9 @@
|
|||||||
"services": {
|
"services": {
|
||||||
"announce": {
|
"announce": {
|
||||||
"service": "mdi:bullhorn"
|
"service": "mdi:bullhorn"
|
||||||
|
},
|
||||||
|
"start_conversation": {
|
||||||
|
"service": "mdi:forum"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -14,3 +14,23 @@ announce:
|
|||||||
required: false
|
required: false
|
||||||
selector:
|
selector:
|
||||||
text:
|
text:
|
||||||
|
start_conversation:
|
||||||
|
target:
|
||||||
|
entity:
|
||||||
|
domain: assist_satellite
|
||||||
|
supported_features:
|
||||||
|
- assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION
|
||||||
|
fields:
|
||||||
|
start_message:
|
||||||
|
required: false
|
||||||
|
example: "You left the lights on in the living room. Turn them off?"
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
start_media_id:
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
extra_system_prompt:
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
@ -25,6 +25,24 @@
|
|||||||
"description": "The media ID to announce instead of using text-to-speech."
|
"description": "The media ID to announce instead of using text-to-speech."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"start_conversation": {
|
||||||
|
"name": "Start Conversation",
|
||||||
|
"description": "Start a conversation from a satellite.",
|
||||||
|
"fields": {
|
||||||
|
"start_message": {
|
||||||
|
"name": "Message",
|
||||||
|
"description": "The message to start with."
|
||||||
|
},
|
||||||
|
"start_media_id": {
|
||||||
|
"name": "Media ID",
|
||||||
|
"description": "The media ID to start with instead of using text-to-speech."
|
||||||
|
},
|
||||||
|
"extra_system_prompt": {
|
||||||
|
"name": "Extra system prompt",
|
||||||
|
"description": "Provide background information to the AI about the request."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -16,7 +16,7 @@ from homeassistant.components.switch import (
|
|||||||
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_USERNAME
|
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_USERNAME
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import PlatformNotReady
|
from homeassistant.exceptions import PlatformNotReady
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
|
@ -22,7 +22,7 @@ from homeassistant.const import (
|
|||||||
UnitOfPower,
|
UnitOfPower,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
from homeassistant.util import Throttle
|
from homeassistant.util import Throttle
|
||||||
|
@ -16,7 +16,7 @@ from homeassistant.const import ATTR_BATTERY_LEVEL
|
|||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.restore_state import RestoreEntity
|
from homeassistant.helpers.restore_state import RestoreEntity
|
||||||
import homeassistant.util.dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
from . import AugustConfigEntry, AugustData
|
from . import AugustConfigEntry, AugustData
|
||||||
from .entity import AugustEntity
|
from .entity import AugustEntity
|
||||||
|
@ -12,7 +12,7 @@ from homeassistant import data_entry_flow
|
|||||||
from homeassistant.components import websocket_api
|
from homeassistant.components import websocket_api
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.data_entry_flow import FlowContext
|
from homeassistant.data_entry_flow import FlowContext
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.util.hass_dict import HassKey
|
from homeassistant.util.hass_dict import HassKey
|
||||||
|
|
||||||
WS_TYPE_SETUP_MFA = "auth/setup_mfa"
|
WS_TYPE_SETUP_MFA = "auth/setup_mfa"
|
||||||
|
@ -48,8 +48,7 @@ from homeassistant.core import (
|
|||||||
valid_entity_id,
|
valid_entity_id,
|
||||||
)
|
)
|
||||||
from homeassistant.exceptions import HomeAssistantError, ServiceNotFound, TemplateError
|
from homeassistant.exceptions import HomeAssistantError, ServiceNotFound, TemplateError
|
||||||
from homeassistant.helpers import condition
|
from homeassistant.helpers import condition, config_validation as cv
|
||||||
import homeassistant.helpers.config_validation as cv
|
|
||||||
from homeassistant.helpers.entity import ToggleEntity
|
from homeassistant.helpers.entity import ToggleEntity
|
||||||
from homeassistant.helpers.entity_component import EntityComponent
|
from homeassistant.helpers.entity_component import EntityComponent
|
||||||
from homeassistant.helpers.issue_registry import (
|
from homeassistant.helpers.issue_registry import (
|
||||||
|
@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.exceptions import PlatformNotReady
|
from homeassistant.exceptions import PlatformNotReady
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
import homeassistant.util.color as color_util
|
from homeassistant.util import color as color_util
|
||||||
|
|
||||||
|
|
||||||
def setup_platform(
|
def setup_platform(
|
||||||
|
@ -23,7 +23,7 @@ from homeassistant.const import (
|
|||||||
CONF_USERNAME,
|
CONF_USERNAME,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
|
|
||||||
|
@ -19,7 +19,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
|||||||
from homeassistant.const import MATCH_ALL
|
from homeassistant.const import MATCH_ALL
|
||||||
from homeassistant.core import Event, HomeAssistant, State
|
from homeassistant.core import Event, HomeAssistant, State
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.entityfilter import FILTER_SCHEMA, EntityFilter
|
from homeassistant.helpers.entityfilter import FILTER_SCHEMA, EntityFilter
|
||||||
from homeassistant.helpers.event import async_call_later
|
from homeassistant.helpers.event import async_call_later
|
||||||
from homeassistant.helpers.json import JSONEncoder
|
from homeassistant.helpers.json import JSONEncoder
|
||||||
|
@ -23,7 +23,7 @@ from homeassistant.components.notify import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import CONTENT_TYPE_JSON
|
from homeassistant.const import CONTENT_TYPE_JSON
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
|
|
||||||
CONF_CONNECTION_STRING = "connection_string"
|
CONF_CONNECTION_STRING = "connection_string"
|
||||||
|
@ -26,10 +26,12 @@ from .manager import (
|
|||||||
BackupReaderWriterError,
|
BackupReaderWriterError,
|
||||||
CoreBackupReaderWriter,
|
CoreBackupReaderWriter,
|
||||||
CreateBackupEvent,
|
CreateBackupEvent,
|
||||||
|
IdleEvent,
|
||||||
IncorrectPasswordError,
|
IncorrectPasswordError,
|
||||||
ManagerBackup,
|
ManagerBackup,
|
||||||
NewBackup,
|
NewBackup,
|
||||||
RestoreBackupEvent,
|
RestoreBackupEvent,
|
||||||
|
RestoreBackupState,
|
||||||
WrittenBackup,
|
WrittenBackup,
|
||||||
)
|
)
|
||||||
from .models import AddonInfo, AgentBackup, Folder
|
from .models import AddonInfo, AgentBackup, Folder
|
||||||
@ -47,12 +49,15 @@ __all__ = [
|
|||||||
"BackupReaderWriterError",
|
"BackupReaderWriterError",
|
||||||
"CreateBackupEvent",
|
"CreateBackupEvent",
|
||||||
"Folder",
|
"Folder",
|
||||||
|
"IdleEvent",
|
||||||
"IncorrectPasswordError",
|
"IncorrectPasswordError",
|
||||||
"LocalBackupAgent",
|
"LocalBackupAgent",
|
||||||
"ManagerBackup",
|
"ManagerBackup",
|
||||||
"NewBackup",
|
"NewBackup",
|
||||||
"RestoreBackupEvent",
|
"RestoreBackupEvent",
|
||||||
|
"RestoreBackupState",
|
||||||
"WrittenBackup",
|
"WrittenBackup",
|
||||||
|
"async_get_manager",
|
||||||
]
|
]
|
||||||
|
|
||||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||||
|
@ -10,31 +10,40 @@ from typing import Any, Protocol
|
|||||||
from propcache.api import cached_property
|
from propcache.api import cached_property
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
|
||||||
|
|
||||||
from .models import AgentBackup
|
from .models import AgentBackup, BackupError
|
||||||
|
|
||||||
|
|
||||||
class BackupAgentError(HomeAssistantError):
|
class BackupAgentError(BackupError):
|
||||||
"""Base class for backup agent errors."""
|
"""Base class for backup agent errors."""
|
||||||
|
|
||||||
|
error_code = "backup_agent_error"
|
||||||
|
|
||||||
|
|
||||||
class BackupAgentUnreachableError(BackupAgentError):
|
class BackupAgentUnreachableError(BackupAgentError):
|
||||||
"""Raised when the agent can't reach its API."""
|
"""Raised when the agent can't reach its API."""
|
||||||
|
|
||||||
|
error_code = "backup_agent_unreachable"
|
||||||
_message = "The backup agent is unreachable."
|
_message = "The backup agent is unreachable."
|
||||||
|
|
||||||
|
|
||||||
|
class BackupNotFound(BackupAgentError):
|
||||||
|
"""Raised when a backup is not found."""
|
||||||
|
|
||||||
|
error_code = "backup_not_found"
|
||||||
|
|
||||||
|
|
||||||
class BackupAgent(abc.ABC):
|
class BackupAgent(abc.ABC):
|
||||||
"""Backup agent interface."""
|
"""Backup agent interface."""
|
||||||
|
|
||||||
domain: str
|
domain: str
|
||||||
name: str
|
name: str
|
||||||
|
unique_id: str
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def agent_id(self) -> str:
|
def agent_id(self) -> str:
|
||||||
"""Return the agent_id."""
|
"""Return the agent_id."""
|
||||||
return f"{self.domain}.{self.name}"
|
return f"{self.domain}.{self.unique_id}"
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
async def async_download_backup(
|
async def async_download_backup(
|
||||||
@ -91,11 +100,16 @@ class LocalBackupAgent(BackupAgent):
|
|||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def get_backup_path(self, backup_id: str) -> Path:
|
def get_backup_path(self, backup_id: str) -> Path:
|
||||||
"""Return the local path to a backup.
|
"""Return the local path to an existing backup.
|
||||||
|
|
||||||
The method should return the path to the backup file with the specified id.
|
The method should return the path to the backup file with the specified id.
|
||||||
|
Raises BackupAgentError if the backup does not exist.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def get_new_backup_path(self, backup: AgentBackup) -> Path:
|
||||||
|
"""Return the local path to a new backup."""
|
||||||
|
|
||||||
|
|
||||||
class BackupAgentPlatformProtocol(Protocol):
|
class BackupAgentPlatformProtocol(Protocol):
|
||||||
"""Define the format of backup platforms which implement backup agents."""
|
"""Define the format of backup platforms which implement backup agents."""
|
||||||
|
@ -11,7 +11,7 @@ from typing import Any
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.hassio import is_hassio
|
from homeassistant.helpers.hassio import is_hassio
|
||||||
|
|
||||||
from .agent import BackupAgent, LocalBackupAgent
|
from .agent import BackupAgent, BackupNotFound, LocalBackupAgent
|
||||||
from .const import DOMAIN, LOGGER
|
from .const import DOMAIN, LOGGER
|
||||||
from .models import AgentBackup
|
from .models import AgentBackup
|
||||||
from .util import read_backup
|
from .util import read_backup
|
||||||
@ -32,13 +32,14 @@ class CoreLocalBackupAgent(LocalBackupAgent):
|
|||||||
|
|
||||||
domain = DOMAIN
|
domain = DOMAIN
|
||||||
name = "local"
|
name = "local"
|
||||||
|
unique_id = "local"
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant) -> None:
|
def __init__(self, hass: HomeAssistant) -> None:
|
||||||
"""Initialize the backup agent."""
|
"""Initialize the backup agent."""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._hass = hass
|
self._hass = hass
|
||||||
self._backup_dir = Path(hass.config.path("backups"))
|
self._backup_dir = Path(hass.config.path("backups"))
|
||||||
self._backups: dict[str, AgentBackup] = {}
|
self._backups: dict[str, tuple[AgentBackup, Path]] = {}
|
||||||
self._loaded_backups = False
|
self._loaded_backups = False
|
||||||
|
|
||||||
async def _load_backups(self) -> None:
|
async def _load_backups(self) -> None:
|
||||||
@ -48,13 +49,13 @@ class CoreLocalBackupAgent(LocalBackupAgent):
|
|||||||
self._backups = backups
|
self._backups = backups
|
||||||
self._loaded_backups = True
|
self._loaded_backups = True
|
||||||
|
|
||||||
def _read_backups(self) -> dict[str, AgentBackup]:
|
def _read_backups(self) -> dict[str, tuple[AgentBackup, Path]]:
|
||||||
"""Read backups from disk."""
|
"""Read backups from disk."""
|
||||||
backups: dict[str, AgentBackup] = {}
|
backups: dict[str, tuple[AgentBackup, Path]] = {}
|
||||||
for backup_path in self._backup_dir.glob("*.tar"):
|
for backup_path in self._backup_dir.glob("*.tar"):
|
||||||
try:
|
try:
|
||||||
backup = read_backup(backup_path)
|
backup = read_backup(backup_path)
|
||||||
backups[backup.backup_id] = backup
|
backups[backup.backup_id] = (backup, backup_path)
|
||||||
except (OSError, TarError, json.JSONDecodeError, KeyError) as err:
|
except (OSError, TarError, json.JSONDecodeError, KeyError) as err:
|
||||||
LOGGER.warning("Unable to read backup %s: %s", backup_path, err)
|
LOGGER.warning("Unable to read backup %s: %s", backup_path, err)
|
||||||
return backups
|
return backups
|
||||||
@ -75,13 +76,13 @@ class CoreLocalBackupAgent(LocalBackupAgent):
|
|||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Upload a backup."""
|
"""Upload a backup."""
|
||||||
self._backups[backup.backup_id] = backup
|
self._backups[backup.backup_id] = (backup, self.get_new_backup_path(backup))
|
||||||
|
|
||||||
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
|
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
|
||||||
"""List backups."""
|
"""List backups."""
|
||||||
if not self._loaded_backups:
|
if not self._loaded_backups:
|
||||||
await self._load_backups()
|
await self._load_backups()
|
||||||
return list(self._backups.values())
|
return [backup for backup, _ in self._backups.values()]
|
||||||
|
|
||||||
async def async_get_backup(
|
async def async_get_backup(
|
||||||
self,
|
self,
|
||||||
@ -92,10 +93,10 @@ class CoreLocalBackupAgent(LocalBackupAgent):
|
|||||||
if not self._loaded_backups:
|
if not self._loaded_backups:
|
||||||
await self._load_backups()
|
await self._load_backups()
|
||||||
|
|
||||||
if not (backup := self._backups.get(backup_id)):
|
if backup_id not in self._backups:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
backup_path = self.get_backup_path(backup_id)
|
backup, backup_path = self._backups[backup_id]
|
||||||
if not await self._hass.async_add_executor_job(backup_path.exists):
|
if not await self._hass.async_add_executor_job(backup_path.exists):
|
||||||
LOGGER.debug(
|
LOGGER.debug(
|
||||||
(
|
(
|
||||||
@ -111,15 +112,28 @@ class CoreLocalBackupAgent(LocalBackupAgent):
|
|||||||
return backup
|
return backup
|
||||||
|
|
||||||
def get_backup_path(self, backup_id: str) -> Path:
|
def get_backup_path(self, backup_id: str) -> Path:
|
||||||
"""Return the local path to a backup."""
|
"""Return the local path to an existing backup.
|
||||||
return self._backup_dir / f"{backup_id}.tar"
|
|
||||||
|
Raises BackupAgentError if the backup does not exist.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return self._backups[backup_id][1]
|
||||||
|
except KeyError as err:
|
||||||
|
raise BackupNotFound(f"Backup {backup_id} does not exist") from err
|
||||||
|
|
||||||
|
def get_new_backup_path(self, backup: AgentBackup) -> Path:
|
||||||
|
"""Return the local path to a new backup."""
|
||||||
|
return self._backup_dir / f"{backup.backup_id}.tar"
|
||||||
|
|
||||||
async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None:
|
async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None:
|
||||||
"""Delete a backup file."""
|
"""Delete a backup file."""
|
||||||
if await self.async_get_backup(backup_id) is None:
|
if not self._loaded_backups:
|
||||||
return
|
await self._load_backups()
|
||||||
|
|
||||||
backup_path = self.get_backup_path(backup_id)
|
try:
|
||||||
|
backup_path = self.get_backup_path(backup_id)
|
||||||
|
except BackupNotFound:
|
||||||
|
return
|
||||||
await self._hass.async_add_executor_job(backup_path.unlink, True)
|
await self._hass.async_add_executor_job(backup_path.unlink, True)
|
||||||
LOGGER.debug("Deleted backup located at %s", backup_path)
|
LOGGER.debug("Deleted backup located at %s", backup_path)
|
||||||
self._backups.pop(backup_id)
|
self._backups.pop(backup_id)
|
||||||
|
@ -40,6 +40,7 @@ BACKUP_START_TIME_JITTER = 60 * 60
|
|||||||
class StoredBackupConfig(TypedDict):
|
class StoredBackupConfig(TypedDict):
|
||||||
"""Represent the stored backup config."""
|
"""Represent the stored backup config."""
|
||||||
|
|
||||||
|
agents: dict[str, StoredAgentConfig]
|
||||||
create_backup: StoredCreateBackupConfig
|
create_backup: StoredCreateBackupConfig
|
||||||
last_attempted_automatic_backup: str | None
|
last_attempted_automatic_backup: str | None
|
||||||
last_completed_automatic_backup: str | None
|
last_completed_automatic_backup: str | None
|
||||||
@ -51,6 +52,7 @@ class StoredBackupConfig(TypedDict):
|
|||||||
class BackupConfigData:
|
class BackupConfigData:
|
||||||
"""Represent loaded backup config data."""
|
"""Represent loaded backup config data."""
|
||||||
|
|
||||||
|
agents: dict[str, AgentConfig]
|
||||||
create_backup: CreateBackupConfig
|
create_backup: CreateBackupConfig
|
||||||
last_attempted_automatic_backup: datetime | None = None
|
last_attempted_automatic_backup: datetime | None = None
|
||||||
last_completed_automatic_backup: datetime | None = None
|
last_completed_automatic_backup: datetime | None = None
|
||||||
@ -84,6 +86,10 @@ class BackupConfigData:
|
|||||||
days = [Day(day) for day in data["schedule"]["days"]]
|
days = [Day(day) for day in data["schedule"]["days"]]
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
|
agents={
|
||||||
|
agent_id: AgentConfig(protected=agent_data["protected"])
|
||||||
|
for agent_id, agent_data in data["agents"].items()
|
||||||
|
},
|
||||||
create_backup=CreateBackupConfig(
|
create_backup=CreateBackupConfig(
|
||||||
agent_ids=data["create_backup"]["agent_ids"],
|
agent_ids=data["create_backup"]["agent_ids"],
|
||||||
include_addons=data["create_backup"]["include_addons"],
|
include_addons=data["create_backup"]["include_addons"],
|
||||||
@ -120,6 +126,9 @@ class BackupConfigData:
|
|||||||
last_completed = None
|
last_completed = None
|
||||||
|
|
||||||
return StoredBackupConfig(
|
return StoredBackupConfig(
|
||||||
|
agents={
|
||||||
|
agent_id: agent.to_dict() for agent_id, agent in self.agents.items()
|
||||||
|
},
|
||||||
create_backup=self.create_backup.to_dict(),
|
create_backup=self.create_backup.to_dict(),
|
||||||
last_attempted_automatic_backup=last_attempted,
|
last_attempted_automatic_backup=last_attempted,
|
||||||
last_completed_automatic_backup=last_completed,
|
last_completed_automatic_backup=last_completed,
|
||||||
@ -134,6 +143,7 @@ class BackupConfig:
|
|||||||
def __init__(self, hass: HomeAssistant, manager: BackupManager) -> None:
|
def __init__(self, hass: HomeAssistant, manager: BackupManager) -> None:
|
||||||
"""Initialize backup config."""
|
"""Initialize backup config."""
|
||||||
self.data = BackupConfigData(
|
self.data = BackupConfigData(
|
||||||
|
agents={},
|
||||||
create_backup=CreateBackupConfig(),
|
create_backup=CreateBackupConfig(),
|
||||||
retention=RetentionConfig(),
|
retention=RetentionConfig(),
|
||||||
schedule=BackupSchedule(),
|
schedule=BackupSchedule(),
|
||||||
@ -149,11 +159,20 @@ class BackupConfig:
|
|||||||
async def update(
|
async def update(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
agents: dict[str, AgentParametersDict] | UndefinedType = UNDEFINED,
|
||||||
create_backup: CreateBackupParametersDict | UndefinedType = UNDEFINED,
|
create_backup: CreateBackupParametersDict | UndefinedType = UNDEFINED,
|
||||||
retention: RetentionParametersDict | UndefinedType = UNDEFINED,
|
retention: RetentionParametersDict | UndefinedType = UNDEFINED,
|
||||||
schedule: ScheduleParametersDict | UndefinedType = UNDEFINED,
|
schedule: ScheduleParametersDict | UndefinedType = UNDEFINED,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Update config."""
|
"""Update config."""
|
||||||
|
if agents is not UNDEFINED:
|
||||||
|
for agent_id, agent_config in agents.items():
|
||||||
|
if agent_id not in self.data.agents:
|
||||||
|
self.data.agents[agent_id] = AgentConfig(**agent_config)
|
||||||
|
else:
|
||||||
|
self.data.agents[agent_id] = replace(
|
||||||
|
self.data.agents[agent_id], **agent_config
|
||||||
|
)
|
||||||
if create_backup is not UNDEFINED:
|
if create_backup is not UNDEFINED:
|
||||||
self.data.create_backup = replace(self.data.create_backup, **create_backup)
|
self.data.create_backup = replace(self.data.create_backup, **create_backup)
|
||||||
if retention is not UNDEFINED:
|
if retention is not UNDEFINED:
|
||||||
@ -170,6 +189,31 @@ class BackupConfig:
|
|||||||
self._manager.store.save()
|
self._manager.store.save()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(kw_only=True)
|
||||||
|
class AgentConfig:
|
||||||
|
"""Represent the config for an agent."""
|
||||||
|
|
||||||
|
protected: bool
|
||||||
|
|
||||||
|
def to_dict(self) -> StoredAgentConfig:
|
||||||
|
"""Convert agent config to a dict."""
|
||||||
|
return {
|
||||||
|
"protected": self.protected,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class StoredAgentConfig(TypedDict):
|
||||||
|
"""Represent the stored config for an agent."""
|
||||||
|
|
||||||
|
protected: bool
|
||||||
|
|
||||||
|
|
||||||
|
class AgentParametersDict(TypedDict, total=False):
|
||||||
|
"""Represent the parameters for an agent."""
|
||||||
|
|
||||||
|
protected: bool
|
||||||
|
|
||||||
|
|
||||||
@dataclass(kw_only=True)
|
@dataclass(kw_only=True)
|
||||||
class RetentionConfig:
|
class RetentionConfig:
|
||||||
"""Represent the backup retention configuration."""
|
"""Represent the backup retention configuration."""
|
||||||
|
@ -69,7 +69,7 @@ class DownloadBackupView(HomeAssistantView):
|
|||||||
CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar"
|
CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar"
|
||||||
}
|
}
|
||||||
|
|
||||||
if not password:
|
if not password or not backup.protected:
|
||||||
return await self._send_backup_no_password(
|
return await self._send_backup_no_password(
|
||||||
request, headers, backup_id, agent_id, agent, manager
|
request, headers, backup_id, agent_id, agent, manager
|
||||||
)
|
)
|
||||||
@ -123,13 +123,13 @@ class DownloadBackupView(HomeAssistantView):
|
|||||||
|
|
||||||
worker_done_event = asyncio.Event()
|
worker_done_event = asyncio.Event()
|
||||||
|
|
||||||
def on_done() -> None:
|
def on_done(error: Exception | None) -> None:
|
||||||
"""Call by the worker thread when it's done."""
|
"""Call by the worker thread when it's done."""
|
||||||
hass.loop.call_soon_threadsafe(worker_done_event.set)
|
hass.loop.call_soon_threadsafe(worker_done_event.set)
|
||||||
|
|
||||||
stream = util.AsyncIteratorWriter(hass)
|
stream = util.AsyncIteratorWriter(hass)
|
||||||
worker = threading.Thread(
|
worker = threading.Thread(
|
||||||
target=util.decrypt_backup, args=[reader, stream, password, on_done]
|
target=util.decrypt_backup, args=[reader, stream, password, on_done, 0, []]
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
worker.start()
|
worker.start()
|
||||||
@ -144,13 +144,17 @@ class DownloadBackupView(HomeAssistantView):
|
|||||||
|
|
||||||
|
|
||||||
class UploadBackupView(HomeAssistantView):
|
class UploadBackupView(HomeAssistantView):
|
||||||
"""Generate backup view."""
|
"""Upload backup view."""
|
||||||
|
|
||||||
url = "/api/backup/upload"
|
url = "/api/backup/upload"
|
||||||
name = "api:backup:upload"
|
name = "api:backup:upload"
|
||||||
|
|
||||||
@require_admin
|
@require_admin
|
||||||
async def post(self, request: Request) -> Response:
|
async def post(self, request: Request) -> Response:
|
||||||
|
"""Upload a backup file."""
|
||||||
|
return await self._post(request)
|
||||||
|
|
||||||
|
async def _post(self, request: Request) -> Response:
|
||||||
"""Upload a backup file."""
|
"""Upload a backup file."""
|
||||||
try:
|
try:
|
||||||
agent_ids = request.query.getall("agent_id")
|
agent_ids = request.query.getall("agent_id")
|
||||||
@ -161,7 +165,9 @@ class UploadBackupView(HomeAssistantView):
|
|||||||
contents = cast(BodyPartReader, await reader.next())
|
contents = cast(BodyPartReader, await reader.next())
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await manager.async_receive_backup(contents=contents, agent_ids=agent_ids)
|
backup_id = await manager.async_receive_backup(
|
||||||
|
contents=contents, agent_ids=agent_ids
|
||||||
|
)
|
||||||
except OSError as err:
|
except OSError as err:
|
||||||
return Response(
|
return Response(
|
||||||
body=f"Can't write backup file: {err}",
|
body=f"Can't write backup file: {err}",
|
||||||
@ -175,4 +181,4 @@ class UploadBackupView(HomeAssistantView):
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
return Response(status=HTTPStatus.INTERNAL_SERVER_ERROR)
|
return Response(status=HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
return Response(status=HTTPStatus.CREATED)
|
return self.json({"backup_id": backup_id}, status_code=HTTPStatus.CREATED)
|
||||||
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
import abc
|
import abc
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import AsyncIterator, Callable, Coroutine
|
from collections.abc import AsyncIterator, Callable, Coroutine
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, replace
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
import hashlib
|
import hashlib
|
||||||
import io
|
import io
|
||||||
@ -19,17 +19,20 @@ from typing import IO, TYPE_CHECKING, Any, Protocol, TypedDict, cast
|
|||||||
import aiohttp
|
import aiohttp
|
||||||
from securetar import SecureTarFile, atomic_contents_add
|
from securetar import SecureTarFile, atomic_contents_add
|
||||||
|
|
||||||
from homeassistant.backup_restore import RESTORE_BACKUP_FILE, password_to_key
|
from homeassistant.backup_restore import (
|
||||||
|
RESTORE_BACKUP_FILE,
|
||||||
|
RESTORE_BACKUP_RESULT_FILE,
|
||||||
|
password_to_key,
|
||||||
|
)
|
||||||
from homeassistant.const import __version__ as HAVERSION
|
from homeassistant.const import __version__ as HAVERSION
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
|
||||||
from homeassistant.helpers import (
|
from homeassistant.helpers import (
|
||||||
instance_id,
|
instance_id,
|
||||||
integration_platform,
|
integration_platform,
|
||||||
issue_registry as ir,
|
issue_registry as ir,
|
||||||
)
|
)
|
||||||
from homeassistant.helpers.json import json_bytes
|
from homeassistant.helpers.json import json_bytes
|
||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util, json as json_util
|
||||||
|
|
||||||
from . import util as backup_util
|
from . import util as backup_util
|
||||||
from .agent import (
|
from .agent import (
|
||||||
@ -47,10 +50,12 @@ from .const import (
|
|||||||
EXCLUDE_FROM_BACKUP,
|
EXCLUDE_FROM_BACKUP,
|
||||||
LOGGER,
|
LOGGER,
|
||||||
)
|
)
|
||||||
from .models import AgentBackup, BackupManagerError, Folder
|
from .models import AgentBackup, BackupError, BackupManagerError, BaseBackup, Folder
|
||||||
from .store import BackupStore
|
from .store import BackupStore
|
||||||
from .util import (
|
from .util import (
|
||||||
AsyncIteratorReader,
|
AsyncIteratorReader,
|
||||||
|
DecryptedBackupStreamer,
|
||||||
|
EncryptedBackupStreamer,
|
||||||
make_backup_dir,
|
make_backup_dir,
|
||||||
read_backup,
|
read_backup,
|
||||||
validate_password,
|
validate_password,
|
||||||
@ -66,10 +71,18 @@ class NewBackup:
|
|||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True, slots=True)
|
@dataclass(frozen=True, kw_only=True, slots=True)
|
||||||
class ManagerBackup(AgentBackup):
|
class AgentBackupStatus:
|
||||||
|
"""Agent specific backup attributes."""
|
||||||
|
|
||||||
|
protected: bool
|
||||||
|
size: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, kw_only=True, slots=True)
|
||||||
|
class ManagerBackup(BaseBackup):
|
||||||
"""Backup class."""
|
"""Backup class."""
|
||||||
|
|
||||||
agent_ids: list[str]
|
agents: dict[str, AgentBackupStatus]
|
||||||
failed_agent_ids: list[str]
|
failed_agent_ids: list[str]
|
||||||
with_automatic_settings: bool | None
|
with_automatic_settings: bool | None
|
||||||
|
|
||||||
@ -171,6 +184,7 @@ class CreateBackupEvent(ManagerStateEvent):
|
|||||||
"""Backup in progress."""
|
"""Backup in progress."""
|
||||||
|
|
||||||
manager_state: BackupManagerState = BackupManagerState.CREATE_BACKUP
|
manager_state: BackupManagerState = BackupManagerState.CREATE_BACKUP
|
||||||
|
reason: str | None
|
||||||
stage: CreateBackupStage | None
|
stage: CreateBackupStage | None
|
||||||
state: CreateBackupState
|
state: CreateBackupState
|
||||||
|
|
||||||
@ -180,6 +194,7 @@ class ReceiveBackupEvent(ManagerStateEvent):
|
|||||||
"""Backup receive."""
|
"""Backup receive."""
|
||||||
|
|
||||||
manager_state: BackupManagerState = BackupManagerState.RECEIVE_BACKUP
|
manager_state: BackupManagerState = BackupManagerState.RECEIVE_BACKUP
|
||||||
|
reason: str | None
|
||||||
stage: ReceiveBackupStage | None
|
stage: ReceiveBackupStage | None
|
||||||
state: ReceiveBackupState
|
state: ReceiveBackupState
|
||||||
|
|
||||||
@ -189,6 +204,7 @@ class RestoreBackupEvent(ManagerStateEvent):
|
|||||||
"""Backup restore."""
|
"""Backup restore."""
|
||||||
|
|
||||||
manager_state: BackupManagerState = BackupManagerState.RESTORE_BACKUP
|
manager_state: BackupManagerState = BackupManagerState.RESTORE_BACKUP
|
||||||
|
reason: str | None
|
||||||
stage: RestoreBackupStage | None
|
stage: RestoreBackupStage | None
|
||||||
state: RestoreBackupState
|
state: RestoreBackupState
|
||||||
|
|
||||||
@ -249,20 +265,32 @@ class BackupReaderWriter(abc.ABC):
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Restore a backup."""
|
"""Restore a backup."""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def async_resume_restore_progress_after_restart(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
on_progress: Callable[[RestoreBackupEvent | IdleEvent], None],
|
||||||
|
) -> None:
|
||||||
|
"""Get restore events after core restart."""
|
||||||
|
|
||||||
class BackupReaderWriterError(HomeAssistantError):
|
|
||||||
|
class BackupReaderWriterError(BackupError):
|
||||||
"""Backup reader/writer error."""
|
"""Backup reader/writer error."""
|
||||||
|
|
||||||
|
error_code = "backup_reader_writer_error"
|
||||||
|
|
||||||
|
|
||||||
class IncorrectPasswordError(BackupReaderWriterError):
|
class IncorrectPasswordError(BackupReaderWriterError):
|
||||||
"""Raised when the password is incorrect."""
|
"""Raised when the password is incorrect."""
|
||||||
|
|
||||||
|
error_code = "password_incorrect"
|
||||||
_message = "The password provided is incorrect."
|
_message = "The password provided is incorrect."
|
||||||
|
|
||||||
|
|
||||||
class DecryptOnDowloadNotSupported(BackupManagerError):
|
class DecryptOnDowloadNotSupported(BackupManagerError):
|
||||||
"""Raised when on-the-fly decryption is not supported."""
|
"""Raised when on-the-fly decryption is not supported."""
|
||||||
|
|
||||||
|
error_code = "decrypt_on_download_not_supported"
|
||||||
_message = "On-the-fly decryption is not supported for this backup."
|
_message = "On-the-fly decryption is not supported for this backup."
|
||||||
|
|
||||||
|
|
||||||
@ -292,6 +320,7 @@ class BackupManager:
|
|||||||
|
|
||||||
# Latest backup event and backup event subscribers
|
# Latest backup event and backup event subscribers
|
||||||
self.last_event: ManagerStateEvent = IdleEvent()
|
self.last_event: ManagerStateEvent = IdleEvent()
|
||||||
|
self.last_non_idle_event: ManagerStateEvent | None = None
|
||||||
self._backup_event_subscriptions: list[Callable[[ManagerStateEvent], None]] = []
|
self._backup_event_subscriptions: list[Callable[[ManagerStateEvent], None]] = []
|
||||||
|
|
||||||
async def async_setup(self) -> None:
|
async def async_setup(self) -> None:
|
||||||
@ -301,6 +330,10 @@ class BackupManager:
|
|||||||
self.config.load(stored["config"])
|
self.config.load(stored["config"])
|
||||||
self.known_backups.load(stored["backups"])
|
self.known_backups.load(stored["backups"])
|
||||||
|
|
||||||
|
await self._reader_writer.async_resume_restore_progress_after_restart(
|
||||||
|
on_progress=self.async_on_backup_event
|
||||||
|
)
|
||||||
|
|
||||||
await self.load_platforms()
|
await self.load_platforms()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -430,20 +463,61 @@ class BackupManager:
|
|||||||
backup: AgentBackup,
|
backup: AgentBackup,
|
||||||
agent_ids: list[str],
|
agent_ids: list[str],
|
||||||
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
|
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
|
||||||
|
password: str | None,
|
||||||
) -> dict[str, Exception]:
|
) -> dict[str, Exception]:
|
||||||
"""Upload a backup to selected agents."""
|
"""Upload a backup to selected agents."""
|
||||||
agent_errors: dict[str, Exception] = {}
|
agent_errors: dict[str, Exception] = {}
|
||||||
|
|
||||||
LOGGER.debug("Uploading backup %s to agents %s", backup.backup_id, agent_ids)
|
LOGGER.debug("Uploading backup %s to agents %s", backup.backup_id, agent_ids)
|
||||||
|
|
||||||
sync_backup_results = await asyncio.gather(
|
async def upload_backup_to_agent(agent_id: str) -> None:
|
||||||
*(
|
"""Upload backup to a single agent, and encrypt or decrypt as needed."""
|
||||||
self.backup_agents[agent_id].async_upload_backup(
|
config = self.config.data.agents.get(agent_id)
|
||||||
open_stream=open_stream,
|
should_encrypt = config.protected if config else password is not None
|
||||||
backup=backup,
|
streamer: DecryptedBackupStreamer | EncryptedBackupStreamer | None = None
|
||||||
|
if should_encrypt == backup.protected or password is None:
|
||||||
|
# The backup we're uploading is already in the correct state, or we
|
||||||
|
# don't have a password to encrypt or decrypt it
|
||||||
|
LOGGER.debug(
|
||||||
|
"Uploading backup %s to agent %s as is", backup.backup_id, agent_id
|
||||||
)
|
)
|
||||||
for agent_id in agent_ids
|
open_stream_func = open_stream
|
||||||
),
|
_backup = backup
|
||||||
|
elif should_encrypt:
|
||||||
|
# The backup we're uploading is not encrypted, but the agent requires it
|
||||||
|
LOGGER.debug(
|
||||||
|
"Uploading encrypted backup %s to agent %s",
|
||||||
|
backup.backup_id,
|
||||||
|
agent_id,
|
||||||
|
)
|
||||||
|
streamer = EncryptedBackupStreamer(
|
||||||
|
self.hass, backup, open_stream, password
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# The backup we're uploading is encrypted, but the agent requires it
|
||||||
|
# decrypted
|
||||||
|
LOGGER.debug(
|
||||||
|
"Uploading decrypted backup %s to agent %s",
|
||||||
|
backup.backup_id,
|
||||||
|
agent_id,
|
||||||
|
)
|
||||||
|
streamer = DecryptedBackupStreamer(
|
||||||
|
self.hass, backup, open_stream, password
|
||||||
|
)
|
||||||
|
if streamer:
|
||||||
|
open_stream_func = streamer.open_stream
|
||||||
|
_backup = replace(
|
||||||
|
backup, protected=should_encrypt, size=streamer.size()
|
||||||
|
)
|
||||||
|
await self.backup_agents[agent_id].async_upload_backup(
|
||||||
|
open_stream=open_stream_func,
|
||||||
|
backup=_backup,
|
||||||
|
)
|
||||||
|
if streamer:
|
||||||
|
await streamer.wait()
|
||||||
|
|
||||||
|
sync_backup_results = await asyncio.gather(
|
||||||
|
*(upload_backup_to_agent(agent_id) for agent_id in agent_ids),
|
||||||
return_exceptions=True,
|
return_exceptions=True,
|
||||||
)
|
)
|
||||||
for idx, result in enumerate(sync_backup_results):
|
for idx, result in enumerate(sync_backup_results):
|
||||||
@ -499,7 +573,7 @@ class BackupManager:
|
|||||||
agent_backup, await instance_id.async_get(self.hass)
|
agent_backup, await instance_id.async_get(self.hass)
|
||||||
)
|
)
|
||||||
backups[backup_id] = ManagerBackup(
|
backups[backup_id] = ManagerBackup(
|
||||||
agent_ids=[],
|
agents={},
|
||||||
addons=agent_backup.addons,
|
addons=agent_backup.addons,
|
||||||
backup_id=backup_id,
|
backup_id=backup_id,
|
||||||
date=agent_backup.date,
|
date=agent_backup.date,
|
||||||
@ -510,11 +584,12 @@ class BackupManager:
|
|||||||
homeassistant_included=agent_backup.homeassistant_included,
|
homeassistant_included=agent_backup.homeassistant_included,
|
||||||
homeassistant_version=agent_backup.homeassistant_version,
|
homeassistant_version=agent_backup.homeassistant_version,
|
||||||
name=agent_backup.name,
|
name=agent_backup.name,
|
||||||
protected=agent_backup.protected,
|
|
||||||
size=agent_backup.size,
|
|
||||||
with_automatic_settings=with_automatic_settings,
|
with_automatic_settings=with_automatic_settings,
|
||||||
)
|
)
|
||||||
backups[backup_id].agent_ids.append(agent_ids[idx])
|
backups[backup_id].agents[agent_ids[idx]] = AgentBackupStatus(
|
||||||
|
protected=agent_backup.protected,
|
||||||
|
size=agent_backup.size,
|
||||||
|
)
|
||||||
|
|
||||||
return (backups, agent_errors)
|
return (backups, agent_errors)
|
||||||
|
|
||||||
@ -550,7 +625,7 @@ class BackupManager:
|
|||||||
result, await instance_id.async_get(self.hass)
|
result, await instance_id.async_get(self.hass)
|
||||||
)
|
)
|
||||||
backup = ManagerBackup(
|
backup = ManagerBackup(
|
||||||
agent_ids=[],
|
agents={},
|
||||||
addons=result.addons,
|
addons=result.addons,
|
||||||
backup_id=result.backup_id,
|
backup_id=result.backup_id,
|
||||||
date=result.date,
|
date=result.date,
|
||||||
@ -561,11 +636,12 @@ class BackupManager:
|
|||||||
homeassistant_included=result.homeassistant_included,
|
homeassistant_included=result.homeassistant_included,
|
||||||
homeassistant_version=result.homeassistant_version,
|
homeassistant_version=result.homeassistant_version,
|
||||||
name=result.name,
|
name=result.name,
|
||||||
protected=result.protected,
|
|
||||||
size=result.size,
|
|
||||||
with_automatic_settings=with_automatic_settings,
|
with_automatic_settings=with_automatic_settings,
|
||||||
)
|
)
|
||||||
backup.agent_ids.append(agent_ids[idx])
|
backup.agents[agent_ids[idx]] = AgentBackupStatus(
|
||||||
|
protected=result.protected,
|
||||||
|
size=result.size,
|
||||||
|
)
|
||||||
|
|
||||||
return (backup, agent_errors)
|
return (backup, agent_errors)
|
||||||
|
|
||||||
@ -614,24 +690,39 @@ class BackupManager:
|
|||||||
*,
|
*,
|
||||||
agent_ids: list[str],
|
agent_ids: list[str],
|
||||||
contents: aiohttp.BodyPartReader,
|
contents: aiohttp.BodyPartReader,
|
||||||
) -> None:
|
) -> str:
|
||||||
"""Receive and store a backup file from upload."""
|
"""Receive and store a backup file from upload."""
|
||||||
if self.state is not BackupManagerState.IDLE:
|
if self.state is not BackupManagerState.IDLE:
|
||||||
raise BackupManagerError(f"Backup manager busy: {self.state}")
|
raise BackupManagerError(f"Backup manager busy: {self.state}")
|
||||||
self.async_on_backup_event(
|
self.async_on_backup_event(
|
||||||
ReceiveBackupEvent(stage=None, state=ReceiveBackupState.IN_PROGRESS)
|
ReceiveBackupEvent(
|
||||||
|
reason=None,
|
||||||
|
stage=None,
|
||||||
|
state=ReceiveBackupState.IN_PROGRESS,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await self._async_receive_backup(agent_ids=agent_ids, contents=contents)
|
backup_id = await self._async_receive_backup(
|
||||||
|
agent_ids=agent_ids, contents=contents
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.async_on_backup_event(
|
self.async_on_backup_event(
|
||||||
ReceiveBackupEvent(stage=None, state=ReceiveBackupState.FAILED)
|
ReceiveBackupEvent(
|
||||||
|
reason="unknown_error",
|
||||||
|
stage=None,
|
||||||
|
state=ReceiveBackupState.FAILED,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
else:
|
else:
|
||||||
self.async_on_backup_event(
|
self.async_on_backup_event(
|
||||||
ReceiveBackupEvent(stage=None, state=ReceiveBackupState.COMPLETED)
|
ReceiveBackupEvent(
|
||||||
|
reason=None,
|
||||||
|
stage=None,
|
||||||
|
state=ReceiveBackupState.COMPLETED,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
return backup_id
|
||||||
finally:
|
finally:
|
||||||
self.async_on_backup_event(IdleEvent())
|
self.async_on_backup_event(IdleEvent())
|
||||||
|
|
||||||
@ -640,11 +731,12 @@ class BackupManager:
|
|||||||
*,
|
*,
|
||||||
agent_ids: list[str],
|
agent_ids: list[str],
|
||||||
contents: aiohttp.BodyPartReader,
|
contents: aiohttp.BodyPartReader,
|
||||||
) -> None:
|
) -> str:
|
||||||
"""Receive and store a backup file from upload."""
|
"""Receive and store a backup file from upload."""
|
||||||
contents.chunk_size = BUF_SIZE
|
contents.chunk_size = BUF_SIZE
|
||||||
self.async_on_backup_event(
|
self.async_on_backup_event(
|
||||||
ReceiveBackupEvent(
|
ReceiveBackupEvent(
|
||||||
|
reason=None,
|
||||||
stage=ReceiveBackupStage.RECEIVE_FILE,
|
stage=ReceiveBackupStage.RECEIVE_FILE,
|
||||||
state=ReceiveBackupState.IN_PROGRESS,
|
state=ReceiveBackupState.IN_PROGRESS,
|
||||||
)
|
)
|
||||||
@ -656,6 +748,7 @@ class BackupManager:
|
|||||||
)
|
)
|
||||||
self.async_on_backup_event(
|
self.async_on_backup_event(
|
||||||
ReceiveBackupEvent(
|
ReceiveBackupEvent(
|
||||||
|
reason=None,
|
||||||
stage=ReceiveBackupStage.UPLOAD_TO_AGENTS,
|
stage=ReceiveBackupStage.UPLOAD_TO_AGENTS,
|
||||||
state=ReceiveBackupState.IN_PROGRESS,
|
state=ReceiveBackupState.IN_PROGRESS,
|
||||||
)
|
)
|
||||||
@ -664,14 +757,19 @@ class BackupManager:
|
|||||||
backup=written_backup.backup,
|
backup=written_backup.backup,
|
||||||
agent_ids=agent_ids,
|
agent_ids=agent_ids,
|
||||||
open_stream=written_backup.open_stream,
|
open_stream=written_backup.open_stream,
|
||||||
|
# When receiving a backup, we don't decrypt or encrypt it according to the
|
||||||
|
# agent settings, we just upload it as is.
|
||||||
|
password=None,
|
||||||
)
|
)
|
||||||
await written_backup.release_stream()
|
await written_backup.release_stream()
|
||||||
self.known_backups.add(written_backup.backup, agent_errors)
|
self.known_backups.add(written_backup.backup, agent_errors)
|
||||||
|
return written_backup.backup.backup_id
|
||||||
|
|
||||||
async def async_create_backup(
|
async def async_create_backup(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
agent_ids: list[str],
|
agent_ids: list[str],
|
||||||
|
extra_metadata: dict[str, bool | str] | None = None,
|
||||||
include_addons: list[str] | None,
|
include_addons: list[str] | None,
|
||||||
include_all_addons: bool,
|
include_all_addons: bool,
|
||||||
include_database: bool,
|
include_database: bool,
|
||||||
@ -684,6 +782,7 @@ class BackupManager:
|
|||||||
"""Create a backup."""
|
"""Create a backup."""
|
||||||
new_backup = await self.async_initiate_backup(
|
new_backup = await self.async_initiate_backup(
|
||||||
agent_ids=agent_ids,
|
agent_ids=agent_ids,
|
||||||
|
extra_metadata=extra_metadata,
|
||||||
include_addons=include_addons,
|
include_addons=include_addons,
|
||||||
include_all_addons=include_all_addons,
|
include_all_addons=include_all_addons,
|
||||||
include_database=include_database,
|
include_database=include_database,
|
||||||
@ -717,6 +816,7 @@ class BackupManager:
|
|||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
agent_ids: list[str],
|
agent_ids: list[str],
|
||||||
|
extra_metadata: dict[str, bool | str] | None = None,
|
||||||
include_addons: list[str] | None,
|
include_addons: list[str] | None,
|
||||||
include_all_addons: bool,
|
include_all_addons: bool,
|
||||||
include_database: bool,
|
include_database: bool,
|
||||||
@ -736,11 +836,16 @@ class BackupManager:
|
|||||||
self.store.save()
|
self.store.save()
|
||||||
|
|
||||||
self.async_on_backup_event(
|
self.async_on_backup_event(
|
||||||
CreateBackupEvent(stage=None, state=CreateBackupState.IN_PROGRESS)
|
CreateBackupEvent(
|
||||||
|
reason=None,
|
||||||
|
stage=None,
|
||||||
|
state=CreateBackupState.IN_PROGRESS,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
return await self._async_create_backup(
|
return await self._async_create_backup(
|
||||||
agent_ids=agent_ids,
|
agent_ids=agent_ids,
|
||||||
|
extra_metadata=extra_metadata,
|
||||||
include_addons=include_addons,
|
include_addons=include_addons,
|
||||||
include_all_addons=include_all_addons,
|
include_all_addons=include_all_addons,
|
||||||
include_database=include_database,
|
include_database=include_database,
|
||||||
@ -751,9 +856,14 @@ class BackupManager:
|
|||||||
raise_task_error=raise_task_error,
|
raise_task_error=raise_task_error,
|
||||||
with_automatic_settings=with_automatic_settings,
|
with_automatic_settings=with_automatic_settings,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as err:
|
||||||
|
reason = err.error_code if isinstance(err, BackupError) else "unknown_error"
|
||||||
self.async_on_backup_event(
|
self.async_on_backup_event(
|
||||||
CreateBackupEvent(stage=None, state=CreateBackupState.FAILED)
|
CreateBackupEvent(
|
||||||
|
reason=reason,
|
||||||
|
stage=None,
|
||||||
|
state=CreateBackupState.FAILED,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
self.async_on_backup_event(IdleEvent())
|
self.async_on_backup_event(IdleEvent())
|
||||||
if with_automatic_settings:
|
if with_automatic_settings:
|
||||||
@ -764,6 +874,7 @@ class BackupManager:
|
|||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
agent_ids: list[str],
|
agent_ids: list[str],
|
||||||
|
extra_metadata: dict[str, bool | str] | None,
|
||||||
include_addons: list[str] | None,
|
include_addons: list[str] | None,
|
||||||
include_all_addons: bool,
|
include_all_addons: bool,
|
||||||
include_database: bool,
|
include_database: bool,
|
||||||
@ -790,6 +901,7 @@ class BackupManager:
|
|||||||
name
|
name
|
||||||
or f"{'Automatic' if with_automatic_settings else 'Custom'} backup {HAVERSION}"
|
or f"{'Automatic' if with_automatic_settings else 'Custom'} backup {HAVERSION}"
|
||||||
)
|
)
|
||||||
|
extra_metadata = extra_metadata or {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
(
|
(
|
||||||
@ -798,7 +910,8 @@ class BackupManager:
|
|||||||
) = await self._reader_writer.async_create_backup(
|
) = await self._reader_writer.async_create_backup(
|
||||||
agent_ids=agent_ids,
|
agent_ids=agent_ids,
|
||||||
backup_name=backup_name,
|
backup_name=backup_name,
|
||||||
extra_metadata={
|
extra_metadata=extra_metadata
|
||||||
|
| {
|
||||||
"instance_id": await instance_id.async_get(self.hass),
|
"instance_id": await instance_id.async_get(self.hass),
|
||||||
"with_automatic_settings": with_automatic_settings,
|
"with_automatic_settings": with_automatic_settings,
|
||||||
},
|
},
|
||||||
@ -814,7 +927,7 @@ class BackupManager:
|
|||||||
raise BackupManagerError(str(err)) from err
|
raise BackupManagerError(str(err)) from err
|
||||||
|
|
||||||
backup_finish_task = self._backup_finish_task = self.hass.async_create_task(
|
backup_finish_task = self._backup_finish_task = self.hass.async_create_task(
|
||||||
self._async_finish_backup(agent_ids, with_automatic_settings),
|
self._async_finish_backup(agent_ids, with_automatic_settings, password),
|
||||||
name="backup_manager_finish_backup",
|
name="backup_manager_finish_backup",
|
||||||
)
|
)
|
||||||
if not raise_task_error:
|
if not raise_task_error:
|
||||||
@ -831,7 +944,7 @@ class BackupManager:
|
|||||||
return new_backup
|
return new_backup
|
||||||
|
|
||||||
async def _async_finish_backup(
|
async def _async_finish_backup(
|
||||||
self, agent_ids: list[str], with_automatic_settings: bool
|
self, agent_ids: list[str], with_automatic_settings: bool, password: str | None
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Finish a backup."""
|
"""Finish a backup."""
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@ -854,6 +967,7 @@ class BackupManager:
|
|||||||
)
|
)
|
||||||
self.async_on_backup_event(
|
self.async_on_backup_event(
|
||||||
CreateBackupEvent(
|
CreateBackupEvent(
|
||||||
|
reason=None,
|
||||||
stage=CreateBackupStage.UPLOAD_TO_AGENTS,
|
stage=CreateBackupStage.UPLOAD_TO_AGENTS,
|
||||||
state=CreateBackupState.IN_PROGRESS,
|
state=CreateBackupState.IN_PROGRESS,
|
||||||
)
|
)
|
||||||
@ -864,6 +978,7 @@ class BackupManager:
|
|||||||
backup=written_backup.backup,
|
backup=written_backup.backup,
|
||||||
agent_ids=agent_ids,
|
agent_ids=agent_ids,
|
||||||
open_stream=written_backup.open_stream,
|
open_stream=written_backup.open_stream,
|
||||||
|
password=password,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
await written_backup.release_stream()
|
await written_backup.release_stream()
|
||||||
@ -884,14 +999,22 @@ class BackupManager:
|
|||||||
finally:
|
finally:
|
||||||
self._backup_task = None
|
self._backup_task = None
|
||||||
self._backup_finish_task = None
|
self._backup_finish_task = None
|
||||||
self.async_on_backup_event(
|
if backup_success:
|
||||||
CreateBackupEvent(
|
self.async_on_backup_event(
|
||||||
stage=None,
|
CreateBackupEvent(
|
||||||
state=CreateBackupState.COMPLETED
|
reason=None,
|
||||||
if backup_success
|
stage=None,
|
||||||
else CreateBackupState.FAILED,
|
state=CreateBackupState.COMPLETED,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.async_on_backup_event(
|
||||||
|
CreateBackupEvent(
|
||||||
|
reason="upload_failed",
|
||||||
|
stage=None,
|
||||||
|
state=CreateBackupState.FAILED,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
self.async_on_backup_event(IdleEvent())
|
self.async_on_backup_event(IdleEvent())
|
||||||
|
|
||||||
async def async_restore_backup(
|
async def async_restore_backup(
|
||||||
@ -910,7 +1033,11 @@ class BackupManager:
|
|||||||
raise BackupManagerError(f"Backup manager busy: {self.state}")
|
raise BackupManagerError(f"Backup manager busy: {self.state}")
|
||||||
|
|
||||||
self.async_on_backup_event(
|
self.async_on_backup_event(
|
||||||
RestoreBackupEvent(stage=None, state=RestoreBackupState.IN_PROGRESS)
|
RestoreBackupEvent(
|
||||||
|
reason=None,
|
||||||
|
stage=None,
|
||||||
|
state=RestoreBackupState.IN_PROGRESS,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await self._async_restore_backup(
|
await self._async_restore_backup(
|
||||||
@ -923,11 +1050,28 @@ class BackupManager:
|
|||||||
restore_homeassistant=restore_homeassistant,
|
restore_homeassistant=restore_homeassistant,
|
||||||
)
|
)
|
||||||
self.async_on_backup_event(
|
self.async_on_backup_event(
|
||||||
RestoreBackupEvent(stage=None, state=RestoreBackupState.COMPLETED)
|
RestoreBackupEvent(
|
||||||
|
reason=None,
|
||||||
|
stage=None,
|
||||||
|
state=RestoreBackupState.COMPLETED,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
except BackupError as err:
|
||||||
|
self.async_on_backup_event(
|
||||||
|
RestoreBackupEvent(
|
||||||
|
reason=err.error_code,
|
||||||
|
stage=None,
|
||||||
|
state=RestoreBackupState.FAILED,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
self.async_on_backup_event(
|
self.async_on_backup_event(
|
||||||
RestoreBackupEvent(stage=None, state=RestoreBackupState.FAILED)
|
RestoreBackupEvent(
|
||||||
|
reason="unknown_error",
|
||||||
|
stage=None,
|
||||||
|
state=RestoreBackupState.FAILED,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
@ -975,6 +1119,8 @@ class BackupManager:
|
|||||||
if (current_state := self.state) != (new_state := event.manager_state):
|
if (current_state := self.state) != (new_state := event.manager_state):
|
||||||
LOGGER.debug("Backup state: %s -> %s", current_state, new_state)
|
LOGGER.debug("Backup state: %s -> %s", current_state, new_state)
|
||||||
self.last_event = event
|
self.last_event = event
|
||||||
|
if not isinstance(event, IdleEvent):
|
||||||
|
self.last_non_idle_event = event
|
||||||
for subscription in self._backup_event_subscriptions:
|
for subscription in self._backup_event_subscriptions:
|
||||||
subscription(event)
|
subscription(event)
|
||||||
|
|
||||||
@ -1020,7 +1166,11 @@ class BackupManager:
|
|||||||
learn_more_url="homeassistant://config/backup",
|
learn_more_url="homeassistant://config/backup",
|
||||||
severity=ir.IssueSeverity.WARNING,
|
severity=ir.IssueSeverity.WARNING,
|
||||||
translation_key="automatic_backup_failed_upload_agents",
|
translation_key="automatic_backup_failed_upload_agents",
|
||||||
translation_placeholders={"failed_agents": ", ".join(agent_errors)},
|
translation_placeholders={
|
||||||
|
"failed_agents": ", ".join(
|
||||||
|
self.backup_agents[agent_id].name for agent_id in agent_errors
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_can_decrypt_on_download(
|
async def async_can_decrypt_on_download(
|
||||||
@ -1048,7 +1198,9 @@ class BackupManager:
|
|||||||
backup_stream = await agent.async_download_backup(backup_id)
|
backup_stream = await agent.async_download_backup(backup_id)
|
||||||
reader = cast(IO[bytes], AsyncIteratorReader(self.hass, backup_stream))
|
reader = cast(IO[bytes], AsyncIteratorReader(self.hass, backup_stream))
|
||||||
try:
|
try:
|
||||||
validate_password_stream(reader, password)
|
await self.hass.async_add_executor_job(
|
||||||
|
validate_password_stream, reader, password
|
||||||
|
)
|
||||||
except backup_util.IncorrectPassword as err:
|
except backup_util.IncorrectPassword as err:
|
||||||
raise IncorrectPasswordError from err
|
raise IncorrectPasswordError from err
|
||||||
except backup_util.UnsupportedSecureTarVersion as err:
|
except backup_util.UnsupportedSecureTarVersion as err:
|
||||||
@ -1194,13 +1346,32 @@ class CoreBackupReaderWriter(BackupReaderWriter):
|
|||||||
"""Generate a backup."""
|
"""Generate a backup."""
|
||||||
manager = self._hass.data[DATA_MANAGER]
|
manager = self._hass.data[DATA_MANAGER]
|
||||||
|
|
||||||
|
agent_config = manager.config.data.agents.get(self._local_agent_id)
|
||||||
|
if agent_config and not agent_config.protected:
|
||||||
|
password = None
|
||||||
|
|
||||||
|
backup = AgentBackup(
|
||||||
|
addons=[],
|
||||||
|
backup_id=backup_id,
|
||||||
|
database_included=include_database,
|
||||||
|
date=date_str,
|
||||||
|
extra_metadata=extra_metadata,
|
||||||
|
folders=[],
|
||||||
|
homeassistant_included=True,
|
||||||
|
homeassistant_version=HAVERSION,
|
||||||
|
name=backup_name,
|
||||||
|
protected=password is not None,
|
||||||
|
size=0,
|
||||||
|
)
|
||||||
|
|
||||||
local_agent_tar_file_path = None
|
local_agent_tar_file_path = None
|
||||||
if self._local_agent_id in agent_ids:
|
if self._local_agent_id in agent_ids:
|
||||||
local_agent = manager.local_backup_agents[self._local_agent_id]
|
local_agent = manager.local_backup_agents[self._local_agent_id]
|
||||||
local_agent_tar_file_path = local_agent.get_backup_path(backup_id)
|
local_agent_tar_file_path = local_agent.get_new_backup_path(backup)
|
||||||
|
|
||||||
on_progress(
|
on_progress(
|
||||||
CreateBackupEvent(
|
CreateBackupEvent(
|
||||||
|
reason=None,
|
||||||
stage=CreateBackupStage.HOME_ASSISTANT,
|
stage=CreateBackupStage.HOME_ASSISTANT,
|
||||||
state=CreateBackupState.IN_PROGRESS,
|
state=CreateBackupState.IN_PROGRESS,
|
||||||
)
|
)
|
||||||
@ -1238,19 +1409,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
|
|||||||
# ValueError from json_bytes
|
# ValueError from json_bytes
|
||||||
raise BackupReaderWriterError(str(err)) from err
|
raise BackupReaderWriterError(str(err)) from err
|
||||||
else:
|
else:
|
||||||
backup = AgentBackup(
|
backup = replace(backup, size=size_in_bytes)
|
||||||
addons=[],
|
|
||||||
backup_id=backup_id,
|
|
||||||
database_included=include_database,
|
|
||||||
date=date_str,
|
|
||||||
extra_metadata=extra_metadata,
|
|
||||||
folders=[],
|
|
||||||
homeassistant_included=True,
|
|
||||||
homeassistant_version=HAVERSION,
|
|
||||||
name=backup_name,
|
|
||||||
protected=password is not None,
|
|
||||||
size=size_in_bytes,
|
|
||||||
)
|
|
||||||
|
|
||||||
async_add_executor_job = self._hass.async_add_executor_job
|
async_add_executor_job = self._hass.async_add_executor_job
|
||||||
|
|
||||||
@ -1364,7 +1523,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
|
|||||||
manager = self._hass.data[DATA_MANAGER]
|
manager = self._hass.data[DATA_MANAGER]
|
||||||
if self._local_agent_id in agent_ids:
|
if self._local_agent_id in agent_ids:
|
||||||
local_agent = manager.local_backup_agents[self._local_agent_id]
|
local_agent = manager.local_backup_agents[self._local_agent_id]
|
||||||
tar_file_path = local_agent.get_backup_path(backup.backup_id)
|
tar_file_path = local_agent.get_new_backup_path(backup)
|
||||||
await async_add_executor_job(make_backup_dir, tar_file_path.parent)
|
await async_add_executor_job(make_backup_dir, tar_file_path.parent)
|
||||||
await async_add_executor_job(shutil.move, temp_file, tar_file_path)
|
await async_add_executor_job(shutil.move, temp_file, tar_file_path)
|
||||||
else:
|
else:
|
||||||
@ -1460,10 +1619,62 @@ class CoreBackupReaderWriter(BackupReaderWriter):
|
|||||||
|
|
||||||
await self._hass.async_add_executor_job(_write_restore_file)
|
await self._hass.async_add_executor_job(_write_restore_file)
|
||||||
on_progress(
|
on_progress(
|
||||||
RestoreBackupEvent(stage=None, state=RestoreBackupState.CORE_RESTART)
|
RestoreBackupEvent(
|
||||||
|
reason=None,
|
||||||
|
stage=None,
|
||||||
|
state=RestoreBackupState.CORE_RESTART,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
await self._hass.services.async_call("homeassistant", "restart", blocking=True)
|
await self._hass.services.async_call("homeassistant", "restart", blocking=True)
|
||||||
|
|
||||||
|
async def async_resume_restore_progress_after_restart(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
on_progress: Callable[[RestoreBackupEvent | IdleEvent], None],
|
||||||
|
) -> None:
|
||||||
|
"""Check restore status after core restart."""
|
||||||
|
|
||||||
|
def _read_restore_file() -> json_util.JsonObjectType | None:
|
||||||
|
"""Read the restore file."""
|
||||||
|
result_path = Path(self._hass.config.path(RESTORE_BACKUP_RESULT_FILE))
|
||||||
|
|
||||||
|
try:
|
||||||
|
restore_result = json_util.json_loads_object(result_path.read_bytes())
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
result_path.unlink(missing_ok=True)
|
||||||
|
except OSError as err:
|
||||||
|
LOGGER.warning(
|
||||||
|
"Unexpected error deleting backup restore result file: %s %s",
|
||||||
|
type(err),
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
|
||||||
|
return restore_result
|
||||||
|
|
||||||
|
restore_result = await self._hass.async_add_executor_job(_read_restore_file)
|
||||||
|
if not restore_result:
|
||||||
|
return
|
||||||
|
|
||||||
|
success = restore_result["success"]
|
||||||
|
if not success:
|
||||||
|
LOGGER.warning(
|
||||||
|
"Backup restore failed with %s: %s",
|
||||||
|
restore_result["error_type"],
|
||||||
|
restore_result["error"],
|
||||||
|
)
|
||||||
|
state = RestoreBackupState.COMPLETED if success else RestoreBackupState.FAILED
|
||||||
|
on_progress(
|
||||||
|
RestoreBackupEvent(
|
||||||
|
reason=cast(str, restore_result["error"]),
|
||||||
|
stage=None,
|
||||||
|
state=state,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
on_progress(IdleEvent())
|
||||||
|
|
||||||
|
|
||||||
def _generate_backup_id(date: str, name: str) -> str:
|
def _generate_backup_id(date: str, name: str) -> str:
|
||||||
"""Generate a backup ID."""
|
"""Generate a backup ID."""
|
||||||
|
@ -28,7 +28,7 @@ class Folder(StrEnum):
|
|||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class AgentBackup:
|
class BaseBackup:
|
||||||
"""Base backup class."""
|
"""Base backup class."""
|
||||||
|
|
||||||
addons: list[AddonInfo]
|
addons: list[AddonInfo]
|
||||||
@ -40,12 +40,6 @@ class AgentBackup:
|
|||||||
homeassistant_included: bool
|
homeassistant_included: bool
|
||||||
homeassistant_version: str | None # None if homeassistant_included is False
|
homeassistant_version: str | None # None if homeassistant_included is False
|
||||||
name: str
|
name: str
|
||||||
protected: bool
|
|
||||||
size: int
|
|
||||||
|
|
||||||
def as_dict(self) -> dict:
|
|
||||||
"""Return a dict representation of this backup."""
|
|
||||||
return asdict(self)
|
|
||||||
|
|
||||||
def as_frontend_json(self) -> dict:
|
def as_frontend_json(self) -> dict:
|
||||||
"""Return a dict representation of this backup for sending to frontend."""
|
"""Return a dict representation of this backup for sending to frontend."""
|
||||||
@ -53,6 +47,18 @@ class AgentBackup:
|
|||||||
key: val for key, val in asdict(self).items() if key != "extra_metadata"
|
key: val for key, val in asdict(self).items() if key != "extra_metadata"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, kw_only=True)
|
||||||
|
class AgentBackup(BaseBackup):
|
||||||
|
"""Agent backup class."""
|
||||||
|
|
||||||
|
protected: bool
|
||||||
|
size: int
|
||||||
|
|
||||||
|
def as_dict(self) -> dict:
|
||||||
|
"""Return a dict representation of this backup."""
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict[str, Any]) -> Self:
|
def from_dict(cls, data: dict[str, Any]) -> Self:
|
||||||
"""Create an instance from a JSON serialization."""
|
"""Create an instance from a JSON serialization."""
|
||||||
@ -71,5 +77,13 @@ class AgentBackup:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class BackupManagerError(HomeAssistantError):
|
class BackupError(HomeAssistantError):
|
||||||
|
"""Base class for backup errors."""
|
||||||
|
|
||||||
|
error_code = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
class BackupManagerError(BackupError):
|
||||||
"""Backup manager error."""
|
"""Backup manager error."""
|
||||||
|
|
||||||
|
error_code = "backup_manager_error"
|
||||||
|
@ -16,7 +16,7 @@ if TYPE_CHECKING:
|
|||||||
STORE_DELAY_SAVE = 30
|
STORE_DELAY_SAVE = 30
|
||||||
STORAGE_KEY = DOMAIN
|
STORAGE_KEY = DOMAIN
|
||||||
STORAGE_VERSION = 1
|
STORAGE_VERSION = 1
|
||||||
STORAGE_VERSION_MINOR = 2
|
STORAGE_VERSION_MINOR = 3
|
||||||
|
|
||||||
|
|
||||||
class StoredBackupData(TypedDict):
|
class StoredBackupData(TypedDict):
|
||||||
@ -47,8 +47,12 @@ class _BackupStore(Store[StoredBackupData]):
|
|||||||
"""Migrate to the new version."""
|
"""Migrate to the new version."""
|
||||||
data = old_data
|
data = old_data
|
||||||
if old_major_version == 1:
|
if old_major_version == 1:
|
||||||
if old_minor_version < 2:
|
if old_minor_version < 3:
|
||||||
# Version 1.2 adds configurable backup time and custom days
|
# Version 1.2 bumped to 1.3 because 1.2 was changed several
|
||||||
|
# times during development.
|
||||||
|
# Version 1.3 adds per agent settings, configurable backup time
|
||||||
|
# and custom days
|
||||||
|
data["config"]["agents"] = {}
|
||||||
data["config"]["schedule"]["time"] = None
|
data["config"]["schedule"]["time"] = None
|
||||||
if (state := data["config"]["schedule"]["state"]) in ("daily", "never"):
|
if (state := data["config"]["schedule"]["state"]) in ("daily", "never"):
|
||||||
data["config"]["schedule"]["days"] = []
|
data["config"]["schedule"]["days"] = []
|
||||||
|
@ -3,14 +3,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import AsyncIterator, Callable
|
from collections.abc import AsyncIterator, Callable, Coroutine
|
||||||
import copy
|
import copy
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from pathlib import Path, PurePath
|
from pathlib import Path, PurePath
|
||||||
from queue import SimpleQueue
|
from queue import SimpleQueue
|
||||||
import tarfile
|
import tarfile
|
||||||
from typing import IO, Self, cast
|
from typing import IO, Any, Self, cast
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from securetar import SecureTarError, SecureTarFile, SecureTarReadError
|
from securetar import SecureTarError, SecureTarFile, SecureTarReadError
|
||||||
@ -19,6 +21,7 @@ from homeassistant.backup_restore import password_to_key
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.util.json import JsonObjectType, json_loads_object
|
from homeassistant.util.json import JsonObjectType, json_loads_object
|
||||||
|
from homeassistant.util.thread import ThreadWithException
|
||||||
|
|
||||||
from .const import BUF_SIZE, LOGGER
|
from .const import BUF_SIZE, LOGGER
|
||||||
from .models import AddonInfo, AgentBackup, Folder
|
from .models import AddonInfo, AgentBackup, Folder
|
||||||
@ -30,6 +33,12 @@ class DecryptError(HomeAssistantError):
|
|||||||
_message = "Unexpected error during decryption."
|
_message = "Unexpected error during decryption."
|
||||||
|
|
||||||
|
|
||||||
|
class EncryptError(HomeAssistantError):
|
||||||
|
"""Error during encryption."""
|
||||||
|
|
||||||
|
_message = "Unexpected error during encryption."
|
||||||
|
|
||||||
|
|
||||||
class UnsupportedSecureTarVersion(DecryptError):
|
class UnsupportedSecureTarVersion(DecryptError):
|
||||||
"""Unsupported securetar version."""
|
"""Unsupported securetar version."""
|
||||||
|
|
||||||
@ -48,6 +57,12 @@ class BackupEmpty(DecryptError):
|
|||||||
_message = "No tar files found in the backup."
|
_message = "No tar files found in the backup."
|
||||||
|
|
||||||
|
|
||||||
|
class AbortCipher(HomeAssistantError):
|
||||||
|
"""Abort the cipher operation."""
|
||||||
|
|
||||||
|
_message = "Abort cipher operation."
|
||||||
|
|
||||||
|
|
||||||
def make_backup_dir(path: Path) -> None:
|
def make_backup_dir(path: Path) -> None:
|
||||||
"""Create a backup directory if it does not exist."""
|
"""Create a backup directory if it does not exist."""
|
||||||
path.mkdir(exist_ok=True)
|
path.mkdir(exist_ok=True)
|
||||||
@ -179,6 +194,7 @@ class AsyncIteratorWriter:
|
|||||||
def __init__(self, hass: HomeAssistant) -> None:
|
def __init__(self, hass: HomeAssistant) -> None:
|
||||||
"""Initialize the wrapper."""
|
"""Initialize the wrapper."""
|
||||||
self._hass = hass
|
self._hass = hass
|
||||||
|
self._pos: int = 0
|
||||||
self._queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=1)
|
self._queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=1)
|
||||||
|
|
||||||
def __aiter__(self) -> Self:
|
def __aiter__(self) -> Self:
|
||||||
@ -191,9 +207,14 @@ class AsyncIteratorWriter:
|
|||||||
return data
|
return data
|
||||||
raise StopAsyncIteration
|
raise StopAsyncIteration
|
||||||
|
|
||||||
|
def tell(self) -> int:
|
||||||
|
"""Return the current position in the iterator."""
|
||||||
|
return self._pos
|
||||||
|
|
||||||
def write(self, s: bytes, /) -> int:
|
def write(self, s: bytes, /) -> int:
|
||||||
"""Write data to the iterator."""
|
"""Write data to the iterator."""
|
||||||
asyncio.run_coroutine_threadsafe(self._queue.put(s), self._hass.loop).result()
|
asyncio.run_coroutine_threadsafe(self._queue.put(s), self._hass.loop).result()
|
||||||
|
self._pos += len(s)
|
||||||
return len(s)
|
return len(s)
|
||||||
|
|
||||||
|
|
||||||
@ -230,24 +251,37 @@ def decrypt_backup(
|
|||||||
input_stream: IO[bytes],
|
input_stream: IO[bytes],
|
||||||
output_stream: IO[bytes],
|
output_stream: IO[bytes],
|
||||||
password: str | None,
|
password: str | None,
|
||||||
on_done: Callable[[], None],
|
on_done: Callable[[Exception | None], None],
|
||||||
|
minimum_size: int,
|
||||||
|
nonces: list[bytes],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Decrypt a backup."""
|
"""Decrypt a backup."""
|
||||||
|
error: Exception | None = None
|
||||||
try:
|
try:
|
||||||
with (
|
try:
|
||||||
tarfile.open(
|
with (
|
||||||
fileobj=input_stream, mode="r|", bufsize=BUF_SIZE
|
tarfile.open(
|
||||||
) as input_tar,
|
fileobj=input_stream, mode="r|", bufsize=BUF_SIZE
|
||||||
tarfile.open(
|
) as input_tar,
|
||||||
fileobj=output_stream, mode="w|", bufsize=BUF_SIZE
|
tarfile.open(
|
||||||
) as output_tar,
|
fileobj=output_stream, mode="w|", bufsize=BUF_SIZE
|
||||||
):
|
) as output_tar,
|
||||||
_decrypt_backup(input_tar, output_tar, password)
|
):
|
||||||
except (DecryptError, SecureTarError, tarfile.TarError) as err:
|
_decrypt_backup(input_tar, output_tar, password)
|
||||||
LOGGER.warning("Error decrypting backup: %s", err)
|
except (DecryptError, SecureTarError, tarfile.TarError) as err:
|
||||||
|
LOGGER.warning("Error decrypting backup: %s", err)
|
||||||
|
error = err
|
||||||
|
else:
|
||||||
|
# Pad the output stream to the requested minimum size
|
||||||
|
padding = max(minimum_size - output_stream.tell(), 0)
|
||||||
|
output_stream.write(b"\0" * padding)
|
||||||
|
finally:
|
||||||
|
# Write an empty chunk to signal the end of the stream
|
||||||
|
output_stream.write(b"")
|
||||||
|
except AbortCipher:
|
||||||
|
LOGGER.debug("Cipher operation aborted")
|
||||||
finally:
|
finally:
|
||||||
output_stream.write(b"") # Write an empty chunk to signal the end of the stream
|
on_done(error)
|
||||||
on_done()
|
|
||||||
|
|
||||||
|
|
||||||
def _decrypt_backup(
|
def _decrypt_backup(
|
||||||
@ -288,6 +322,189 @@ def _decrypt_backup(
|
|||||||
output_tar.addfile(decrypted_obj, decrypted)
|
output_tar.addfile(decrypted_obj, decrypted)
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_backup(
|
||||||
|
input_stream: IO[bytes],
|
||||||
|
output_stream: IO[bytes],
|
||||||
|
password: str | None,
|
||||||
|
on_done: Callable[[Exception | None], None],
|
||||||
|
minimum_size: int,
|
||||||
|
nonces: list[bytes],
|
||||||
|
) -> None:
|
||||||
|
"""Encrypt a backup."""
|
||||||
|
error: Exception | None = None
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
with (
|
||||||
|
tarfile.open(
|
||||||
|
fileobj=input_stream, mode="r|", bufsize=BUF_SIZE
|
||||||
|
) as input_tar,
|
||||||
|
tarfile.open(
|
||||||
|
fileobj=output_stream, mode="w|", bufsize=BUF_SIZE
|
||||||
|
) as output_tar,
|
||||||
|
):
|
||||||
|
_encrypt_backup(input_tar, output_tar, password, nonces)
|
||||||
|
except (EncryptError, SecureTarError, tarfile.TarError) as err:
|
||||||
|
LOGGER.warning("Error encrypting backup: %s", err)
|
||||||
|
error = err
|
||||||
|
else:
|
||||||
|
# Pad the output stream to the requested minimum size
|
||||||
|
padding = max(minimum_size - output_stream.tell(), 0)
|
||||||
|
output_stream.write(b"\0" * padding)
|
||||||
|
finally:
|
||||||
|
# Write an empty chunk to signal the end of the stream
|
||||||
|
output_stream.write(b"")
|
||||||
|
except AbortCipher:
|
||||||
|
LOGGER.debug("Cipher operation aborted")
|
||||||
|
finally:
|
||||||
|
on_done(error)
|
||||||
|
|
||||||
|
|
||||||
|
def _encrypt_backup(
|
||||||
|
input_tar: tarfile.TarFile,
|
||||||
|
output_tar: tarfile.TarFile,
|
||||||
|
password: str | None,
|
||||||
|
nonces: list[bytes],
|
||||||
|
) -> None:
|
||||||
|
"""Encrypt a backup."""
|
||||||
|
inner_tar_idx = 0
|
||||||
|
for obj in input_tar:
|
||||||
|
# We compare with PurePath to avoid issues with different path separators,
|
||||||
|
# for example when backup.json is added as "./backup.json"
|
||||||
|
if PurePath(obj.name) == PurePath("backup.json"):
|
||||||
|
# Rewrite the backup.json file to indicate that the backup is encrypted
|
||||||
|
if not (reader := input_tar.extractfile(obj)):
|
||||||
|
raise EncryptError
|
||||||
|
metadata = json_loads_object(reader.read())
|
||||||
|
metadata["protected"] = True
|
||||||
|
updated_metadata_b = json.dumps(metadata).encode()
|
||||||
|
metadata_obj = copy.deepcopy(obj)
|
||||||
|
metadata_obj.size = len(updated_metadata_b)
|
||||||
|
output_tar.addfile(metadata_obj, BytesIO(updated_metadata_b))
|
||||||
|
continue
|
||||||
|
if not obj.name.endswith((".tar", ".tgz", ".tar.gz")):
|
||||||
|
output_tar.addfile(obj, input_tar.extractfile(obj))
|
||||||
|
continue
|
||||||
|
istf = SecureTarFile(
|
||||||
|
None, # Not used
|
||||||
|
gzip=False,
|
||||||
|
key=password_to_key(password) if password is not None else None,
|
||||||
|
mode="r",
|
||||||
|
fileobj=input_tar.extractfile(obj),
|
||||||
|
nonce=nonces[inner_tar_idx],
|
||||||
|
)
|
||||||
|
inner_tar_idx += 1
|
||||||
|
with istf.encrypt(obj) as encrypted:
|
||||||
|
encrypted_obj = copy.deepcopy(obj)
|
||||||
|
encrypted_obj.size = encrypted.encrypted_size
|
||||||
|
output_tar.addfile(encrypted_obj, encrypted)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(kw_only=True)
|
||||||
|
class _CipherWorkerStatus:
|
||||||
|
done: asyncio.Event
|
||||||
|
error: Exception | None = None
|
||||||
|
thread: ThreadWithException
|
||||||
|
|
||||||
|
|
||||||
|
class _CipherBackupStreamer:
|
||||||
|
"""Encrypt or decrypt a backup."""
|
||||||
|
|
||||||
|
_cipher_func: Callable[
|
||||||
|
[
|
||||||
|
IO[bytes],
|
||||||
|
IO[bytes],
|
||||||
|
str | None,
|
||||||
|
Callable[[Exception | None], None],
|
||||||
|
int,
|
||||||
|
list[bytes],
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
backup: AgentBackup,
|
||||||
|
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
|
||||||
|
password: str | None,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize."""
|
||||||
|
self._workers: list[_CipherWorkerStatus] = []
|
||||||
|
self._backup = backup
|
||||||
|
self._hass = hass
|
||||||
|
self._open_stream = open_stream
|
||||||
|
self._password = password
|
||||||
|
self._nonces: list[bytes] = []
|
||||||
|
|
||||||
|
def size(self) -> int:
|
||||||
|
"""Return the maximum size of the decrypted or encrypted backup."""
|
||||||
|
return self._backup.size + self._num_tar_files() * tarfile.RECORDSIZE
|
||||||
|
|
||||||
|
def _num_tar_files(self) -> int:
|
||||||
|
"""Return the number of inner tar files."""
|
||||||
|
b = self._backup
|
||||||
|
return len(b.addons) + len(b.folders) + b.homeassistant_included + 1
|
||||||
|
|
||||||
|
async def open_stream(self) -> AsyncIterator[bytes]:
|
||||||
|
"""Open a stream."""
|
||||||
|
|
||||||
|
def on_done(error: Exception | None) -> None:
|
||||||
|
"""Call by the worker thread when it's done."""
|
||||||
|
worker_status.error = error
|
||||||
|
self._hass.loop.call_soon_threadsafe(worker_status.done.set)
|
||||||
|
|
||||||
|
stream = await self._open_stream()
|
||||||
|
reader = AsyncIteratorReader(self._hass, stream)
|
||||||
|
writer = AsyncIteratorWriter(self._hass)
|
||||||
|
worker = ThreadWithException(
|
||||||
|
target=self._cipher_func,
|
||||||
|
args=[reader, writer, self._password, on_done, self.size(), self._nonces],
|
||||||
|
)
|
||||||
|
worker_status = _CipherWorkerStatus(done=asyncio.Event(), thread=worker)
|
||||||
|
self._workers.append(worker_status)
|
||||||
|
worker.start()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
async def wait(self) -> None:
|
||||||
|
"""Wait for the worker threads to finish."""
|
||||||
|
for worker in self._workers:
|
||||||
|
if not worker.thread.is_alive():
|
||||||
|
continue
|
||||||
|
worker.thread.raise_exc(AbortCipher)
|
||||||
|
await asyncio.gather(*(worker.done.wait() for worker in self._workers))
|
||||||
|
|
||||||
|
|
||||||
|
class DecryptedBackupStreamer(_CipherBackupStreamer):
|
||||||
|
"""Decrypt a backup."""
|
||||||
|
|
||||||
|
_cipher_func = staticmethod(decrypt_backup)
|
||||||
|
|
||||||
|
def backup(self) -> AgentBackup:
|
||||||
|
"""Return the decrypted backup."""
|
||||||
|
return replace(self._backup, protected=False, size=self.size())
|
||||||
|
|
||||||
|
|
||||||
|
class EncryptedBackupStreamer(_CipherBackupStreamer):
|
||||||
|
"""Encrypt a backup."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
backup: AgentBackup,
|
||||||
|
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
|
||||||
|
password: str | None,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize."""
|
||||||
|
super().__init__(hass, backup, open_stream, password)
|
||||||
|
self._nonces = [os.urandom(16) for _ in range(self._num_tar_files())]
|
||||||
|
|
||||||
|
_cipher_func = staticmethod(encrypt_backup)
|
||||||
|
|
||||||
|
def backup(self) -> AgentBackup:
|
||||||
|
"""Return the encrypted backup."""
|
||||||
|
return replace(self._backup, protected=True, size=self.size())
|
||||||
|
|
||||||
|
|
||||||
async def receive_file(
|
async def receive_file(
|
||||||
hass: HomeAssistant, contents: aiohttp.BodyPartReader, path: Path
|
hass: HomeAssistant, contents: aiohttp.BodyPartReader, path: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
|
@ -60,8 +60,10 @@ async def handle_info(
|
|||||||
"backups": [backup.as_frontend_json() for backup in backups.values()],
|
"backups": [backup.as_frontend_json() for backup in backups.values()],
|
||||||
"last_attempted_automatic_backup": manager.config.data.last_attempted_automatic_backup,
|
"last_attempted_automatic_backup": manager.config.data.last_attempted_automatic_backup,
|
||||||
"last_completed_automatic_backup": manager.config.data.last_completed_automatic_backup,
|
"last_completed_automatic_backup": manager.config.data.last_completed_automatic_backup,
|
||||||
|
"last_non_idle_event": manager.last_non_idle_event,
|
||||||
"next_automatic_backup": manager.config.data.schedule.next_automatic_backup,
|
"next_automatic_backup": manager.config.data.schedule.next_automatic_backup,
|
||||||
"next_automatic_backup_additional": manager.config.data.schedule.next_automatic_backup_additional,
|
"next_automatic_backup_additional": manager.config.data.schedule.next_automatic_backup_additional,
|
||||||
|
"state": manager.state,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -198,7 +200,7 @@ async def handle_can_decrypt_on_download(
|
|||||||
vol.Optional("include_folders"): [vol.Coerce(Folder)],
|
vol.Optional("include_folders"): [vol.Coerce(Folder)],
|
||||||
vol.Optional("include_homeassistant", default=True): bool,
|
vol.Optional("include_homeassistant", default=True): bool,
|
||||||
vol.Optional("name"): str,
|
vol.Optional("name"): str,
|
||||||
vol.Optional("password"): str,
|
vol.Optional("password"): vol.Any(str, None),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@websocket_api.async_response
|
@websocket_api.async_response
|
||||||
@ -306,7 +308,10 @@ async def backup_agents_info(
|
|||||||
connection.send_result(
|
connection.send_result(
|
||||||
msg["id"],
|
msg["id"],
|
||||||
{
|
{
|
||||||
"agents": [{"agent_id": agent_id} for agent_id in manager.backup_agents],
|
"agents": [
|
||||||
|
{"agent_id": agent.agent_id, "name": agent.name}
|
||||||
|
for agent in manager.backup_agents.values()
|
||||||
|
],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -341,6 +346,7 @@ async def handle_config_info(
|
|||||||
@websocket_api.websocket_command(
|
@websocket_api.websocket_command(
|
||||||
{
|
{
|
||||||
vol.Required("type"): "backup/config/update",
|
vol.Required("type"): "backup/config/update",
|
||||||
|
vol.Optional("agents"): vol.Schema({str: {"protected": bool}}),
|
||||||
vol.Optional("create_backup"): vol.Schema(
|
vol.Optional("create_backup"): vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Optional("agent_ids"): vol.All([str], vol.Unique()),
|
vol.Optional("agent_ids"): vol.All([str], vol.Unique()),
|
||||||
|
@ -11,7 +11,7 @@ from homeassistant.components.tts import (
|
|||||||
Provider,
|
Provider,
|
||||||
)
|
)
|
||||||
from homeassistant.const import CONF_API_KEY
|
from homeassistant.const import CONF_API_KEY
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -12,7 +12,7 @@ from homeassistant.const import CONF_HOST, Platform
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
from homeassistant.helpers.event import async_track_time_interval
|
from homeassistant.helpers.event import async_track_time_interval
|
||||||
import homeassistant.util.dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
from .const import CONF_SYNC_TIME, DEFAULT_SYNC_TIME
|
from .const import CONF_SYNC_TIME, DEFAULT_SYNC_TIME
|
||||||
|
|
||||||
|
@ -10,7 +10,7 @@ from pybalboa.exceptions import SpaConnectionError
|
|||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
|
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
|
||||||
from homeassistant.const import CONF_HOST
|
from homeassistant.const import CONF_HOST, CONF_MODEL
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers.device_registry import format_mac
|
from homeassistant.helpers.device_registry import format_mac
|
||||||
@ -18,6 +18,7 @@ from homeassistant.helpers.schema_config_entry_flow import (
|
|||||||
SchemaFlowFormStep,
|
SchemaFlowFormStep,
|
||||||
SchemaOptionsFlowHandler,
|
SchemaOptionsFlowHandler,
|
||||||
)
|
)
|
||||||
|
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||||
|
|
||||||
from .const import CONF_SYNC_TIME, DOMAIN
|
from .const import CONF_SYNC_TIME, DOMAIN
|
||||||
|
|
||||||
@ -55,7 +56,8 @@ class BalboaSpaClientFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
VERSION = 1
|
VERSION = 1
|
||||||
|
|
||||||
_host: str | None
|
_host: str
|
||||||
|
_model: str
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@callback
|
@callback
|
||||||
@ -63,6 +65,43 @@ class BalboaSpaClientFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
"""Get the options flow for this handler."""
|
"""Get the options flow for this handler."""
|
||||||
return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW)
|
return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW)
|
||||||
|
|
||||||
|
async def async_step_dhcp(
|
||||||
|
self, discovery_info: DhcpServiceInfo
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Handle DHCP discovery."""
|
||||||
|
await self.async_set_unique_id(format_mac(discovery_info.macaddress))
|
||||||
|
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip})
|
||||||
|
self._async_abort_entries_match({CONF_HOST: discovery_info.ip})
|
||||||
|
|
||||||
|
error = None
|
||||||
|
try:
|
||||||
|
info = await validate_input({CONF_HOST: discovery_info.ip})
|
||||||
|
except CannotConnect:
|
||||||
|
error = "cannot_connect"
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.exception("Unexpected exception")
|
||||||
|
error = "unknown"
|
||||||
|
if not error:
|
||||||
|
self._host = discovery_info.ip
|
||||||
|
self._model = info["title"]
|
||||||
|
self.context["title_placeholders"] = {CONF_MODEL: self._model}
|
||||||
|
return await self.async_step_discovery_confirm()
|
||||||
|
return self.async_abort(reason=error)
|
||||||
|
|
||||||
|
async def async_step_discovery_confirm(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Allow the user to confirm adding the device."""
|
||||||
|
if user_input is not None:
|
||||||
|
data = {CONF_HOST: self._host}
|
||||||
|
return self.async_create_entry(title=self._model, data=data)
|
||||||
|
|
||||||
|
self._set_confirm_only()
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="discovery_confirm",
|
||||||
|
description_placeholders={CONF_HOST: self._host},
|
||||||
|
)
|
||||||
|
|
||||||
async def async_step_user(
|
async def async_step_user(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
@ -78,7 +117,9 @@ class BalboaSpaClientFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
_LOGGER.exception("Unexpected exception")
|
_LOGGER.exception("Unexpected exception")
|
||||||
errors["base"] = "unknown"
|
errors["base"] = "unknown"
|
||||||
else:
|
else:
|
||||||
await self.async_set_unique_id(info["formatted_mac"])
|
await self.async_set_unique_id(
|
||||||
|
info["formatted_mac"], raise_on_progress=False
|
||||||
|
)
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured()
|
||||||
return self.async_create_entry(title=info["title"], data=user_input)
|
return self.async_create_entry(title=info["title"], data=user_input)
|
||||||
|
|
||||||
|
@ -3,6 +3,14 @@
|
|||||||
"name": "Balboa Spa Client",
|
"name": "Balboa Spa Client",
|
||||||
"codeowners": ["@garbled1", "@natekspencer"],
|
"codeowners": ["@garbled1", "@natekspencer"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
|
"dhcp": [
|
||||||
|
{
|
||||||
|
"registered_devices": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"macaddress": "001527*"
|
||||||
|
}
|
||||||
|
],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/balboa",
|
"documentation": "https://www.home-assistant.io/integrations/balboa",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["pybalboa"],
|
"loggers": ["pybalboa"],
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"config": {
|
"config": {
|
||||||
|
"flow_title": "{model}",
|
||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"description": "Connect to the Balboa Wi-Fi device",
|
"description": "Connect to the Balboa Wi-Fi device",
|
||||||
@ -9,6 +10,9 @@
|
|||||||
"data_description": {
|
"data_description": {
|
||||||
"host": "Hostname or IP address of your Balboa Spa Wi-Fi Device. For example, 192.168.1.58."
|
"host": "Hostname or IP address of your Balboa Spa Wi-Fi Device. For example, 192.168.1.58."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"confirm_discovery": {
|
||||||
|
"description": "Do you want to set up the spa at {host}?"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
|
@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry
|
|||||||
from homeassistant.const import CONF_HOST, CONF_MODEL, Platform
|
from homeassistant.const import CONF_HOST, CONF_MODEL, Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
import homeassistant.helpers.device_registry as dr
|
from homeassistant.helpers import device_registry as dr
|
||||||
from homeassistant.util.ssl import get_default_context
|
from homeassistant.util.ssl import get_default_context
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
|
@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Any
|
|||||||
|
|
||||||
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
|
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
import homeassistant.helpers.entity_registry as er
|
from homeassistant.helpers import entity_registry as er
|
||||||
|
|
||||||
from . import BangOlufsenConfigEntry
|
from . import BangOlufsenConfigEntry
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
|
@ -31,8 +31,7 @@ from homeassistant.const import (
|
|||||||
)
|
)
|
||||||
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
|
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
|
||||||
from homeassistant.exceptions import ConditionError, TemplateError
|
from homeassistant.exceptions import ConditionError, TemplateError
|
||||||
from homeassistant.helpers import condition
|
from homeassistant.helpers import condition, config_validation as cv
|
||||||
import homeassistant.helpers.config_validation as cv
|
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.event import (
|
from homeassistant.helpers.event import (
|
||||||
TrackTemplate,
|
TrackTemplate,
|
||||||
|
@ -16,10 +16,9 @@ from homeassistant.components.device_tracker import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import CONF_HOST
|
from homeassistant.const import CONF_HOST
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
from homeassistant.util import Throttle
|
from homeassistant.util import Throttle, dt as dt_util
|
||||||
import homeassistant.util.dt as dt_util
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -18,7 +18,7 @@ from homeassistant.components.sensor import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import CONF_MONITORED_VARIABLES, CONF_NAME, UnitOfDataRate
|
from homeassistant.const import CONF_MONITORED_VARIABLES, CONF_NAME, UnitOfDataRate
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
from homeassistant.util import Throttle
|
from homeassistant.util import Throttle
|
||||||
|
@ -12,7 +12,7 @@ from homeassistant.components.sensor import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import CONF_MAC, CONF_NAME, PERCENTAGE, UnitOfTemperature
|
from homeassistant.const import CONF_MAC, CONF_NAME, PERCENTAGE, UnitOfTemperature
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
|
|
||||||
|
@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import CONF_CURRENCY, CONF_DISPLAY_OPTIONS, UnitOfTime
|
from homeassistant.const import CONF_CURRENCY, CONF_DISPLAY_OPTIONS, UnitOfTime
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
|
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user