Initil after Upgrade

This commit is contained in:
2026-06-15 10:53:52 -04:00
parent 2fe9bf0dd6
commit 887feaa50a
143 changed files with 2288 additions and 881 deletions
@@ -141,7 +141,13 @@ class ProfileLibrary:
raise LibraryError(f"Model {model_info.manufacturer} {model_info.model} not found")
model_info = next(iter(models))
profile = await self.create_power_profile(model_info, source_entity, custom_directory, variables, process_variables)
profile = await self.create_power_profile(
model_info,
source_entity,
custom_directory,
variables,
process_variables,
)
if sub_profile:
await profile.select_sub_profile(sub_profile)
@@ -163,7 +169,11 @@ class ProfileLibrary:
if linked_profile := json_data.get("linked_profile", json_data.get("linked_lut")):
linked_manufacturer, linked_model = linked_profile.split("/")
linked_json_data, directory = await self._load_model_data(linked_manufacturer, linked_model, custom_directory)
linked_json_data, directory = await self._load_model_data(
linked_manufacturer,
linked_model,
custom_directory,
)
json_data.update(linked_json_data)
raw_sub_profiles = await self._hass.async_add_executor_job(load_sub_profile_data, directory)
@@ -202,7 +212,12 @@ class ProfileLibrary:
replacements = self.compute_replacement_variables(placeholders, variables.copy(), source_entity)
return cast(dict[str, Any], replace_placeholders(json_data, replacements))
def compute_replacement_variables(self, placeholders: set[str], variables: dict[str, str], source_entity: SourceEntity | None) -> dict[str, str]:
def compute_replacement_variables(
self,
placeholders: set[str],
variables: dict[str, str],
source_entity: SourceEntity | None,
) -> dict[str, str]:
variables = variables or {}
if source_entity:
@@ -216,7 +231,9 @@ class ProfileLibrary:
source_entity=source_entity,
)
if not related_entity:
raise LibraryError(build_related_entity_placeholder_not_found_message(placeholder, source_entity.entity_id))
raise LibraryError(
build_related_entity_placeholder_not_found_message(placeholder, source_entity.entity_id),
)
variables[placeholder] = related_entity
return variables
@@ -286,7 +303,9 @@ class ProfileLibrary:
async def _load_model_data(self, manufacturer: str, model: str, custom_directory: str | None) -> tuple[dict, str]:
"""Load the model data from the appropriate directory."""
loader = LocalLoader(self._hass, custom_directory, is_custom_directory=True) if custom_directory else self._loader
loader = (
LocalLoader(self._hass, custom_directory, is_custom_directory=True) if custom_directory else self._loader
)
result = await loader.load_model(manufacturer, model)
if not result:
raise LibraryError(f"Model {manufacturer} {model} not found")
@@ -11,7 +11,8 @@ class CompositeLoader(Loader):
self.loaders = loaders
async def initialize(self) -> None:
[await loader.initialize() for loader in self.loaders] # type: ignore[func-returns-value]
for loader in self.loaders:
await loader.initialize()
async def get_manufacturer_listing(
self,
@@ -20,7 +21,11 @@ class CompositeLoader(Loader):
) -> set[tuple[str, str]]:
"""Get listing of available manufacturers."""
return {manufacturer for loader in self.loaders for manufacturer in await loader.get_manufacturer_listing(device_types, discovery_by)}
return {
manufacturer
for loader in self.loaders
for manufacturer in await loader.get_manufacturer_listing(device_types, discovery_by)
}
async def find_manufacturers(self, search: str) -> set[str]:
"""Check if a manufacturer is available. Also must check aliases."""
@@ -42,7 +47,11 @@ class CompositeLoader(Loader):
) -> set[tuple[str, str]]:
"""Get listing of available models and display names for a given manufacturer."""
return {model for loader in self.loaders for model in await loader.get_model_listing(manufacturer, device_types, discovery_by)}
return {
model
for loader in self.loaders
for model in await loader.get_model_listing(manufacturer, device_types, discovery_by)
}
async def load_model(self, manufacturer: str, model: str) -> tuple[dict, str] | None:
for loader in self.loaders:
@@ -1,3 +1,4 @@
from functools import partial
import json
import logging
import os
@@ -101,12 +102,9 @@ class LocalLoader(Loader):
_model = model.lower()
if self._is_custom_directory:
model_path = os.path.join(self._data_directory)
model_json_path = os.path.join(model_path, "model.json")
if not os.path.exists(model_json_path):
raise LibraryLoadingError(f"model.json not found for manufacturer {_manufacturer} " + f"and model {_model} in path {model_json_path}")
model_json = await self._hass.async_add_executor_job(self._load_json, model_json_path)
model_path, model_json = await self._hass.async_add_executor_job(
partial(self._load_custom_model, _manufacturer, _model),
)
return model_json, model_path
lib_models = self._manufacturer_model_listing.get(_manufacturer)
@@ -205,6 +203,17 @@ class LocalLoader(Loader):
self._manufacturer_model_listing[manufacturer].update({search_key: profile})
def _load_custom_model(self, manufacturer: str, model: str) -> tuple[str, dict[str, Any]]:
"""Load model.json from a directly configured custom model directory."""
model_path = os.path.join(self._data_directory)
model_json_path = os.path.join(model_path, "model.json")
if not os.path.exists(model_json_path):
raise LibraryLoadingError(
f"model.json not found for manufacturer {manufacturer} and model {model} in path {model_json_path}",
)
return model_path, self._load_json(model_json_path)
def _load_json(self, model_json_path: str) -> dict[str, Any]:
"""Load model.json file for a given model."""
with open(model_json_path) as file:
@@ -64,7 +64,7 @@ class RemoteLoader(Loader):
"""Initialize the loader."""
integration = await async_get_integration(self.hass, DOMAIN)
powercalc_version = AwesomeVersion(integration.version)
powercalc_version = AwesomeVersion(str(integration.version))
self.library_contents = await self.load_library_json()
self.profile_hashes = await self.hass.async_add_executor_job(self._load_profile_hashes)
@@ -138,7 +138,7 @@ class RemoteLoader(Loader):
async def _download_remote_library_json() -> dict[str, Any] | None:
"""
Download library.json from Github.
If download is successful, save it to local storage to use as fallback in case of internet connection issues.
On success, save it to local storage as a fallback for internet connection issues.
"""
_LOGGER.debug("Loading library.json from github")
@@ -182,7 +182,10 @@ class RemoteLoader(Loader):
return {
(manufacturer["dir_name"], manufacturer["full_name"])
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", []))
if any(
self._model_matches_filters(model, device_types, discovery_by)
for model in manufacturer.get("models", [])
)
}
@async_cache
@@ -225,7 +228,12 @@ class RemoteLoader(Loader):
async def find_model(self, manufacturer: str, search: set[str]) -> list[str]:
"""Find matching model IDs in the library."""
models = self.model_lookup.get(manufacturer, {})
return [model["id"] for phrase in search if (phrase_lower := phrase.lower()) in models for model in models[phrase_lower]]
return [
model["id"]
for phrase in search
if (phrase_lower := phrase.lower()) in models
for model in models[phrase_lower]
]
@async_cache
async def find_model_migration(self, manufacturer: str, model: str) -> str | None:
@@ -274,12 +282,19 @@ class RemoteLoader(Loader):
raise LibraryLoadingError("Model not found in library: %s/%s", manufacturer, model)
return model_info
async def _needs_update(self, model_info: LibraryModel, manufacturer: str, model: str, model_path: str, force_update: bool) -> bool:
async def _needs_update(
self,
model_info: LibraryModel,
manufacturer: str,
model: str,
model_path: str,
force_update: bool,
) -> bool:
"""Check if the model needs to be updated."""
if force_update:
return True
path_exists = os.path.exists(model_path)
path_exists = await self.hass.async_add_executor_job(os.path.exists, model_path)
if not path_exists:
return True
@@ -287,7 +302,13 @@ class RemoteLoader(Loader):
new_hash = model_info.get("hash")
return existing_hash != new_hash
async def _download_profile_with_retry(self, manufacturer: str, model: str, storage_path: str, model_path: str) -> None:
async def _download_profile_with_retry(
self,
manufacturer: str,
model: str,
storage_path: str,
model_path: str,
) -> None:
"""Attempt to download the profile, with retry logic and error handling."""
try:
model_info = self._get_library_model(manufacturer, model)
@@ -297,12 +318,22 @@ class RemoteLoader(Loader):
self.profile_hashes[f"{manufacturer}/{model}"] = model_hash
await self.hass.async_add_executor_job(self._write_profile_hashes, self.profile_hashes)
except ProfileDownloadError as e:
if not os.path.exists(model_path):
if os.path.exists(storage_path):
path_exists, storage_path_exists = await self.hass.async_add_executor_job(
self._profile_paths_exist,
model_path,
storage_path,
)
if not path_exists:
if storage_path_exists:
await self.hass.async_add_executor_job(shutil.rmtree, storage_path) # pragma: no cover
raise e
_LOGGER.debug("Failed to download profile, falling back to local profile")
@staticmethod
def _profile_paths_exist(model_path: str, storage_path: str) -> tuple[bool, bool]:
"""Check profile paths from the executor."""
return os.path.exists(model_path), os.path.exists(storage_path)
async def _load_model_json(self, model_path: str) -> dict:
"""Load the JSON data from the model file."""
@@ -330,7 +361,10 @@ class RemoteLoader(Loader):
"""Retrieve the storage path for a given manufacturer and model."""
return str(self.hass.config.path(STORAGE_DIR, BUILT_IN_LIBRARY_DIR, manufacturer, model))
async def download_with_retry(self, callback: Callable[[], Coroutine[Any, Any, None | dict[str, Any]]]) -> None | dict[str, Any]:
async def download_with_retry(
self,
callback: Callable[[], Coroutine[Any, Any, None | dict[str, Any]]],
) -> None | dict[str, Any]:
"""Download a file from a remote endpoint with retries"""
max_retries = 3
retry_count = 0
@@ -342,7 +376,9 @@ class RemoteLoader(Loader):
_LOGGER.debug(e)
retry_count += 1
if retry_count == max_retries:
raise ProfileDownloadError(f"Failed to download even after {max_retries} retries, falling back to local copy") from e
raise ProfileDownloadError(
f"Failed to download even after {max_retries} retries, falling back to local copy",
) from e
await asyncio.sleep(self.retry_timeout)
_LOGGER.warning("Failed to download, retrying... (Attempt %d of %d)", retry_count + 1, max_retries)
@@ -396,7 +432,7 @@ class RemoteLoader(Loader):
return {}
with open(path) as f:
return json.load(f) # type: ignore
return json.load(f) # type: ignore[no-any-return]
def _write_profile_hashes(self, hashes: dict[str, str]) -> None:
"""Write profile hashes to local storage"""
@@ -95,7 +95,9 @@ DEVICE_TYPE_DOMAIN: dict[DeviceType, str | set[str]] = {
DeviceType.UPS: SENSOR_DOMAIN,
}
SUPPORTED_DOMAINS: set[str] = {domain for domains in DEVICE_TYPE_DOMAIN.values() for domain in (domains if isinstance(domains, set) else {domains})}
SUPPORTED_DOMAINS: set[str] = {
domain for domains in DEVICE_TYPE_DOMAIN.values() for domain in (domains if isinstance(domains, set) else {domains})
}
def _build_domain_device_type_mapping() -> Mapping[str, set[DeviceType]]:
@@ -393,7 +395,8 @@ class PowerProfile:
if found_profile is None:
raise ModelNotSupportedError(
f"Sub profile not found (manufacturer: {self._manufacturer}, model: {self._model}, sub_profile: {sub_profile})",
f"Sub profile not found (manufacturer: {self._manufacturer}, "
f"model: {self._model}, sub_profile: {sub_profile})",
)
self._sub_profile_dir = os.path.join(self._directory, sub_profile)
@@ -2,7 +2,7 @@ from __future__ import annotations
from enum import StrEnum
import re
from typing import Any, NamedTuple, Protocol
from typing import NamedTuple, Protocol
from homeassistant.core import HomeAssistant, State
@@ -79,7 +79,13 @@ class SubProfileSelectConfig(NamedTuple):
class SubProfileMatcher(Protocol):
@classmethod
def from_config(cls, config: dict, **kwargs: Any) -> SubProfileMatcher: # noqa: ANN401
def from_config(
cls,
config: dict,
*,
hass: HomeAssistant | None = None,
source_entity: SourceEntity | None = None,
) -> SubProfileMatcher:
"""Create a matcher from a config dict."""
def match(self, entity_state: State, source_entity: SourceEntity) -> str | None:
@@ -114,8 +120,15 @@ class EntityStateMatcher(SubProfileMatcher):
return self._mapping.get(state.state)
@classmethod
def from_config(cls, config: dict, **kwargs: Any) -> EntityStateMatcher: # noqa: ANN401
return cls(kwargs["hass"], kwargs["source_entity"], config["entity_id"], config["map"])
def from_config(
cls,
config: dict,
*,
hass: HomeAssistant | None = None,
source_entity: SourceEntity | None = None,
) -> EntityStateMatcher:
assert hass is not None
return cls(hass, source_entity, config["entity_id"], config["map"])
def get_tracking_entities(self) -> list[str]:
return [self._entity_id]
@@ -134,7 +147,13 @@ class AttributeMatcher(SubProfileMatcher):
return self._mapping.get(val)
@classmethod
def from_config(cls, config: dict, **kwargs: Any) -> AttributeMatcher: # noqa: ANN401
def from_config(
cls,
config: dict,
*,
hass: HomeAssistant | None = None,
source_entity: SourceEntity | None = None,
) -> AttributeMatcher:
return cls(config["attribute"], config["map"])
def get_tracking_entities(self) -> list[str]:
@@ -153,7 +172,13 @@ class EntityIdMatcher(SubProfileMatcher):
return None
@classmethod
def from_config(cls, config: dict, **kwargs: Any) -> EntityIdMatcher: # noqa: ANN401
def from_config(
cls,
config: dict,
*,
hass: HomeAssistant | None = None,
source_entity: SourceEntity | None = None,
) -> EntityIdMatcher:
return cls(config["pattern"], config["profile"])
def get_tracking_entities(self) -> list[str]:
@@ -176,7 +201,13 @@ class IntegrationMatcher(SubProfileMatcher):
return None
@classmethod
def from_config(cls, config: dict, **kwargs: Any) -> IntegrationMatcher: # noqa: ANN401
def from_config(
cls,
config: dict,
*,
hass: HomeAssistant | None = None,
source_entity: SourceEntity | None = None,
) -> IntegrationMatcher:
return cls(config["integration"], config["profile"])
def get_tracking_entities(self) -> list[str]:
@@ -204,7 +235,13 @@ class EntityRegistryMatcher(SubProfileMatcher):
return None
@classmethod
def from_config(cls, config: dict, **kwargs: Any) -> EntityRegistryMatcher: # noqa: ANN401
def from_config(
cls,
config: dict,
*,
hass: HomeAssistant | None = None,
source_entity: SourceEntity | None = None,
) -> EntityRegistryMatcher:
return cls(config["property"], config["value"], config["profile"])
def get_tracking_entities(self) -> list[str]:
@@ -236,7 +273,13 @@ class ModelIdMatcher(SubProfileMatcher):
return None
@classmethod
def from_config(cls, config: dict, **kwargs: Any) -> ModelIdMatcher: # noqa: ANN401
def from_config(
cls,
config: dict,
*,
hass: HomeAssistant | None = None,
source_entity: SourceEntity | None = None,
) -> ModelIdMatcher:
return cls(config["model_id"], config["profile"])
def get_tracking_entities(self) -> list[str]: