197 files

This commit is contained in:
Home Assistant Version Control committed 2026-08-21 18:53:37 +00:00
1 parent 89d9f5e49b
commit 383e9ed04e
197 files changed
+4386 -1837

No files matched your search

@@ -1,7 +1,7 @@
import logging
from typing import Any
from custom_components.powercalc.power_profile.loader.protocol import Loader
from custom_components.powercalc.power_profile.loader.protocol import Loader, ModelMetadata
from custom_components.powercalc.power_profile.power_profile import DeviceType, DiscoveryBy
_LOGGER = logging.getLogger(__name__)
@@ -11,13 +11,13 @@ class CompositeLoader(Loader):
def __init__(self, loaders: list[Loader]) -> None:
self.loaders = loaders
async def initialize(self) -> None:
async def initialize(self, prefer_cached: bool = False) -> None:
for loader in self.loaders:
await loader.initialize()
await loader.initialize(prefer_cached)
def get_discovery_ignored_domains(self) -> set[str]:
"""Get all integration domains excluded by the combined libraries."""
return {domain for loader in self.loaders for domain in loader.get_discovery_ignored_domains()}
def get_discovery_low_priority_domains(self) -> set[str]:
"""Get all low priority integration domains of the combined libraries."""
return {domain for loader in self.loaders for domain in loader.get_discovery_low_priority_domains()}
async def get_manufacturer_listing(
self,
@@ -75,6 +75,15 @@ class CompositeLoader(Loader):
return models
async def get_model_metadata(self, manufacturer: str, model: str) -> ModelMetadata | None:
"""Return the metadata of the first loader knowing the model, matching load_model precedence."""
for loader in self.loaders:
metadata = await loader.get_model_metadata(manufacturer, model)
if metadata:
return metadata
return None
async def find_model_migration(self, manufacturer: str, model: str) -> str | None:
"""Find the canonical model id for a legacy profile id."""
matches: set[str] = set()
@@ -7,7 +7,7 @@ from typing import Any, cast
from homeassistant.core import HomeAssistant
from custom_components.powercalc.power_profile.error import LibraryLoadingError
from custom_components.powercalc.power_profile.loader.protocol import Loader
from custom_components.powercalc.power_profile.loader.protocol import Loader, ModelMetadata
from custom_components.powercalc.power_profile.power_profile import DeviceType, DiscoveryBy, PowerProfile
_LOGGER = logging.getLogger(__name__)
@@ -20,12 +20,12 @@ class LocalLoader(Loader):
self._hass = hass
self._manufacturer_model_listing: dict[str, dict[str, PowerProfile]] = {}
async def initialize(self) -> None:
"""Initialize the loader."""
async def initialize(self, prefer_cached: bool = False) -> None:
"""Initialize the loader. Local profiles are read from disk, so nothing is ever cached remotely."""
if not self._is_custom_directory:
await self._hass.async_add_executor_job(self._load_custom_library)
def get_discovery_ignored_domains(self) -> set[str]:
def get_discovery_low_priority_domains(self) -> set[str]:
"""Local profile directories do not provide global library metadata."""
return set()
@@ -139,6 +139,15 @@ class LocalLoader(Loader):
"""Local custom libraries do not support metadata-driven legacy profile migrations."""
return None
async def get_model_metadata(self, manufacturer: str, model: str) -> ModelMetadata | None:
"""Return discovery metadata from the already parsed local profile."""
models = self._manufacturer_model_listing.get(manufacturer.lower())
profile = models.get(model.lower()) if models else None
if profile is None or profile.device_type is None:
return None
return ModelMetadata(device_type=profile.device_type, discovery_by=profile.discovery_by)
def _load_custom_library(self) -> None:
"""Loading custom models and aliases from file system.
Manufacturer directories without model directories and model.json files within
@@ -1,14 +1,25 @@
from typing import Any, Protocol
from typing import Any, NamedTuple, Protocol
from custom_components.powercalc.power_profile.power_profile import DeviceType, DiscoveryBy
class Loader(Protocol):
async def initialize(self) -> None:
"""Initialize the loader."""
class ModelMetadata(NamedTuple):
"""Discovery relevant model properties, available from the library index without loading the profile."""
def get_discovery_ignored_domains(self) -> set[str]:
"""Get integration domains excluded from discovery."""
device_type: DeviceType
discovery_by: DiscoveryBy
class Loader(Protocol):
async def initialize(self, prefer_cached: bool = False) -> None:
"""Initialize the loader.
Pass `prefer_cached` to load from local storage when available, keeping remote
downloads off the caller's critical path.
"""
def get_discovery_low_priority_domains(self) -> set[str]:
"""Get integration domains that are the least preferred source for discovery."""
async def get_manufacturer_listing(
self,
@@ -36,3 +47,6 @@ class Loader(Protocol):
async def find_model_migration(self, manufacturer: str, model: str) -> str | None:
"""Return the canonical model id for a legacy profile id using library metadata."""
async def get_model_metadata(self, manufacturer: str, model: str) -> ModelMetadata | None:
"""Return discovery metadata for a model, or None when this loader does not know the model."""
@@ -20,11 +20,11 @@ from custom_components.powercalc.const import (
API_URL,
BUILT_IN_LIBRARY_DIR,
DOMAIN,
LIBRARY_DISCOVERY_IGNORED_DOMAINS,
LIBRARY_DISCOVERY_LOW_PRIORITY_DOMAINS,
)
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.protocol import Loader
from custom_components.powercalc.power_profile.loader.protocol import Loader, ModelMetadata
from custom_components.powercalc.power_profile.power_profile import DeviceType, DiscoveryBy
_LOGGER = logging.getLogger(__name__)
@@ -65,14 +65,18 @@ class RemoteLoader(Loader):
self.manufacturer_lookup: dict[str, set[str]] = {}
self.profile_hashes: dict[str, str] = {}
async def initialize(self) -> None:
"""Initialize the loader."""
async def initialize(self, prefer_cached: bool = False) -> None:
"""Initialize the loader.
Pass `prefer_cached` to keep the network off the critical path, using the library.json
already in local storage when there is one. Only the very first run has to download.
"""
integration = await async_get_integration(self.hass, DOMAIN)
powercalc_version = AwesomeVersion(str(integration.version))
self._clear_caches()
self.library_contents = await self.load_library_json()
self.library_contents = await self.load_library_json(prefer_cached)
self.profile_hashes = await self.hass.async_add_executor_job(self._load_profile_hashes)
self.model_infos.clear()
@@ -85,9 +89,9 @@ class RemoteLoader(Loader):
for manufacturer in manufacturers:
self._index_manufacturer(manufacturer, powercalc_version)
def get_discovery_ignored_domains(self) -> set[str]:
"""Get integration domains excluded from discovery by library metadata."""
return set(self.library_contents.get(LIBRARY_DISCOVERY_IGNORED_DOMAINS, []))
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, []))
def _index_manufacturer(self, manufacturer: LibraryManufacturer, powercalc_version: AwesomeVersion) -> None:
"""Register a manufacturer, its aliases and all of its supported models in the lookup tables."""
@@ -160,53 +164,74 @@ class RemoteLoader(Loader):
clear_async_cache(self.find_model_migration)
clear_async_cache(self.load_model)
async def load_library_json(self) -> dict[str, Any]:
"""Load library.json file"""
async def load_library_json(self, prefer_cached: bool = False) -> dict[str, Any]:
"""Load library.json, from local storage or from the download API.
local_path = self.hass.config.path(STORAGE_DIR, BUILT_IN_LIBRARY_DIR, "library.json")
def _load_local_library_json() -> dict[str, Any]:
"""Load library.json file from local storage"""
if not os.path.exists(local_path):
raise ProfileDownloadError("Local library.json file not found")
with open(local_path) as f:
return cast(dict[str, Any], json.load(f))
async def _download_remote_library_json() -> dict[str, Any] | None:
"""
Download library.json from Github.
On success, save it to local storage as a fallback for internet connection issues.
"""
_LOGGER.debug("Loading library.json from github")
session = async_get_clientsession(self.hass)
try:
async with asyncio.timeout(TIMEOUT_SECONDS), session.get(ENDPOINT_LIBRARY) as resp:
if resp.status != 200:
raise ProfileDownloadError(
f"Failed to download library.json, unexpected status code: {resp.status}",
)
data = await resp.read()
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)
return cast(dict[str, Any], json.loads(data))
With `prefer_cached` the locally stored copy wins when it exists, so the caller never
waits on the network. The periodic library update refreshes it later.
"""
if prefer_cached:
cached_library = await self.hass.async_add_executor_job(self._read_local_library_json)
if cached_library is not None:
_LOGGER.debug("Loaded library.json from local storage")
return cached_library
_LOGGER.debug("No library.json in local storage yet, downloading it")
try:
return cast(dict[str, Any], await self.download_with_retry(_download_remote_library_json))
return cast(dict[str, Any], await self.download_with_retry(self._download_remote_library_json))
except ProfileDownloadError:
_LOGGER.debug("Failed to download library.json, falling back to local copy")
return await self.hass.async_add_executor_job(_load_local_library_json)
return await self.hass.async_add_executor_job(self._load_local_library_json)
def _get_library_json_path(self) -> str:
"""Retrieve the local storage path for the library.json file."""
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."""
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))
def _load_local_library_json(self) -> dict[str, Any]:
"""Load library.json from local storage, raising when it is not there."""
library_json = self._read_local_library_json()
if library_json is None:
raise ProfileDownloadError("Local library.json file not found")
return library_json
async def _download_remote_library_json(self) -> dict[str, Any] | None:
"""
Download library.json from Github.
On success, save it to local storage as a fallback for internet connection issues.
"""
_LOGGER.debug("Loading library.json from github")
local_path = self._get_library_json_path()
session = async_get_clientsession(self.hass)
try:
async with asyncio.timeout(TIMEOUT_SECONDS), session.get(ENDPOINT_LIBRARY) as resp:
if resp.status != 200:
raise ProfileDownloadError(
f"Failed to download library.json, unexpected status code: {resp.status}",
)
data = await resp.read()
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)
return cast(dict[str, Any], json.loads(data))
@async_cache
async def get_manufacturer_listing(
@@ -221,7 +246,8 @@ class RemoteLoader(Loader):
for manufacturer in self.library_contents.get("manufacturers", [])
if any(
self._model_matches_filters(model, device_types, discovery_by)
for model in manufacturer.get("models", [])
# Use the indexed models, so models requiring a newer Powercalc version are left out here as well.
for model in self.manufacturer_models.get(str(manufacturer.get("dir_name")), [])
)
}
@@ -254,11 +280,20 @@ class RemoteLoader(Loader):
device_types: set[DeviceType] | None,
discovery_by: DiscoveryBy | None,
) -> bool:
model_device_type = DeviceType(model.get("device_type", DeviceType.LIGHT))
"""Check whether an indexed model passes the requested filters.
Device types and discovery modes this Powercalc version does not know about are treated
as a non match, so profiles using a newly introduced value never break the listings.
"""
try:
model_device_type = DeviceType(model.get("device_type", DeviceType.LIGHT))
model_discovery_by = DiscoveryBy(model.get("discovery_by", DiscoveryBy.ENTITY))
except ValueError:
return False
if device_types and model_device_type not in device_types:
return False
model_discovery_by = DiscoveryBy(model.get("discovery_by", DiscoveryBy.ENTITY))
return not discovery_by or model_discovery_by == discovery_by
@async_cache
@@ -289,6 +324,20 @@ class RemoteLoader(Loader):
return next(iter(matches))
async def get_model_metadata(self, manufacturer: str, model: str) -> ModelMetadata | None:
"""Return discovery metadata straight from the library index, without downloading the profile."""
model_info = self.model_infos.get(f"{manufacturer}/{model}")
if not model_info:
return None
try:
device_type = DeviceType(model_info.get("device_type", DeviceType.LIGHT))
discovery_by = DiscoveryBy(model_info.get("discovery_by", DiscoveryBy.ENTITY))
except ValueError:
return None
return ModelMetadata(device_type=device_type, discovery_by=discovery_by)
@async_cache
async def load_model(
self,
@@ -400,8 +449,8 @@ class RemoteLoader(Loader):
async def download_with_retry(
self,
callback: Callable[[], Coroutine[Any, Any, None | dict[str, Any]]],
) -> None | dict[str, Any]:
callback: Callable[[], Coroutine[Any, Any, dict[str, Any] | None]],
) -> dict[str, Any] | None:
"""Download a file from a remote endpoint with retries"""
max_retries = 3
retry_count = 0