393 files

This commit is contained in:
Home Assistant Version Control committed 2026-09-27 17:16:54 +00:00
1 parent b05b7a897e
commit 39d97ac2db
404 files changed
+54006 -11034

No files matched your search

@@ -1,6 +1,8 @@
import asyncio
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from functools import partial
import gzip
import json
from json import JSONDecodeError
import logging
@@ -28,6 +30,7 @@ from custom_components.powercalc.helpers import async_cache, clear_async_cache
from custom_components.powercalc.power_profile.error import LibraryLoadingError, ProfileDownloadError
from custom_components.powercalc.power_profile.loader.profile_cache import (
InstalledProfile,
compress_installed_profile_csv_files,
create_staging_directory,
install_profile,
read_installed_profile,
@@ -58,6 +61,15 @@ MAX_PROFILE_DOWNLOAD_SIZE = 25 * 1024 * 1024
DOWNLOAD_CHUNK_SIZE = 64 * 1024
@dataclass(frozen=True)
class RemoteResource:
"""A validated remote resource and its normalized local destination."""
url: str
destination: Path
compress: bool
def _is_library_repository_url(parsed_url: SplitResult) -> bool:
"""Check that a resource URL addresses the profile library of the Powercalc repository."""
segments = [segment for segment in unquote(parsed_url.path).split("/") if segment]
@@ -173,7 +185,7 @@ def _resolve_resource_path(storage_path: str, resource_path: object) -> Path:
return destination
def _validate_resources(resources: object, storage_path: str) -> list[tuple[str, Path]]:
def _validate_resources(resources: object, storage_path: str) -> list[RemoteResource]:
"""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")
@@ -182,16 +194,35 @@ def _validate_resources(resources: object, storage_path: str) -> list[tuple[str,
f"Remote profile contains more than the maximum of {MAX_PROFILE_RESOURCES} resources",
)
return [
(_validate_resource_url(resource.get("url")), _resolve_resource_path(storage_path, resource.get("path")))
for resource in resources
]
resources_by_destination: dict[Path, RemoteResource] = {}
for resource in resources:
destination = _resolve_resource_path(storage_path, resource.get("path"))
compress = destination.suffix == ".csv"
if compress:
destination = destination.with_name(f"{destination.name}.gz")
validated_resource = RemoteResource(
url=_validate_resource_url(resource.get("url")),
destination=destination,
compress=compress,
)
existing_resource = resources_by_destination.get(destination)
if existing_resource is not None and existing_resource.compress == compress:
raise ProfileDownloadError(f"Remote profile contains duplicate resource path: {destination.name}")
if existing_resource is None or existing_resource.compress:
resources_by_destination[destination] = validated_resource
return list(resources_by_destination.values())
def _save_resources(resources: list[tuple[bytes, Path]]) -> None:
def _save_resources(resources: list[tuple[bytes, RemoteResource]]) -> None:
"""Save all downloaded resources after every response has completed successfully."""
for data, path in resources:
save_resource(data, path)
prepared_resources = [
(gzip.compress(data, mtime=0) if resource.compress else data, resource.destination)
for data, resource in resources
]
for data, destination in prepared_resources:
save_resource(data, destination)
class LibraryModel(TypedDict):
@@ -269,7 +300,7 @@ class RemoteLoader(Loader):
if (manufacturer, model) not in models:
continue
key = f"{manufacturer}/{model}"
profile = read_installed_profile(
profile = self._read_and_compress_installed_profile(
Path(self.get_storage_path(manufacturer, model)),
model,
self.profile_hashes.get(key),
@@ -279,6 +310,23 @@ class RemoteLoader(Loader):
installed[key] = profile
return installed
@staticmethod
def _read_and_compress_installed_profile(
storage: Path,
model_id: str,
legacy_hash: str | None,
version: AwesomeVersion,
) -> InstalledProfile | None:
"""Read an installed profile and migrate its plain CSV resources to gzip."""
installed = read_installed_profile(storage, model_id, legacy_hash, version)
if installed is None:
return None
try:
compress_installed_profile_csv_files(installed)
except (OSError, ValueError, KeyError, TypeError, JSONDecodeError) as err:
_LOGGER.warning("Could not compress cached CSV files for %s: %s", storage, err)
return installed
def get_discovery_low_priority_domains(self) -> set[str]:
"""Get the low priority discovery integration domains declared by library metadata."""
return set(self.library_contents.get(LIBRARY_DISCOVERY_LOW_PRIORITY_DOMAINS, []))
@@ -566,7 +614,7 @@ class RemoteLoader(Loader):
raise LibraryLoadingError(f"Profile {key} requires Powercalc {model_info['min_version']}")
storage_path = self.get_storage_path(manufacturer, model)
installed = await self.hass.async_add_executor_job(
read_installed_profile,
self._read_and_compress_installed_profile,
Path(storage_path),
model,
self.profile_hashes.get(key),
@@ -713,12 +761,12 @@ class RemoteLoader(Loader):
await self.hass.async_add_executor_job(lambda: os.makedirs(storage_path, exist_ok=True))
# Download the files
downloaded_resources: list[tuple[bytes, Path]] = []
downloaded_resources: list[tuple[bytes, RemoteResource]] = []
downloaded_size = 0
for url, destination in validated_resources:
async with session.get(url, allow_redirects=False) as resp:
for resource in validated_resources:
async with session.get(resource.url, allow_redirects=False) as resp:
if resp.status != 200:
raise ProfileDownloadError(f"Failed to download github URL: {url}")
raise ProfileDownloadError(f"Failed to download github URL: {resource.url}")
remaining_size = MAX_PROFILE_DOWNLOAD_SIZE - downloaded_size
if remaining_size <= 0:
@@ -729,7 +777,7 @@ class RemoteLoader(Loader):
contents = await _read_capped(
resp,
min(MAX_RESOURCE_SIZE, remaining_size),
f"Remote profile resource {url}",
f"Remote profile resource {resource.url}",
)
except ProfileDownloadError as err:
if remaining_size < MAX_RESOURCE_SIZE:
@@ -738,7 +786,7 @@ class RemoteLoader(Loader):
) from err
raise
downloaded_size += len(contents)
downloaded_resources.append((contents, destination))
downloaded_resources.append((contents, resource))
await self.hass.async_add_executor_job(_save_resources, downloaded_resources)
except (TimeoutError, aiohttp.ClientError) as e:
@@ -2,10 +2,11 @@ from collections import defaultdict
from collections.abc import Mapping
from copy import deepcopy
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from enum import StrEnum
import logging
import os
from typing import Any, cast
from typing import Any, TypedDict, cast
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
@@ -32,6 +33,7 @@ from custom_components.powercalc.const import (
CONF_ENERGY_SENSOR_NAMING,
CONF_MAX_POWER,
CONF_MIN_POWER,
CONF_MULTIPLY_FACTOR,
CONF_POWER,
CONF_POWER_SENSOR_NAMING,
DEFAULT_SELF_USAGE_ENERGY_NAME_PATTERN,
@@ -42,6 +44,7 @@ from custom_components.powercalc.const import (
)
from custom_components.powercalc.errors import (
ModelNotSupportedError,
StrategyConfigurationError,
UnsupportedStrategyError,
)
from custom_components.powercalc.power_profile.sub_profile_selector import SubProfileSelectConfig
@@ -80,6 +83,12 @@ class DiscoveryBy(StrEnum):
MANUAL = "manual"
class EntityAutoSelectConfig(TypedDict, total=False):
integration: str
translation_key: str
unique_id_pattern: str
@dataclass(frozen=True)
class CustomField:
key: str
@@ -87,6 +96,7 @@ class CustomField:
selector: dict[str, Any]
description: str | None = None
default: Any = None
auto_select: EntityAutoSelectConfig | None = None
DEVICE_TYPE_DOMAIN: dict[DeviceType, str | set[str]] = {
@@ -212,6 +222,21 @@ class PowerProfile:
return self.standby_power
return standby_power_on or 0
@property
def multiply_factor(self) -> Decimal | None:
"""Get the default multiplier after profile variables have been substituted."""
value = self._json_data.get(CONF_MULTIPLY_FACTOR)
if value is None:
return None
message = f"Invalid multiply_factor {value!r} for {self.manufacturer}/{self.model}: expected a finite number"
try:
factor = Decimal(str(value))
except InvalidOperation as err:
raise StrategyConfigurationError(message) from err
if not factor.is_finite():
raise StrategyConfigurationError(message)
return factor
@property
def calculation_strategy(self) -> CalculationStrategy:
"""Get the calculation strategy this profile provides"""