Files
HomeAssistantVS/custom_components/powercalc/power_profile/power_profile.py
T

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