Skip to content

Commit

Permalink
Merge pull request #2806 from bramstroker/feat/profile-composite
Browse files Browse the repository at this point in the history
Allow to use composite config in library profiles
  • Loading branch information
bramstroker authored Dec 15, 2024
2 parents f96525a + 32da3f4 commit 949446e
Show file tree
Hide file tree
Showing 16 changed files with 171 additions and 30 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/validate-model-json.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,6 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
json_schema: ./profile_library/model_schema.json
json_path_pattern: .*/data/([^/]+/)?([^/]+/)?model.json$
json_path_pattern: model.json$
send_comment: true
clear_comments: true
6 changes: 3 additions & 3 deletions custom_components/powercalc/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -801,7 +801,7 @@ def get_fixed_power_config_for_smart_switch(self, user_input: dict[str, Any]) ->
"""Get the fixed power config for smart switch."""
if self.selected_profile is None:
return {CONF_POWER: 0} # pragma: no cover
self_usage_on = self.selected_profile.fixed_mode_config.get(CONF_POWER, 0) if self.selected_profile.fixed_mode_config else 0
self_usage_on = self.selected_profile.fixed_config.get(CONF_POWER, 0) if self.selected_profile.fixed_config else 0
power = user_input.get(CONF_POWER, 0)
self_usage_included = user_input.get(CONF_SELF_USAGE_INCLUDED, True)
if self_usage_included:
Expand Down Expand Up @@ -1051,8 +1051,8 @@ async def _validate(user_input: dict[str, Any]) -> dict[str, Any]:
}

