This commit is contained in:
Jonh Sady 2025-02-02 18:08:08 -03:00
commit 784dc233b9
1743 changed files with 30188 additions and 9479 deletions

View File

@ -32,7 +32,7 @@ jobs:
fetch-depth: 0
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.3.0
uses: actions/setup-python@v5.4.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.3.0
uses: actions/setup-python@v5.4.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.3.0
uses: actions/setup-python@v5.4.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}

View File

@ -40,7 +40,7 @@ env:
CACHE_VERSION: 11
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 9
HA_SHORT_VERSION: "2025.2"
HA_SHORT_VERSION: "2025.3"
DEFAULT_PYTHON: "3.13"
ALL_PYTHON_VERSIONS: "['3.13']"
# 10.3 is the oldest supported version
@ -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.3.0
uses: actions/setup-python@v5.4.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.3.0
uses: actions/setup-python@v5.4.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.3.0
uses: actions/setup-python@v5.4.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.3.0
uses: actions/setup-python@v5.4.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.3.0
uses: actions/setup-python@v5.4.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.3.0
uses: actions/setup-python@v5.4.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.3.0
uses: actions/setup-python@v5.4.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.3.0
uses: actions/setup-python@v5.4.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.3.0
uses: actions/setup-python@v5.4.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.3.0
uses: actions/setup-python@v5.4.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.3.0
uses: actions/setup-python@v5.4.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.3.0
uses: actions/setup-python@v5.4.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.3.0
uses: actions/setup-python@v5.4.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.3.0
uses: actions/setup-python@v5.4.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.3.0
uses: actions/setup-python@v5.4.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
@ -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.3.0
uses: actions/setup-python@v5.4.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true

View File

@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Initialize CodeQL
uses: github/codeql-action/init@v3.28.5
uses: github/codeql-action/init@v3.28.8
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3.28.5
uses: github/codeql-action/analyze@v3.28.8
with:
category: "/language:python"

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.3.0
uses: actions/setup-python@v5.4.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}

View File

@ -36,7 +36,7 @@ jobs:
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.3.0
uses: actions/setup-python@v5.4.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@ -131,7 +131,7 @@ jobs:
strategy:
fail-fast: false
matrix:
abi: ["cp312", "cp313"]
abi: ["cp313"]
arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps:
- name: Checkout the repository
@ -180,7 +180,7 @@ jobs:
strategy:
fail-fast: false
matrix:
abi: ["cp312", "cp313"]
abi: ["cp313"]
arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps:
- name: Checkout the repository

View File

@ -217,6 +217,7 @@ 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.*
@ -227,6 +228,7 @@ 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.*
@ -359,6 +361,7 @@ 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.*

4
CODEOWNERS generated
View File

@ -566,6 +566,8 @@ 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
@ -1071,6 +1073,8 @@ 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

View File

@ -21,7 +21,7 @@ import voluptuous as vol
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.network import is_cloud_connection
from .. import InvalidAuthError

View File

