Initial Commit
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
@@ -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
|
||||
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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 []
|
||||
Reference in New Issue
Block a user