Skip to content

Commit

Permalink
Implement dynamic sub profile selection
Browse files Browse the repository at this point in the history
  • Loading branch information
bramstroker committed Oct 16, 2022
1 parent 9751f66 commit 253818b
Show file tree
Hide file tree
Showing 14 changed files with 227 additions and 9 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,20 @@
"standby_power": 0.47,
"supported_modes": [
"lut"
]
],
"sub_profile_select": {
"matchers": [
{
"type": "attribute",
"attribute": "infrared_brightness",
"map": {
"Disabled": "infrared_off",
"25%": "infrared_25",
"50%": "infrared_50",
"100%": "infrared_100"
}
}
],
"default": "infrared_off"
}
}
23 changes: 23 additions & 0 deletions custom_components/powercalc/data/model_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,29 @@
"sensor_config": {
"type": "object",
"description": "Sensor configuration options. See the README"
},
"sub_profile_select": {
"type": "object",
"description": "Configuration to automatically select a sub profile",
"required": [
"default",
"matchers"
],
"properties": {
"default": {
"type": "string",
"description": "Default profile to select when non of the matchers apply"
},
"matchers": {
"type": "array",
"prefixItems": [
{
"type": "object"
}
],
"description": "List of matchers which will be checked"
}
}
}
}
}
2 changes: 1 addition & 1 deletion custom_components/powercalc/power_profile/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ async def get_profile(
for profile in profiles:
if profile.supports(model_info.model):
if sub_profile:
profile.load_sub_profile(sub_profile)
profile.select_sub_profile(sub_profile)
return profile

return None
Expand Down
48 changes: 46 additions & 2 deletions custom_components/powercalc/power_profile/power_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
import logging
import os
from enum import Enum
from typing import Optional
from typing import Optional, Protocol

from homeassistant.core import State
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
Expand Down Expand Up @@ -41,7 +42,7 @@ def __init__(
self.sub_profile: str | None = None
self._sub_profile_dir: str | None = None

def load_sub_profile(self, sub_profile: str) -> None:
def select_sub_profile(self, sub_profile: str) -> None:
"""Load the model.json file data containing information about the light model"""

self._sub_profile_dir = os.path.join(self._directory, sub_profile)
Expand Down Expand Up @@ -158,6 +159,10 @@ def device_type(self) -> str:
def has_sub_profiles(self) -> bool:
return len(self.get_sub_profiles()) > 0

@property
def sub_profile_select(self) -> dict | None:
return self._json_data.get("sub_profile_select")

def is_entity_domain_supported(self, domain: str) -> bool:
"""Check whether this power profile supports a given entity domain"""
if self.device_type == DeviceType.LIGHT and domain != LIGHT_DOMAIN:
Expand All @@ -173,3 +178,42 @@ def is_entity_domain_supported(self, domain: str) -> bool:
return False

return True


class SubProfileSelector:
def select_sub_profile(self, power_profile: PowerProfile, entity_state: State) -> str | None:
select_config = power_profile.sub_profile_select
if not select_config:
return None

matchers: list[dict] = select_config["matchers"]
for matcher_config in matchers:
matcher = self.create_matcher(matcher_config)
sub_profile = matcher.match(entity_state)
if sub_profile:
return sub_profile

return select_config["default"]

@staticmethod
def create_matcher(matcher_config: dict) -> SubProfileMatcher:
return AttributeMatcher(matcher_config["attribute"], matcher_config["map"])


class SubProfileMatcher(Protocol):
def match(self, entity_state: State) -> str | None:
pass


class AttributeMatcher(SubProfileMatcher):
def __init__(self, attribute: str, mapping: dict[str, str]):
self._attribute = attribute
self._mapping = mapping
pass

def match(self, entity_state: State) -> str | None:
val = entity_state.attributes.get(self._attribute)
if val is None:
return None

return self._mapping.get(val)
10 changes: 9 additions & 1 deletion custom_components/powercalc/sensors/power.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
)
from ..errors import ModelNotSupported, StrategyConfigurationError, UnsupportedMode
from ..power_profile.model_discovery import get_power_profile
from ..power_profile.power_profile import PowerProfile
from ..power_profile.power_profile import PowerProfile, SubProfileSelector
from ..strategy.factory import PowerCalculatorStrategyFactory
from ..strategy.strategy_interface import PowerCalculationStrategyInterface
from .abstract import (
Expand Down Expand Up @@ -200,6 +200,7 @@ async def create_virtual_power_sensor(
ignore_unavailable_state=sensor_config.get(CONF_IGNORE_UNAVAILABLE_STATE),
rounding_digits=sensor_config.get(CONF_POWER_SENSOR_PRECISION),
sensor_config=sensor_config,
power_profile=power_profile,
)


Expand Down Expand Up @@ -290,6 +291,7 @@ def __init__(
ignore_unavailable_state: bool,
rounding_digits: int,
sensor_config: dict,
power_profile: PowerProfile | None
):
"""Initialize the sensor."""
self._power_calculator = power_calculator
Expand Down Expand Up @@ -319,6 +321,7 @@ def __init__(
ATTR_SOURCE_ENTITY: source_entity,
ATTR_SOURCE_DOMAIN: source_domain,
}
self._power_profile = power_profile

async def async_added_to_hass(self):
"""Register callbacks."""
Expand Down Expand Up @@ -434,6 +437,11 @@ def _has_valid_state(self, state: State | None) -> bool:
async def calculate_power(self, state: State) -> Decimal | None:
"""Calculate power consumption using configured strategy."""

if self._power_profile and self._power_profile.sub_profile_select:
sub_profile_selector = SubProfileSelector()
sub_profile = sub_profile_selector.select_sub_profile(self._power_profile, state)
self._power_profile.select_sub_profile(sub_profile)

is_calculation_enabled = await self.is_calculation_enabled()
if state.state in OFF_STATES or not is_calculation_enabled:
return await self.calculate_standby_power(state)
Expand Down
2 changes: 1 addition & 1 deletion custom_components/powercalc/strategy/lut.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def __init__(self) -> None:
async def get_lookup_dictionary(
self, power_profile: PowerProfile, color_mode: ColorMode
) -> dict | None:
cache_key = f"{power_profile.manufacturer}_{power_profile.model}_{color_mode}"
cache_key = f"{power_profile.manufacturer}_{power_profile.model}_{color_mode}_{power_profile.sub_profile}"
lookup_dict = self._lookup_dictionaries.get(cache_key)
if lookup_dict is None:
defaultdict_of_dict = partial(defaultdict, dict)
Expand Down
8 changes: 8 additions & 0 deletions tests/common.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import os

from homeassistant import config_entries
from homeassistant.components import input_boolean, input_number, light, sensor
from homeassistant.components.light import ColorMode
Expand Down Expand Up @@ -135,6 +137,12 @@ def get_simple_fixed_config(entity_id: str, power: float = 50) -> ConfigType:
}


def get_test_profile_dir(sub_dir: str) -> str:
return os.path.join(
os.path.dirname(__file__), "testing_config/powercalc_profiles", sub_dir
)


async def create_mocked_virtual_power_sensor_entry(
hass: HomeAssistant, name: str, unique_id: str | None
) -> config_entries.ConfigEntry:
Expand Down
1 change: 0 additions & 1 deletion tests/power_profile/device_types/test_media_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from homeassistant.const import (
CONF_ENTITY_ID,
STATE_OFF,
STATE_ON,
STATE_PAUSED,
STATE_PLAYING,
)
Expand Down
89 changes: 87 additions & 2 deletions tests/sensors/test_power.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import logging
from datetime import timedelta
from unittest import mock

import pytest
from homeassistant.components import input_boolean, sensor
from homeassistant.components.utility_meter.sensor import SensorDeviceClass
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_MODE,
ATTR_HS_COLOR,
ColorMode,
)
from homeassistant.components.vacuum import (
ATTR_BATTERY_LEVEL,
STATE_CLEANING,
Expand All @@ -24,7 +28,11 @@
from homeassistant.core import EVENT_HOMEASSISTANT_START, CoreState, HomeAssistant
from homeassistant.setup import async_setup_component
from homeassistant.util import dt
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.entity_registry import RegistryEntry
from pytest_homeassistant_custom_component.common import (
mock_device_registry,
mock_registry,
MockEntity,
MockEntityPlatform,
async_fire_time_changed,
Expand All @@ -35,6 +43,7 @@
CONF_CALCULATION_ENABLED_CONDITION,
CONF_CALIBRATE,
CONF_CREATE_GROUP,
CONF_CUSTOM_MODEL_DIRECTORY,
CONF_DELAY,
CONF_FIXED,
CONF_LINEAR,
Expand All @@ -57,6 +66,7 @@
create_input_boolean,
create_input_number,
get_simple_fixed_config,
get_test_profile_dir,
run_powercalc_setup_yaml_config,
)

Expand Down Expand Up @@ -374,3 +384,78 @@ async def test_sleep_power(hass: HomeAssistant):
await hass.async_block_till_done()

assert hass.states.get(power_entity_id).state == "100.00"


async def test_dynamic_sub_profile_selection(hass: HomeAssistant):
"""
Test that media player can be setup from profile library
"""
entity_id = "light.test"
manufacturer = "LIFX"
model = "LIFX A19 Night Vision"

mock_registry(
hass,
{
entity_id: RegistryEntry(
entity_id=entity_id,
unique_id="1234",
platform="light",
device_id="nest-device-id",
),
},
)
mock_device_registry(
hass,
{
"nest-device": DeviceEntry(
id="nest-device-id", manufacturer=manufacturer, model=model
)
},
)

power_sensor_id = "sensor.test_power"

await run_powercalc_setup_yaml_config(
hass,
{
CONF_ENTITY_ID: entity_id,
CONF_MANUFACTURER: manufacturer,
CONF_MODEL: model,
CONF_CUSTOM_MODEL_DIRECTORY: get_test_profile_dir("infrared_light"),
},
)

power_state = hass.states.get(power_sensor_id)
assert power_state
assert power_state.state == "unavailable"

hass.states.async_set(
entity_id,
STATE_ON,
{
"infrared_brightness": "50%",
ATTR_COLOR_MODE: ColorMode.HS,
ATTR_BRIGHTNESS: 100,
ATTR_HS_COLOR: (200, 300)
}
)
await hass.async_block_till_done()

assert hass.states.get(power_sensor_id).state == "1.19"

hass.states.async_set(
entity_id,
STATE_ON,
{
"infrared_brightness": "Disabled",
ATTR_COLOR_MODE: ColorMode.HS,
ATTR_BRIGHTNESS: 100,
ATTR_HS_COLOR: (200, 300)
}
)
await hass.async_block_till_done()

assert hass.states.get(power_sensor_id).state == "1.18"


Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"standby_power": 4.36
}
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"standby_power": 0.47
}
30 changes: 30 additions & 0 deletions tests/testing_config/powercalc_profiles/infrared_light/model.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"measure_description": "Measured with utils/measure script. TP-link kp125 was having inconsistent readings towards the 0.52 watt and lower range, so I manually smoothed out some of the outliers",
"measure_device": "TP-Link KP125",
"measure_method": "script",
"measure_settings": {
"SAMPLE_COUNT": 2,
"SLEEP_TIME": 2,
"VERSION": "v0.19.12:docker"
},
"name": "LIFX A19 Night Vision",
"standby_power": 0.47,
"supported_modes": [
"lut"
],
"sub_profile_select": {
"matchers": [
{
"type": "attribute",
"attribute": "infrared_brightness",
"map": {
"Disabled": "infrared_off",
"25%": "infrared_25",
"50%": "infrared_50",
"100%": "infrared_100"
}
}
],
"default": "infrared_off"
}
}

0 comments on commit 253818b

Please sign in to comment.