diff --git a/.github/workflows/validate-model-json.yml b/.github/workflows/validate-model-json.yml index ff5fcc6d3..e7e5f5c54 100644 --- a/.github/workflows/validate-model-json.yml +++ b/.github/workflows/validate-model-json.yml @@ -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 diff --git a/custom_components/powercalc/config_flow.py b/custom_components/powercalc/config_flow.py index d7afd1e98..a6e457370 100644 --- a/custom_components/powercalc/config_flow.py +++ b/custom_components/powercalc/config_flow.py @@ -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: @@ -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, diff --git a/custom_components/powercalc/power_profile/power_profile.py b/custom_components/powercalc/power_profile/power_profile.py index 42ccfa3b9..72a68b7e0 100644 --- a/custom_components/powercalc/power_profile/power_profile.py +++ b/custom_components/powercalc/power_profile/power_profile.py @@ -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( diff --git a/custom_components/powercalc/strategy/factory.py b/custom_components/powercalc/strategy/factory.py index 922842d17..f6c240046 100644 --- a/custom_components/powercalc/strategy/factory.py +++ b/custom_components/powercalc/strategy/factory.py @@ -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, @@ -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") @@ -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") @@ -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, @@ -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: diff --git a/profile_library/belkin/F7C063/model.json b/profile_library/belkin/F7C063/model.json index c4e9a8e75..0458ad9da 100644 --- a/profile_library/belkin/F7C063/model.json +++ b/profile_library/belkin/F7C063/model.json @@ -9,9 +9,7 @@ "energy_sensor_naming": "{} Device Energy" }, "device_type": "smart_switch", - "supported_modes": [ - "fixed" - ], + "calculation_strategy": "fixed", "fixed_config": { "power": 1.3 }, diff --git a/profile_library/eve/20EBU4101/model.json b/profile_library/eve/20EBU4101/model.json index c614d1153..5f95be372 100644 --- a/profile_library/eve/20EBU4101/model.json +++ b/profile_library/eve/20EBU4101/model.json @@ -9,9 +9,7 @@ "energy_sensor_naming": "{} Device Energy" }, "device_type": "smart_switch", - "supported_modes": [ - "fixed" - ], + "calculation_strategy": "fixed", "fixed_config": { "power": 0.9 }, diff --git a/profile_library/ledworks/TWS600STP/model.json b/profile_library/ledworks/TWS600STP/model.json index e4b099e45..4b118ef1e 100644 --- a/profile_library/ledworks/TWS600STP/model.json +++ b/profile_library/ledworks/TWS600STP/model.json @@ -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": { diff --git a/profile_library/lidl/HG08007/model.json b/profile_library/lidl/HG08007/model.json index 7c85f0039..e20cc6a7a 100644 --- a/profile_library/lidl/HG08007/model.json +++ b/profile_library/lidl/HG08007/model.json @@ -10,5 +10,5 @@ "VERSION": "v1.14.0:docker" }, "name": "Livarno Home outdoor LED band", - "standby_power": 0 + "standby_power": 0.2 } diff --git a/profile_library/model_schema.json b/profile_library/model_schema.json index a00755209..fa9bb0d4b 100644 --- a/profile_library/model_schema.json +++ b/profile_library/model_schema.json @@ -37,9 +37,11 @@ "enum": [ "lut", "linear", - "fixed" + "fixed", + "multi_switch", + "composite" ], - "description": "Supported calculation modes" + "description": "Supported calculation strategies" }, "measure_method": { "type": "string", @@ -76,6 +78,7 @@ "cover", "light", "printer", + "smart_dimmer", "smart_switch", "smart_speaker", "network", @@ -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" @@ -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 + } } } diff --git a/profile_library/tp-link/HS300/model.json b/profile_library/tp-link/HS300/model.json index 5541b28d7..cebe1ad86 100644 --- a/profile_library/tp-link/HS300/model.json +++ b/profile_library/tp-link/HS300/model.json @@ -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 diff --git a/profile_library/tp-link/KP125M/model.json b/profile_library/tp-link/KP125M/model.json index 03fa46f84..6020536b9 100644 --- a/profile_library/tp-link/KP125M/model.json +++ b/profile_library/tp-link/KP125M/model.json @@ -9,9 +9,7 @@ "energy_sensor_naming": "{} Device Energy" }, "device_type": "smart_switch", - "supported_modes": [ - "fixed" - ], + "calculation_strategy": "fixed", "fixed_config": { "power": 0.92 }, diff --git a/tests/power_profile/test_power_profile.py b/tests/power_profile/test_power_profile.py index f9d003450..8e9758f00 100644 --- a/tests/power_profile/test_power_profile.py +++ b/tests/power_profile/test_power_profile.py @@ -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: @@ -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: diff --git a/tests/strategy/test_composite.py b/tests/strategy/test_composite.py index b6f6c8cd0..aff5f806a 100644 --- a/tests/strategy/test_composite.py +++ b/tests/strategy/test_composite.py @@ -18,6 +18,7 @@ from custom_components.powercalc.const import ( CONF_COMPOSITE, + CONF_CUSTOM_MODEL_DIRECTORY, CONF_FIXED, CONF_LINEAR, CONF_MAX_POWER, @@ -31,6 +32,7 @@ ) from tests.common import ( get_test_config_dir, + get_test_profile_dir, run_powercalc_setup, ) @@ -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" diff --git a/tests/strategy/test_factory.py b/tests/strategy/test_factory.py index fa29ae0e5..4d994ba74 100644 --- a/tests/strategy/test_factory.py +++ b/tests/strategy/test_factory.py @@ -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( diff --git a/tests/testing_config/powercalc_profiles/composite/model.json b/tests/testing_config/powercalc_profiles/composite/model.json new file mode 100644 index 000000000..bceceb297 --- /dev/null +++ b/tests/testing_config/powercalc_profiles/composite/model.json @@ -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 + } + } + ] +} diff --git a/utils/library/validate_model_json.py b/utils/library/validate_model_json.py new file mode 100644 index 000000000..79233022e --- /dev/null +++ b/utils/library/validate_model_json.py @@ -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)