Compare commits

..

2 Commits

Author SHA1 Message Date
epenet c8034f176b Use exceptions module 2025-01-23 17:53:09 +00:00
epenet 59e36e0294 Move lovelace function and exception out of const.py 2025-01-23 17:51:53 +00:00
2159 changed files with 13613 additions and 42899 deletions
+5 -5
View File
@@ -32,7 +32,7 @@ jobs:
fetch-depth: 0
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.3.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@@ -116,7 +116,7 @@ jobs:
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
if: needs.init.outputs.channel == 'dev'
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.3.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@@ -454,7 +454,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.3.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@@ -509,7 +509,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker image
uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6.13.0
uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6.12.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
@@ -522,7 +522,7 @@ jobs:
- name: Push Docker image
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
id: push
uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6.13.0
uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6.12.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
+18 -18
View File
@@ -234,7 +234,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.3.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@@ -279,7 +279,7 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.3.0
id: python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@@ -319,7 +319,7 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.3.0
id: python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@@ -359,7 +359,7 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.3.0
id: python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@@ -469,7 +469,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.3.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
@@ -572,7 +572,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.3.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@@ -605,7 +605,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.3.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@@ -643,7 +643,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.3.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
@@ -686,7 +686,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.3.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@@ -733,7 +733,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.3.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@@ -778,7 +778,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.3.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@@ -859,7 +859,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.3.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@@ -923,7 +923,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.3.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
@@ -1044,7 +1044,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.3.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
@@ -1173,7 +1173,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.3.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
@@ -1273,7 +1273,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'true'
uses: codecov/codecov-action@v5.3.1
uses: codecov/codecov-action@v5.2.0
with:
fail_ci_if_error: true
flags: full-suite
@@ -1319,7 +1319,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.3.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
@@ -1411,7 +1411,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'false'
uses: codecov/codecov-action@v5.3.1
uses: codecov/codecov-action@v5.2.0
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
+2 -2
View File
@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Initialize CodeQL
uses: github/codeql-action/init@v3.28.6
uses: github/codeql-action/init@v3.28.3
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3.28.6
uses: github/codeql-action/analyze@v3.28.3
with:
category: "/language:python"
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.3.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
+3 -3
View File
@@ -36,7 +36,7 @@ jobs:
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.3.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@@ -131,7 +131,7 @@ jobs:
strategy:
fail-fast: false
matrix:
abi: ["cp313"]
abi: ["cp312", "cp313"]
arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps:
- name: Checkout the repository
@@ -180,7 +180,7 @@ jobs:
strategy:
fail-fast: false
matrix:
abi: ["cp313"]
abi: ["cp312", "cp313"]
arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps:
- name: Checkout the repository
-6
View File
@@ -217,7 +217,6 @@ homeassistant.components.goalzero.*
homeassistant.components.google.*
homeassistant.components.google_assistant_sdk.*
homeassistant.components.google_cloud.*
homeassistant.components.google_drive.*
homeassistant.components.google_photos.*
homeassistant.components.google_sheets.*
homeassistant.components.govee_ble.*
@@ -228,7 +227,6 @@ homeassistant.components.guardian.*
homeassistant.components.habitica.*
homeassistant.components.hardkernel.*
homeassistant.components.hardware.*
homeassistant.components.heos.*
homeassistant.components.here_travel_time.*
homeassistant.components.history.*
homeassistant.components.history_stats.*
@@ -239,7 +237,6 @@ homeassistant.components.homeassistant_green.*
homeassistant.components.homeassistant_hardware.*
homeassistant.components.homeassistant_sky_connect.*
homeassistant.components.homeassistant_yellow.*
homeassistant.components.homee.*
homeassistant.components.homekit.*
homeassistant.components.homekit_controller
homeassistant.components.homekit_controller.alarm_control_panel
@@ -265,7 +262,6 @@ homeassistant.components.image_processing.*
homeassistant.components.image_upload.*
homeassistant.components.imap.*
homeassistant.components.imgw_pib.*
homeassistant.components.incomfort.*
homeassistant.components.input_button.*
homeassistant.components.input_select.*
homeassistant.components.input_text.*
@@ -318,7 +314,6 @@ homeassistant.components.manual.*
homeassistant.components.mastodon.*
homeassistant.components.matrix.*
homeassistant.components.matter.*
homeassistant.components.mcp.*
homeassistant.components.mcp_server.*
homeassistant.components.mealie.*
homeassistant.components.media_extractor.*
@@ -361,7 +356,6 @@ homeassistant.components.number.*
homeassistant.components.nut.*
homeassistant.components.onboarding.*
homeassistant.components.oncue.*
homeassistant.components.onedrive.*
homeassistant.components.onewire.*
homeassistant.components.onkyo.*
homeassistant.components.open_meteo.*
Generated
+2 -8
View File
@@ -566,8 +566,6 @@ build.json @home-assistant/supervisor
/tests/components/google_assistant_sdk/ @tronikos
/homeassistant/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
/tests/components/google_generative_ai_conversation/ @tronikos
/homeassistant/components/google_mail/ @tkdrob
@@ -893,8 +891,6 @@ build.json @home-assistant/supervisor
/tests/components/matrix/ @PaarthShah
/homeassistant/components/matter/ @home-assistant/matter
/tests/components/matter/ @home-assistant/matter
/homeassistant/components/mcp/ @allenporter
/tests/components/mcp/ @allenporter
/homeassistant/components/mcp_server/ @allenporter
/tests/components/mcp_server/ @allenporter
/homeassistant/components/mealie/ @joostlek @andrew-codechimp
@@ -1073,8 +1069,6 @@ build.json @home-assistant/supervisor
/tests/components/oncue/ @bdraco @peterager
/homeassistant/components/ondilo_ico/ @JeromeHXP
/tests/components/ondilo_ico/ @JeromeHXP
/homeassistant/components/onedrive/ @zweckj
/tests/components/onedrive/ @zweckj
/homeassistant/components/onewire/ @garbled1 @epenet
/tests/components/onewire/ @garbled1 @epenet
/homeassistant/components/onkyo/ @arturpragacz @eclair4151
@@ -1414,8 +1408,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/solaredge_local/ @drobtravels @scheric
/homeassistant/components/solarlog/ @Ernst79 @dontinelli
/tests/components/solarlog/ @Ernst79 @dontinelli
/homeassistant/components/solax/ @squishykid @Darsstar
/tests/components/solax/ @squishykid @Darsstar
/homeassistant/components/solax/ @squishykid
/tests/components/solax/ @squishykid
/homeassistant/components/soma/ @ratsept @sebfortier2288
/tests/components/soma/ @ratsept @sebfortier2288
/homeassistant/components/sonarr/ @ctalkington
@@ -21,7 +21,7 @@ import voluptuous as vol
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.network import is_cloud_connection
from .. import InvalidAuthError
+2 -31
View File
@@ -18,7 +18,6 @@ import securetar
from .const import __version__ as HA_VERSION
RESTORE_BACKUP_FILE = ".HA_RESTORE"
RESTORE_BACKUP_RESULT_FILE = ".HA_RESTORE_RESULT"
KEEP_BACKUPS = ("backups",)
KEEP_DATABASE = (
"home-assistant_v2.db",
@@ -63,10 +62,7 @@ def restore_backup_file_content(config_dir: Path) -> RestoreBackupFileContent |
restore_database=instruction_content["restore_database"],
restore_homeassistant=instruction_content["restore_homeassistant"],
)
except FileNotFoundError:
return None
except (KeyError, json.JSONDecodeError) as err:
_write_restore_result_file(config_dir, False, err)
except (FileNotFoundError, KeyError, json.JSONDecodeError):
return None
finally:
# Always remove the backup instruction file to prevent a boot loop
@@ -146,7 +142,6 @@ def _extract_backup(
config_dir,
dirs_exist_ok=True,
ignore=shutil.ignore_patterns(*(keep)),
ignore_dangling_symlinks=True,
)
elif restore_content.restore_database:
for entry in KEEP_DATABASE:
@@ -164,23 +159,6 @@ 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:
"""Restore the backup file if any.
@@ -199,14 +177,7 @@ def restore_backup(config_dir_path: str) -> bool:
restore_content=restore_content,
)
except FileNotFoundError as 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)
raise ValueError(f"Backup file {backup_file_path} does not exist") from err
if restore_content.remove_after_restore:
backup_file_path.unlink(missing_ok=True)
_LOGGER.info("Restore complete, restarting")
-15
View File
@@ -112,11 +112,6 @@ with contextlib.suppress(ImportError):
# Ensure anyio backend is imported to avoid it being imported in the event loop
from anyio._backends import _asyncio # noqa: F401
with contextlib.suppress(ImportError):
# httpx will import trio if it is installed which does
# blocking I/O in the event loop. We want to avoid that.
import trio # noqa: F401
if TYPE_CHECKING:
from .runner import RuntimeConfig
@@ -161,16 +156,6 @@ FRONTEND_INTEGRATIONS = {
# integrations can be removed and database migration status is
# visible in frontend
"frontend",
# Hassio is an after dependency of backup, after dependencies
# are not promoted from stage 2 to earlier stages, so we need to
# add it here. Hassio needs to be setup before backup, otherwise
# the backup integration will think we are a container/core install
# when using HAOS or Supervised install.
"hassio",
# Backup is an after dependency of frontend, after dependencies
# are not promoted from stage 2 to earlier stages, so we need to
# add it here.
"backup",
}
RECORDER_INTEGRATIONS = {
# Setup after frontend
-1
View File
@@ -5,7 +5,6 @@
"google_assistant",
"google_assistant_sdk",
"google_cloud",
"google_drive",
"google_generative_ai_conversation",
"google_mail",
"google_maps",
-1
View File
@@ -11,7 +11,6 @@
"microsoft_face",
"microsoft",
"msteams",
"onedrive",
"xbox"
]
}
+1 -1
View File
@@ -26,5 +26,5 @@
"iot_class": "local_push",
"loggers": ["aioacaia"],
"quality_scale": "platinum",
"requirements": ["aioacaia==0.1.14"]
"requirements": ["aioacaia==0.1.13"]
}
@@ -12,8 +12,8 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
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
import homeassistant.helpers.config_validation as cv
from .const import DOMAIN
@@ -22,7 +22,7 @@ from homeassistant.const import (
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+1 -1
View File
@@ -3,7 +3,7 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
import homeassistant.helpers.entity_registry as er
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.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import LEASES_REGEX
+1 -1
View File
@@ -12,7 +12,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType
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.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+1 -1
View File
@@ -17,7 +17,7 @@ from homeassistant.components.cover import (
)
from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+1 -1
View File
@@ -15,7 +15,7 @@ from homeassistant.components.light import (
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+1 -1
View File
@@ -11,7 +11,7 @@ from homeassistant.components.select import (
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+1 -1
View File
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_UNIT_OF_MEASUREMENT
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType
+1 -1
View File
@@ -13,7 +13,7 @@ from homeassistant.components.switch import (
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+1 -1
View File
@@ -14,7 +14,7 @@ from homeassistant.components.valve import (
)
from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+1 -1
View File
@@ -7,7 +7,7 @@ from typing import Final
import voluptuous as vol
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
DOMAIN: Final = "aftership"
+1 -1
View File
@@ -9,7 +9,7 @@ from pyaftership import AfterShip, AfterShipException
from homeassistant.components.sensor import SensorEntity
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
@@ -13,8 +13,8 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
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
import homeassistant.helpers.config_validation as cv
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.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from .const import DOMAIN
+12 -9
View File
@@ -21,6 +21,7 @@ from .const import (
ATTR_API_CAT_DESCRIPTION,
ATTR_API_CAT_LEVEL,
ATTR_API_CATEGORY,
ATTR_API_PM25,
ATTR_API_POLLUTANT,
ATTR_API_REPORT_DATE,
ATTR_API_REPORT_HOUR,
@@ -90,16 +91,18 @@ class AirNowDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
max_aqi_desc = obv[ATTR_API_CATEGORY][ATTR_API_CAT_DESCRIPTION]
max_aqi_poll = pollutant
# Copy Report Details
data[ATTR_API_REPORT_DATE] = obv[ATTR_API_REPORT_DATE]
data[ATTR_API_REPORT_HOUR] = obv[ATTR_API_REPORT_HOUR]
data[ATTR_API_REPORT_TZ] = obv[ATTR_API_REPORT_TZ]
# Copy other data from PM2.5 Value
if obv[ATTR_API_AQI_PARAM] == ATTR_API_PM25:
# Copy Report Details
data[ATTR_API_REPORT_DATE] = obv[ATTR_API_REPORT_DATE]
data[ATTR_API_REPORT_HOUR] = obv[ATTR_API_REPORT_HOUR]
data[ATTR_API_REPORT_TZ] = obv[ATTR_API_REPORT_TZ]
# Copy Station Details
data[ATTR_API_STATE] = obv[ATTR_API_STATE]
data[ATTR_API_STATION] = obv[ATTR_API_STATION]
data[ATTR_API_STATION_LATITUDE] = obv[ATTR_API_STATION_LATITUDE]
data[ATTR_API_STATION_LONGITUDE] = obv[ATTR_API_STATION_LONGITUDE]
# Copy Station Details
data[ATTR_API_STATE] = obv[ATTR_API_STATE]
data[ATTR_API_STATION] = obv[ATTR_API_STATION]
data[ATTR_API_STATION_LATITUDE] = obv[ATTR_API_STATION_LATITUDE]
data[ATTR_API_STATION_LONGITUDE] = obv[ATTR_API_STATION_LONGITUDE]
# Store Overall AQI
data[ATTR_API_AQI] = max_aqi
@@ -144,7 +144,7 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(title=discovery.name, data={})
current_addresses = self._async_current_ids(include_ignore=False)
current_addresses = self._async_current_ids()
for discovery_info in async_discovered_service_info(self.hass):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:
@@ -24,7 +24,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.config_validation import make_entity_service_schema
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
@@ -23,7 +23,8 @@ from homeassistant.const import (
SERVICE_ALARM_TRIGGER,
)
from homeassistant.core import Context, HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers import entity_registry as er
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import get_supported_features
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
@@ -12,7 +12,8 @@ from homeassistant.components.alarm_control_panel import (
)
from homeassistant.const import ATTR_CODE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers import entity_platform
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+1 -1
View File
@@ -15,7 +15,7 @@ from homeassistant.const import (
STATE_ON,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
@@ -50,7 +50,8 @@ from homeassistant.const import (
UnitOfVolume,
)
from homeassistant.core import HomeAssistant, State
from homeassistant.util import color as color_util, dt as dt_util
import homeassistant.util.color as color_util
import homeassistant.util.dt as dt_util
from .const import (
API_TEMP_UNITS,
@@ -12,7 +12,7 @@ from homeassistant.const import CONF_PASSWORD
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import template
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
import homeassistant.util.dt as dt_util
from .const import (
API_PASSWORD,
@@ -24,7 +24,7 @@ from homeassistant.core import (
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.significant_change import create_checker
from homeassistant.util import dt as dt_util
import homeassistant.util.dt as dt_util
from homeassistant.util.json import JsonObjectType, json_loads_object
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.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+1 -1
View File
@@ -22,7 +22,7 @@ from homeassistant.generated.amazon_polly import (
SUPPORTED_REGIONS,
SUPPORTED_VOICES,
)
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import (
@@ -17,8 +17,9 @@ from homeassistant.const import (
)
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr, entity_registry as er
import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_send
import homeassistant.helpers.entity_registry as er
from .const import (
ATTR_LAST_DATA,
+2 -1
View File
@@ -37,7 +37,8 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import Unauthorized, UnknownUser
from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers import discovery
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send
from homeassistant.helpers.event import async_track_time_interval
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.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
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.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
@@ -7,7 +7,7 @@ import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import Event, HassJob, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.event import async_call_later, async_track_time_interval
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
@@ -11,7 +11,6 @@ import uuid
import aiohttp
from homeassistant import config as conf_util
from homeassistant.components import hassio
from homeassistant.components.api import ATTR_INSTALLATION_TYPE
from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN
@@ -23,12 +22,13 @@ from homeassistant.components.recorder import (
DOMAIN as RECORDER_DOMAIN,
get_instance as get_recorder_instance,
)
import homeassistant.config as conf_util
from homeassistant.config_entries import SOURCE_IGNORE
from homeassistant.const import ATTR_DOMAIN, __version__ as HA_VERSION
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
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.storage import Store
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.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
@@ -12,7 +12,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_MODEL, CONF_PORT
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import format_mac
from .const import DEFAULT_NAME, DEFAULT_PORT, DEVICE_TIMEOUT_SECONDS, DOMAIN
@@ -18,7 +18,7 @@ from homeassistant.const import (
EVENT_STATE_CHANGED,
)
from homeassistant.core import Event, EventStateChangedData, HomeAssistant
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entityfilter import FILTER_SCHEMA, EntityFilter
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import ssl as ssl_util
@@ -10,7 +10,8 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.helpers import config_validation as cv, selector
from homeassistant.helpers import selector
import homeassistant.helpers.config_validation as cv
from .const import CONNECTION_TIMEOUT, DOMAIN
from .coordinator import APCUPSdData
+1 -1
View File
@@ -11,7 +11,6 @@ from aiohttp import web
from aiohttp.web_exceptions import HTTPBadRequest
import voluptuous as vol
from homeassistant import core as ha
from homeassistant.auth.models import User
from homeassistant.auth.permissions.const import POLICY_READ
from homeassistant.components.http import (
@@ -37,6 +36,7 @@ from homeassistant.const import (
URL_API_STREAM,
URL_API_TEMPLATE,
)
import homeassistant.core as ha
from homeassistant.core import Event, EventStateChangedData, HomeAssistant
from homeassistant.exceptions import (
InvalidEntityFormatError,
@@ -40,7 +40,7 @@ from homeassistant.components.media_player import (
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
import homeassistant.util.dt as dt_util
from . import AppleTvConfigEntry, AppleTVManager
from .browse_media import build_app_list
@@ -26,11 +26,8 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import (
collection,
config_entry_oauth2_flow,
config_validation as cv,
)
from homeassistant.helpers import collection, config_entry_oauth2_flow
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType, VolDictType
from homeassistant.loader import (
@@ -146,6 +143,8 @@ class ApplicationCredentialsStorageCollection(collection.DictStorageCollection):
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Application Credentials."""
hass.data[DOMAIN] = {}
id_manager = collection.IDManager()
storage_collection = ApplicationCredentialsStorageCollection(
Store(hass, STORAGE_VERSION, STORAGE_KEY),
+1 -1
View File
@@ -17,7 +17,7 @@ from homeassistant.components.notify import (
)
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
_LOGGER = logging.getLogger(__name__)
@@ -10,7 +10,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import format_mac
from .const import DOMAIN
@@ -11,7 +11,7 @@ from pyaprilaire.const import MODELS, Attribute, FunctionalDomain
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import BaseDataUpdateCoordinatorProtocol
@@ -26,7 +26,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import Event, HomeAssistant
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import slugify
@@ -8,8 +8,8 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
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
import homeassistant.helpers.config_validation as cv
from .const import DEFAULT_PORT, DOMAIN
+1 -1
View File
@@ -19,7 +19,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+1 -1
View File
@@ -13,7 +13,7 @@ from homeassistant.components.switch import (
)
from homeassistant.const import CONF_MONITORED_CONDITIONS
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@@ -24,7 +24,7 @@ from homeassistant.const import (
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@@ -92,7 +92,7 @@ class AranetConfigFlow(ConfigFlow, domain=DOMAIN):
title=self._discovered_devices[address][0], data={}
)
current_addresses = self._async_current_ids(include_ignore=False)
current_addresses = self._async_current_ids()
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:
@@ -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.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
+1 -1
View File
@@ -22,7 +22,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import TemplateError
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
+1 -1
View File
@@ -15,7 +15,7 @@ from homeassistant.components.switch import (
)
from homeassistant.const import CONF_NAME, CONF_RESOURCE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
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.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType
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.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType
_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.const import ATTR_DEVICE_ID
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
import homeassistant.helpers.device_registry as dr
from .const import DOMAIN, EVENT_RECORDING
@@ -1101,10 +1101,11 @@ class PipelineRun:
"speech", ""
)
chat_session.async_add_message(
conversation.Content(
conversation.ChatMessage(
role="assistant",
agent_id=agent_id,
content=speech,
native=intent_response,
)
)
conversation_result = conversation.ConversationResult(
@@ -1122,7 +1123,6 @@ class PipelineRun:
context=user_input.context,
language=user_input.language,
agent_id=user_input.agent_id,
extra_system_prompt=user_input.extra_system_prompt,
)
speech = conversation_result.response.speech.get("plain", {}).get(
"speech", ""
@@ -63,21 +63,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"async_internal_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] = {}
async_register_websocket_api(hass)
hass.http.register_view(ConnectionTestView())
@@ -26,6 +26,3 @@ class AssistSatelliteEntityFeature(IntFlag):
ANNOUNCE = 1
"""Device supports remotely triggered announcements."""
START_CONVERSATION = 2
"""Device supports starting conversations."""
@@ -10,7 +10,7 @@ import logging
import time
from typing import Any, Final, Literal, final
from homeassistant.components import conversation, media_source, stt, tts
from homeassistant.components import media_source, stt, tts
from homeassistant.components.assist_pipeline import (
OPTION_PREFERRED,
AudioSettings,
@@ -27,7 +27,6 @@ from homeassistant.components.tts import (
generate_media_source_id as tts_generate_media_source_id,
)
from homeassistant.core import Context, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity
from homeassistant.helpers.entity import EntityDescription
@@ -118,7 +117,6 @@ class AssistSatelliteEntity(entity.Entity):
_run_has_tts: bool = False
_is_announcing = False
_extra_system_prompt: str | None = None
_wake_word_intercept_future: asyncio.Future[str | None] | None = None
_attr_tts_options: dict[str, Any] | None = None
_pipeline_task: asyncio.Task | None = None
@@ -218,59 +216,6 @@ class AssistSatelliteEntity(entity.Entity):
"""
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
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(
self,
audio_stream: AsyncIterable[bytes],
@@ -281,10 +226,6 @@ class AssistSatelliteEntity(entity.Entity):
"""Triggers an Assist pipeline in Home Assistant from a satellite."""
await self._cancel_running_pipeline()
# Consume system prompt in first pipeline
extra_system_prompt = self._extra_system_prompt
self._extra_system_prompt = None
if self._wake_word_intercept_future and start_stage in (
PipelineStage.WAKE_WORD,
PipelineStage.STT,
@@ -361,7 +302,6 @@ class AssistSatelliteEntity(entity.Entity):
),
start_stage=start_stage,
end_stage=end_stage,
conversation_extra_system_prompt=extra_system_prompt,
),
f"{self.entity_id}_pipeline",
)
@@ -7,9 +7,6 @@
"services": {
"announce": {
"service": "mdi:bullhorn"
},
"start_conversation": {
"service": "mdi:forum"
}
}
}
@@ -14,23 +14,3 @@ announce:
required: false
selector:
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,24 +25,6 @@
"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."
}
}
}
}
}
@@ -10,6 +10,7 @@ from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.util import uuid as uuid_util
from .connection_test import CONNECTION_TEST_URL_BASE
@@ -19,6 +20,7 @@ from .const import (
DOMAIN,
AssistSatelliteEntityFeature,
)
from .entity import AssistSatelliteEntity
CONNECTION_TEST_TIMEOUT = 30
@@ -165,7 +167,7 @@ async def websocket_test_connection(
Send an announcement to the device with a special media id.
"""
component = hass.data[DATA_COMPONENT]
component: EntityComponent[AssistSatelliteEntity] = hass.data[DOMAIN]
satellite = component.get_entity(msg["entity_id"])
if satellite is None:
connection.send_error(
+1 -1
View File
@@ -16,7 +16,7 @@ from homeassistant.components.switch import (
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+1 -1
View File
@@ -22,7 +22,7 @@ from homeassistant.const import (
UnitOfPower,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
+1 -1
View File
@@ -16,7 +16,7 @@ from homeassistant.const import ATTR_BATTERY_LEVEL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.util import dt as dt_util
import homeassistant.util.dt as dt_util
from . import AugustConfigEntry, AugustData
from .entity import AugustEntity
@@ -12,7 +12,7 @@ from homeassistant import data_entry_flow
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import FlowContext
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
from homeassistant.util.hass_dict import HassKey
WS_TYPE_SETUP_MFA = "auth/setup_mfa"
@@ -48,7 +48,8 @@ from homeassistant.core import (
valid_entity_id,
)
from homeassistant.exceptions import HomeAssistantError, ServiceNotFound, TemplateError
from homeassistant.helpers import condition, config_validation as cv
from homeassistant.helpers import condition
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import ToggleEntity
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.issue_registry import (
+1 -1
View File
@@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import color as color_util
import homeassistant.util.color as color_util
def setup_platform(
+1 -1
View File
@@ -23,7 +23,7 @@ from homeassistant.const import (
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
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.core import Event, HomeAssistant, State
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entityfilter import FILTER_SCHEMA, EntityFilter
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.json import JSONEncoder
@@ -23,7 +23,7 @@ from homeassistant.components.notify import (
)
from homeassistant.const import CONTENT_TYPE_JSON
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
CONF_CONNECTION_STRING = "connection_string"
+13 -25
View File
@@ -1,7 +1,6 @@
"""The Backup integration."""
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.typing import ConfigType
@@ -20,22 +19,18 @@ from .const import DATA_MANAGER, DOMAIN
from .http import async_register_http_views
from .manager import (
BackupManager,
BackupManagerError,
BackupPlatformProtocol,
BackupReaderWriter,
BackupReaderWriterError,
CoreBackupReaderWriter,
CreateBackupEvent,
IdleEvent,
IncorrectPasswordError,
ManagerBackup,
NewBackup,
RestoreBackupEvent,
RestoreBackupState,
WrittenBackup,
)
from .models import AddonInfo, AgentBackup, Folder
from .util import suggested_filename, suggested_filename_from_name_date
from .websocket import async_register_websocket_handlers
__all__ = [
@@ -44,23 +39,17 @@ __all__ = [
"BackupAgent",
"BackupAgentError",
"BackupAgentPlatformProtocol",
"BackupManagerError",
"BackupPlatformProtocol",
"BackupReaderWriter",
"BackupReaderWriterError",
"CreateBackupEvent",
"Folder",
"IdleEvent",
"IncorrectPasswordError",
"LocalBackupAgent",
"ManagerBackup",
"NewBackup",
"RestoreBackupEvent",
"RestoreBackupState",
"WrittenBackup",
"async_get_manager",
"suggested_filename",
"suggested_filename_from_name_date",
]
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
@@ -101,7 +90,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_handle_create_automatic_service(call: ServiceCall) -> None:
"""Service handler for creating automatic backups."""
await backup_manager.async_create_automatic_backup()
config_data = backup_manager.config.data
await backup_manager.async_create_backup(
agent_ids=config_data.create_backup.agent_ids,
include_addons=config_data.create_backup.include_addons,
include_all_addons=config_data.create_backup.include_all_addons,
include_database=config_data.create_backup.include_database,
include_folders=config_data.create_backup.include_folders,
include_homeassistant=True, # always include HA
name=config_data.create_backup.name,
password=config_data.create_backup.password,
with_automatic_settings=True,
)
if not with_hassio:
hass.services.async_register(DOMAIN, "create", async_handle_create_service)
@@ -112,15 +112,3 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async_register_http_views(hass)
return True
@callback
def async_get_manager(hass: HomeAssistant) -> BackupManager:
"""Get the backup manager instance.
Raises HomeAssistantError if the backup integration is not available.
"""
if DATA_MANAGER not in hass.data:
raise HomeAssistantError("Backup integration is not available")
return hass.data[DATA_MANAGER]
+5 -19
View File
@@ -10,40 +10,31 @@ from typing import Any, Protocol
from propcache.api import cached_property
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from .models import AgentBackup, BackupError
from .models import AgentBackup
class BackupAgentError(BackupError):
class BackupAgentError(HomeAssistantError):
"""Base class for backup agent errors."""
error_code = "backup_agent_error"
class BackupAgentUnreachableError(BackupAgentError):
"""Raised when the agent can't reach its API."""
error_code = "backup_agent_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):
"""Backup agent interface."""
domain: str
name: str
unique_id: str
@cached_property
def agent_id(self) -> str:
"""Return the agent_id."""
return f"{self.domain}.{self.unique_id}"
return f"{self.domain}.{self.name}"
@abc.abstractmethod
async def async_download_backup(
@@ -100,16 +91,11 @@ class LocalBackupAgent(BackupAgent):
@abc.abstractmethod
def get_backup_path(self, backup_id: str) -> Path:
"""Return the local path to an existing backup.
"""Return the local path to a backup.
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):
"""Define the format of backup platforms which implement backup agents."""
+15 -29
View File
@@ -11,10 +11,10 @@ from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.helpers.hassio import is_hassio
from .agent import BackupAgent, BackupNotFound, LocalBackupAgent
from .agent import BackupAgent, LocalBackupAgent
from .const import DOMAIN, LOGGER
from .models import AgentBackup
from .util import read_backup, suggested_filename
from .util import read_backup
async def async_get_backup_agents(
@@ -32,14 +32,13 @@ class CoreLocalBackupAgent(LocalBackupAgent):
domain = DOMAIN
name = "local"
unique_id = "local"
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the backup agent."""
super().__init__()
self._hass = hass
self._backup_dir = Path(hass.config.path("backups"))
self._backups: dict[str, tuple[AgentBackup, Path]] = {}
self._backups: dict[str, AgentBackup] = {}
self._loaded_backups = False
async def _load_backups(self) -> None:
@@ -49,13 +48,13 @@ class CoreLocalBackupAgent(LocalBackupAgent):
self._backups = backups
self._loaded_backups = True
def _read_backups(self) -> dict[str, tuple[AgentBackup, Path]]:
def _read_backups(self) -> dict[str, AgentBackup]:
"""Read backups from disk."""
backups: dict[str, tuple[AgentBackup, Path]] = {}
backups: dict[str, AgentBackup] = {}
for backup_path in self._backup_dir.glob("*.tar"):
try:
backup = read_backup(backup_path)
backups[backup.backup_id] = (backup, backup_path)
backups[backup.backup_id] = backup
except (OSError, TarError, json.JSONDecodeError, KeyError) as err:
LOGGER.warning("Unable to read backup %s: %s", backup_path, err)
return backups
@@ -76,13 +75,13 @@ class CoreLocalBackupAgent(LocalBackupAgent):
**kwargs: Any,
) -> None:
"""Upload a backup."""
self._backups[backup.backup_id] = (backup, self.get_new_backup_path(backup))
self._backups[backup.backup_id] = backup
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
"""List backups."""
if not self._loaded_backups:
await self._load_backups()
return [backup for backup, _ in self._backups.values()]
return list(self._backups.values())
async def async_get_backup(
self,
@@ -93,10 +92,10 @@ class CoreLocalBackupAgent(LocalBackupAgent):
if not self._loaded_backups:
await self._load_backups()
if backup_id not in self._backups:
if not (backup := self._backups.get(backup_id)):
return None
backup, backup_path = self._backups[backup_id]
backup_path = self.get_backup_path(backup_id)
if not await self._hass.async_add_executor_job(backup_path.exists):
LOGGER.debug(
(
@@ -112,28 +111,15 @@ class CoreLocalBackupAgent(LocalBackupAgent):
return backup
def get_backup_path(self, backup_id: str) -> Path:
"""Return the local path to an existing backup.
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 / suggested_filename(backup)
"""Return the local path to a backup."""
return self._backup_dir / f"{backup_id}.tar"
async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None:
"""Delete a backup file."""
if not self._loaded_backups:
await self._load_backups()
try:
backup_path = self.get_backup_path(backup_id)
except BackupNotFound:
if await self.async_get_backup(backup_id) is None:
return
backup_path = self.get_backup_path(backup_id)
await self._hass.async_add_executor_job(backup_path.unlink, True)
LOGGER.debug("Deleted backup located at %s", backup_path)
self._backups.pop(backup_id)
+76 -58
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
from dataclasses import dataclass, field, replace
import datetime as dt
from datetime import datetime, timedelta
@@ -38,7 +40,6 @@ BACKUP_START_TIME_JITTER = 60 * 60
class StoredBackupConfig(TypedDict):
"""Represent the stored backup config."""
agents: dict[str, StoredAgentConfig]
create_backup: StoredCreateBackupConfig
last_attempted_automatic_backup: str | None
last_completed_automatic_backup: str | None
@@ -50,7 +51,6 @@ class StoredBackupConfig(TypedDict):
class BackupConfigData:
"""Represent loaded backup config data."""
agents: dict[str, AgentConfig]
create_backup: CreateBackupConfig
last_attempted_automatic_backup: datetime | None = None
last_completed_automatic_backup: datetime | None = None
@@ -84,10 +84,6 @@ class BackupConfigData:
days = [Day(day) for day in data["schedule"]["days"]]
return cls(
agents={
agent_id: AgentConfig(protected=agent_data["protected"])
for agent_id, agent_data in data["agents"].items()
},
create_backup=CreateBackupConfig(
agent_ids=data["create_backup"]["agent_ids"],
include_addons=data["create_backup"]["include_addons"],
@@ -124,9 +120,6 @@ class BackupConfigData:
last_completed = None
return StoredBackupConfig(
agents={
agent_id: agent.to_dict() for agent_id, agent in self.agents.items()
},
create_backup=self.create_backup.to_dict(),
last_attempted_automatic_backup=last_attempted,
last_completed_automatic_backup=last_completed,
@@ -141,7 +134,6 @@ class BackupConfig:
def __init__(self, hass: HomeAssistant, manager: BackupManager) -> None:
"""Initialize backup config."""
self.data = BackupConfigData(
agents={},
create_backup=CreateBackupConfig(),
retention=RetentionConfig(),
schedule=BackupSchedule(),
@@ -157,20 +149,11 @@ class BackupConfig:
async def update(
self,
*,
agents: dict[str, AgentParametersDict] | UndefinedType = UNDEFINED,
create_backup: CreateBackupParametersDict | UndefinedType = UNDEFINED,
retention: RetentionParametersDict | UndefinedType = UNDEFINED,
schedule: ScheduleParametersDict | UndefinedType = UNDEFINED,
) -> None:
"""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:
self.data.create_backup = replace(self.data.create_backup, **create_backup)
if retention is not UNDEFINED:
@@ -187,31 +170,6 @@ class BackupConfig:
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)
class RetentionConfig:
"""Represent the backup retention configuration."""
@@ -250,7 +208,7 @@ class RetentionConfig:
"""Delete backups older than days."""
self._schedule_next(manager)
def _delete_filter(
def _backups_filter(
backups: dict[str, ManagerBackup],
) -> dict[str, ManagerBackup]:
"""Return backups older than days to delete."""
@@ -267,9 +225,7 @@ class RetentionConfig:
< now
}
await manager.async_delete_filtered_backups(
include_filter=_automatic_backups_filter, delete_filter=_delete_filter
)
await _delete_filtered_backups(manager, _backups_filter)
manager.remove_next_delete_event = async_call_later(
manager.hass, timedelta(days=1), _delete_backups
@@ -434,11 +390,22 @@ class BackupSchedule:
async def _create_backup(now: datetime) -> None:
"""Create backup."""
manager.remove_next_backup_event = None
config_data = manager.config.data
self._schedule_next(cron_pattern, manager)
# create the backup
try:
await manager.async_create_automatic_backup()
await manager.async_create_backup(
agent_ids=config_data.create_backup.agent_ids,
include_addons=config_data.create_backup.include_addons,
include_all_addons=config_data.create_backup.include_all_addons,
include_database=config_data.create_backup.include_database,
include_folders=config_data.create_backup.include_folders,
include_homeassistant=True, # always include HA
name=config_data.create_backup.name,
password=config_data.create_backup.password,
with_automatic_settings=True,
)
except BackupManagerError as err:
LOGGER.error("Error creating backup: %s", err)
except Exception: # noqa: BLE001
@@ -521,21 +488,74 @@ class CreateBackupParametersDict(TypedDict, total=False):
password: str | None
def _automatic_backups_filter(
backups: dict[str, ManagerBackup],
) -> dict[str, ManagerBackup]:
"""Return automatic backups."""
return {
async def _delete_filtered_backups(
manager: BackupManager,
backup_filter: Callable[[dict[str, ManagerBackup]], dict[str, ManagerBackup]],
) -> None:
"""Delete backups parsed with a filter.
:param manager: The backup manager.
:param backup_filter: A filter that should return the backups to delete.
"""
backups, get_agent_errors = await manager.async_get_backups()
if get_agent_errors:
LOGGER.debug(
"Error getting backups; continuing anyway: %s",
get_agent_errors,
)
# only delete backups that are created with the saved automatic settings
backups = {
backup_id: backup
for backup_id, backup in backups.items()
if backup.with_automatic_settings
}
LOGGER.debug("Total automatic backups: %s", backups)
filtered_backups = backup_filter(backups)
if not filtered_backups:
return
# always delete oldest backup first
filtered_backups = dict(
sorted(
filtered_backups.items(),
key=lambda backup_item: backup_item[1].date,
)
)
if len(filtered_backups) >= len(backups):
# Never delete the last backup.
last_backup = filtered_backups.popitem()
LOGGER.debug("Keeping the last backup: %s", last_backup)
LOGGER.debug("Backups to delete: %s", filtered_backups)
if not filtered_backups:
return
backup_ids = list(filtered_backups)
delete_results = await asyncio.gather(
*(manager.async_delete_backup(backup_id) for backup_id in filtered_backups)
)
agent_errors = {
backup_id: error
for backup_id, error in zip(backup_ids, delete_results, strict=True)
if error
}
if agent_errors:
LOGGER.error(
"Error deleting old copies: %s",
agent_errors,
)
async def delete_backups_exceeding_configured_count(manager: BackupManager) -> None:
"""Delete backups exceeding the configured retention count."""
def _delete_filter(
def _backups_filter(
backups: dict[str, ManagerBackup],
) -> dict[str, ManagerBackup]:
"""Return oldest backups more numerous than copies to delete."""
@@ -550,6 +570,4 @@ async def delete_backups_exceeding_configured_count(manager: BackupManager) -> N
)[: max(len(backups) - manager.config.data.retention.copies, 0)]
)
await manager.async_delete_filtered_backups(
include_filter=_automatic_backups_filter, delete_filter=_delete_filter
)
await _delete_filtered_backups(manager, _backups_filter)
+6 -12
View File
@@ -69,7 +69,7 @@ class DownloadBackupView(HomeAssistantView):
CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar"
}
if not password or not backup.protected:
if not password:
return await self._send_backup_no_password(
request, headers, backup_id, agent_id, agent, manager
)
@@ -123,13 +123,13 @@ class DownloadBackupView(HomeAssistantView):
worker_done_event = asyncio.Event()
def on_done(error: Exception | None) -> None:
def on_done() -> None:
"""Call by the worker thread when it's done."""
hass.loop.call_soon_threadsafe(worker_done_event.set)
stream = util.AsyncIteratorWriter(hass)
worker = threading.Thread(
target=util.decrypt_backup, args=[reader, stream, password, on_done, 0, []]
target=util.decrypt_backup, args=[reader, stream, password, on_done]
)
try:
worker.start()
@@ -144,17 +144,13 @@ class DownloadBackupView(HomeAssistantView):
class UploadBackupView(HomeAssistantView):
"""Upload backup view."""
"""Generate backup view."""
url = "/api/backup/upload"
name = "api:backup:upload"
@require_admin
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."""
try:
agent_ids = request.query.getall("agent_id")
@@ -165,9 +161,7 @@ class UploadBackupView(HomeAssistantView):
contents = cast(BodyPartReader, await reader.next())
try:
backup_id = await manager.async_receive_backup(
contents=contents, agent_ids=agent_ids
)
await manager.async_receive_backup(contents=contents, agent_ids=agent_ids)
except OSError as err:
return Response(
body=f"Can't write backup file: {err}",
@@ -181,4 +175,4 @@ class UploadBackupView(HomeAssistantView):
except asyncio.CancelledError:
return Response(status=HTTPStatus.INTERNAL_SERVER_ERROR)
return self.json({"backup_id": backup_id}, status_code=HTTPStatus.CREATED)
return Response(status=HTTPStatus.CREATED)
+64 -354
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import abc
import asyncio
from collections.abc import AsyncIterator, Callable, Coroutine
from dataclasses import dataclass, replace
from dataclasses import dataclass
from enum import StrEnum
import hashlib
import io
@@ -19,20 +19,17 @@ from typing import IO, TYPE_CHECKING, Any, Protocol, TypedDict, cast
import aiohttp
from securetar import SecureTarFile, atomic_contents_add
from homeassistant.backup_restore import (
RESTORE_BACKUP_FILE,
RESTORE_BACKUP_RESULT_FILE,
password_to_key,
)
from homeassistant.backup_restore import RESTORE_BACKUP_FILE, password_to_key
from homeassistant.const import __version__ as HAVERSION
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import (
instance_id,
integration_platform,
issue_registry as ir,
)
from homeassistant.helpers.json import json_bytes
from homeassistant.util import dt as dt_util, json as json_util
from homeassistant.util import dt as dt_util
from . import util as backup_util
from .agent import (
@@ -50,12 +47,10 @@ from .const import (
EXCLUDE_FROM_BACKUP,
LOGGER,
)
from .models import AgentBackup, BackupError, BackupManagerError, BaseBackup, Folder
from .models import AgentBackup, BackupManagerError, Folder
from .store import BackupStore
from .util import (
AsyncIteratorReader,
DecryptedBackupStreamer,
EncryptedBackupStreamer,
make_backup_dir,
read_backup,
validate_password,
@@ -71,18 +66,10 @@ class NewBackup:
@dataclass(frozen=True, kw_only=True, slots=True)
class AgentBackupStatus:
"""Agent specific backup attributes."""
protected: bool
size: int
@dataclass(frozen=True, kw_only=True, slots=True)
class ManagerBackup(BaseBackup):
class ManagerBackup(AgentBackup):
"""Backup class."""
agents: dict[str, AgentBackupStatus]
agent_ids: list[str]
failed_agent_ids: list[str]
with_automatic_settings: bool | None
@@ -184,7 +171,6 @@ class CreateBackupEvent(ManagerStateEvent):
"""Backup in progress."""
manager_state: BackupManagerState = BackupManagerState.CREATE_BACKUP
reason: str | None
stage: CreateBackupStage | None
state: CreateBackupState
@@ -194,7 +180,6 @@ class ReceiveBackupEvent(ManagerStateEvent):
"""Backup receive."""
manager_state: BackupManagerState = BackupManagerState.RECEIVE_BACKUP
reason: str | None
stage: ReceiveBackupStage | None
state: ReceiveBackupState
@@ -204,7 +189,6 @@ class RestoreBackupEvent(ManagerStateEvent):
"""Backup restore."""
manager_state: BackupManagerState = BackupManagerState.RESTORE_BACKUP
reason: str | None
stage: RestoreBackupStage | None
state: RestoreBackupState
@@ -265,32 +249,20 @@ class BackupReaderWriter(abc.ABC):
) -> None:
"""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(BackupError):
class BackupReaderWriterError(HomeAssistantError):
"""Backup reader/writer error."""
error_code = "backup_reader_writer_error"
class IncorrectPasswordError(BackupReaderWriterError):
"""Raised when the password is incorrect."""
error_code = "password_incorrect"
_message = "The password provided is incorrect."
class DecryptOnDowloadNotSupported(BackupManagerError):
"""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."
@@ -320,7 +292,6 @@ class BackupManager:
# Latest backup event and backup event subscribers
self.last_event: ManagerStateEvent = IdleEvent()
self.last_non_idle_event: ManagerStateEvent | None = None
self._backup_event_subscriptions: list[Callable[[ManagerStateEvent], None]] = []
async def async_setup(self) -> None:
@@ -330,10 +301,6 @@ class BackupManager:
self.config.load(stored["config"])
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()
@property
@@ -463,61 +430,20 @@ class BackupManager:
backup: AgentBackup,
agent_ids: list[str],
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
password: str | None,
) -> dict[str, Exception]:
"""Upload a backup to selected agents."""
agent_errors: dict[str, Exception] = {}
LOGGER.debug("Uploading backup %s to agents %s", backup.backup_id, agent_ids)
async def upload_backup_to_agent(agent_id: str) -> None:
"""Upload backup to a single agent, and encrypt or decrypt as needed."""
config = self.config.data.agents.get(agent_id)
should_encrypt = config.protected if config else password is not None
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
)
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),
*(
self.backup_agents[agent_id].async_upload_backup(
open_stream=open_stream,
backup=backup,
)
for agent_id in agent_ids
),
return_exceptions=True,
)
for idx, result in enumerate(sync_backup_results):
@@ -573,7 +499,7 @@ class BackupManager:
agent_backup, await instance_id.async_get(self.hass)
)
backups[backup_id] = ManagerBackup(
agents={},
agent_ids=[],
addons=agent_backup.addons,
backup_id=backup_id,
date=agent_backup.date,
@@ -584,12 +510,11 @@ class BackupManager:
homeassistant_included=agent_backup.homeassistant_included,
homeassistant_version=agent_backup.homeassistant_version,
name=agent_backup.name,
protected=agent_backup.protected,
size=agent_backup.size,
with_automatic_settings=with_automatic_settings,
)
backups[backup_id].agents[agent_ids[idx]] = AgentBackupStatus(
protected=agent_backup.protected,
size=agent_backup.size,
)
backups[backup_id].agent_ids.append(agent_ids[idx])
return (backups, agent_errors)
@@ -625,7 +550,7 @@ class BackupManager:
result, await instance_id.async_get(self.hass)
)
backup = ManagerBackup(
agents={},
agent_ids=[],
addons=result.addons,
backup_id=result.backup_id,
date=result.date,
@@ -636,12 +561,11 @@ class BackupManager:
homeassistant_included=result.homeassistant_included,
homeassistant_version=result.homeassistant_version,
name=result.name,
protected=result.protected,
size=result.size,
with_automatic_settings=with_automatic_settings,
)
backup.agents[agent_ids[idx]] = AgentBackupStatus(
protected=result.protected,
size=result.size,
)
backup.agent_ids.append(agent_ids[idx])
return (backup, agent_errors)
@@ -685,108 +609,29 @@ class BackupManager:
return agent_errors
async def async_delete_filtered_backups(
self,
*,
include_filter: Callable[[dict[str, ManagerBackup]], dict[str, ManagerBackup]],
delete_filter: Callable[[dict[str, ManagerBackup]], dict[str, ManagerBackup]],
) -> None:
"""Delete backups parsed with a filter.
:param include_filter: A filter that should return the backups to consider for
deletion. Note: The newest of the backups returned by include_filter will
unconditionally be kept, even if delete_filter returns all backups.
:param delete_filter: A filter that should return the backups to delete.
"""
backups, get_agent_errors = await self.async_get_backups()
if get_agent_errors:
LOGGER.debug(
"Error getting backups; continuing anyway: %s",
get_agent_errors,
)
# Run the include filter first to ensure we only consider backups that
# should be included in the deletion process.
backups = include_filter(backups)
LOGGER.debug("Total automatic backups: %s", backups)
backups_to_delete = delete_filter(backups)
if not backups_to_delete:
return
# always delete oldest backup first
backups_to_delete = dict(
sorted(
backups_to_delete.items(),
key=lambda backup_item: backup_item[1].date,
)
)
if len(backups_to_delete) >= len(backups):
# Never delete the last backup.
last_backup = backups_to_delete.popitem()
LOGGER.debug("Keeping the last backup: %s", last_backup)
LOGGER.debug("Backups to delete: %s", backups_to_delete)
if not backups_to_delete:
return
backup_ids = list(backups_to_delete)
delete_results = await asyncio.gather(
*(self.async_delete_backup(backup_id) for backup_id in backups_to_delete)
)
agent_errors = {
backup_id: error
for backup_id, error in zip(backup_ids, delete_results, strict=True)
if error
}
if agent_errors:
LOGGER.error(
"Error deleting old copies: %s",
agent_errors,
)
async def async_receive_backup(
self,
*,
agent_ids: list[str],
contents: aiohttp.BodyPartReader,
) -> str:
) -> None:
"""Receive and store a backup file from upload."""
if self.state is not BackupManagerState.IDLE:
raise BackupManagerError(f"Backup manager busy: {self.state}")
self.async_on_backup_event(
ReceiveBackupEvent(
reason=None,
stage=None,
state=ReceiveBackupState.IN_PROGRESS,
)
ReceiveBackupEvent(stage=None, state=ReceiveBackupState.IN_PROGRESS)
)
try:
backup_id = await self._async_receive_backup(
agent_ids=agent_ids, contents=contents
)
await self._async_receive_backup(agent_ids=agent_ids, contents=contents)
except Exception:
self.async_on_backup_event(
ReceiveBackupEvent(
reason="unknown_error",
stage=None,
state=ReceiveBackupState.FAILED,
)
ReceiveBackupEvent(stage=None, state=ReceiveBackupState.FAILED)
)
raise
else:
self.async_on_backup_event(
ReceiveBackupEvent(
reason=None,
stage=None,
state=ReceiveBackupState.COMPLETED,
)
ReceiveBackupEvent(stage=None, state=ReceiveBackupState.COMPLETED)
)
return backup_id
finally:
self.async_on_backup_event(IdleEvent())
@@ -795,12 +640,11 @@ class BackupManager:
*,
agent_ids: list[str],
contents: aiohttp.BodyPartReader,
) -> str:
) -> None:
"""Receive and store a backup file from upload."""
contents.chunk_size = BUF_SIZE
self.async_on_backup_event(
ReceiveBackupEvent(
reason=None,
stage=ReceiveBackupStage.RECEIVE_FILE,
state=ReceiveBackupState.IN_PROGRESS,
)
@@ -812,7 +656,6 @@ class BackupManager:
)
self.async_on_backup_event(
ReceiveBackupEvent(
reason=None,
stage=ReceiveBackupStage.UPLOAD_TO_AGENTS,
state=ReceiveBackupState.IN_PROGRESS,
)
@@ -821,19 +664,14 @@ class BackupManager:
backup=written_backup.backup,
agent_ids=agent_ids,
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()
self.known_backups.add(written_backup.backup, agent_errors)
return written_backup.backup.backup_id
async def async_create_backup(
self,
*,
agent_ids: list[str],
extra_metadata: dict[str, bool | str] | None = None,
include_addons: list[str] | None,
include_all_addons: bool,
include_database: bool,
@@ -846,7 +684,6 @@ class BackupManager:
"""Create a backup."""
new_backup = await self.async_initiate_backup(
agent_ids=agent_ids,
extra_metadata=extra_metadata,
include_addons=include_addons,
include_all_addons=include_all_addons,
include_database=include_database,
@@ -861,26 +698,10 @@ class BackupManager:
await self._backup_finish_task
return new_backup
async def async_create_automatic_backup(self) -> NewBackup:
"""Create a backup with automatic backup settings."""
config_data = self.config.data
return await self.async_create_backup(
agent_ids=config_data.create_backup.agent_ids,
include_addons=config_data.create_backup.include_addons,
include_all_addons=config_data.create_backup.include_all_addons,
include_database=config_data.create_backup.include_database,
include_folders=config_data.create_backup.include_folders,
include_homeassistant=True, # always include HA
name=config_data.create_backup.name,
password=config_data.create_backup.password,
with_automatic_settings=True,
)
async def async_initiate_backup(
self,
*,
agent_ids: list[str],
extra_metadata: dict[str, bool | str] | None = None,
include_addons: list[str] | None,
include_all_addons: bool,
include_database: bool,
@@ -900,16 +721,11 @@ class BackupManager:
self.store.save()
self.async_on_backup_event(
CreateBackupEvent(
reason=None,
stage=None,
state=CreateBackupState.IN_PROGRESS,
)
CreateBackupEvent(stage=None, state=CreateBackupState.IN_PROGRESS)
)
try:
return await self._async_create_backup(
agent_ids=agent_ids,
extra_metadata=extra_metadata,
include_addons=include_addons,
include_all_addons=include_all_addons,
include_database=include_database,
@@ -920,14 +736,9 @@ class BackupManager:
raise_task_error=raise_task_error,
with_automatic_settings=with_automatic_settings,
)
except Exception as err:
reason = err.error_code if isinstance(err, BackupError) else "unknown_error"
except Exception:
self.async_on_backup_event(
CreateBackupEvent(
reason=reason,
stage=None,
state=CreateBackupState.FAILED,
)
CreateBackupEvent(stage=None, state=CreateBackupState.FAILED)
)
self.async_on_backup_event(IdleEvent())
if with_automatic_settings:
@@ -938,7 +749,6 @@ class BackupManager:
self,
*,
agent_ids: list[str],
extra_metadata: dict[str, bool | str] | None,
include_addons: list[str] | None,
include_all_addons: bool,
include_database: bool,
@@ -962,10 +772,9 @@ class BackupManager:
)
backup_name = (
(name if name is None else name.strip())
name
or f"{'Automatic' if with_automatic_settings else 'Custom'} backup {HAVERSION}"
)
extra_metadata = extra_metadata or {}
try:
(
@@ -974,8 +783,7 @@ class BackupManager:
) = await self._reader_writer.async_create_backup(
agent_ids=agent_ids,
backup_name=backup_name,
extra_metadata=extra_metadata
| {
extra_metadata={
"instance_id": await instance_id.async_get(self.hass),
"with_automatic_settings": with_automatic_settings,
},
@@ -991,7 +799,7 @@ class BackupManager:
raise BackupManagerError(str(err)) from err
backup_finish_task = self._backup_finish_task = self.hass.async_create_task(
self._async_finish_backup(agent_ids, with_automatic_settings, password),
self._async_finish_backup(agent_ids, with_automatic_settings),
name="backup_manager_finish_backup",
)
if not raise_task_error:
@@ -1008,7 +816,7 @@ class BackupManager:
return new_backup
async def _async_finish_backup(
self, agent_ids: list[str], with_automatic_settings: bool, password: str | None
self, agent_ids: list[str], with_automatic_settings: bool
) -> None:
"""Finish a backup."""
if TYPE_CHECKING:
@@ -1031,7 +839,6 @@ class BackupManager:
)
self.async_on_backup_event(
CreateBackupEvent(
reason=None,
stage=CreateBackupStage.UPLOAD_TO_AGENTS,
state=CreateBackupState.IN_PROGRESS,
)
@@ -1042,7 +849,6 @@ class BackupManager:
backup=written_backup.backup,
agent_ids=agent_ids,
open_stream=written_backup.open_stream,
password=password,
)
finally:
await written_backup.release_stream()
@@ -1063,22 +869,14 @@ class BackupManager:
finally:
self._backup_task = None
self._backup_finish_task = None
if backup_success:
self.async_on_backup_event(
CreateBackupEvent(
reason=None,
stage=None,
state=CreateBackupState.COMPLETED,
)
)
else:
self.async_on_backup_event(
CreateBackupEvent(
reason="upload_failed",
stage=None,
state=CreateBackupState.FAILED,
)
self.async_on_backup_event(
CreateBackupEvent(
stage=None,
state=CreateBackupState.COMPLETED
if backup_success
else CreateBackupState.FAILED,
)
)
self.async_on_backup_event(IdleEvent())
async def async_restore_backup(
@@ -1097,11 +895,7 @@ class BackupManager:
raise BackupManagerError(f"Backup manager busy: {self.state}")
self.async_on_backup_event(
RestoreBackupEvent(
reason=None,
stage=None,
state=RestoreBackupState.IN_PROGRESS,
)
RestoreBackupEvent(stage=None, state=RestoreBackupState.IN_PROGRESS)
)
try:
await self._async_restore_backup(
@@ -1114,28 +908,11 @@ class BackupManager:
restore_homeassistant=restore_homeassistant,
)
self.async_on_backup_event(
RestoreBackupEvent(
reason=None,
stage=None,
state=RestoreBackupState.COMPLETED,
)
RestoreBackupEvent(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:
self.async_on_backup_event(
RestoreBackupEvent(
reason="unknown_error",
stage=None,
state=RestoreBackupState.FAILED,
)
RestoreBackupEvent(stage=None, state=RestoreBackupState.FAILED)
)
raise
finally:
@@ -1183,8 +960,6 @@ class BackupManager:
if (current_state := self.state) != (new_state := event.manager_state):
LOGGER.debug("Backup state: %s -> %s", current_state, new_state)
self.last_event = event
if not isinstance(event, IdleEvent):
self.last_non_idle_event = event
for subscription in self._backup_event_subscriptions:
subscription(event)
@@ -1230,11 +1005,7 @@ class BackupManager:
learn_more_url="homeassistant://config/backup",
severity=ir.IssueSeverity.WARNING,
translation_key="automatic_backup_failed_upload_agents",
translation_placeholders={
"failed_agents": ", ".join(
self.backup_agents[agent_id].name for agent_id in agent_errors
)
},
translation_placeholders={"failed_agents": ", ".join(agent_errors)},
)
async def async_can_decrypt_on_download(
@@ -1262,9 +1033,7 @@ class BackupManager:
backup_stream = await agent.async_download_backup(backup_id)
reader = cast(IO[bytes], AsyncIteratorReader(self.hass, backup_stream))
try:
await self.hass.async_add_executor_job(
validate_password_stream, reader, password
)
validate_password_stream(reader, password)
except backup_util.IncorrectPassword as err:
raise IncorrectPasswordError from err
except backup_util.UnsupportedSecureTarVersion as err:
@@ -1410,32 +1179,13 @@ class CoreBackupReaderWriter(BackupReaderWriter):
"""Generate a backup."""
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
if self._local_agent_id in agent_ids:
local_agent = manager.local_backup_agents[self._local_agent_id]
local_agent_tar_file_path = local_agent.get_new_backup_path(backup)
local_agent_tar_file_path = local_agent.get_backup_path(backup_id)
on_progress(
CreateBackupEvent(
reason=None,
stage=CreateBackupStage.HOME_ASSISTANT,
state=CreateBackupState.IN_PROGRESS,
)
@@ -1473,7 +1223,19 @@ class CoreBackupReaderWriter(BackupReaderWriter):
# ValueError from json_bytes
raise BackupReaderWriterError(str(err)) from err
else:
backup = replace(backup, size=size_in_bytes)
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=size_in_bytes,
)
async_add_executor_job = self._hass.async_add_executor_job
@@ -1587,7 +1349,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
manager = self._hass.data[DATA_MANAGER]
if self._local_agent_id in agent_ids:
local_agent = manager.local_backup_agents[self._local_agent_id]
tar_file_path = local_agent.get_new_backup_path(backup)
tar_file_path = local_agent.get_backup_path(backup.backup_id)
await async_add_executor_job(make_backup_dir, tar_file_path.parent)
await async_add_executor_job(shutil.move, temp_file, tar_file_path)
else:
@@ -1683,62 +1445,10 @@ class CoreBackupReaderWriter(BackupReaderWriter):
await self._hass.async_add_executor_job(_write_restore_file)
on_progress(
RestoreBackupEvent(
reason=None,
stage=None,
state=RestoreBackupState.CORE_RESTART,
)
RestoreBackupEvent(stage=None, state=RestoreBackupState.CORE_RESTART)
)
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:
"""Generate a backup ID."""
@@ -8,5 +8,5 @@
"integration_type": "system",
"iot_class": "calculated",
"quality_scale": "internal",
"requirements": ["cronsim==2.6", "securetar==2025.1.4"]
"requirements": ["cronsim==2.6", "securetar==2025.1.3"]
}
+8 -22
View File
@@ -28,7 +28,7 @@ class Folder(StrEnum):
@dataclass(frozen=True, kw_only=True)
class BaseBackup:
class AgentBackup:
"""Base backup class."""
addons: list[AddonInfo]
@@ -40,18 +40,6 @@ class BaseBackup:
homeassistant_included: bool
homeassistant_version: str | None # None if homeassistant_included is False
name: str
def as_frontend_json(self) -> dict:
"""Return a dict representation of this backup for sending to frontend."""
return {
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
@@ -59,6 +47,12 @@ class AgentBackup(BaseBackup):
"""Return a dict representation of this backup."""
return asdict(self)
def as_frontend_json(self) -> dict:
"""Return a dict representation of this backup for sending to frontend."""
return {
key: val for key, val in asdict(self).items() if key != "extra_metadata"
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Self:
"""Create an instance from a JSON serialization."""
@@ -77,13 +71,5 @@ class AgentBackup(BaseBackup):
)
class BackupError(HomeAssistantError):
"""Base class for backup errors."""
error_code = "unknown"
class BackupManagerError(BackupError):
class BackupManagerError(HomeAssistantError):
"""Backup manager error."""
error_code = "backup_manager_error"
+3 -7
View File
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
STORE_DELAY_SAVE = 30
STORAGE_KEY = DOMAIN
STORAGE_VERSION = 1
STORAGE_VERSION_MINOR = 3
STORAGE_VERSION_MINOR = 2
class StoredBackupData(TypedDict):
@@ -47,12 +47,8 @@ class _BackupStore(Store[StoredBackupData]):
"""Migrate to the new version."""
data = old_data
if old_major_version == 1:
if old_minor_version < 3:
# 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"] = {}
if old_minor_version < 2:
# Version 1.2 adds configurable backup time and custom days
data["config"]["schedule"]["time"] = None
if (state := data["config"]["schedule"]["state"]) in ("daily", "never"):
data["config"]["schedule"]["days"] = []
+16 -245
View File
@@ -3,16 +3,14 @@
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Callable, Coroutine
from collections.abc import AsyncIterator, Callable
import copy
from dataclasses import dataclass, replace
from io import BytesIO
import json
import os
from pathlib import Path, PurePath
from queue import SimpleQueue
import tarfile
from typing import IO, Any, Self, cast
from typing import IO, Self, cast
import aiohttp
from securetar import SecureTarError, SecureTarFile, SecureTarReadError
@@ -20,9 +18,7 @@ from securetar import SecureTarError, SecureTarFile, SecureTarReadError
from homeassistant.backup_restore import password_to_key
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.util import dt as dt_util
from homeassistant.util.json import JsonObjectType, json_loads_object
from homeassistant.util.thread import ThreadWithException
from .const import BUF_SIZE, LOGGER
from .models import AddonInfo, AgentBackup, Folder
@@ -34,12 +30,6 @@ class DecryptError(HomeAssistantError):
_message = "Unexpected error during decryption."
class EncryptError(HomeAssistantError):
"""Error during encryption."""
_message = "Unexpected error during encryption."
class UnsupportedSecureTarVersion(DecryptError):
"""Unsupported securetar version."""
@@ -58,12 +48,6 @@ class BackupEmpty(DecryptError):
_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:
"""Create a backup directory if it does not exist."""
path.mkdir(exist_ok=True)
@@ -118,17 +102,6 @@ def read_backup(backup_path: Path) -> AgentBackup:
)
def suggested_filename_from_name_date(name: str, date_str: str) -> str:
"""Suggest a filename for the backup."""
date = dt_util.parse_datetime(date_str, raise_on_error=True)
return "_".join(f"{name} - {date.strftime('%Y-%m-%d %H.%M %S%f')}.tar".split())
def suggested_filename(backup: AgentBackup) -> str:
"""Suggest a filename for the backup."""
return suggested_filename_from_name_date(backup.name, backup.date)
def validate_password(path: Path, password: str | None) -> bool:
"""Validate the password."""
with tarfile.open(path, "r:", bufsize=BUF_SIZE) as backup_file:
@@ -206,7 +179,6 @@ class AsyncIteratorWriter:
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the wrapper."""
self._hass = hass
self._pos: int = 0
self._queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=1)
def __aiter__(self) -> Self:
@@ -219,14 +191,9 @@ class AsyncIteratorWriter:
return data
raise StopAsyncIteration
def tell(self) -> int:
"""Return the current position in the iterator."""
return self._pos
def write(self, s: bytes, /) -> int:
"""Write data to the iterator."""
asyncio.run_coroutine_threadsafe(self._queue.put(s), self._hass.loop).result()
self._pos += len(s)
return len(s)
@@ -263,37 +230,24 @@ def decrypt_backup(
input_stream: IO[bytes],
output_stream: IO[bytes],
password: str | None,
on_done: Callable[[Exception | None], None],
minimum_size: int,
nonces: list[bytes],
on_done: Callable[[], None],
) -> None:
"""Decrypt 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,
):
_decrypt_backup(input_tar, output_tar, password)
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")
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,
):
_decrypt_backup(input_tar, output_tar, password)
except (DecryptError, SecureTarError, tarfile.TarError) as err:
LOGGER.warning("Error decrypting backup: %s", err)
finally:
on_done(error)
output_stream.write(b"") # Write an empty chunk to signal the end of the stream
on_done()
def _decrypt_backup(
@@ -334,189 +288,6 @@ def _decrypt_backup(
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(
hass: HomeAssistant, contents: aiohttp.BodyPartReader, path: Path
) -> None:
+3 -9
View File
@@ -60,10 +60,8 @@ async def handle_info(
"backups": [backup.as_frontend_json() for backup in backups.values()],
"last_attempted_automatic_backup": manager.config.data.last_attempted_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_additional": manager.config.data.schedule.next_automatic_backup_additional,
"state": manager.state,
},
)
@@ -199,8 +197,8 @@ async def handle_can_decrypt_on_download(
vol.Optional("include_database", default=True): bool,
vol.Optional("include_folders"): [vol.Coerce(Folder)],
vol.Optional("include_homeassistant", default=True): bool,
vol.Optional("name"): vol.Any(str, None),
vol.Optional("password"): vol.Any(str, None),
vol.Optional("name"): str,
vol.Optional("password"): str,
}
)
@websocket_api.async_response
@@ -308,10 +306,7 @@ async def backup_agents_info(
connection.send_result(
msg["id"],
{
"agents": [
{"agent_id": agent.agent_id, "name": agent.name}
for agent in manager.backup_agents.values()
],
"agents": [{"agent_id": agent_id} for agent_id in manager.backup_agents],
},
)
@@ -346,7 +341,6 @@ async def handle_config_info(
@websocket_api.websocket_command(
{
vol.Required("type"): "backup/config/update",
vol.Optional("agents"): vol.Schema({str: {"protected": bool}}),
vol.Optional("create_backup"): vol.Schema(
{
vol.Optional("agent_ids"): vol.All([str], vol.Unique()),
+1 -1
View File
@@ -11,7 +11,7 @@ from homeassistant.components.tts import (
Provider,
)
from homeassistant.const import CONF_API_KEY
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.config_validation as cv
_LOGGER = logging.getLogger(__name__)
+1 -1
View File
@@ -12,7 +12,7 @@ from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.util import dt as dt_util
import homeassistant.util.dt as dt_util
from .const import CONF_SYNC_TIME, DEFAULT_SYNC_TIME
+3 -44
View File
@@ -10,7 +10,7 @@ from pybalboa.exceptions import SpaConnectionError
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_MODEL
from homeassistant.const import CONF_HOST
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import format_mac
@@ -18,7 +18,6 @@ from homeassistant.helpers.schema_config_entry_flow import (
SchemaFlowFormStep,
SchemaOptionsFlowHandler,
)
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import CONF_SYNC_TIME, DOMAIN
@@ -56,8 +55,7 @@ class BalboaSpaClientFlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1
_host: str
_model: str
_host: str | None
@staticmethod
@callback
@@ -65,43 +63,6 @@ class BalboaSpaClientFlowHandler(ConfigFlow, domain=DOMAIN):
"""Get the options flow for this handler."""
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(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -117,9 +78,7 @@ class BalboaSpaClientFlowHandler(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(
info["formatted_mac"], raise_on_progress=False
)
await self.async_set_unique_id(info["formatted_mac"])
self._abort_if_unique_id_configured()
return self.async_create_entry(title=info["title"], data=user_input)
@@ -3,14 +3,6 @@
"name": "Balboa Spa Client",
"codeowners": ["@garbled1", "@natekspencer"],
"config_flow": true,
"dhcp": [
{
"registered_devices": true
},
{
"macaddress": "001527*"
}
],
"documentation": "https://www.home-assistant.io/integrations/balboa",
"iot_class": "local_push",
"loggers": ["pybalboa"],
@@ -1,6 +1,5 @@
{
"config": {
"flow_title": "{model}",
"step": {
"user": {
"description": "Connect to the Balboa Wi-Fi device",
@@ -10,9 +9,6 @@
"data_description": {
"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": {

Some files were not shown because too many files have changed in this diff Show More