429 files

This commit is contained in:
Home Assistant Version Control
2026-08-30 02:43:31 +00:00
parent 472c19bceb
commit 963cce81a7
566 changed files with 50311 additions and 47616 deletions
@@ -5,8 +5,11 @@ import json
from json import JSONDecodeError
import logging
import os
from pathlib import Path
import shutil
import tempfile
from typing import Any, NotRequired, TypedDict, cast
from urllib.parse import urlsplit
import aiohttp
from aiohttp import ClientError
@@ -33,6 +36,106 @@ ENDPOINT_LIBRARY = f"{API_URL}/library"
ENDPOINT_DOWNLOAD = f"{API_URL}/download"
TIMEOUT_SECONDS = 30
MODEL_JSON_RETRY_LIMIT = 2
ALLOWED_RESOURCE_HOSTS = frozenset({"github.com", "raw.githubusercontent.com"})
def _validate_resource_url(url: object) -> str:
"""Validate that a resource URL points to an allowed HTTPS host."""
if not isinstance(url, str):
raise ProfileDownloadError("Remote profile resource has an invalid URL")
try:
parsed_url = urlsplit(url)
is_allowed = (
parsed_url.scheme == "https"
and parsed_url.hostname in ALLOWED_RESOURCE_HOSTS
and parsed_url.username is None
and parsed_url.password is None
and parsed_url.port in (None, 443)
)
except ValueError as err:
raise ProfileDownloadError(f"Remote profile resource has an invalid URL: {url}") from err
if not is_allowed:
raise ProfileDownloadError(f"Remote profile resource URL is not allowed: {url}")
return url
def _resolve_resource_path(storage_path: str, resource_path: object) -> Path:
"""Resolve and validate a resource path within the profile storage directory."""
if not isinstance(resource_path, str) or not resource_path or "\0" in resource_path:
raise ProfileDownloadError("Remote profile resource has an invalid path")
relative_path = Path(resource_path)
if relative_path.is_absolute():
raise ProfileDownloadError(f"Remote profile resource path is not allowed: {resource_path}")
try:
storage_directory = Path(storage_path).resolve()
destination = (storage_directory / relative_path).resolve()
except (OSError, ValueError) as err:
raise ProfileDownloadError(f"Remote profile resource has an invalid path: {resource_path}") from err
if destination == storage_directory or not destination.is_relative_to(storage_directory):
raise ProfileDownloadError(f"Remote profile resource path is not allowed: {resource_path}")
return destination
def _validate_resources(resources: object, storage_path: str) -> list[tuple[str, Path]]:
"""Validate all resources in a remote profile response."""
if not isinstance(resources, list) or not all(isinstance(resource, dict) for resource in resources):
raise ProfileDownloadError("Remote profile response contains invalid resources")
return [
(_validate_resource_url(resource.get("url")), _resolve_resource_path(storage_path, resource.get("path")))
for resource in resources
]
def _sync_directory(directory: Path) -> None:
"""Persist a directory entry, so a completed rename survives an unclean shutdown."""
try:
directory_descriptor = os.open(directory, os.O_RDONLY)
except OSError: # pragma: no cover - directories cannot be opened on all platforms
return
try:
os.fsync(directory_descriptor)
except OSError: # pragma: no cover - directory fsync is not supported on all platforms
pass
finally:
os.close(directory_descriptor)
def _save_resource(data: bytes, path: Path) -> None:
"""Atomically save a downloaded resource to the local profile storage directory.
The contents are flushed to disk before the rename, and the directory entry is flushed
after it. Without both, a power loss shortly after an update can leave the new file name
pointing at unwritten data, which is how a cached profile ends up as invalid JSON.
"""
os.makedirs(path.parent, exist_ok=True)
file_descriptor, temporary_name = tempfile.mkstemp(
dir=path.parent,
prefix=f".{path.name}.",
suffix=".tmp",
)
temporary_path = Path(temporary_name)
try:
with os.fdopen(file_descriptor, "wb") as file_handle:
file_handle.write(data)
file_handle.flush()
os.fsync(file_handle.fileno())
os.replace(temporary_path, path)
_sync_directory(path.parent)
finally:
temporary_path.unlink(missing_ok=True)
def _save_resources(resources: list[tuple[bytes, Path]]) -> None:
"""Save all downloaded resources after every response has completed successfully."""
for data, path in resources:
_save_resource(data, path)
class LibraryModel(TypedDict):
@@ -64,6 +167,7 @@ class RemoteLoader(Loader):
self.model_lookup: dict[str, dict[str, list[LibraryModel]]] = {}
self.manufacturer_lookup: dict[str, set[str]] = {}
self.profile_hashes: dict[str, str] = {}
self._model_load_locks: dict[tuple[str, str], asyncio.Lock] = {}
async def initialize(self, prefer_cached: bool = False) -> None:
"""Initialize the loader.
@@ -188,18 +292,26 @@ class RemoteLoader(Loader):
return str(self.hass.config.path(STORAGE_DIR, BUILT_IN_LIBRARY_DIR, "library.json"))
def _read_local_library_json(self) -> dict[str, Any] | None:
"""Read library.json from local storage, None when it has not been downloaded yet."""
"""Read library.json from local storage, None when it is missing or unusable.
A truncated or unreadable copy is reported as absent rather than raised, so the caller
falls through to a fresh download instead of failing setup on every restart.
"""
local_path = self._get_library_json_path()
if not os.path.exists(local_path):
return None
with open(local_path) as f:
return cast(dict[str, Any], json.load(f))
try:
with open(local_path) as f:
return cast(dict[str, Any], json.load(f))
except (JSONDecodeError, OSError) as err:
_LOGGER.warning("Local library.json is unusable (%s), discarding it and downloading a fresh copy", err)
return None
def _load_local_library_json(self) -> dict[str, Any]:
"""Load library.json from local storage, raising when it is not there."""
"""Load library.json from local storage, raising when it is not usable."""
library_json = self._read_local_library_json()
if library_json is None:
raise ProfileDownloadError("Local library.json file not found")
raise ProfileDownloadError("Local library.json file not found or unusable")
return library_json
async def _download_remote_library_json(self) -> dict[str, Any] | None:
@@ -224,12 +336,7 @@ class RemoteLoader(Loader):
except (TimeoutError, ClientError) as err:
raise ProfileDownloadError(f"Failed to download library.json: {err}") from err
def _save_to_local_storage(data: bytes) -> None:
os.makedirs(os.path.dirname(local_path), exist_ok=True)
with open(local_path, "wb") as f:
f.write(data)
await self.hass.async_add_executor_job(_save_to_local_storage, data)
await self.hass.async_add_executor_job(_save_resource, data, Path(local_path))
return cast(dict[str, Any], json.loads(data))
@@ -347,19 +454,51 @@ class RemoteLoader(Loader):
retry_count: int = 0,
) -> tuple[dict[str, Any], str] | None:
"""Load a model, downloading it if necessary, with retry logic."""
lock = self._model_load_locks.setdefault((manufacturer, model), asyncio.Lock())
async with lock:
return await self._load_model_locked(manufacturer, model, force_update, retry_count)
async def _load_model_locked(
self,
manufacturer: str,
model: str,
force_update: bool,
retry_count: int,
) -> tuple[dict[str, Any], str] | None:
"""Load a model while holding its per-profile lock."""
model_info = self._get_library_model(manufacturer, model)
storage_path = self.get_storage_path(manufacturer, model)
model_path = os.path.join(storage_path, "model.json")
if await self._needs_update(model_info, manufacturer, model, model_path, force_update):
await self._download_profile_with_retry(manufacturer, model, storage_path, model_path)
while True:
if await self._needs_update(model_info, manufacturer, model, model_path, force_update):
await self._download_profile_with_retry(manufacturer, model, storage_path, model_path)
try:
json_data = await self._load_model_json(model_path)
except JSONDecodeError as e:
return await self._handle_json_decode_error(e, manufacturer, model, retry_count)
try:
json_data = await self._load_model_json(model_path)
except JSONDecodeError as error:
if retry_count >= MODEL_JSON_RETRY_LIMIT:
_LOGGER.error(
"model.json remains invalid after %d redownload attempts for manufacturer: %s, model: %s",
MODEL_JSON_RETRY_LIMIT,
manufacturer,
model,
)
raise LibraryLoadingError("Failed to load model.json file") from error
return json_data, storage_path
retry_count += 1
force_update = True
_LOGGER.warning(
"model.json is not valid JSON for manufacturer: %s, model: %s; redownloading profile "
"(attempt %d of %d)",
manufacturer,
model,
retry_count,
MODEL_JSON_RETRY_LIMIT,
)
continue
return json_data, storage_path
def _get_library_model(self, manufacturer: str, model: str) -> LibraryModel:
"""Retrieve model info, or raise an error if not found."""
@@ -402,7 +541,7 @@ class RemoteLoader(Loader):
callback = partial(self.download_profile, manufacturer, model, storage_path, model_hash)
await self.download_with_retry(callback)
self.profile_hashes[f"{manufacturer}/{model}"] = model_hash
await self.hass.async_add_executor_job(self._write_profile_hashes, self.profile_hashes)
await self.hass.async_add_executor_job(self._write_profile_hashes, dict(self.profile_hashes))
except ProfileDownloadError as e:
path_exists, storage_path_exists = await self.hass.async_add_executor_job(
self._profile_paths_exist,
@@ -429,20 +568,6 @@ class RemoteLoader(Loader):
return await self.hass.async_add_executor_job(_load_json)
async def _handle_json_decode_error(
self,
error: JSONDecodeError,
manufacturer: str,
model: str,
retry_count: int,
) -> tuple[dict[str, Any], str] | None:
"""Handle JSON decode errors with retry logic."""
_LOGGER.error("model.json file is not valid JSON for manufacturer: %s, model: %s", manufacturer, model)
if retry_count < 2:
_LOGGER.debug("Retrying to load model.json file")
return await self.load_model(manufacturer, model, True, retry_count + 1)
raise LibraryLoadingError("Failed to load model.json file") from error
def get_storage_path(self, manufacturer: str, model: str) -> str:
"""Retrieve the storage path for a given manufacturer and model."""
return str(self.hass.config.path(STORAGE_DIR, BUILT_IN_LIBRARY_DIR, manufacturer, model))
@@ -480,13 +605,6 @@ class RemoteLoader(Loader):
endpoint = f"{ENDPOINT_DOWNLOAD}/{manufacturer}/{model}"
def _save_file(data: bytes, directory: str) -> None:
"""Save file from Github to local storage directory"""
path = os.path.join(storage_path, directory)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
f.write(data)
session = async_get_clientsession(self.hass)
try:
@@ -496,17 +614,25 @@ class RemoteLoader(Loader):
raise ProfileDownloadError(f"Failed to download profile: {manufacturer}/{model}")
resources = await resp.json()
validated_resources = await self.hass.async_add_executor_job(
_validate_resources,
resources,
storage_path,
)
await self.hass.async_add_executor_job(lambda: os.makedirs(storage_path, exist_ok=True))
# Download the files
for resource in resources:
url = resource.get("url")
async with session.get(url) as resp:
downloaded_resources: list[tuple[bytes, Path]] = []
for url, destination in validated_resources:
async with session.get(url, allow_redirects=False) as resp:
if resp.status != 200:
raise ProfileDownloadError(f"Failed to download github URL: {url}")
contents = await resp.read()
await self.hass.async_add_executor_job(_save_file, contents, resource.get("path"))
downloaded_resources.append((contents, destination))
await self.hass.async_add_executor_job(_save_resources, downloaded_resources)
except (TimeoutError, aiohttp.ClientError) as e:
raise ProfileDownloadError(f"Failed to download profile: {manufacturer}/{model}") from e
@@ -515,18 +641,25 @@ class RemoteLoader(Loader):
return str(self.hass.config.path(STORAGE_DIR, BUILT_IN_LIBRARY_DIR, ".profile_hashes"))
def _load_profile_hashes(self) -> dict[str, str]:
"""Load profile hashes from local storage"""
"""Load profile hashes from local storage.
An unusable file is treated as empty rather than raised: the hashes are only a cache
validity marker, so the worst case is that every profile is downloaded once more.
"""
path = self._get_profile_hashes_path()
if not os.path.exists(path):
return {}
with open(path) as f:
return json.load(f) # type: ignore[no-any-return]
try:
with open(path) as f:
return cast(dict[str, str], json.load(f))
except (JSONDecodeError, OSError) as err:
_LOGGER.warning("Profile hashes file is unusable (%s), profiles will be downloaded again", err)
return {}
def _write_profile_hashes(self, hashes: dict[str, str]) -> None:
"""Write profile hashes to local storage"""
"""Write profile hashes to local storage, atomically."""
path = self._get_profile_hashes_path()
with open(path, "w") as json_file:
json.dump(hashes, json_file, indent=4)
_save_resource(json.dumps(hashes, indent=4).encode(), Path(path))
@@ -69,6 +69,7 @@ class DiscoveryBy(StrEnum):
CONFIG_ENTRY = "config_entry"
DEVICE = "device"
ENTITY = "entity"
MANUAL = "manual"
@dataclass(frozen=True)
@@ -3,6 +3,7 @@ import re
from typing import Any, NamedTuple, Protocol
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.device_registry import DeviceEntry
from custom_components.powercalc.common import SourceEntity
from custom_components.powercalc.errors import PowercalcSetupError
@@ -197,7 +198,7 @@ class ModelIdMatcher(SubProfileMatcher):
def match(self, entity_state: State, source_entity: SourceEntity) -> str | None:
device_entry = source_entity.device_entry
if not device_entry:
if not isinstance(device_entry, DeviceEntry):
return None
if device_entry.model_id == self._model_id: