Initial Commit

This commit is contained in:
2026-06-11 11:50:50 -04:00
commit d4a69c41be
2748 changed files with 80489 additions and 0 deletions
@@ -0,0 +1,13 @@
from homeassistant.exceptions import HomeAssistantError
class LibraryError(HomeAssistantError):
"""Raised when an error occurred in the library logic."""
class LibraryLoadingError(LibraryError):
"""Raised when an error occurred during library loading."""
class ProfileDownloadError(LibraryError):
"""Raised when an error occurred during profile download."""
@@ -0,0 +1,72 @@
from __future__ import annotations
import logging
import os
from homeassistant.core import HomeAssistant
from custom_components.powercalc.common import SourceEntity
from custom_components.powercalc.const import (
CONF_CUSTOM_MODEL_DIRECTORY,
CONF_MANUFACTURER,
CONF_MODEL,
CONF_VARIABLES,
MANUFACTURER_WLED,
)
from custom_components.powercalc.errors import ModelNotSupportedError
from .error import LibraryError
from .library import ModelInfo, ProfileLibrary
from .power_profile import PowerProfile
_LOGGER = logging.getLogger(__name__)
async def get_power_profile(
hass: HomeAssistant,
config: dict,
source_entity: SourceEntity | None = None,
model_info: ModelInfo | None = None,
log_errors: bool = True,
process_variables: bool = True,
) -> PowerProfile | None:
manufacturer = config.get(CONF_MANUFACTURER)
model = config.get(CONF_MODEL)
model_id = None
if (manufacturer is None or model is None) and model_info:
manufacturer = config.get(CONF_MANUFACTURER) or model_info.manufacturer
model = config.get(CONF_MODEL) or model_info.model
model_id = model_info.model_id
custom_model_directory = config.get(CONF_CUSTOM_MODEL_DIRECTORY)
if (not manufacturer or not model) and not custom_model_directory:
return None
if manufacturer == MANUFACTURER_WLED:
return None
if custom_model_directory:
custom_model_directory = os.path.join(
hass.config.config_dir,
custom_model_directory,
)
library = await ProfileLibrary.factory(hass)
try:
variables = config.get(CONF_VARIABLES, {}).copy()
profile = await library.get_profile(
ModelInfo(manufacturer or "", model or "", model_id),
source_entity,
custom_model_directory,
variables,
process_variables,
)
except LibraryError as err:
if log_errors:
_LOGGER.error("Problem loading model: %s", err)
raise ModelNotSupportedError(
f"Model not found in library (manufacturer: {manufacturer}, model: {model})",
) from err
return profile
@@ -0,0 +1,327 @@
from __future__ import annotations
import json
import os
import re
from typing import Any, NamedTuple, cast
from homeassistant.core import HomeAssistant
from homeassistant.helpers.singleton import singleton
from custom_components.powercalc.common import SourceEntity
from custom_components.powercalc.const import CONF_DISABLE_LIBRARY_DOWNLOAD, DOMAIN, DOMAIN_CONFIG
from custom_components.powercalc.helpers import (
build_related_entity_placeholder_not_found_message,
collect_placeholders,
iter_related_entity_placeholders,
replace_placeholders,
resolve_related_entity_placeholder,
)
from .error import LibraryError
from .loader.composite import CompositeLoader
from .loader.local import LocalLoader
from .loader.protocol import Loader
from .loader.remote import RemoteLoader
from .power_profile import DeviceType, DiscoveryBy, PowerProfile
LEGACY_CUSTOM_DATA_DIRECTORY = "powercalc-custom-models"
CUSTOM_DATA_DIRECTORY = "powercalc/profiles"
def load_sub_profile_data(base_dir: str) -> list[tuple[str, dict[str, Any]]]:
"""Load sub-profile JSON blobs from disk."""
sub_dirs = next(os.walk(base_dir))[1]
result = []
for sub_dir in sub_dirs:
json_path = os.path.join(base_dir, sub_dir, "model.json")
if os.path.isfile(json_path):
with open(json_path, encoding="utf-8") as f:
json_data = cast(dict[str, Any], json.load(f))
else:
json_data = {}
result.append((sub_dir, json_data))
return sorted(result, key=lambda item: item[0])
class ProfileLibrary:
def __init__(self, hass: HomeAssistant, loader: Loader) -> None:
self._hass = hass
self._loader = loader
self._profiles: dict[str, list[PowerProfile]] = {}
self._manufacturer_models: dict[str, set[tuple[str, str]]] = {}
self._manufacturer_device_types: dict[str, list] = {}
async def initialize(self) -> None:
await self._loader.initialize()
@staticmethod
@singleton("powercalc_library")
async def factory(hass: HomeAssistant) -> ProfileLibrary:
"""
Creates and loads the profile library.
Make sure we have a single instance throughout the application.
"""
library = ProfileLibrary(hass, ProfileLibrary.create_loader(hass))
await library.initialize()
return library
@staticmethod
def create_loader(hass: HomeAssistant, skip_remote_loader: bool = False) -> Loader:
loaders: list[Loader] = [
LocalLoader(hass, data_dir)
for data_dir in [
os.path.join(hass.config.config_dir, LEGACY_CUSTOM_DATA_DIRECTORY),
os.path.join(hass.config.config_dir, CUSTOM_DATA_DIRECTORY),
os.path.join(os.path.dirname(__file__), "../custom_data"),
]
if os.path.exists(data_dir)
]
domain_config = hass.data.get(DOMAIN, {})
global_config = domain_config.get(DOMAIN_CONFIG, {})
disable_library_download: bool = bool(global_config.get(CONF_DISABLE_LIBRARY_DOWNLOAD, False))
if not disable_library_download and not skip_remote_loader:
loaders.append(RemoteLoader(hass))
return CompositeLoader(loaders)
async def get_manufacturer_listing(
self,
device_types: set[DeviceType] | None = None,
discovery_by: DiscoveryBy | None = None,
) -> list[tuple[str, str]]:
"""Get listing of available manufacturers."""
manufacturers = await self._loader.get_manufacturer_listing(device_types, discovery_by)
return sorted(manufacturers)
async def get_model_listing(
self,
manufacturer: str,
device_types: set[DeviceType] | None = None,
discovery_by: DiscoveryBy | None = None,
) -> list[tuple[str, str]]:
"""Get listing of available models and display names for a given manufacturer."""
resolved_manufacturers = await self._loader.find_manufacturers(manufacturer)
if not resolved_manufacturers:
return []
all_models: list[tuple[str, str]] = []
for manufacturer in resolved_manufacturers:
cache_key = f"{manufacturer}/{device_types}/{discovery_by}"
cached_models = self._manufacturer_models.get(cache_key)
if cached_models:
all_models.extend(sorted(cached_models))
continue
models = await self._loader.get_model_listing(manufacturer, device_types, discovery_by)
self._manufacturer_models[cache_key] = models
all_models.extend(sorted(models))
return sorted(all_models, key=lambda model: model[0])
async def get_profile(
self,
model_info: ModelInfo,
source_entity: SourceEntity | None = None,
custom_directory: str | None = None,
variables: dict[str, str] | None = None,
process_variables: bool = True,
) -> PowerProfile:
"""Get a power profile for a given manufacturer and model."""
# Support multiple LUT in subdirectories
sub_profile = None
if "/" in model_info.model:
(model, sub_profile) = model_info.model.split("/", 1)
model_info = ModelInfo(model_info.manufacturer, model, model_info.model_id)
if not custom_directory:
models = await self.find_models(model_info)
if not models:
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)
if sub_profile:
await profile.select_sub_profile(sub_profile)
return profile
async def create_power_profile(
self,
model_info: ModelInfo,
source_entity: SourceEntity | None = None,
custom_directory: str | None = None,
variables: dict[str, str] | None = None,
process_variables: bool = True,
) -> PowerProfile:
"""Create a power profile object from the model JSON data."""
json_data, directory = await self._load_model_data(model_info.manufacturer, model_info.model, custom_directory)
json_data = self._process_profile_json(json_data, variables or {}, source_entity, process_variables)
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)
json_data.update(linked_json_data)
raw_sub_profiles = await self._hass.async_add_executor_job(load_sub_profile_data, directory)
sub_profiles = [
(
sub_dir,
self._process_profile_json(sub_profile_json, variables or {}, source_entity, process_variables),
)
for sub_dir, sub_profile_json in raw_sub_profiles
]
return await self._create_power_profile_instance(
model_info.manufacturer,
model_info.model,
directory,
json_data,
sub_profiles,
)
def _process_profile_json(
self,
json_data: dict[str, Any],
variables: dict[str, str],
source_entity: SourceEntity | None,
process_variables: bool,
) -> dict[str, Any]:
# json_data is potentially retrieved from cache, so we need to copy it to avoid modifying the cache
json_data = json_data.copy()
if not process_variables:
return json_data
if json_data.get("fields"): # When custom fields in profile are defined, make sure all variables are passed
self.validate_variables(json_data, variables)
placeholders = collect_placeholders(json_data)
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]:
variables = variables or {}
if source_entity:
if "entity" in placeholders:
variables["entity"] = source_entity.entity_id
for placeholder in iter_related_entity_placeholders(placeholders):
related_entity = resolve_related_entity_placeholder(
self._hass,
placeholder,
source_entity=source_entity,
)
if not related_entity:
raise LibraryError(build_related_entity_placeholder_not_found_message(placeholder, source_entity.entity_id))
variables[placeholder] = related_entity
return variables
@staticmethod
def validate_variables(json_data: dict[str, Any], variables: dict[str, str]) -> None:
fields = json_data.get("fields", {}).keys()
# Check if all variables are valid for the model
for variable in variables:
if variable not in fields and variable != "entity":
raise LibraryError(f"Variable {variable} is not valid for this model")
# Check if all fields have corresponding variables
missing_fields = [field for field in fields if field not in variables]
if missing_fields:
raise LibraryError(f"Missing variables for fields: {', '.join(missing_fields)}")
async def find_manufacturers(self, manufacturer: str) -> set[str]:
"""Resolve the manufacturer, either from the model info or by loading it."""
return await self._loader.find_manufacturers(manufacturer)
async def find_models(self, model_info: ModelInfo) -> list[ModelInfo]:
"""Resolve the model identifier, searching for it if no custom directory is provided."""
search: set[str] = set()
for model_identifier in (model_info.model_id, model_info.model):
if model_identifier:
model_identifier = model_identifier.replace("#slash#", "/")
search.update(
{
model_identifier,
model_identifier.lower(),
re.sub(r"^(.*)\(([^()]+)\)$", r"\2", model_identifier),
},
)
if "/" in model_identifier:
search.update(model_identifier.split("/"))
manufacturers = await self._loader.find_manufacturers(model_info.manufacturer)
if not manufacturers:
return []
found_models: list[ModelInfo] = []
for manufacturer in manufacturers:
models = await self._loader.find_model(manufacturer, search)
if models:
found_models.extend(ModelInfo(manufacturer, model) for model in models)
return list(dict.fromkeys(found_models))
async def find_model_migration(self, model_info: ModelInfo) -> ModelInfo | None:
"""Resolve a legacy canonical model id to its replacement using library metadata."""
manufacturers = await self._loader.find_manufacturers(model_info.manufacturer)
if not manufacturers:
return None
matches: set[ModelInfo] = set()
for manufacturer in manufacturers:
migrated_model = await self._loader.find_model_migration(manufacturer, model_info.model)
if migrated_model:
matches.add(ModelInfo(manufacturer, migrated_model))
if len(matches) != 1:
return None
return next(iter(matches))
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
result = await loader.load_model(manufacturer, model)
if not result:
raise LibraryError(f"Model {manufacturer} {model} not found")
return result
async def _create_power_profile_instance(
self,
manufacturer: str,
model: str,
directory: str,
json_data: dict,
sub_profiles: list[tuple[str, dict]] | None = None,
) -> PowerProfile:
"""Create and initialize the PowerProfile object."""
profile = PowerProfile(
self._hass,
manufacturer=manufacturer,
model=model,
directory=directory,
json_data=json_data,
sub_profiles=sub_profiles,
)
if not profile.sub_profile and profile.sub_profile_select:
await profile.select_sub_profile(profile.sub_profile_select.default)
return profile
def get_loader(self) -> Loader:
return self._loader
class ModelInfo(NamedTuple):
manufacturer: str
model: str
# Starting from HA 2024.8 we can use model_id to identify the model
model_id: str | None = None
@@ -0,0 +1,75 @@
import logging
from custom_components.powercalc.power_profile.loader.protocol import Loader
from custom_components.powercalc.power_profile.power_profile import DeviceType, DiscoveryBy
_LOGGER = logging.getLogger(__name__)
class CompositeLoader(Loader):
def __init__(self, loaders: list[Loader]) -> None:
self.loaders = loaders
async def initialize(self) -> None:
[await loader.initialize() for loader in self.loaders] # type: ignore[func-returns-value]
async def get_manufacturer_listing(
self,
device_types: set[DeviceType] | None,
discovery_by: DiscoveryBy | None = None,
) -> 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)}
async def find_manufacturers(self, search: str) -> set[str]:
"""Check if a manufacturer is available. Also must check aliases."""
search = search.lower()
found_manufacturers = set()
for loader in self.loaders:
manufacturers = await loader.find_manufacturers(search)
if manufacturers:
found_manufacturers.update(manufacturers)
return found_manufacturers
async def get_model_listing(
self,
manufacturer: str,
device_types: set[DeviceType] | None,
discovery_by: DiscoveryBy | None = None,
) -> 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)}
async def load_model(self, manufacturer: str, model: str) -> tuple[dict, str] | None:
for loader in self.loaders:
result = await loader.load_model(manufacturer, model)
if result:
return result
return None
async def find_model(self, manufacturer: str, search: set[str]) -> list[str]:
"""Find the model in the library."""
models = []
for loader in self.loaders:
models.extend(await loader.find_model(manufacturer, search))
return models
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()
for loader in self.loaders:
migrated_model = await loader.find_model_migration(manufacturer, model)
if migrated_model:
matches.add(migrated_model)
if len(matches) != 1:
return None
return next(iter(matches))
@@ -0,0 +1,211 @@
import json
import logging
import os
import re
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.power_profile import DeviceType, DiscoveryBy, PowerProfile
_LOGGER = logging.getLogger(__name__)
class LocalLoader(Loader):
def __init__(self, hass: HomeAssistant, directory: str, is_custom_directory: bool = False) -> None:
self._is_custom_directory = is_custom_directory
self._data_directory = directory
self._hass = hass
self._manufacturer_model_listing: dict[str, dict[str, PowerProfile]] = {}
async def initialize(self) -> None:
"""Initialize the loader."""
if not self._is_custom_directory:
await self._hass.async_add_executor_job(self._load_custom_library)
async def get_manufacturer_listing(
self,
device_types: set[DeviceType] | None,
discovery_by: DiscoveryBy | None = None,
) -> set[tuple[str, str]]:
"""Get listing of all available manufacturers or filtered by model device_type."""
if device_types is None:
if discovery_by is None:
return {(manufacturer, manufacturer) for manufacturer in self._manufacturer_model_listing}
return {
(manufacturer, manufacturer)
for manufacturer, profiles in self._manufacturer_model_listing.items()
if any(profile.discovery_by == discovery_by for profile in profiles.values())
}
manufacturers: set[tuple[str, str]] = set()
for manufacturer in self._manufacturer_model_listing:
models = await self.get_model_listing(manufacturer, device_types, discovery_by)
if not models:
continue
manufacturers.add((manufacturer, manufacturer))
return manufacturers
async def find_manufacturers(self, search: str) -> set[str]:
"""Check if a manufacturer is available."""
_search = search.lower()
manufacturer_list = self._manufacturer_model_listing.keys()
if _search in manufacturer_list:
return {_search}
return set()
async def get_model_listing(
self,
manufacturer: str,
device_types: set[DeviceType] | None,
discovery_by: DiscoveryBy | None = None,
) -> set[tuple[str, str]]:
"""Get listing of available models for a given manufacturer.
param manufacturer: manufacturer always handled in lower case
param device_type: models of the manufacturer will be filtered by DeviceType, models
without assigned device_type will be handled as DeviceType.LIGHT.
None will return all models of a manufacturer.
returns: Set[tuple[str, str]] of (model_id, model_name)
"""
found_models: set[tuple[str, str]] = set()
models = self._manufacturer_model_listing.get(manufacturer.lower())
if not models:
return found_models
for profile in models.values():
if device_types and profile.device_type not in device_types:
continue
if discovery_by and profile.discovery_by != discovery_by:
continue
found_models.add((profile.model, profile.name or profile.model))
return found_models
async def load_model(self, manufacturer: str, model: str) -> tuple[dict, str] | None:
"""Load a model.json file from disk for a given manufacturer.lower() and model.lower()
by querying the custom library.
If self._is_custom_directory == true model.json will be loaded directly from there.
returns: tuple[dict, str] model.json as dictionary and model as lower case
returns: None when manufacturer, model or model path not found
raises LibraryLoadingError: model.json not found
"""
_manufacturer = manufacturer.lower()
_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)
return model_json, model_path
lib_models = self._manufacturer_model_listing.get(_manufacturer)
if lib_models is None:
return None
lib_model = lib_models.get(_model)
if lib_model is None:
return None
model_path = lib_model.get_model_directory()
model_json = lib_model.json_data
return model_json, model_path
async def find_model(self, manufacturer: str, search: set[str]) -> list[str]:
"""Find a model for a given manufacturer. Also must check aliases."""
_manufacturer = manufacturer.lower()
models = self._manufacturer_model_listing.get(_manufacturer)
if not models:
return []
search_lower = {phrase.lower() for phrase in search}
profile = next((models[model] for model in models if model.lower() in search_lower), None)
return [profile.model] if profile else []
async def find_model_migration(self, manufacturer: str, model: str) -> str | None:
"""Local custom libraries do not support metadata-driven legacy profile migrations."""
return None
def _load_custom_library(self) -> None:
"""Loading custom models and aliases from file system.
Manufacturer directories without model directories and model.json files within
are not loaded. Same is with model directories without model.json files.
"""
base_path = self._data_directory
if not os.path.exists(base_path):
_LOGGER.error("Custom library directory does not exist: %s", base_path)
return
self._manufacturer_model_listing.clear()
for manufacturer_dir in next(os.walk(base_path))[1]:
manufacturer_path = os.path.join(base_path, manufacturer_dir)
manufacturer = manufacturer_dir.lower()
for model_dir in next(os.walk(manufacturer_path))[1]:
pattern = re.compile(r"^\..*")
if pattern.match(model_dir):
continue
model_path = os.path.join(manufacturer_path, model_dir)
model_json_path = os.path.join(model_path, "model.json")
if not os.path.exists(model_json_path):
_LOGGER.warning("model.json should exist in %s!", model_path)
continue
model_json = self._load_json(model_json_path)
profile = PowerProfile(
self._hass,
manufacturer=manufacturer,
model=model_dir,
directory=model_path,
json_data=model_json,
)
self._add_profile_to_library(profile)
for alias in profile.aliases:
self._add_profile_to_library(
PowerProfile(
self._hass,
manufacturer=manufacturer,
model=alias,
directory=model_path,
json_data=model_json,
),
)
def _add_profile_to_library(self, profile: PowerProfile) -> None:
"""Add profile to the library lookup dictionary."""
manufacturer = profile.manufacturer
if self._manufacturer_model_listing.get(manufacturer) is None:
self._manufacturer_model_listing[manufacturer] = {}
search_key = profile.model.lower()
if self._manufacturer_model_listing[manufacturer].get(search_key):
_LOGGER.error(
"Double entry manufacturer/model in custom library: %s/%s",
profile.manufacturer,
profile.model,
)
return
self._manufacturer_model_listing[manufacturer].update({search_key: profile})
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:
return cast(dict[str, Any], json.load(file))
@@ -0,0 +1,35 @@
from typing import Protocol
from custom_components.powercalc.power_profile.power_profile import DeviceType, DiscoveryBy
class Loader(Protocol):
async def initialize(self) -> None:
"""Initialize the loader."""
async def get_manufacturer_listing(
self,
device_types: set[DeviceType] | None,
discovery_by: DiscoveryBy | None = None,
) -> set[tuple[str, str]]:
"""Get listing of possible manufacturers."""
async def find_manufacturers(self, search: str) -> set[str]:
"""Check if a manufacturer is available. Also must check aliases."""
async def get_model_listing(
self,
manufacturer: str,
device_types: set[DeviceType] | None,
discovery_by: DiscoveryBy | None = None,
) -> set[tuple[str, str]]:
"""Get listing of available models and display names for a given manufacturer."""
async def load_model(self, manufacturer: str, model: str) -> tuple[dict, str] | None:
"""Load and optionally download a model profile."""
async def find_model(self, manufacturer: str, search: set[str]) -> list[str]:
"""Check if a model is available. Also must check aliases."""
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."""
@@ -0,0 +1,406 @@
import asyncio
from collections.abc import Callable, Coroutine
from functools import partial
import json
from json import JSONDecodeError
import logging
import os
import shutil
from typing import Any, NotRequired, TypedDict, cast
import aiohttp
from aiohttp import ClientError
from awesomeversion import AwesomeVersion
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.storage import STORAGE_DIR
from homeassistant.loader import async_get_integration
from custom_components.powercalc.const import API_URL, BUILT_IN_LIBRARY_DIR, DOMAIN
from custom_components.powercalc.helpers import 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.power_profile import DeviceType, DiscoveryBy
_LOGGER = logging.getLogger(__name__)
ENDPOINT_LIBRARY = f"{API_URL}/library"
ENDPOINT_DOWNLOAD = f"{API_URL}/download"
TIMEOUT_SECONDS = 30
class LibraryModel(TypedDict):
id: str
name: NotRequired[str]
aliases: NotRequired[list[str]]
legacy_ids: NotRequired[list[str]]
hash: str
device_type: NotRequired[DeviceType]
discovery_by: NotRequired[DiscoveryBy]
min_version: NotRequired[str]
class LibraryManufacturer(TypedDict):
name: str
dir_name: str
aliases: NotRequired[list[str]]
models: list[LibraryModel]
class RemoteLoader(Loader):
retry_timeout = 3
def __init__(self, hass: HomeAssistant) -> None:
self.hass = hass
self.library_contents: dict = {}
self.model_infos: dict[str, LibraryModel] = {}
self.manufacturer_models: dict[str, list[LibraryModel]] = {}
self.model_lookup: dict[str, dict[str, list[LibraryModel]]] = {}
self.manufacturer_lookup: dict[str, set[str]] = {}
self.profile_hashes: dict[str, str] = {}
async def initialize(self) -> None:
"""Initialize the loader."""
integration = await async_get_integration(self.hass, DOMAIN)
powercalc_version = AwesomeVersion(integration.version)
self.library_contents = await self.load_library_json()
self.profile_hashes = await self.hass.async_add_executor_job(self._load_profile_hashes)
self.model_infos.clear()
self.model_lookup.clear()
self.manufacturer_models.clear()
self.manufacturer_lookup.clear()
manufacturers: list[LibraryManufacturer] = self.library_contents.get("manufacturers", [])
for manufacturer in manufacturers:
manufacturer_name = str(manufacturer.get("dir_name"))
models: list[LibraryModel] = manufacturer.get("models", []) or []
# manufacturer alias map (alias -> {canonical manufacturer_name})
self.manufacturer_lookup.setdefault(manufacturer_name.lower(), set()).add(manufacturer_name)
for alias in manufacturer.get("aliases", []) or []:
self.manufacturer_lookup.setdefault(str(alias).lower(), set()).add(manufacturer_name)
# per-manufacturer model lookup
kept_models: list[LibraryModel] = []
lookup: dict[str, list[LibraryModel]] = {}
for model in models:
min_version = model.get("min_version")
model_id = str(model.get("id"))
model_id_lower = model_id.lower()
self.model_infos[f"{manufacturer_name}/{model_id!s}"] = model
if min_version and powercalc_version < AwesomeVersion(min_version):
_LOGGER.debug(
"Skipping model %s/%s as it requires powercalc version %s (current: %s)",
manufacturer_name,
model_id,
min_version,
powercalc_version,
)
continue
kept_models.append(model)
# Exact id bucket first (highest priority)
bucket = lookup.setdefault(model_id_lower, [])
bucket.insert(0, model)
# Alias buckets afterwards (lower priority)
for alias in model.get("aliases", []) or []:
alias_lower = str(alias).lower()
if alias_lower == model_id_lower:
continue
# Append to the end to ensure aliased models are always last
lookup.setdefault(alias_lower, []).append(model)
self.manufacturer_models[manufacturer_name] = kept_models
self.model_lookup[manufacturer_name] = lookup
async def load_library_json(self) -> dict[str, Any]:
"""Load library.json file"""
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.
If download is successful, save it to local storage to use as fallback in case of 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))
try:
return cast(dict[str, Any], await self.download_with_retry(_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)
@async_cache
async def get_manufacturer_listing(
self,
device_types: set[DeviceType] | None,
discovery_by: DiscoveryBy | None = None,
) -> set[tuple[str, str]]:
"""Get listing of available manufacturers."""
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", []))
}
@async_cache
async def find_manufacturers(self, search: str) -> set[str]:
"""Find the manufacturer in the library."""
return self.manufacturer_lookup.get(search, set())
@async_cache
async def get_model_listing(
self,
manufacturer: str,
device_types: set[DeviceType] | None,
discovery_by: DiscoveryBy | None = None,
) -> set[tuple[str, str]]:
"""Get listing of available models and display names for a given manufacturer."""
models = self.manufacturer_models.get(manufacturer)
if not models:
return set()
return {
(model["id"], str(model.get("name") or model["id"]))
for model in self.manufacturer_models.get(manufacturer, [])
if self._model_matches_filters(model, device_types, discovery_by)
}
@staticmethod
def _model_matches_filters(
model: LibraryModel,
device_types: set[DeviceType] | None,
discovery_by: DiscoveryBy | None,
) -> bool:
model_device_type = DeviceType(model.get("device_type", DeviceType.LIGHT))
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
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]]
@async_cache
async def find_model_migration(self, manufacturer: str, model: str) -> str | None:
"""Find the canonical model id for a legacy profile id."""
model_lower = model.lower()
matches = {
str(model_data.get("id"))
for manufacturer_data in self.library_contents.get("manufacturers", [])
if str(manufacturer_data.get("dir_name", "")).lower() == manufacturer
for model_data in manufacturer_data.get("models", []) or []
if model_lower in {str(legacy_id).lower() for legacy_id in model_data.get("legacy_ids", []) or []}
}
if len(matches) != 1:
return None
return next(iter(matches))
@async_cache
async def load_model(
self,
manufacturer: str,
model: str,
force_update: bool = False,
retry_count: int = 0,
) -> tuple[dict, str] | None:
"""Load a model, downloading it if necessary, with retry logic."""
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)
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)
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."""
model_info = self.model_infos.get(f"{manufacturer}/{model}")
if not model_info:
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:
"""Check if the model needs to be updated."""
if force_update:
return True
path_exists = os.path.exists(model_path)
if not path_exists:
return True
existing_hash = self.profile_hashes.get(f"{manufacturer}/{model}")
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:
"""Attempt to download the profile, with retry logic and error handling."""
try:
model_info = self._get_library_model(manufacturer, model)
model_hash = str(model_info.get("hash"))
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)
except ProfileDownloadError as e:
if not os.path.exists(model_path):
if os.path.exists(storage_path):
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")
async def _load_model_json(self, model_path: str) -> dict:
"""Load the JSON data from the model file."""
def _load_json() -> dict[str, Any]:
with open(model_path) as f:
return cast(dict[str, Any], json.load(f))
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] | 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))
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
while retry_count < max_retries:
try:
return await callback()
except (ClientError, TimeoutError, ProfileDownloadError) as e:
_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
await asyncio.sleep(self.retry_timeout)
_LOGGER.warning("Failed to download, retrying... (Attempt %d of %d)", retry_count + 1, max_retries)
return None # pragma: no cover
async def download_profile(self, manufacturer: str, model: str, storage_path: str, model_hash: str) -> None:
"""
Download the profile from Github using the Powercalc download API
Saves the profile to manufacturer/model directory in .storage/powercalc_profiles folder
"""
_LOGGER.debug("Downloading profile: %s/%s from github", manufacturer, model)
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:
async with asyncio.timeout(TIMEOUT_SECONDS):
async with session.get(endpoint, params={"hash": model_hash}) as resp:
if resp.status != 200:
raise ProfileDownloadError(f"Failed to download profile: {manufacturer}/{model}")
resources = await resp.json()
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:
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"))
except (TimeoutError, aiohttp.ClientError) as e:
raise ProfileDownloadError(f"Failed to download profile: {manufacturer}/{model}") from e
def _load_profile_hashes(self) -> dict[str, str]:
"""Load profile hashes from local storage"""
path = self.hass.config.path(STORAGE_DIR, BUILT_IN_LIBRARY_DIR, ".profile_hashes")
if not os.path.exists(path):
return {}
with open(path) as f:
return json.load(f) # type: ignore
def _write_profile_hashes(self, hashes: dict[str, str]) -> None:
"""Write profile hashes to local storage"""
path = self.hass.config.path(STORAGE_DIR, BUILT_IN_LIBRARY_DIR, ".profile_hashes")
with open(path, "w") as json_file:
json.dump(hashes, json_file, indent=4)
@@ -0,0 +1,440 @@
from __future__ import annotations
from collections import defaultdict
from collections.abc import Mapping
from dataclasses import dataclass
from enum import StrEnum
import logging
import os
from typing import Any, cast
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
from homeassistant.components.cover import DOMAIN as COVER_DOMAIN
from homeassistant.components.fan import DOMAIN as FAN_DOMAIN
from homeassistant.components.lawn_mower import DOMAIN as LAWN_MOWER_DOMAIN
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import translation
from homeassistant.helpers.entity_registry import RegistryEntry
from homeassistant.helpers.storage import STORAGE_DIR
from homeassistant.helpers.typing import ConfigType
from custom_components.powercalc.const import (
BUILT_IN_LIBRARY_DIR,
CONF_MAX_POWER,
CONF_MIN_POWER,
CONF_POWER,
DOMAIN,
CalculationStrategy,
PowerProfileSource,
)
from custom_components.powercalc.errors import (
ModelNotSupportedError,
UnsupportedStrategyError,
)
from custom_components.powercalc.power_profile.sub_profile_selector import SubProfileSelectConfig
_LOGGER = logging.getLogger(__name__)
class DeviceType(StrEnum):
CAMERA = "camera"
COVER = "cover"
FAN = "fan"
GENERIC_IOT = "generic_iot"
LIGHT = "light"
POWER_METER = "power_meter"
PRINTER = "printer"
SMART_DIMMER = "smart_dimmer"
SMART_SWITCH = "smart_switch"
SMART_SPEAKER = "smart_speaker"
TELEVISION = "television"
NETWORK = "network"
VACUUM_ROBOT = "vacuum_robot"
LAWN_MOWER_ROBOT = "lawn_mower_robot"
HEATING = "heating"
UPS = "ups"
class DiscoveryBy(StrEnum):
DEVICE = "device"
ENTITY = "entity"
@dataclass(frozen=True)
class CustomField:
key: str
label: str
selector: dict[str, Any]
description: str | None = None
default: Any = None
DEVICE_TYPE_DOMAIN: dict[DeviceType, str | set[str]] = {
DeviceType.CAMERA: CAMERA_DOMAIN,
DeviceType.COVER: COVER_DOMAIN,
DeviceType.FAN: FAN_DOMAIN,
DeviceType.GENERIC_IOT: {SENSOR_DOMAIN, MEDIA_PLAYER_DOMAIN},
DeviceType.LIGHT: LIGHT_DOMAIN,
DeviceType.POWER_METER: SENSOR_DOMAIN,
DeviceType.SMART_DIMMER: LIGHT_DOMAIN,
DeviceType.SMART_SWITCH: {SWITCH_DOMAIN, LIGHT_DOMAIN},
DeviceType.SMART_SPEAKER: MEDIA_PLAYER_DOMAIN,
DeviceType.TELEVISION: MEDIA_PLAYER_DOMAIN,
DeviceType.NETWORK: BINARY_SENSOR_DOMAIN,
DeviceType.PRINTER: SENSOR_DOMAIN,
DeviceType.VACUUM_ROBOT: VACUUM_DOMAIN,
DeviceType.LAWN_MOWER_ROBOT: LAWN_MOWER_DOMAIN,
DeviceType.HEATING: CLIMATE_DOMAIN,
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})}
def _build_domain_device_type_mapping() -> Mapping[str, set[DeviceType]]:
"""Get the device types for a given entity domain."""
domain_to_device_type: defaultdict[str, set[DeviceType]] = defaultdict(set)
for device_type, domains in DEVICE_TYPE_DOMAIN.items():
domain_set = domains if isinstance(domains, set) else {domains}
for domain in domain_set:
domain_to_device_type[domain].add(device_type)
return domain_to_device_type
DOMAIN_DEVICE_TYPE_MAPPING: Mapping[str, set[DeviceType]] = _build_domain_device_type_mapping()
class PowerProfile:
def __init__(
self,
hass: HomeAssistant,
manufacturer: str,
model: str,
directory: str,
json_data: ConfigType,
sub_profiles: list[tuple[str, dict]] | None = None,
) -> None:
self._manufacturer = manufacturer
self._model = model.replace("#slash#", "/")
self._hass = hass
self._directory = directory
self._json_data = json_data
self.sub_profile: str | None = None
self._sub_profile_dir: str | None = None
self._sub_profiles = sub_profiles or []
def get_model_directory(self, root_only: bool = False) -> str:
"""Get the model directory containing the data files."""
if root_only:
return self._directory
return self._sub_profile_dir or self._directory
@property
def manufacturer(self) -> str:
"""Get the manufacturer of this profile."""
return self._manufacturer
@property
def model(self) -> str:
"""Get the model of this profile."""
return self._model
@property
def unique_id(self) -> str:
"""Get the unique id of this profile."""
return self._json_data.get("unique_id") or f"{self._manufacturer}_{self._model}"
@property
def name(self) -> str:
"""Get the name of this profile."""
return self._json_data.get("name") or ""
@property
def json_data(self) -> ConfigType:
"""Get the raw json data."""
return self._json_data
@property
def standby_power(self) -> float:
"""Get the standby power when the device is off."""
return self._json_data.get("standby_power") or 0
@property
def standby_power_on(self) -> float:
"""Get the standby power (self usage) when the device is on."""
standby_power_on = self._json_data.get("standby_power_on")
if standby_power_on is None and self.only_self_usage:
return self.standby_power
return standby_power_on or 0
@property
def calculation_strategy(self) -> CalculationStrategy:
"""Get the calculation strategy this profile provides"""
return CalculationStrategy(str(self._json_data.get("calculation_strategy", CalculationStrategy.LUT)))
@property
def linked_profile(self) -> str | None:
"""Get the linked profile."""
return self._json_data.get("linked_profile", self._json_data.get("linked_lut"))
@property
def calculation_enabled_condition(self) -> str | None:
"""Get the condition to enable the calculation."""
return self._json_data.get("calculation_enabled_condition")
@property
def aliases(self) -> list[str]:
"""Get a list of aliases for this model."""
return self._json_data.get("aliases") or []
@property
def linear_config(self) -> ConfigType | None:
"""Get configuration to set up linear strategy."""
config = self.get_strategy_config(CalculationStrategy.LINEAR)
if config is None:
return {CONF_MIN_POWER: 0, CONF_MAX_POWER: 0}
return config
@property
def min_version(self) -> str | None:
"""Get the minimum required version for this profile."""
return self._json_data.get("min_version") # pragma: no cover
@property
def multi_switch_config(self) -> ConfigType | None:
"""Get configuration to set up multi_switch strategy."""
return self.get_strategy_config(CalculationStrategy.MULTI_SWITCH)
@property
def fixed_config(self) -> ConfigType | None:
"""Get configuration to set up fixed strategy."""
config = self.get_strategy_config(CalculationStrategy.FIXED)
if config is None and self.standby_power_on:
return {CONF_POWER: 0}
return config
@property
def composite_config(self) -> list | None:
"""Get configuration to set up composite strategy."""
return cast(list, self._json_data.get("composite_config"))
@property
def playbook_config(self) -> ConfigType | None:
"""Get configuration to set up playbook strategy."""
return self.get_strategy_config(CalculationStrategy.PLAYBOOK)
def get_strategy_config(self, strategy: CalculationStrategy) -> ConfigType | None:
"""Get configuration for a certain strategy."""
if not self.is_strategy_supported(strategy):
raise UnsupportedStrategyError(
f"Strategy {strategy} is not supported by model: {self._model}",
)
return self._json_data.get(f"{strategy}_config")
@property
def sensor_config(self) -> ConfigType:
"""Additional sensor configuration."""
return self._json_data.get("sensor_config") or {}
def is_strategy_supported(self, mode: CalculationStrategy) -> bool:
"""Whether a certain calculation strategy is supported by this profile."""
return mode == self.calculation_strategy
@property
def needs_fixed_config(self) -> bool:
"""Used for smart switches which only provides standby power values.
This indicates the user must supply the power values in the config flow.
"""
if self.only_self_usage:
return False
return self.is_strategy_supported(
CalculationStrategy.FIXED,
) and not self._json_data.get("fixed_config")
@property
def needs_linear_config(self) -> bool:
"""
Used for smart dimmers. This indicates the user must supply the power values in the config flow.
"""
if self.only_self_usage:
return False
return self.is_strategy_supported(
CalculationStrategy.LINEAR,
) and not self._json_data.get("linear_config")
@property
def device_type(self) -> DeviceType | None:
"""Get the device type of this profile."""
device_type = self._json_data.get("device_type")
if not device_type:
return DeviceType.LIGHT
try:
return DeviceType(device_type)
except ValueError:
_LOGGER.warning("Unknown device type: %s", device_type)
return None
@property
def discovery_by(self) -> DiscoveryBy:
return DiscoveryBy(self._json_data.get("discovery_by", DiscoveryBy.ENTITY))
@property
def only_self_usage(self) -> bool:
"""Whether this profile only provides self usage."""
return bool(self._json_data.get("only_self_usage", False))
@property
def has_custom_fields(self) -> bool:
"""Whether this profile has custom fields."""
return bool(self._json_data.get("fields"))
@property
def custom_fields(self) -> list[CustomField]:
"""Get the custom fields of this profile."""
return [CustomField(key=key, **field) for key, field in self._json_data.get("fields", {}).items()]
@property
def documentation_url(self) -> str | None:
"""Get the documentation URL for this profile."""
return self._json_data.get("documentation_url")
@property
def config_flow_discovery_remarks(self) -> str | None:
"""Get remarks to show at the config flow discovery step."""
remarks = self._json_data.get("config_flow_discovery_remarks")
if not remarks:
translation_key = self.get_default_discovery_remarks_translation_key()
if translation_key:
translations = translation.async_get_cached_translations(
self._hass,
self._hass.config.language,
"common",
DOMAIN,
)
return translations.get(f"component.{DOMAIN}.common.{translation_key}")
return remarks
@property
def config_flow_sub_profile_remarks(self) -> str | None:
"""Get extra remarks to show at the config flow sub profile step."""
return self._json_data.get("config_flow_sub_profile_remarks")
@property
def compatible_integrations(self) -> list[str] | None:
"""Get the list of compatible integrations for this profile."""
return self._json_data.get("compatible_integrations")
def get_default_discovery_remarks_translation_key(self) -> str | None:
"""When no remarks are provided in the profile, see if we need to show a default remark."""
if self.device_type == DeviceType.SMART_SWITCH and self.needs_fixed_config:
return "remarks_smart_switch"
if self.device_type == DeviceType.SMART_DIMMER and self.needs_linear_config:
return "remarks_smart_dimmer"
return None
async def get_sub_profiles(self) -> list[tuple[str, dict]]:
"""Get listing of possible sub profiles and their corresponding JSON data."""
return self._sub_profiles
@property
async def has_sub_profiles(self) -> bool:
"""Check whether this profile has sub profiles."""
return len(await self.get_sub_profiles()) > 0
@property
async def requires_manual_sub_profile_selection(self) -> bool:
"""Check whether this profile requires manual sub profile selection."""
if not await self.has_sub_profiles:
return False
return not self.has_sub_profile_select_matchers
@property
def sub_profile_select(self) -> SubProfileSelectConfig | None:
"""Get the configuration for automatic sub profile switching."""
select_dict = self._json_data.get("sub_profile_select")
if not select_dict:
return None
return SubProfileSelectConfig(**select_dict)
@property
def has_sub_profile_select_matchers(self) -> bool:
"""Check whether the sub profile select has matchers."""
if not self.sub_profile_select:
return False
return bool(self.sub_profile_select.matchers)
async def select_sub_profile(self, sub_profile: str) -> None:
"""Select a sub profile. Only applicable when to profile actually supports sub profiles."""
if not await self.has_sub_profiles:
return
# Sub profile already selected, no need to load it again
if self.sub_profile == sub_profile:
return
sub_profiles = await self.get_sub_profiles()
found_profile = None
for sub_dir, json_data in sub_profiles:
if sub_dir == sub_profile:
found_profile = json_data
break
if found_profile is None:
raise ModelNotSupportedError(
f"Sub profile not found (manufacturer: {self._manufacturer}, model: {self._model}, sub_profile: {sub_profile})",
)
self._sub_profile_dir = os.path.join(self._directory, sub_profile)
_LOGGER.debug("Loading sub profile: %s", sub_profile)
self._json_data.update(found_profile)
self.sub_profile = sub_profile
@property
async def needs_user_configuration(self) -> bool:
"""Check whether this profile needs user configuration."""
if self.calculation_strategy == CalculationStrategy.MULTI_SWITCH:
return True
if self.needs_fixed_config or self.needs_linear_config:
return True
if self.has_custom_fields:
return True
return await self.has_sub_profiles and not self.sub_profile_select
def is_entity_domain_supported(self, entity_entry: RegistryEntry) -> bool:
"""Check whether this power profile supports a given entity domain."""
if self.device_type is None:
return False
domain = entity_entry.domain
# see https://github.com/bramstroker/homeassistant-powercalc/issues/2529
if self.device_type == DeviceType.PRINTER and entity_entry.unit_of_measurement:
return False
return self.device_type in DOMAIN_DEVICE_TYPE_MAPPING[domain]
@property
def is_custom_profile(self) -> bool:
"""Whether this profile is a custom profile."""
return not self._directory.startswith(self._hass.config.path(STORAGE_DIR, BUILT_IN_LIBRARY_DIR))
@property
def configuration_source(self) -> PowerProfileSource:
return PowerProfileSource.LIBRARY_CUSTOM if self.is_custom_profile else PowerProfileSource.LIBRARY_BUILTIN
@@ -0,0 +1,243 @@
from __future__ import annotations
from enum import StrEnum
import re
from typing import Any, NamedTuple, Protocol
from homeassistant.core import HomeAssistant, State
from custom_components.powercalc.common import SourceEntity
from custom_components.powercalc.errors import PowercalcSetupError
class SubProfileMatcherType(StrEnum):
ATTRIBUTE = "attribute"
ENTITY_ID = "entity_id"
ENTITY_REGISTRY = "entity_registry"
ENTITY_STATE = "entity_state"
INTEGRATION = "integration"
MODEL_ID = "model_id"
class SubProfileSelector:
def __init__(
self,
hass: HomeAssistant,
config: SubProfileSelectConfig,
source_entity: SourceEntity,
) -> None:
self._hass = hass
self._config = config
self._source_entity = source_entity
self._matchers: list[SubProfileMatcher] = self._build_matchers()
def _build_matchers(self) -> list[SubProfileMatcher]:
"""Create matchers from json config."""
return [self._create_matcher(matcher_config) for matcher_config in self._config.matchers or []]
def select_sub_profile(self, entity_state: State) -> str:
"""Dynamically tries to select a sub profile depending on the entity state.
This method always need to return a sub profile, when nothing is matched it will return a default.
"""
for matcher in self._matchers:
sub_profile = matcher.match(entity_state, self._source_entity)
if sub_profile:
return sub_profile
return self._config.default
def get_tracking_entities(self) -> list[str]:
"""Get additional list of entities to track for state changes."""
return [entity_id for matcher in self._matchers for entity_id in matcher.get_tracking_entities()]
def _create_matcher(self, matcher_config: dict) -> SubProfileMatcher:
"""Create a matcher from json config. Can be extended for more matchers in the future."""
matcher_type: SubProfileMatcherType = matcher_config["type"]
matcher_classes: dict[SubProfileMatcherType, type[SubProfileMatcher]] = {
SubProfileMatcherType.ATTRIBUTE: AttributeMatcher,
SubProfileMatcherType.ENTITY_STATE: EntityStateMatcher,
SubProfileMatcherType.ENTITY_ID: EntityIdMatcher,
SubProfileMatcherType.ENTITY_REGISTRY: EntityRegistryMatcher,
SubProfileMatcherType.INTEGRATION: IntegrationMatcher,
SubProfileMatcherType.MODEL_ID: ModelIdMatcher,
}
if matcher_type not in matcher_classes:
raise PowercalcSetupError(f"Unknown sub profile matcher type: {matcher_type}")
return matcher_classes[matcher_type].from_config(
matcher_config,
hass=self._hass,
source_entity=self._source_entity,
)
class SubProfileSelectConfig(NamedTuple):
default: str
matchers: list[dict] | None = None
class SubProfileMatcher(Protocol):
@classmethod
def from_config(cls, config: dict, **kwargs: Any) -> SubProfileMatcher: # noqa: ANN401
"""Create a matcher from a config dict."""
def match(self, entity_state: State, source_entity: SourceEntity) -> str | None:
"""Returns a sub profile."""
def get_tracking_entities(self) -> list[str]:
"""Get extra entities to track for state changes."""
class EntityStateMatcher(SubProfileMatcher):
def __init__(
self,
hass: HomeAssistant,
source_entity: SourceEntity | None,
entity_id: str,
mapping: dict[str, str],
) -> None:
self._hass = hass
if source_entity:
entity_id = entity_id.replace(
"{{source_object_id}}",
source_entity.object_id,
)
self._entity_id = entity_id
self._mapping = mapping
def match(self, entity_state: State, source_entity: SourceEntity) -> str | None:
state = self._hass.states.get(self._entity_id)
if state is None:
return None
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 get_tracking_entities(self) -> list[str]:
return [self._entity_id]
class AttributeMatcher(SubProfileMatcher):
def __init__(self, attribute: str, mapping: dict[str, str]) -> None:
self._attribute = attribute
self._mapping = mapping
def match(self, entity_state: State, source_entity: SourceEntity) -> str | None:
val = entity_state.attributes.get(self._attribute)
if val is None:
return None
return self._mapping.get(val)
@classmethod
def from_config(cls, config: dict, **kwargs: Any) -> AttributeMatcher: # noqa: ANN401
return cls(config["attribute"], config["map"])
def get_tracking_entities(self) -> list[str]:
return []
class EntityIdMatcher(SubProfileMatcher):
def __init__(self, pattern: str, profile: str) -> None:
self._pattern = pattern
self._profile = profile
def match(self, entity_state: State, source_entity: SourceEntity) -> str | None:
if re.search(self._pattern, entity_state.entity_id):
return self._profile
return None
@classmethod
def from_config(cls, config: dict, **kwargs: Any) -> EntityIdMatcher: # noqa: ANN401
return cls(config["pattern"], config["profile"])
def get_tracking_entities(self) -> list[str]:
return []
class IntegrationMatcher(SubProfileMatcher):
def __init__(self, integration: str, profile: str) -> None:
self._integration = integration
self._profile = profile
def match(self, entity_state: State, source_entity: SourceEntity) -> str | None:
registry_entry = source_entity.entity_entry
if not registry_entry:
return None
if registry_entry.platform == self._integration:
return self._profile
return None
@classmethod
def from_config(cls, config: dict, **kwargs: Any) -> IntegrationMatcher: # noqa: ANN401
return cls(config["integration"], config["profile"])
def get_tracking_entities(self) -> list[str]:
return []
class EntityRegistryMatcher(SubProfileMatcher):
def __init__(self, property_name: str, value: object, profile: str) -> None:
self._property_name = property_name
self._value = value
self._profile = profile
def match(self, entity_state: State, source_entity: SourceEntity) -> str | None:
registry_entry = source_entity.entity_entry
if not registry_entry or not hasattr(registry_entry, self._property_name):
return None
registry_value = getattr(registry_entry, self._property_name)
if registry_value is None:
return None
if self._matches_registry_value(registry_value):
return self._profile
return None
@classmethod
def from_config(cls, config: dict, **kwargs: Any) -> EntityRegistryMatcher: # noqa: ANN401
return cls(config["property"], config["value"], config["profile"])
def get_tracking_entities(self) -> list[str]:
return []
def _matches_registry_value(self, registry_value: object) -> bool:
if registry_value == self._value:
return True
if isinstance(registry_value, list | set | tuple | frozenset):
return self._value in registry_value
return str(registry_value) == str(self._value)
class ModelIdMatcher(SubProfileMatcher):
def __init__(self, model_id: str, profile: str) -> None:
self._model_id = model_id
self._profile = profile
def match(self, entity_state: State, source_entity: SourceEntity) -> str | None:
device_entry = source_entity.device_entry
if not device_entry:
return None
if device_entry.model_id == self._model_id:
return self._profile
return None
@classmethod
def from_config(cls, config: dict, **kwargs: Any) -> ModelIdMatcher: # noqa: ANN401
return cls(config["model_id"], config["profile"])
def get_tracking_entities(self) -> list[str]:
return []