self_usage_on = 0
if self.selected_profile and self.selected_profile.fixed_mode_config:
self_usage_on = self.selected_profile.fixed_mode_config.get(CONF_POWER, 0)
if self.selected_profile and self.selected_profile.fixed_config:
self_usage_on = self.selected_profile.fixed_config.get(CONF_POWER, 0)
return await self.handle_form_step(
PowercalcFormStep(
step=Step.SMART_SWITCH,
Expand Down
11 changes: 8 additions & 3 deletions custom_components/powercalc/power_profile/power_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,23 +150,28 @@ def aliases(self) -> list[str]:
return self._json_data.get("aliases") or []

@property
def linear_mode_config(self) -> ConfigType | None:
def linear_config(self) -> ConfigType | None:
"""Get configuration to setup linear strategy."""
return self.get_strategy_config(CalculationStrategy.LINEAR)

@property
def multi_switch_mode_config(self) -> ConfigType | None:
def multi_switch_config(self) -> ConfigType | None:
"""Get configuration to setup linear strategy."""
return self.get_strategy_config(CalculationStrategy.MULTI_SWITCH)

@property
def fixed_mode_config(self) -> ConfigType | None:
def fixed_config(self) -> ConfigType | None:
"""Get configuration to setup fixed strategy."""
config = self.get_strategy_config(CalculationStrategy.FIXED)
if config is None and self.standby_power_on:
config = {CONF_POWER: 0}
return config

@property
def composite_config(self) -> ConfigType | None:
"""Get configuration to setup composite strategy."""
return self.get_strategy_config(CalculationStrategy.COMPOSITE)

def get_strategy_config(self, strategy: CalculationStrategy) -> ConfigType | None:
if not self.is_strategy_supported(strategy):
raise UnsupportedStrategyError(
Expand Down
23 changes: 16 additions & 7 deletions custom_components/powercalc/strategy/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ async def create(
if strategy in strategy_mapping:
return strategy_mapping[strategy]()

raise UnsupportedStrategyError("Invalid calculation mode", strategy)
raise UnsupportedStrategyError("Invalid calculation strategy", strategy)

def _create_linear(
self,
Expand All @@ -85,7 +85,7 @@ def _create_linear(

if linear_config is None:
if power_profile:
linear_config = power_profile.linear_mode_config or {CONF_MIN_POWER: 0, CONF_MAX_POWER: 0}
linear_config = power_profile.linear_config or {CONF_MIN_POWER: 0, CONF_MAX_POWER: 0}
else:
raise StrategyConfigurationError("No linear configuration supplied")

Expand All @@ -105,8 +105,8 @@ def _create_fixed(
"""Create the fixed strategy."""
fixed_config: dict | None = config.get(CONF_FIXED)
if fixed_config is None:
if power_profile and power_profile.fixed_mode_config:
fixed_config = power_profile.fixed_mode_config
if power_profile and power_profile.fixed_config:
fixed_config = power_profile.fixed_config
else:
raise StrategyConfigurationError("No fixed configuration supplied")

Expand Down Expand Up @@ -163,12 +163,21 @@ async def _create_composite(
power_profile: PowerProfile | None,
source_entity: SourceEntity,
) -> CompositeStrategy:
sub_strategies = list(config.get(CONF_COMPOSITE)) # type: ignore
composite_config: dict | None = config.get(CONF_COMPOSITE)
if composite_config is None:
if power_profile and power_profile.composite_config:
composite_config = power_profile.composite_config
else:
raise StrategyConfigurationError("No composite configuration supplied")

sub_strategies = list(composite_config)

async def _create_sub_strategy(strategy_config: ConfigType) -> SubStrategy:
condition_instance = None
condition_config = strategy_config.get(CONF_CONDITION)
if condition_config:
if condition_config.get(CONF_CONDITION) == "state":
condition_config = condition.state_validate_config(self._hass, condition_config)
condition_instance = await condition.async_from_config(
self._hass,
condition_config,
Expand All @@ -189,8 +198,8 @@ async def _create_sub_strategy(strategy_config: ConfigType) -> SubStrategy:
def _create_multi_switch(self, config: ConfigType, power_profile: PowerProfile | None) -> MultiSwitchStrategy:
"""Create instance of multi switch strategy."""
multi_switch_config: ConfigType = {}
if power_profile and power_profile.multi_switch_mode_config:
multi_switch_config = power_profile.multi_switch_mode_config
if power_profile and power_profile.multi_switch_config:
multi_switch_config = power_profile.multi_switch_config
multi_switch_config.update(config.get(CONF_MULTI_SWITCH, {}))

if not multi_switch_config:
Expand Down
4 changes: 1 addition & 3 deletions profile_library/belkin/F7C063/model.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,7 @@
"energy_sensor_naming": "{} Device Energy"
},
"device_type": "smart_switch",
"supported_modes": [
"fixed"
],
"calculation_strategy": "fixed",
"fixed_config": {
"power": 1.3
},
Expand Down
4 changes: 1 addition & 3 deletions profile_library/eve/20EBU4101/model.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,7 @@
"energy_sensor_naming": "{} Device Energy"
},
"device_type": "smart_switch",
"supported_modes": [
"fixed"
],
"calculation_strategy": "fixed",
"fixed_config": {
"power": 0.9
},
Expand Down
1 change: 1 addition & 0 deletions profile_library/ledworks/TWS600STP/model.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"SLEEP_TIME": 3,
"VERSION": "v1.15.5:docker"
},
"calculation_strategy": "fixed",
"name": "Twinkly 600 String Lights",
"standby_power": 2.2,
"fixed_config": {
Expand Down
2 changes: 1 addition & 1 deletion profile_library/lidl/HG08007/model.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@
"VERSION": "v1.14.0:docker"
},
"name": "Livarno Home outdoor LED band",
"standby_power": 0
"standby_power": 0.2
}
43 changes: 41 additions & 2 deletions profile_library/model_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,11 @@
"enum": [
"lut",
"linear",
"fixed"
"fixed",
"multi_switch",
"composite"
],
"description": "Supported calculation modes"
"description": "Supported calculation strategies"
},
"measure_method": {
"type": "string",
Expand Down Expand Up @@ -76,6 +78,7 @@
"cover",
"light",
"printer",
"smart_dimmer",
"smart_switch",
"smart_speaker",
"network",
Expand Down Expand Up @@ -163,6 +166,18 @@
}
}
},
"composite_config": {
"type": "array",
"description": "Configuration for composite calculation mode",
"items": {
"type": "object",
"properties": {
"condition": {
"$ref": "#/definitions/condition"
}
}
}
},
"config_flow_discovery_remarks": {
"type": "string",
"description": "Some remarks to show in the GUI config flow on first step of discovery"
Expand Down Expand Up @@ -220,5 +235,29 @@
}
}
}
},
"definitions": {
"condition": {
"type": "object",
"properties": {
"condition": {
"type": "string",
"enum": [
"and",
"or",
"state",
"numeric_state",
"template"
]
},
"conditions": {
"type": "array",
"items": {
"$ref": "#/definitions/condition"
}
}
},
"additionalProperties": true
}
}
}
2 changes: 2 additions & 0 deletions profile_library/tp-link/HS300/model.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
"name": "Kasa Smart Wi-Fi Power Strip",
"device_type": "smart_switch",
"calculation_strategy": "multi_switch",
"measure_method": "manual",
"measure_device": "unknown",
"multi_switch_config": {
"power": 0.725,
"power_off": 0.225
Expand Down
4 changes: 1 addition & 3 deletions profile_library/tp-link/KP125M/model.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,7 @@
"energy_sensor_naming": "{} Device Energy"
},
"device_type": "smart_switch",
"supported_modes": [
"fixed"
],
"calculation_strategy": "fixed",
"fixed_config": {
"power": 0.92
},
Expand Down
8 changes: 4 additions & 4 deletions tests/power_profile/test_power_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,10 @@ async def test_load_fixed_profile(hass: HomeAssistant) -> None:
)
assert power_profile.calculation_strategy == CalculationStrategy.FIXED
assert power_profile.standby_power == 0.5
assert power_profile.fixed_mode_config == {CONF_POWER: 50}
assert power_profile.fixed_config == {CONF_POWER: 50}