@ -18,6 +18,7 @@ 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",
@ -62,7 +63,10 @@ def restore_backup_file_content(config_dir: Path) -> RestoreBackupFileContent |
restore_database=instruction_content["restore_database"],
restore_homeassistant=instruction_content["restore_homeassistant"],
)
except (FileNotFoundError, KeyError, json.JSONDecodeError):
except FileNotFoundError:
return None
except (KeyError, json.JSONDecodeError) as err:
_write_restore_result_file(config_dir, False, err)
return None
finally:
# Always remove the backup instruction file to prevent a boot loop
@ -142,6 +146,7 @@ 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:
@ -159,6 +164,23 @@ def _extract_backup(
)
def _write_restore_result_file(
config_dir: Path, success: bool, error: Exception | None
) -> None:
"""Write the restore result file."""
result_path = config_dir.joinpath(RESTORE_BACKUP_RESULT_FILE)
result_path.write_text(
json.dumps(
{
"success": success,
"error": str(error) if error else None,
"error_type": str(type(error).__name__) if error else None,
}
),
encoding="utf-8",
)
def restore_backup(config_dir_path: str) -> bool:
"""Restore the backup file if any.
@ -177,7 +199,14 @@ def restore_backup(config_dir_path: str) -> bool:
restore_content=restore_content,
)
except FileNotFoundError as err:
raise ValueError(f"Backup file {backup_file_path} does not exist") from err
file_not_found = ValueError(f"Backup file {backup_file_path} does not exist")
_write_restore_result_file(config_dir, False, file_not_found)
raise file_not_found from err
except Exception as err:
_write_restore_result_file(config_dir, False, err)
raise
else:
_write_restore_result_file(config_dir, True, None)
if restore_content.remove_after_restore:
backup_file_path.unlink(missing_ok=True)
_LOGGER.info("Restore complete, restarting")

View File

@ -5,6 +5,7 @@
"google_assistant",
"google_assistant_sdk",
"google_cloud",
"google_drive",
"google_generative_ai_conversation",
"google_mail",
"google_maps",

View File

@ -11,6 +11,7 @@
"microsoft_face",
"microsoft",
"msteams",
"onedrive",
"xbox"
]
}

View File

@ -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

View File

@ -22,7 +22,7 @@ from homeassistant.const import (
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType

View File

@ -3,7 +3,7 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
import homeassistant.helpers.entity_registry as er
from homeassistant.helpers import entity_registry as er
from .hub import PulseHub

View File

@ -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
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import LEASES_REGEX

View File

@ -12,7 +12,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import HomeAssistant, ServiceCall
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import CONF_ADS_VAR, DATA_ADS, DOMAIN, AdsType

View File

@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType

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
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType

View File

@ -15,7 +15,7 @@ from homeassistant.components.light import (
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType

View File

@ -11,7 +11,7 @@ from homeassistant.components.select import (
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType

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
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType

View File

@ -13,7 +13,7 @@ from homeassistant.components.switch import (
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType

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
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType

View File

@ -7,7 +7,7 @@ from typing import Final
import voluptuous as vol
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
DOMAIN: Final = "aftership"

View File

@ -9,7 +9,7 @@ from pyaftership import AfterShip, AfterShipException
from homeassistant.components.sensor import SensorEntity
from homeassistant.core import HomeAssistant, ServiceCall
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,

View File

@ -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

View File

@ -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

View File

@ -24,7 +24,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.config_validation import make_entity_service_schema
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent

View File

@ -23,8 +23,7 @@ from homeassistant.const import (
SERVICE_ALARM_TRIGGER,
)
from homeassistant.core import Context, HomeAssistant
from homeassistant.helpers import entity_registry as er
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity import get_supported_features
from homeassistant.helpers.typing import ConfigType, TemplateVarsType

View File

@ -12,8 +12,7 @@ from homeassistant.components.alarm_control_panel import (
)
from homeassistant.const import ATTR_CODE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_platform
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback

View File

@ -15,7 +15,7 @@ from homeassistant.const import (
STATE_ON,
)
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType

View File

@ -50,8 +50,7 @@ from homeassistant.const import (
UnitOfVolume,
)
from homeassistant.core import HomeAssistant, State
import homeassistant.util.color as color_util
import homeassistant.util.dt as dt_util
from homeassistant.util import color as color_util, dt as dt_util
from .const import (
API_TEMP_UNITS,

View File

@ -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
import homeassistant.util.dt as dt_util
from homeassistant.util import dt as dt_util
from .const import (
API_PASSWORD,

View File

@ -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
import homeassistant.util.dt as dt_util
from homeassistant.util import dt as dt_util
from homeassistant.util.json import JsonObjectType, json_loads_object
from .const import (

View File

@ -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
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType

View File

@ -22,7 +22,7 @@ from homeassistant.generated.amazon_polly import (
SUPPORTED_REGIONS,
SUPPORTED_VOICES,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import (

View File

@ -17,9 +17,8 @@ from homeassistant.const import (
)
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
import homeassistant.helpers.device_registry as dr
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_send
import homeassistant.helpers.entity_registry as er
from .const import (
ATTR_LAST_DATA,

View File

@ -37,8 +37,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import Unauthorized, UnknownUser
from homeassistant.helpers import discovery
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv, discovery
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

View File

@ -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

View File

@ -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
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.event import async_call_later, async_track_time_interval
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey

View File

@ -11,6 +11,7 @@ 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
@ -22,13 +23,12 @@ 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

View File

@ -15,7 +15,7 @@ from homeassistant.components.switch import (
)
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle

View File

@ -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
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import format_mac
from .const import DEFAULT_NAME, DEFAULT_PORT, DEVICE_TIMEOUT_SECONDS, DOMAIN

View File

@ -18,7 +18,7 @@ from homeassistant.const import (
EVENT_STATE_CHANGED,
)
from homeassistant.core import Event, EventStateChangedData, HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entityfilter import FILTER_SCHEMA, EntityFilter
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import ssl as ssl_util

View File

@ -10,8 +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 selector
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv, selector
from .const import CONNECTION_TIMEOUT, DOMAIN
from .coordinator import APCUPSdData

View File

@ -11,6 +11,7 @@ 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 (
@ -36,7 +37,6 @@ 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,

View File

@ -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
import homeassistant.util.dt as dt_util
from homeassistant.util import dt as dt_util
from . import AppleTvConfigEntry, AppleTVManager
from .browse_media import build_app_list

View File

@ -26,8 +26,11 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import collection, config_entry_oauth2_flow
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import (
collection,
config_entry_oauth2_flow,
config_validation as cv,
)
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType, VolDictType
from homeassistant.loader import (

View File

@ -17,7 +17,7 @@ from homeassistant.components.notify import (
)
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
_LOGGER = logging.getLogger(__name__)

View File

@ -10,7 +10,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PORT
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import format_mac
from .const import DOMAIN

View File

@ -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
import homeassistant.helpers.device_registry as dr
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import BaseDataUpdateCoordinatorProtocol

View File

@ -26,7 +26,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import Event, HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import slugify

View File

@ -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

View File

@ -19,7 +19,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType

View File

@ -13,7 +13,7 @@ from homeassistant.components.switch import (
)
from homeassistant.const import CONF_MONITORED_CONDITIONS
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType

View File

@ -24,7 +24,7 @@ from homeassistant.const import (
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType

View File

@ -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
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle

View File

@ -22,7 +22,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import TemplateError
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle

View File

@ -15,7 +15,7 @@ from homeassistant.components.switch import (
)
from homeassistant.const import CONF_NAME, CONF_RESOURCE
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType

View File

@ -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"

View File

@ -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
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)

View File

@ -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
import homeassistant.helpers.device_registry as dr
from homeassistant.helpers import device_registry as dr
from .const import DOMAIN, EVENT_RECORDING

View File

@ -1101,11 +1101,10 @@ class PipelineRun:
"speech", ""
)
chat_session.async_add_message(
conversation.ChatMessage(
conversation.Content(
role="assistant",
agent_id=agent_id,
content=speech,
native=intent_response,
)
)
conversation_result = conversation.ConversationResult(
@ -1123,6 +1122,7 @@ 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", ""

View File

@ -63,6 +63,21 @@ 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())

View File

@ -26,3 +26,6 @@ class AssistSatelliteEntityFeature(IntFlag):
ANNOUNCE = 1
"""Device supports remotely triggered announcements."""
START_CONVERSATION = 2
"""Device supports starting conversations."""

View File

@ -10,7 +10,7 @@ import logging
import time
from typing import Any, Final, Literal, final
from homeassistant.components import media_source, stt, tts
from homeassistant.components import conversation, media_source, stt, tts
from homeassistant.components.assist_pipeline import (
OPTION_PREFERRED,
AudioSettings,
@ -27,6 +27,7 @@ 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
@ -117,6 +118,7 @@ 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
@ -216,6 +218,60 @@ 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
self._extra_system_prompt = None
async def async_start_conversation(
self, start_announcement: AssistSatelliteAnnouncement
) -> None:
"""Start a conversation from the satellite."""
raise NotImplementedError
async def async_accept_pipeline_from_satellite(
self,
audio_stream: AsyncIterable[bytes],
@ -302,6 +358,7 @@ class AssistSatelliteEntity(entity.Entity):
),
start_stage=start_stage,
end_stage=end_stage,
conversation_extra_system_prompt=self._extra_system_prompt,
),
f"{self.entity_id}_pipeline",
)

View File

@ -7,6 +7,9 @@
"services": {
"announce": {
"service": "mdi:bullhorn"
},
"start_conversation": {
"service": "mdi:forum"
}
}
}

View File

@ -14,3 +14,23 @@ 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:

View File

@ -25,6 +25,24 @@
"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."
}
}
}
}
}

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
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType

View File

@ -22,7 +22,7 @@ from homeassistant.const import (
UnitOfPower,
)
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle

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
import homeassistant.util.dt as dt_util
from homeassistant.util import dt as dt_util
from . import AugustConfigEntry, AugustData
from .entity import AugustEntity

View File

@ -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
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.util.hass_dict import HassKey
WS_TYPE_SETUP_MFA = "auth/setup_mfa"

View File

@ -48,8 +48,7 @@ from homeassistant.core import (
valid_entity_id,
)
from homeassistant.exceptions import HomeAssistantError, ServiceNotFound, TemplateError
from homeassistant.helpers import condition
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import condition, config_validation as cv
from homeassistant.helpers.entity import ToggleEntity
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.issue_registry import (

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
import homeassistant.util.color as color_util
from homeassistant.util import color as color_util
def setup_platform(

View File

@ -23,7 +23,7 @@ from homeassistant.const import (
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType

View File

@ -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
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entityfilter import FILTER_SCHEMA, EntityFilter
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.json import JSONEncoder

View File

@ -23,7 +23,7 @@ from homeassistant.components.notify import (
)
from homeassistant.const import CONTENT_TYPE_JSON
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
CONF_CONNECTION_STRING = "connection_string"

View File

@ -26,10 +26,12 @@ from .manager import (
BackupReaderWriterError,
CoreBackupReaderWriter,
CreateBackupEvent,
IdleEvent,
IncorrectPasswordError,
ManagerBackup,
NewBackup,
RestoreBackupEvent,
RestoreBackupState,
WrittenBackup,
)
from .models import AddonInfo, AgentBackup, Folder
@ -47,12 +49,15 @@ __all__ = [
"BackupReaderWriterError",
"CreateBackupEvent",
"Folder",
"IdleEvent",
"IncorrectPasswordError",
"LocalBackupAgent",
"ManagerBackup",
"NewBackup",
"RestoreBackupEvent",
"RestoreBackupState",
"WrittenBackup",
"async_get_manager",
]
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)

View File

@ -10,31 +10,40 @@ 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
from .models import AgentBackup, BackupError
class BackupAgentError(HomeAssistantError):
class BackupAgentError(BackupError):
"""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.name}"
return f"{self.domain}.{self.unique_id}"
@abc.abstractmethod
async def async_download_backup(
@ -91,11 +100,16 @@ class LocalBackupAgent(BackupAgent):
@abc.abstractmethod
def get_backup_path(self, backup_id: str) -> Path:
"""Return the local path to a backup.
"""Return the local path to an existing backup.
The method should return the path to the backup file with the specified id.
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."""

View File

@ -11,7 +11,7 @@ from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.helpers.hassio import is_hassio
from .agent import BackupAgent, LocalBackupAgent
from .agent import BackupAgent, BackupNotFound, LocalBackupAgent
from .const import DOMAIN, LOGGER
from .models import AgentBackup
from .util import read_backup
@ -32,13 +32,14 @@ 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, AgentBackup] = {}
self._backups: dict[str, tuple[AgentBackup, Path]] = {}
self._loaded_backups = False
async def _load_backups(self) -> None:
@ -48,13 +49,13 @@ class CoreLocalBackupAgent(LocalBackupAgent):
self._backups = backups
self._loaded_backups = True
def _read_backups(self) -> dict[str, AgentBackup]:
def _read_backups(self) -> dict[str, tuple[AgentBackup, Path]]:
"""Read backups from disk."""
backups: dict[str, AgentBackup] = {}
backups: dict[str, tuple[AgentBackup, Path]] = {}
for backup_path in self._backup_dir.glob("*.tar"):
try:
backup = read_backup(backup_path)
backups[backup.backup_id] = backup
backups[backup.backup_id] = (backup, backup_path)
except (OSError, TarError, json.JSONDecodeError, KeyError) as err:
LOGGER.warning("Unable to read backup %s: %s", backup_path, err)
return backups
@ -75,13 +76,13 @@ class CoreLocalBackupAgent(LocalBackupAgent):
**kwargs: Any,
) -> None:
"""Upload a backup."""
self._backups[backup.backup_id] = backup
self._backups[backup.backup_id] = (backup, self.get_new_backup_path(backup))
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
"""List backups."""
if not self._loaded_backups:
await self._load_backups()
return list(self._backups.values())
return [backup for backup, _ in self._backups.values()]
async def async_get_backup(
self,
@ -92,10 +93,10 @@ class CoreLocalBackupAgent(LocalBackupAgent):
if not self._loaded_backups:
await self._load_backups()
if not (backup := self._backups.get(backup_id)):
if backup_id not in self._backups:
return None
backup_path = self.get_backup_path(backup_id)
backup, backup_path = self._backups[backup_id]
if not await self._hass.async_add_executor_job(backup_path.exists):
LOGGER.debug(
(
@ -111,15 +112,28 @@ class CoreLocalBackupAgent(LocalBackupAgent):
return backup
def get_backup_path(self, backup_id: str) -> Path:
"""Return the local path to a backup."""
return self._backup_dir / f"{backup_id}.tar"
"""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 / f"{backup.backup_id}.tar"
async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None:
"""Delete a backup file."""
if await self.async_get_backup(backup_id) is None:
return
if not self._loaded_backups:
await self._load_backups()
backup_path = self.get_backup_path(backup_id)
try:
backup_path = self.get_backup_path(backup_id)
except BackupNotFound:
return
await self._hass.async_add_executor_job(backup_path.unlink, True)
LOGGER.debug("Deleted backup located at %s", backup_path)
self._backups.pop(backup_id)

View File

@ -40,6 +40,7 @@ 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
@ -51,6 +52,7 @@ 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,6 +86,10 @@ 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"],
@ -120,6 +126,9 @@ 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,
@ -134,6 +143,7 @@ class BackupConfig:
def __init__(self, hass: HomeAssistant, manager: BackupManager) -> None:
"""Initialize backup config."""
self.data = BackupConfigData(
agents={},
create_backup=CreateBackupConfig(),
retention=RetentionConfig(),
schedule=BackupSchedule(),
@ -149,11 +159,20 @@ 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:
@ -170,6 +189,31 @@ 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."""

View File

@ -69,7 +69,7 @@ class DownloadBackupView(HomeAssistantView):
CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar"
}
if not password:
if not password or not backup.protected:
return await self._send_backup_no_password(
request, headers, backup_id, agent_id, agent, manager
)
@ -123,13 +123,13 @@ class DownloadBackupView(HomeAssistantView):
worker_done_event = asyncio.Event()
def on_done() -> None:
def on_done(error: Exception | None) -> None:
"""Call by the worker thread when it's done."""
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]
target=util.decrypt_backup, args=[reader, stream, password, on_done, 0, []]
)
try:
worker.start()
@ -144,13 +144,17 @@ class DownloadBackupView(HomeAssistantView):
class UploadBackupView(HomeAssistantView):
"""Generate backup view."""
"""Upload 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")
@ -161,7 +165,9 @@ class UploadBackupView(HomeAssistantView):
contents = cast(BodyPartReader, await reader.next())
try:
await manager.async_receive_backup(contents=contents, agent_ids=agent_ids)
backup_id = await manager.async_receive_backup(
contents=contents, agent_ids=agent_ids
)
except OSError as err:
return Response(
body=f"Can't write backup file: {err}",
@ -175,4 +181,4 @@ class UploadBackupView(HomeAssistantView):
except asyncio.CancelledError:
return Response(status=HTTPStatus.INTERNAL_SERVER_ERROR)
return Response(status=HTTPStatus.CREATED)
return self.json({"backup_id": backup_id}, status_code=HTTPStatus.CREATED)

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
from dataclasses import dataclass, replace
from enum import StrEnum
import hashlib
import io
@ -19,17 +19,20 @@ 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, password_to_key
from homeassistant.backup_restore import (
RESTORE_BACKUP_FILE,
RESTORE_BACKUP_RESULT_FILE,
password_to_key,
)
from homeassistant.const import __version__ as HAVERSION
from homeassistant.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
from homeassistant.util import dt as dt_util, json as json_util
from . import util as backup_util
from .agent import (
@ -47,10 +50,12 @@ from .const import (
EXCLUDE_FROM_BACKUP,
LOGGER,
)
from .models import AgentBackup, BackupManagerError, Folder
from .models import AgentBackup, BackupError, BackupManagerError, BaseBackup, Folder
from .store import BackupStore
from .util import (
AsyncIteratorReader,
DecryptedBackupStreamer,
EncryptedBackupStreamer,
make_backup_dir,
read_backup,
validate_password,
@ -66,10 +71,18 @@ class NewBackup:
@dataclass(frozen=True, kw_only=True, slots=True)
class ManagerBackup(AgentBackup):
class AgentBackupStatus:
"""Agent specific backup attributes."""
protected: bool
size: int
@dataclass(frozen=True, kw_only=True, slots=True)
class ManagerBackup(BaseBackup):
"""Backup class."""
agent_ids: list[str]
agents: dict[str, AgentBackupStatus]
failed_agent_ids: list[str]
with_automatic_settings: bool | None
@ -171,6 +184,7 @@ class CreateBackupEvent(ManagerStateEvent):
"""Backup in progress."""
manager_state: BackupManagerState = BackupManagerState.CREATE_BACKUP
reason: str | None
stage: CreateBackupStage | None
state: CreateBackupState
@ -180,6 +194,7 @@ class ReceiveBackupEvent(ManagerStateEvent):
"""Backup receive."""
manager_state: BackupManagerState = BackupManagerState.RECEIVE_BACKUP
reason: str | None
stage: ReceiveBackupStage | None
state: ReceiveBackupState
@ -189,6 +204,7 @@ class RestoreBackupEvent(ManagerStateEvent):
"""Backup restore."""
manager_state: BackupManagerState = BackupManagerState.RESTORE_BACKUP
reason: str | None
stage: RestoreBackupStage | None
state: RestoreBackupState
@ -249,20 +265,32 @@ 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(HomeAssistantError):
class BackupReaderWriterError(BackupError):
"""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."
@ -292,6 +320,7 @@ 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:
@ -301,6 +330,10 @@ 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
@ -430,20 +463,61 @@ 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)
sync_backup_results = await asyncio.gather(
*(
self.backup_agents[agent_id].async_upload_backup(
open_stream=open_stream,
backup=backup,
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
)
for agent_id in agent_ids
),
open_stream_func = open_stream
_backup = backup
elif should_encrypt:
# The backup we're uploading is not encrypted, but the agent requires it
LOGGER.debug(
"Uploading encrypted backup %s to agent %s",
backup.backup_id,
agent_id,
)
streamer = EncryptedBackupStreamer(
self.hass, backup, open_stream, password
)
else:
# The backup we're uploading is encrypted, but the agent requires it
# decrypted
LOGGER.debug(
"Uploading decrypted backup %s to agent %s",
backup.backup_id,
agent_id,
)
streamer = DecryptedBackupStreamer(
self.hass, backup, open_stream, password
)
if streamer:
open_stream_func = streamer.open_stream
_backup = replace(
backup, protected=should_encrypt, size=streamer.size()
)
await self.backup_agents[agent_id].async_upload_backup(
open_stream=open_stream_func,
backup=_backup,
)
if streamer:
await streamer.wait()
sync_backup_results = await asyncio.gather(
*(upload_backup_to_agent(agent_id) for agent_id in agent_ids),
return_exceptions=True,
)
for idx, result in enumerate(sync_backup_results):
@ -499,7 +573,7 @@ class BackupManager:
agent_backup, await instance_id.async_get(self.hass)
)
backups[backup_id] = ManagerBackup(
agent_ids=[],
agents={},
addons=agent_backup.addons,
backup_id=backup_id,
date=agent_backup.date,
@ -510,11 +584,12 @@ 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].agent_ids.append(agent_ids[idx])
backups[backup_id].agents[agent_ids[idx]] = AgentBackupStatus(
protected=agent_backup.protected,
size=agent_backup.size,
)
return (backups, agent_errors)
@ -550,7 +625,7 @@ class BackupManager:
result, await instance_id.async_get(self.hass)
)
backup = ManagerBackup(
agent_ids=[],
agents={},
addons=result.addons,
backup_id=result.backup_id,
date=result.date,
@ -561,11 +636,12 @@ 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.agent_ids.append(agent_ids[idx])
backup.agents[agent_ids[idx]] = AgentBackupStatus(
protected=result.protected,
size=result.size,
)
return (backup, agent_errors)
@ -614,24 +690,39 @@ class BackupManager:
*,
agent_ids: list[str],
contents: aiohttp.BodyPartReader,
) -> None:
) -> str:
"""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(stage=None, state=ReceiveBackupState.IN_PROGRESS)
ReceiveBackupEvent(
reason=None,
stage=None,
state=ReceiveBackupState.IN_PROGRESS,
)
)
try:
await self._async_receive_backup(agent_ids=agent_ids, contents=contents)
backup_id = await self._async_receive_backup(
agent_ids=agent_ids, contents=contents
)
except Exception:
self.async_on_backup_event(
ReceiveBackupEvent(stage=None, state=ReceiveBackupState.FAILED)
ReceiveBackupEvent(
reason="unknown_error",
stage=None,
state=ReceiveBackupState.FAILED,
)
)
raise
else:
self.async_on_backup_event(
ReceiveBackupEvent(stage=None, state=ReceiveBackupState.COMPLETED)
ReceiveBackupEvent(
reason=None,
stage=None,
state=ReceiveBackupState.COMPLETED,
)
)
return backup_id
finally:
self.async_on_backup_event(IdleEvent())
@ -640,11 +731,12 @@ class BackupManager:
*,
agent_ids: list[str],
contents: aiohttp.BodyPartReader,
) -> None:
) -> str:
"""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,
)
@ -656,6 +748,7 @@ class BackupManager:
)
self.async_on_backup_event(
ReceiveBackupEvent(
reason=None,
stage=ReceiveBackupStage.UPLOAD_TO_AGENTS,
state=ReceiveBackupState.IN_PROGRESS,
)
@ -664,14 +757,19 @@ 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,
@ -684,6 +782,7 @@ 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,
@ -717,6 +816,7 @@ class BackupManager:
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,
@ -736,11 +836,16 @@ class BackupManager:
self.store.save()
self.async_on_backup_event(
CreateBackupEvent(stage=None, state=CreateBackupState.IN_PROGRESS)
CreateBackupEvent(
reason=None,
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,
@ -751,9 +856,14 @@ class BackupManager:
raise_task_error=raise_task_error,
with_automatic_settings=with_automatic_settings,
)
except Exception:
except Exception as err:
reason = err.error_code if isinstance(err, BackupError) else "unknown_error"
self.async_on_backup_event(
CreateBackupEvent(stage=None, state=CreateBackupState.FAILED)
CreateBackupEvent(
reason=reason,
stage=None,
state=CreateBackupState.FAILED,
)
)
self.async_on_backup_event(IdleEvent())
if with_automatic_settings:
@ -764,6 +874,7 @@ 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,
@ -790,6 +901,7 @@ class BackupManager:
name
or f"{'Automatic' if with_automatic_settings else 'Custom'} backup {HAVERSION}"
)
extra_metadata = extra_metadata or {}
try:
(
@ -798,7 +910,8 @@ 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,
},
@ -814,7 +927,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),
self._async_finish_backup(agent_ids, with_automatic_settings, password),
name="backup_manager_finish_backup",
)
if not raise_task_error:
@ -831,7 +944,7 @@ class BackupManager:
return new_backup
async def _async_finish_backup(
self, agent_ids: list[str], with_automatic_settings: bool
self, agent_ids: list[str], with_automatic_settings: bool, password: str | None
) -> None:
"""Finish a backup."""
if TYPE_CHECKING:
@ -854,6 +967,7 @@ class BackupManager:
)
self.async_on_backup_event(
CreateBackupEvent(
reason=None,
stage=CreateBackupStage.UPLOAD_TO_AGENTS,
state=CreateBackupState.IN_PROGRESS,
)
@ -864,6 +978,7 @@ class BackupManager:
backup=written_backup.backup,
agent_ids=agent_ids,
open_stream=written_backup.open_stream,
password=password,
)
finally:
await written_backup.release_stream()
@ -884,14 +999,22 @@ class BackupManager:
finally:
self._backup_task = None
self._backup_finish_task = None
self.async_on_backup_event(
CreateBackupEvent(
stage=None,
state=CreateBackupState.COMPLETED
if backup_success
else CreateBackupState.FAILED,
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(IdleEvent())
async def async_restore_backup(
@ -910,7 +1033,11 @@ class BackupManager:
raise BackupManagerError(f"Backup manager busy: {self.state}")
self.async_on_backup_event(
RestoreBackupEvent(stage=None, state=RestoreBackupState.IN_PROGRESS)
RestoreBackupEvent(
reason=None,
stage=None,
state=RestoreBackupState.IN_PROGRESS,
)
)
try:
await self._async_restore_backup(
@ -923,11 +1050,28 @@ class BackupManager:
restore_homeassistant=restore_homeassistant,
)
self.async_on_backup_event(
RestoreBackupEvent(stage=None, state=RestoreBackupState.COMPLETED)
RestoreBackupEvent(
reason=None,
stage=None,
state=RestoreBackupState.COMPLETED,
)
)
except BackupError as err:
self.async_on_backup_event(
RestoreBackupEvent(
reason=err.error_code,
stage=None,
state=RestoreBackupState.FAILED,
)
)
raise
except Exception:
self.async_on_backup_event(
RestoreBackupEvent(stage=None, state=RestoreBackupState.FAILED)
RestoreBackupEvent(
reason="unknown_error",
stage=None,
state=RestoreBackupState.FAILED,
)
)
raise
finally:
@ -975,6 +1119,8 @@ 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)
@ -1020,7 +1166,11 @@ class BackupManager:
learn_more_url="homeassistant://config/backup",
severity=ir.IssueSeverity.WARNING,
translation_key="automatic_backup_failed_upload_agents",
translation_placeholders={"failed_agents": ", ".join(agent_errors)},
translation_placeholders={
"failed_agents": ", ".join(
self.backup_agents[agent_id].name for agent_id in agent_errors
)
},
)
async def async_can_decrypt_on_download(
@ -1048,7 +1198,9 @@ class BackupManager:
backup_stream = await agent.async_download_backup(backup_id)
reader = cast(IO[bytes], AsyncIteratorReader(self.hass, backup_stream))
try:
validate_password_stream(reader, password)
await self.hass.async_add_executor_job(
validate_password_stream, reader, password
)
except backup_util.IncorrectPassword as err:
raise IncorrectPasswordError from err
except backup_util.UnsupportedSecureTarVersion as err:
@ -1194,13 +1346,32 @@ 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_backup_path(backup_id)
local_agent_tar_file_path = local_agent.get_new_backup_path(backup)
on_progress(
CreateBackupEvent(
reason=None,
stage=CreateBackupStage.HOME_ASSISTANT,
state=CreateBackupState.IN_PROGRESS,
)
@ -1238,19 +1409,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
# ValueError from json_bytes
raise BackupReaderWriterError(str(err)) from err
else:
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,
)
backup = replace(backup, size=size_in_bytes)
async_add_executor_job = self._hass.async_add_executor_job
@ -1364,7 +1523,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_backup_path(backup.backup_id)
tar_file_path = local_agent.get_new_backup_path(backup)
await async_add_executor_job(make_backup_dir, tar_file_path.parent)
await async_add_executor_job(shutil.move, temp_file, tar_file_path)
else:
@ -1460,10 +1619,62 @@ class CoreBackupReaderWriter(BackupReaderWriter):
await self._hass.async_add_executor_job(_write_restore_file)
on_progress(
RestoreBackupEvent(stage=None, state=RestoreBackupState.CORE_RESTART)
RestoreBackupEvent(
reason=None,
stage=None,
state=RestoreBackupState.CORE_RESTART,
)
)
await self._hass.services.async_call("homeassistant", "restart", blocking=True)
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."""

View File

@ -28,7 +28,7 @@ class Folder(StrEnum):
@dataclass(frozen=True, kw_only=True)
class AgentBackup:
class BaseBackup:
"""Base backup class."""
addons: list[AddonInfo]
@ -40,12 +40,6 @@ class AgentBackup:
homeassistant_included: bool
homeassistant_version: str | None # None if homeassistant_included is False
name: str
protected: bool
size: int
def as_dict(self) -> dict:
"""Return a dict representation of this backup."""
return asdict(self)
def as_frontend_json(self) -> dict:
"""Return a dict representation of this backup for sending to frontend."""
@ -53,6 +47,18 @@ class AgentBackup:
key: val for key, val in asdict(self).items() if key != "extra_metadata"
}
@dataclass(frozen=True, kw_only=True)
class AgentBackup(BaseBackup):
"""Agent backup class."""
protected: bool
size: int
def as_dict(self) -> dict:
"""Return a dict representation of this backup."""
return asdict(self)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Self:
"""Create an instance from a JSON serialization."""
@ -71,5 +77,13 @@ class AgentBackup:
)
class BackupManagerError(HomeAssistantError):
class BackupError(HomeAssistantError):
"""Base class for backup errors."""
error_code = "unknown"
class BackupManagerError(BackupError):
"""Backup manager error."""
error_code = "backup_manager_error"

View File

@ -16,7 +16,7 @@ if TYPE_CHECKING:
STORE_DELAY_SAVE = 30
STORAGE_KEY = DOMAIN
STORAGE_VERSION = 1
STORAGE_VERSION_MINOR = 2
STORAGE_VERSION_MINOR = 3
class StoredBackupData(TypedDict):
@ -47,8 +47,12 @@ class _BackupStore(Store[StoredBackupData]):
"""Migrate to the new version."""
data = old_data
if old_major_version == 1:
if old_minor_version < 2:
# Version 1.2 adds configurable backup time and custom days
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"] = {}
data["config"]["schedule"]["time"] = None
if (state := data["config"]["schedule"]["state"]) in ("daily", "never"):
data["config"]["schedule"]["days"] = []

View File

@ -3,14 +3,16 @@
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Callable
from collections.abc import AsyncIterator, Callable, Coroutine
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, Self, cast
from typing import IO, Any, Self, cast
import aiohttp
from securetar import SecureTarError, SecureTarFile, SecureTarReadError
@ -19,6 +21,7 @@ from homeassistant.backup_restore import password_to_key
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
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
@ -30,6 +33,12 @@ 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."""
@ -48,6 +57,12 @@ 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)
@ -179,6 +194,7 @@ 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:
@ -191,9 +207,14 @@ 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)
@ -230,24 +251,37 @@ def decrypt_backup(
input_stream: IO[bytes],
output_stream: IO[bytes],
password: str | None,
on_done: Callable[[], None],
on_done: Callable[[Exception | None], None],
minimum_size: int,
nonces: list[bytes],
) -> None:
"""Decrypt a backup."""
error: Exception | None = None
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)
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")
finally:
output_stream.write(b"") # Write an empty chunk to signal the end of the stream
on_done()
on_done(error)
def _decrypt_backup(
@ -288,6 +322,189 @@ 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:

View File

@ -60,8 +60,10 @@ 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,
},
)
@ -198,7 +200,7 @@ async def handle_can_decrypt_on_download(
vol.Optional("include_folders"): [vol.Coerce(Folder)],
vol.Optional("include_homeassistant", default=True): bool,
vol.Optional("name"): str,
vol.Optional("password"): str,
vol.Optional("password"): vol.Any(str, None),
}
)
@websocket_api.async_response
@ -306,7 +308,10 @@ async def backup_agents_info(
connection.send_result(
msg["id"],
{
"agents": [{"agent_id": agent_id} for agent_id in manager.backup_agents],
"agents": [
{"agent_id": agent.agent_id, "name": agent.name}
for agent in manager.backup_agents.values()
],
},
)
@ -341,6 +346,7 @@ async def handle_config_info(
@websocket_api.websocket_command(
{
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()),

View File

@ -11,7 +11,7 @@ from homeassistant.components.tts import (
Provider,
)
from homeassistant.const import CONF_API_KEY
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
_LOGGER = logging.getLogger(__name__)

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
import homeassistant.util.dt as dt_util
from homeassistant.util import dt as dt_util
from .const import CONF_SYNC_TIME, DEFAULT_SYNC_TIME

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
from homeassistant.const import CONF_HOST, CONF_MODEL
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import format_mac
@ -18,6 +18,7 @@ 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
@ -55,7 +56,8 @@ class BalboaSpaClientFlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1
_host: str | None
_host: str
_model: str
@staticmethod
@callback
@ -63,6 +65,43 @@ 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:
@ -78,7 +117,9 @@ class BalboaSpaClientFlowHandler(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(info["formatted_mac"])
await self.async_set_unique_id(
info["formatted_mac"], raise_on_progress=False
)
self._abort_if_unique_id_configured()
return self.async_create_entry(title=info["title"], data=user_input)

View File

@ -3,6 +3,14 @@
"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"],

View File

@ -1,5 +1,6 @@
{
"config": {
"flow_title": "{model}",
"step": {
"user": {
"description": "Connect to the Balboa Wi-Fi device",
@ -9,6 +10,9 @@
"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": {

View File

@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_MODEL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
import homeassistant.helpers.device_registry as dr
from homeassistant.helpers import device_registry as dr
from homeassistant.util.ssl import get_default_context
from .const import DOMAIN

View File

@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Any
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
from homeassistant.core import HomeAssistant
import homeassistant.helpers.entity_registry as er
from homeassistant.helpers import entity_registry as er
from . import BangOlufsenConfigEntry
from .const import DOMAIN

View File

@ -31,8 +31,7 @@ from homeassistant.const import (
)
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
from homeassistant.exceptions import ConditionError, TemplateError
from homeassistant.helpers import condition
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import condition, config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import (
TrackTemplate,

View File

@ -16,10 +16,9 @@ from homeassistant.components.device_tracker import (
)
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import Throttle
import homeassistant.util.dt as dt_util
from homeassistant.util import Throttle, dt as dt_util
_LOGGER = logging.getLogger(__name__)

View File

@ -18,7 +18,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_MONITORED_VARIABLES, CONF_NAME, UnitOfDataRate
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle

View File

@ -12,7 +12,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_MAC, CONF_NAME, PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType

View File

@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_CURRENCY, CONF_DISPLAY_OPTIONS, UnitOfTime
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType

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