456 lines
17 KiB
Python
456 lines
17 KiB
Python
from __future__ import annotations
|
|
|
|
from collections import defaultdict
|
|
from collections.abc import Mapping
|
|
from copy import deepcopy
|
|
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_ENERGY_SENSOR_NAMING,
|
|
CONF_MAX_POWER,
|
|
CONF_MIN_POWER,
|
|
CONF_POWER,
|
|
CONF_POWER_SENSOR_NAMING,
|
|
DEFAULT_SELF_USAGE_ENERGY_NAME_PATTERN,
|
|
DEFAULT_SELF_USAGE_POWER_NAME_PATTERN,
|
|
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[str, Any]]] | None = None,
|
|
) -> None:
|
|
self._manufacturer = manufacturer
|
|
self._model = model.replace("#slash#", "/")
|
|
self._hass = hass
|
|
self._directory = directory
|
|
self._base_json_data = deepcopy(json_data)
|
|
self._json_data = deepcopy(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[ConfigType] | None:
|
|
"""Get configuration to set up composite strategy."""
|
|
return cast(list[ConfigType], 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."""
|
|
sensor_config = dict(self._json_data.get("sensor_config") or {})
|
|
if self.only_self_usage and CONF_POWER_SENSOR_NAMING not in sensor_config:
|
|
sensor_config[CONF_POWER_SENSOR_NAMING] = DEFAULT_SELF_USAGE_POWER_NAME_PATTERN
|
|
if self.only_self_usage and CONF_ENERGY_SENSOR_NAMING not in sensor_config:
|
|
sensor_config[CONF_ENERGY_SENSOR_NAMING] = DEFAULT_SELF_USAGE_ENERGY_NAME_PATTERN
|
|
return sensor_config
|
|
|
|
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[str, Any]]]:
|
|
"""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}, "
|
|
f"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 = deepcopy(self._base_json_data)
|
|
self._json_data.update(deepcopy(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
|