with pytest.raises(UnsupportedStrategyError):
_ = power_profile.linear_mode_config
_ = power_profile.linear_config


async def test_load_linear_profile(hass: HomeAssistant) -> None:
Expand All @@ -62,10 +62,10 @@ async def test_load_linear_profile(hass: HomeAssistant) -> None:
)
assert power_profile.calculation_strategy == CalculationStrategy.LINEAR
assert power_profile.standby_power == 0.5
assert power_profile.linear_mode_config == {CONF_MIN_POWER: 10, CONF_MAX_POWER: 30}
assert power_profile.linear_config == {CONF_MIN_POWER: 10, CONF_MAX_POWER: 30}

with pytest.raises(UnsupportedStrategyError):
_ = power_profile.fixed_mode_config
_ = power_profile.fixed_config


async def test_load_linked_profile(hass: HomeAssistant) -> None:
Expand Down
28 changes: 28 additions & 0 deletions tests/strategy/test_composite.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

from custom_components.powercalc.const import (
CONF_COMPOSITE,
CONF_CUSTOM_MODEL_DIRECTORY,
CONF_FIXED,
CONF_LINEAR,
CONF_MAX_POWER,
Expand All @@ -31,6 +32,7 @@
)
from tests.common import (
get_test_config_dir,
get_test_profile_dir,
run_powercalc_setup,
)

Expand Down Expand Up @@ -385,3 +387,29 @@ async def test_calculate_standby_power2(hass: HomeAssistant) -> None:
await hass.async_block_till_done()

assert hass.states.get("sensor.test_power").state == "1.00"


async def test_composite_strategy_from_library_profile(hass: HomeAssistant) -> None:
mock_registry(
hass,
{
"light.test": RegistryEntry(
entity_id="light.test",
unique_id="1234",
platform="light",
),
},
)

await run_powercalc_setup(
hass,
{
CONF_ENTITY_ID: "light.test",
CONF_CUSTOM_MODEL_DIRECTORY: get_test_profile_dir("composite"),
},
)

hass.states.async_set("light.test", STATE_ON, {ATTR_BRIGHTNESS: 200})
await hass.async_block_till_done()

assert hass.states.get("sensor.test_power").state == "0.82"
1 change: 1 addition & 0 deletions tests/strategy/test_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ async def test_exception_raised_when_no_power_profile_passed_lut_strategy(
CalculationStrategy.LINEAR,
CalculationStrategy.WLED,
CalculationStrategy.PLAYBOOK,
CalculationStrategy.COMPOSITE,
],
)
async def test_exception_raised_when_strategy_config_not_provided(
Expand Down
24 changes: 24 additions & 0 deletions tests/testing_config/powercalc_profiles/composite/model.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "IKEA Control outlet",
"measure_method": "manual",
"measure_device": "IKEA Control outlet",
"device_type": "smart_switch",
"calculation_strategy": "composite",
"composite_config": [
{
"condition": {
"condition": "state",
"entity_id": "light.test",
"state": "on"
},
"fixed": {
"power": 0.82
}
},
{
"fixed": {
"power": 0.52
}
}
]
}
38 changes: 38 additions & 0 deletions utils/library/validate_model_json.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import glob
import json
import os

from jsonschema import ValidationError, validate


def load_json(file_path: str) -> dict:
"""Load a JSON file from the given file path."""
with open(file_path) as file:
return json.load(file)


def validate_model(model_path: str, schema: dict) -> None:
"""Validate a JSON model against the schema."""
try:
model = load_json(model_path)
validate(instance=model, schema=schema)
print(f"VALID: {model_path}") # noqa: T201
except ValidationError as e:
print(f"INVALID: {model_path}\nError: {e.message}") # noqa: T201
except Exception as e: # noqa: BLE001
print(f"ERROR: {model_path}\nError: {e}") # noqa: T201


def validate_models_with_glob(directory: str, schema_path: str) -> None:
"""Validate model.json files up to 2 subdirectory levels using glob."""
schema = load_json(schema_path)
pattern = os.path.join(directory, "*/*/model.json")
for model_path in glob.glob(pattern):
validate_model(model_path, schema)


if __name__ == "__main__":
directory = os.path.join(os.path.dirname(__file__), "../../profile_library")
schema_file_path = os.path.join(os.path.dirname(__file__), "../../profile_library/model_schema.json")

validate_models_with_glob(directory, schema_file_path)

0 comments on commit 949446e

Please sign in to comment.