From 49a6ccea785f7d557f2eef4283c0c10f38e754fa Mon Sep 17 00:00:00 2001 From: Jean-Marc Collin Date: Wed, 17 Jul 2024 07:11:58 +0000 Subject: [PATCH 1/2] Add battery_soc in config_flow ok --- .devcontainer/configuration.yaml | 8 +++ .../solar_optimizer/config_flow.py | 3 + .../solar_optimizer/strings.json | 12 ++-- .../solar_optimizer/translations/en.json | 12 ++-- .../solar_optimizer/translations/fr.json | 12 ++-- tests/test_config_flow.py | 61 ++++++++++++++++++- 6 files changed, 94 insertions(+), 14 deletions(-) diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml index eb01698..12d8cfb 100644 --- a/.devcontainer/configuration.yaml +++ b/.devcontainer/configuration.yaml @@ -122,6 +122,14 @@ input_number: unit_of_measurement: A mode: slider step: 1 + battery_soc: + name: Battery SOC + min: 0 + max: 100 + icon: mdi:battery + unit_of_measurement: '%' + mode: slider + step: 1 template: - trigger: diff --git a/custom_components/solar_optimizer/config_flow.py b/custom_components/solar_optimizer/config_flow.py index ce12c9c..845cb87 100644 --- a/custom_components/solar_optimizer/config_flow.py +++ b/custom_components/solar_optimizer/config_flow.py @@ -41,6 +41,9 @@ selector.EntitySelectorConfig(domain=[INPUT_NUMBER_DOMAIN]) ), vol.Optional("smooth_production", default=True): cv.boolean, + vol.Optional("battery_soc_entity_id"): selector.EntitySelector( + selector.EntitySelectorConfig(domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN]) + ), } diff --git a/custom_components/solar_optimizer/strings.json b/custom_components/solar_optimizer/strings.json index 6a48ef0..563c8fa 100644 --- a/custom_components/solar_optimizer/strings.json +++ b/custom_components/solar_optimizer/strings.json @@ -13,7 +13,8 @@ "sell_cost_entity_id": "Energy sell price", "buy_cost_entity_id": "Energy buy price", "sell_tax_percent_entity_id": "Sell taxe percent", - "smooth_production": "Smooth the solar production" + "smooth_production": "Smooth the solar production", + "battery_soc_entity_id": "Battery soc" }, "data_description": { "refresh_period_sec": "Refresh period in seconds. Warning heavy calculs are done at each period. Don't refresh to often", @@ -22,7 +23,8 @@ "sell_cost_entity_id": "The entity_id which holds the current energy sell price.", "buy_cost_entity_id": "The entity_id which holds the current energy buy price.", "sell_tax_percent_entity_id": "The energy resell tax percent (0 to 100)", - "smooth_production": "If checked, the solar production will be smoothed to avoid hard variation" + "smooth_production": "If checked, the solar production will be smoothed to avoid hard variation", + "battery_soc_entity_id": "Battery state of charge in %. If you don't have battery, keep it empty" } } } @@ -40,7 +42,8 @@ "sell_cost_entity_id": "Energy sell price", "buy_cost_entity_id": "Energy buy price", "sell_tax_percent_entity_id": "Sell taxe percent", - "smooth_production": "Smooth the solar production" + "smooth_production": "Smooth the solar production", + "battery_soc_entity_id": "Battery soc" }, "data_description": { "refresh_period_sec": "Refresh period in seconds. Warning heavy calculs are done at each period. Don't refresh to often", @@ -49,7 +52,8 @@ "sell_cost_entity_id": "The entity_id which holds the current energy sell price.", "buy_cost_entity_id": "The entity_id which holds the current energy buy price.", "sell_tax_percent_entity_id": "The energy resell tax percent (0 to 100)", - "smooth_production": "If checked, the solar production will be smoothed to avoid hard variation" + "smooth_production": "If checked, the solar production will be smoothed to avoid hard variation", + "battery_soc_entity_id": "Battery state of charge in %. If you don't have battery, keep it empty" } } } diff --git a/custom_components/solar_optimizer/translations/en.json b/custom_components/solar_optimizer/translations/en.json index 6a48ef0..563c8fa 100644 --- a/custom_components/solar_optimizer/translations/en.json +++ b/custom_components/solar_optimizer/translations/en.json @@ -13,7 +13,8 @@ "sell_cost_entity_id": "Energy sell price", "buy_cost_entity_id": "Energy buy price", "sell_tax_percent_entity_id": "Sell taxe percent", - "smooth_production": "Smooth the solar production" + "smooth_production": "Smooth the solar production", + "battery_soc_entity_id": "Battery soc" }, "data_description": { "refresh_period_sec": "Refresh period in seconds. Warning heavy calculs are done at each period. Don't refresh to often", @@ -22,7 +23,8 @@ "sell_cost_entity_id": "The entity_id which holds the current energy sell price.", "buy_cost_entity_id": "The entity_id which holds the current energy buy price.", "sell_tax_percent_entity_id": "The energy resell tax percent (0 to 100)", - "smooth_production": "If checked, the solar production will be smoothed to avoid hard variation" + "smooth_production": "If checked, the solar production will be smoothed to avoid hard variation", + "battery_soc_entity_id": "Battery state of charge in %. If you don't have battery, keep it empty" } } } @@ -40,7 +42,8 @@ "sell_cost_entity_id": "Energy sell price", "buy_cost_entity_id": "Energy buy price", "sell_tax_percent_entity_id": "Sell taxe percent", - "smooth_production": "Smooth the solar production" + "smooth_production": "Smooth the solar production", + "battery_soc_entity_id": "Battery soc" }, "data_description": { "refresh_period_sec": "Refresh period in seconds. Warning heavy calculs are done at each period. Don't refresh to often", @@ -49,7 +52,8 @@ "sell_cost_entity_id": "The entity_id which holds the current energy sell price.", "buy_cost_entity_id": "The entity_id which holds the current energy buy price.", "sell_tax_percent_entity_id": "The energy resell tax percent (0 to 100)", - "smooth_production": "If checked, the solar production will be smoothed to avoid hard variation" + "smooth_production": "If checked, the solar production will be smoothed to avoid hard variation", + "battery_soc_entity_id": "Battery state of charge in %. If you don't have battery, keep it empty" } } } diff --git a/custom_components/solar_optimizer/translations/fr.json b/custom_components/solar_optimizer/translations/fr.json index 57424e1..563c8fa 100644 --- a/custom_components/solar_optimizer/translations/fr.json +++ b/custom_components/solar_optimizer/translations/fr.json @@ -13,7 +13,8 @@ "sell_cost_entity_id": "Energy sell price", "buy_cost_entity_id": "Energy buy price", "sell_tax_percent_entity_id": "Sell taxe percent", - "smooth_production": "Lisse la production" + "smooth_production": "Smooth the solar production", + "battery_soc_entity_id": "Battery soc" }, "data_description": { "refresh_period_sec": "Refresh period in seconds. Warning heavy calculs are done at each period. Don't refresh to often", @@ -22,7 +23,8 @@ "sell_cost_entity_id": "The entity_id which holds the current energy sell price.", "buy_cost_entity_id": "The entity_id which holds the current energy buy price.", "sell_tax_percent_entity_id": "The energy resell tax percent (0 to 100)", - "smooth_production": "Si True la production solaire sera lissée pour éviter les fortes variations" + "smooth_production": "If checked, the solar production will be smoothed to avoid hard variation", + "battery_soc_entity_id": "Battery state of charge in %. If you don't have battery, keep it empty" } } } @@ -40,7 +42,8 @@ "sell_cost_entity_id": "Energy sell price", "buy_cost_entity_id": "Energy buy price", "sell_tax_percent_entity_id": "Sell taxe percent", - "smooth_production": "Lisse la production" + "smooth_production": "Smooth the solar production", + "battery_soc_entity_id": "Battery soc" }, "data_description": { "refresh_period_sec": "Refresh period in seconds. Warning heavy calculs are done at each period. Don't refresh to often", @@ -49,7 +52,8 @@ "sell_cost_entity_id": "The entity_id which holds the current energy sell price.", "buy_cost_entity_id": "The entity_id which holds the current energy buy price.", "sell_tax_percent_entity_id": "The energy resell tax percent (0 to 100)", - "smooth_production": "Si True la production solaire sera lissée pour éviter les fortes variations" + "smooth_production": "If checked, the solar production will be smoothed to avoid hard variation", + "battery_soc_entity_id": "Battery state of charge in %. If you don't have battery, keep it empty" } } } diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 9ff6165..084dd94 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -24,15 +24,16 @@ async def test_empty_config(hass: HomeAssistant): @pytest.mark.parametrize( - "power_consumption,power_production,sell_cost,buy_cost", + "power_consumption,power_production,sell_cost,buy_cost, battery_soc", itertools.product( ["sensor.power_consumption", "input_number.power_consumption"], ["sensor.power_production", "input_number.power_production"], ["sensor.sell_cost", "input_number.sell_cost"], ["sensor.buy_cost", "input_number.buy_cost"], + ["sensor.battery_soc", "input_number.battery_soc"], ), ) -async def test_config_inputs( +async def test_config_inputs_with_battery( hass: HomeAssistant, init_solar_optimizer_with_2_devices_power_not_power, init_solar_optimizer_entry, @@ -40,6 +41,60 @@ async def test_config_inputs( power_production, sell_cost, buy_cost, + battery_soc +): + _result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + + assert _result["step_id"] == "user" + assert _result["type"] == FlowResultType.FORM + assert _result["errors"] is None + + user_input = { + "refresh_period_sec": 300, + "power_consumption_entity_id": power_consumption, + "power_production_entity_id": power_production, + "sell_cost_entity_id": sell_cost, + "buy_cost_entity_id": buy_cost, + "sell_tax_percent_entity_id": "input_number.tax_percent", + "battery_soc_entity_id": battery_soc, + } + + result = await hass.config_entries.flow.async_configure( + _result["flow_id"], + user_input + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + data = result.get("data") + assert data is not None + + for key, value in user_input.items(): + assert data.get(key) == value + + assert data["smooth_production"] + + assert result["title"] == "SolarOptimizer" + +@pytest.mark.parametrize( + "power_consumption,power_production,sell_cost,buy_cost", + itertools.product( + ["sensor.power_consumption", "input_number.power_consumption"], + ["sensor.power_production", "input_number.power_production"], + ["sensor.sell_cost", "input_number.sell_cost"], + ["sensor.buy_cost", "input_number.buy_cost"], + ), +) +async def test_config_inputs_wo_battery( + hass: HomeAssistant, + init_solar_optimizer_with_2_devices_power_not_power, + init_solar_optimizer_entry, + power_consumption, + power_production, + sell_cost, + buy_cost ): _result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": "user"} @@ -71,6 +126,8 @@ async def test_config_inputs( for key, value in user_input.items(): assert data.get(key) == value + assert data.get("battery_soc_entity_id") is None + assert data["smooth_production"] assert result["title"] == "SolarOptimizer" From e837d85192f855f3a57d585de26ea81b70c4c82a Mon Sep 17 00:00:00 2001 From: Jean-Marc Collin Date: Thu, 18 Jul 2024 06:53:06 +0000 Subject: [PATCH 2/2] Add battery management --- .devcontainer/configuration.yaml | 4 ++ README-fr.md | 12 +++- README.md | 12 +++- custom_components/solar_optimizer/__init__.py | 1 + .../solar_optimizer/coordinator.py | 9 ++- .../solar_optimizer/managed_device.py | 19 +++++- .../solar_optimizer/manifest.json | 2 +- custom_components/solar_optimizer/sensor.py | 9 ++- .../simulated_annealing_algo.py | 2 + custom_components/solar_optimizer/switch.py | 24 +++++++ tests/conftest.py | 62 ++++++++++++++++++ tests/test_battery.py | 63 +++++++++++++++++++ tests/test_config_flow.py | 47 +++++++------- 13 files changed, 235 insertions(+), 31 deletions(-) create mode 100644 tests/test_battery.py diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml index 12d8cfb..71d83cf 100644 --- a/.devcontainer/configuration.yaml +++ b/.devcontainer/configuration.yaml @@ -201,6 +201,7 @@ solar_optimizer: action_mode: "service_call" activation_service: "input_boolean/turn_on" deactivation_service: "input_boolean/turn_off" + battery_soc_threshold: 30 - name: "Equipement G" entity_id: "input_boolean.fake_device_g" power_max: 1200 @@ -209,6 +210,7 @@ solar_optimizer: action_mode: "service_call" activation_service: "input_boolean/turn_on" deactivation_service: "input_boolean/turn_off" + battery_soc_threshold: 40 - name: "Equipement H" entity_id: "input_boolean.fake_device_h" power_entity_id: "input_number.tesla_amps" @@ -225,6 +227,7 @@ solar_optimizer: deactivation_service: "input_boolean/turn_off" change_power_service: "input_number/set_value" convert_power_divide_factor: 660 + battery_soc_threshold: 50 - name: "Equipement I" entity_id: "switch.fake_switch_1" power_max: 20 @@ -233,6 +236,7 @@ solar_optimizer: action_mode: "service_call" activation_service: "switch/turn_on" deactivation_service: "switch/turn_off" + battery_soc_threshold: 60 diff --git a/README-fr.md b/README-fr.md index 1e550f0..33c1ba3 100644 --- a/README-fr.md +++ b/README-fr.md @@ -24,6 +24,8 @@ > ![Nouveau](https://github.com/jmcollin78/solar_optimizer/blob/main/images/new-icon.png?raw=true) _*Nouveautés*_ +> * **release 1.7.0** : +> - ajout d'une gestion d'une batterie. Vous pouvez spécifier une entité de type pourcentage qui donne l'état de charge de la batterie (soc). Sur chaque équipements vous pouvez spécifier un paramètre `battery_soc_threshold` : le seuil de batterie en dessous duquel l'équipement ne sera pas utilisable. > * **release 1.3.0** : > - ajout du paramètre `duration_stop_min` qui permet de spécifier une durée minimale de désactivation pour le distinguer du délai minimal d'activation `duration_min`. Si non spécifié, ce paramètre prend la valeur de `duration_min`. > - restaure l'état des switchs `enable` au démarrage de l'intégration. @@ -58,7 +60,9 @@ A chaque équipement configuré est associé une entité de type switch qui perm Par ailleurs, il est possible de définir une règle d'utilisabilité des équipements. Par exemple, si la voiture est chargée à plus de 90%, l'algorithme considère que l'équipement qui pilote la charge de la voiture doit être éteint. Cette régle est définit sous la forme d'un template configurable qui vaut True si l'équipement est utilisable. -Ces 2 règles permettent à l'algorithme de ne commander que ce qui est réellement utile à un instant t. Ces règles sont ré-évaluées à chaque cycle. +Si une batterie est spécifiée lors du paramétrage de l'intégration et si le seuil `battery_soc_threshold` est spécifié, l'équipement ne sera utilisable que si le soc (pourcentage de charge de la batterie) est supérieur ou égal au seuil. + +Ces 3 règles permettent à l'algorithme de ne commander que ce qui est réellement utile à un instant t. Ces règles sont ré-évaluées à chaque cycle. # Comment on l'installe ? ## HACS installation (recommendé) @@ -107,6 +111,7 @@ devices: action_mode: "service_call" activation_service: " deactivation_service: "switch/turn_off" + battery_soc_threshold: 30 ``` Note: les paramètres sous `algorithm` ne doivent pas être touchés sauf si vous savez exactement ce que vous faites. @@ -124,6 +129,7 @@ Sous `devices` il faut déclarer tous les équipements qui seront commandés par | `action_mode` | tous | le mode d'action pour allumer ou éteindre l'équipement. Peut être "service_call" ou "event" (*) | "service_call" | "service_call" indique que l'équipement s'allume et s'éteint via un appel de service. Cf. ci-dessous. "event" indique qu'un évènement est envoyé lorsque l'état doit changer. Cf. (*) | | `activation_service` | uniquement si action_mode="service_call" | le service a appeler pour activer l'équipement sous la forme "domain/service" | "switch/turn_on" | l'activation déclenchera le service "switch/turn_on" sur l'entité "entity_id" | | `deactivation_service` | uniquement si action_mode="service_call" | le service a appeler pour désactiver l'équipement sous la forme "domain/service" | "switch/turn_off" | la désactivation déclenchera le service "switch/turn_off" sur l'entité "entity_id" | +| `battery_soc_threshold` | tous | le pourcentage minimal de charge de la batterie pour que l'équipement soit utilisable | 30 | | Pour les équipements à puissance variable, les attributs suivants doivent être valorisés : @@ -155,6 +161,8 @@ devices: activation_service: "switch/turn_on" # Le service permettant de désactiver le switch deactivation_service: "switch/turn_off" + # On autorise le démarrage de la pompe si il y a 10% de batterie dans l'installation solaire + battery_soc_threshold: 10 - name: "Recharge Tesla" entity_id: "switch.cloucloute_charger" @@ -182,6 +190,8 @@ devices: change_power_service: "number/set_value" # le facteur permettant de convertir la puissance consigne en Ampères (number.tesla_charging_amps prend des Ampères) convert_power_divide_factor: 660 + # On ne démarre pas une charge si la batterie de l'installation solaire n'est pas chargée à au moins 50% + battery_soc_threshold: 50 ... ``` Tout changement dans la configuration nécessite un arrêt / relance de l'intégration (ou de Home Assistant) pour être pris en compte. diff --git a/README.md b/README.md index ed58aaa..b86edec 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ >![New](https://github.com/jmcollin78/solar_optimizer/blob/main/images/new-icon.png?raw=true) _*News*_ +> * **release 1.7.0**: +> - added battery management. You can specify a percentage type entity that gives the state of charge of the battery (soc). On each device you can specify a `battery_soc_threshold` parameter: the battery threshold below which the device will not be usable. > * **release 1.3.0**: > - added the parameter `duration_stop_min` which allows to specify a minimal duration of deactivation to distinguish it from the minimal delay of activation `duration_min`. If not specified, this parameter takes the value of `duration_min`. > - restores the state of the `enable` switches when the integration starts. @@ -58,7 +60,9 @@ Each configured device is associated with a switch-type entity that authorizes t In addition, it is possible to define a usability rule for equipment. For example, if the car is charged at more than 90%, the algorithm considers that the equipment which controls the charging of the car must be switched off. This rule is defined in the form of a configurable template which is True if the equipment is usable. -These 2 rules allow the algorithm to control only what is really useful at a time t. These rules are re-evaluated at each cycle. +If a battery is specified when configuring the integration and if the threshold `battery_soc_threshold` is specified, the equipment will only be usable if the soc (percentage of battery charge) is greater than or equal to the threshold. + +These 3 rules allow the algorithm to only order what is really useful at a given time. These rules are re-evaluated each cycle. # How do we install it? ## HACS installation (recommended) @@ -107,6 +111,7 @@ devices: action_mode: "service_call" activation_service: "" deactivation_service: "" + battery_soc_threshold: 30 ``` Note: parameters under `algorithm` should not be touched unless you know exactly what you are doing. @@ -124,6 +129,7 @@ Under `devices` you must declare all the equipment that will be controlled by So | `action_mode` | all | the mode of action for turning the equipment on or off. Can be "service_call" or "event" (*) | "service_call" | "service_call" indicates that the equipment is switched on and off via a service call. See below. "event" indicates that an event is sent when the state should change. See (*) | | `activation_service` | only if action_mode="service_call" | the service to be called to activate the equipment in the form "domain/service" | "switch/turn_on" | activation will trigger the "switch/turn_on" service on the entity "entity_id" | | `deactivation_service` | only if action_mode="service_call" | the service to call to deactivate the equipment in the form "domain/service" | "switch/turn_off" | deactivation will trigger the "switch/turn_off" service on the entity "entity_id" | +| `battery_soc_threshold` | tous | minimal percentage of charge of the solar battery to enable this device | 30 | | For variable power equipment, the following attributes must be valued: @@ -155,6 +161,8 @@ devices: activation_service: "switch/turn_on" # The service to deactivate the switch deactivation_service: "switch/turn_off" + # We authorize the pump to start if there is 10% battery in the solar installation + battery_soc_threshold: 10 - name: "Tesla Recharge" entity_id: "switch.cloucloute_charger" @@ -182,6 +190,8 @@ devices: change_power_service: "number/set_value" # the factor used to convert the set power into Amps (number.tesla_charging_amps takes Amps) convert_power_divide_factor: 660 + # We do not start a charge if the battery of the solar installation is not at least 50% charged + battery_soc_threshold: 50 ... ``` Any change in the configuration requires a stop / restart of the integration (or of Home Assistant) to be taken into account. diff --git a/custom_components/solar_optimizer/__init__.py b/custom_components/solar_optimizer/__init__.py index c3f4bc2..b8de78e 100644 --- a/custom_components/solar_optimizer/__init__.py +++ b/custom_components/solar_optimizer/__init__.py @@ -68,6 +68,7 @@ vol.Optional("convert_power_divide_factor"): vol.Coerce( float ), + vol.Optional("battery_soc_threshold", default=0): vol.Coerce(float), } ] ), diff --git a/custom_components/solar_optimizer/coordinator.py b/custom_components/solar_optimizer/coordinator.py index edd1fed..1707295 100644 --- a/custom_components/solar_optimizer/coordinator.py +++ b/custom_components/solar_optimizer/coordinator.py @@ -22,8 +22,7 @@ def get_safe_float(hass, entity_id: str): """Get a safe float state value for an entity. Return None if entity is not available""" - state = hass.states.get(entity_id) - if not state or state.state == "unknown" or state.state == "unavailable": + if entity_id is None or not (state := hass.states.get(entity_id)) or state.state == "unknown" or state.state == "unavailable": return None float_val = float(state.state) return None if math.isinf(float_val) or not math.isfinite(float_val) else float_val @@ -38,6 +37,7 @@ class SolarOptimizerCoordinator(DataUpdateCoordinator): _sell_cost_entity_id: str _buy_cost_entity_id: str _sell_tax_percent_entity_id: str + _battery_soc_entity_id: str _smooth_production: bool _last_production: float @@ -87,6 +87,7 @@ async def configure(self, config: ConfigEntry) -> None: self._sell_cost_entity_id = config.data.get("sell_cost_entity_id") self._buy_cost_entity_id = config.data.get("buy_cost_entity_id") self._sell_tax_percent_entity_id = config.data.get("sell_tax_percent_entity_id") + self._battery_soc_entity_id = config.data.get("battery_soc_entity_id") self._smooth_production = config.data.get("smooth_production") is True self._last_production = 0.0 @@ -143,6 +144,9 @@ async def _async_update_data(self): self.hass, self._sell_tax_percent_entity_id ) + soc = get_safe_float(self.hass, self._battery_soc_entity_id) + calculated_data["battery_soc"] = soc if soc is not None else 0 + # # Call Algorithm Recuit simulé # @@ -153,6 +157,7 @@ async def _async_update_data(self): calculated_data["sell_cost"], calculated_data["buy_cost"], calculated_data["sell_tax_percent"], + calculated_data["battery_soc"] ) calculated_data["best_solution"] = best_solution diff --git a/custom_components/solar_optimizer/managed_device.py b/custom_components/solar_optimizer/managed_device.py index f3ac43d..0d0fb69 100644 --- a/custom_components/solar_optimizer/managed_device.py +++ b/custom_components/solar_optimizer/managed_device.py @@ -122,6 +122,8 @@ class ManagedDevice: _deactivation_service: str _change_power_service: str _convert_power_divide_factor: int + _battery_soc: float + _battery_soc_threshold: float def __init__(self, hass: HomeAssistant, device_config): """Initialize a manageable device""" @@ -133,7 +135,6 @@ def __init__(self, hass: HomeAssistant, device_config): self._power_max = int(device_config.get("power_max")) self._power_min = int(device_config.get("power_min") or -1) self._power_step = int(device_config.get("power_step") or 0) - self._power_step = int(device_config.get("power_step") or 0) self._can_change_power = self._power_min >= 0 self._convert_power_divide_factor = int( device_config.get("convert_power_divide_factor") or 1 @@ -174,6 +175,9 @@ def __init__(self, hass: HomeAssistant, device_config): self._deactivation_service = device_config.get("deactivation_service") self._change_power_service = device_config.get("change_power_service") + self._battery_soc = None + self._battery_soc_threshold = float(device_config.get("battery_soc_threshold") or 0) + if self.is_active: self._requested_power = self._current_power = ( self._power_max if self._can_change_power else self._power_min @@ -345,7 +349,7 @@ def is_active(self) -> bool: @property def is_usable(self) -> bool: """A device is usable for optimisation if the check_usable_template returns true and - if the device is not waiting for the end of its cycle""" + if the device is not waiting for the end of its cycle and if the battery_soc_threshold is >= battery_soc""" context = {} now = datetime.now(get_tz(self._hass)) @@ -356,6 +360,11 @@ def is_usable(self) -> bool: if not result: _LOGGER.debug("%s is not usable", self._name) + if result and self._battery_soc is not None and self._battery_soc_threshold is not None: + if self._battery_soc < self._battery_soc_threshold: + result = False + _LOGGER.debug("%s is not usable due to battery soc threshold (%s < %s)", self._name, self._battery_soc, self._battery_soc_threshold) + return result @property @@ -449,6 +458,12 @@ def convert_power_divide_factor(self) -> int: """return""" return self._convert_power_divide_factor + def set_battery_soc(self, battery_soc): + """Define the battery soc. This is used with is_usable + to determine if the device is usable""" + self._battery_soc = battery_soc + + def publish_enable_state_change(self) -> None: """Publish an event when the state is changed""" diff --git a/custom_components/solar_optimizer/manifest.json b/custom_components/solar_optimizer/manifest.json index c335aef..f97ae47 100644 --- a/custom_components/solar_optimizer/manifest.json +++ b/custom_components/solar_optimizer/manifest.json @@ -10,5 +10,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/jmcollin78/solar_optimizer/issues", "quality_scale": "silver", - "version": "1.6.0" + "version": "1.7.0" } \ No newline at end of file diff --git a/custom_components/solar_optimizer/sensor.py b/custom_components/solar_optimizer/sensor.py index e5c7d04..6206436 100644 --- a/custom_components/solar_optimizer/sensor.py +++ b/custom_components/solar_optimizer/sensor.py @@ -33,8 +33,9 @@ async def async_setup_entry( entity2 = SolarOptimizerSensorEntity(coordinator, hass, "total_power") entity3 = SolarOptimizerSensorEntity(coordinator, hass, "power_production") entity4 = SolarOptimizerSensorEntity(coordinator, hass, "power_production_brut") + entity5 = SolarOptimizerSensorEntity(coordinator, hass, "battery_soc") - async_add_entities([entity1, entity2, entity3, entity4], False) + async_add_entities([entity1, entity2, entity3, entity4, entity5], False) await coordinator.configure(entry) @@ -80,6 +81,8 @@ def icon(self) -> str | None: return "mdi:bullseye-arrow" elif self.idx == "total_power": return "mdi:flash" + elif self.idx == "battery_soc": + return "mdi:battery" else: return "mdi:solar-power-variant" @@ -87,6 +90,8 @@ def icon(self) -> str | None: def device_class(self) -> SensorDeviceClass | None: if self.idx == "best_objective": return SensorDeviceClass.MONETARY + elif self.idx == "battery_soc": + return SensorDeviceClass.BATTERY else: return SensorDeviceClass.POWER @@ -101,5 +106,7 @@ def state_class(self) -> SensorStateClass | None: def native_unit_of_measurement(self) -> str | None: if self.idx == "best_objective": return "€" + elif self.idx == "battery_soc": + return "%" else: return UnitOfPower.WATT diff --git a/custom_components/solar_optimizer/simulated_annealing_algo.py b/custom_components/solar_optimizer/simulated_annealing_algo.py index 1a935d3..0c4fd24 100644 --- a/custom_components/solar_optimizer/simulated_annealing_algo.py +++ b/custom_components/solar_optimizer/simulated_annealing_algo.py @@ -55,6 +55,7 @@ def recuit_simule( sell_cost: float, buy_cost: float, sell_tax_percent: float, + battery_soc: float ): """The entrypoint of the algorithm: You should give: @@ -104,6 +105,7 @@ def recuit_simule( _LOGGER.debug("%s is disabled. Forget it", device.name) continue + device.set_battery_soc(battery_soc) usable = device.is_usable waiting = device.is_waiting # Force deactivation if active, not usable and not waiting diff --git a/custom_components/solar_optimizer/switch.py b/custom_components/solar_optimizer/switch.py index bb6692a..50eff94 100644 --- a/custom_components/solar_optimizer/switch.py +++ b/custom_components/solar_optimizer/switch.py @@ -61,6 +61,28 @@ async def async_setup_entry( class ManagedDeviceSwitch(CoordinatorEntity, SwitchEntity): """The entity holding the algorithm calculation""" + _entity_component_unrecorded_attributes = ( + SwitchEntity._entity_component_unrecorded_attributes.union( + frozenset( + { + "is_enabled", + "is_active", + "is_waiting", + "is_usable", + "can_change_power", + "duration_sec", + "duration_power_sec", + "power_min", + "power_max", + "next_date_available", + "next_date_available_power", + "battery_soc_threshold", + "battery_soc", + } + ) + ) + ) + def __init__(self, coordinator, hass, name, idx, entity_id): _LOGGER.debug("Adding ManagedDeviceSwitch for %s", name) super().__init__(coordinator, context=idx) @@ -176,6 +198,8 @@ def update_custom_attributes(self, device): "next_date_available_power": device.next_date_available_power.astimezone( current_tz ).isoformat(), + "battery_soc_threshold": device._battery_soc_threshold, + "battery_soc": device._battery_soc, } @callback diff --git a/tests/conftest.py b/tests/conftest.py index 099b4f3..fd60e6f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -105,6 +105,68 @@ async def init_solar_optimizer_with_2_devices_power_not_power(hass, config_2_dev return hass.data[DOMAIN]["coordinator"] +@pytest.fixture(name="config_2_devices_power_not_power_battery") +def define_config_2_devices_battery(): + """Define a configuration with 2 devices. One with power and the other without power""" + + return { + "solar_optimizer": { + "algorithm": { + "initial_temp": 1000, + "min_temp": 0.1, + "cooling_factor": 0.95, + "max_iteration_number": 1000, + }, + "devices": [ + { + "name": "Equipement A", + "entity_id": "input_boolean.fake_device_a", + "power_max": 1000, + "check_usable_template": "{{ True }}", + "duration_min": 0.3, + "duration_stop_min": 0.1, + "action_mode": "service_call", + "activation_service": "input_boolean/turn_on", + "deactivation_service": "input_boolean/turn_off", + "battery_soc_threshold": 30, + }, + { + "name": "Equipement B", + "entity_id": "input_boolean.fake_device_b", + "power_max": 2000, + "power_min": 100, + "power_step": 150, + "check_usable_template": "{{ False }}", + "duration_min": 1, + "duration_stop_min": 2, + "duration_power_min": 3, + "action_mode": "event", + "convert_power_divide_factor": 6, + "change_power_service": "input_number/set_value", + "power_entity_id": "input_number.tesla_amps", + "activation_service": "input_boolean/turn_on", + "deactivation_service": "input_boolean/turn_off", + "battery_soc_threshold": 50, + }, + ], + } + } + + +@pytest.fixture(name="init_solar_optimizer_with_2_devices_power_not_power_battery") +async def init_solar_optimizer_with_2_devices_power_not_power_battery( + hass, config_2_devices_power_not_power_battery +) -> SolarOptimizerCoordinator: + """Initialization of Solar Optimizer with 2 managed device: + The first don't have the power activated, and second is configured for power. + The second is also not usable because the temple returns always False + """ + await async_setup_component( + hass, "solar_optimizer", config_2_devices_power_not_power_battery + ) + return hass.data[DOMAIN]["coordinator"] + + @pytest.fixture(name="init_solar_optimizer_entry") async def init_solar_optimizer_entry(hass): """ Initialization of the integration from an Entry """ diff --git a/tests/test_battery.py b/tests/test_battery.py new file mode 100644 index 0000000..11809c1 --- /dev/null +++ b/tests/test_battery.py @@ -0,0 +1,63 @@ +""" Test the "is_usable" flag with battery """ + +# from unittest.mock import patch +# from datetime import datetime + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN + +from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import + + +async def test_is_usable( + hass: HomeAssistant, + init_solar_optimizer_with_2_devices_power_not_power_battery, + init_solar_optimizer_entry, # pylint: disable=unused-argument +): + """Testing is_usable feature""" + + coordinator: SolarOptimizerCoordinator = ( + init_solar_optimizer_with_2_devices_power_not_power_battery + ) + + assert coordinator is not None + assert coordinator.devices is not None + assert len(coordinator.devices) == 2 + + device: ManagedDevice = coordinator.devices[0] + assert device.name == "Equipement A" + device_switch = search_entity( + hass, "switch.solar_optimizer_equipement_a", SWITCH_DOMAIN + ) + + assert ( + device_switch.get_attr_extra_state_attributes.get("battery_soc_threshold") == 30 + ) + + # no soc set + assert device.is_usable is True + assert device_switch.get_attr_extra_state_attributes.get("is_usable") is True + + device.set_battery_soc(20) + # device A threshold is 30 + assert device.is_usable is False + # Change state to force writing new state + device_switch.update_custom_attributes(device) + assert device_switch.get_attr_extra_state_attributes.get("is_usable") is False + + device.set_battery_soc(30) + # device A threshold is 30 + assert device.is_usable is True + device_switch.update_custom_attributes(device) + assert device_switch.get_attr_extra_state_attributes.get("is_usable") is True + + device.set_battery_soc(40) + # device A threshold is 30 + assert device.is_usable is True + device_switch.update_custom_attributes(device) + assert device_switch.get_attr_extra_state_attributes.get("is_usable") is True + + device.set_battery_soc(None) + # device A threshold is 30 + assert device.is_usable is True + device_switch.update_custom_attributes(device) + assert device_switch.get_attr_extra_state_attributes.get("is_usable") is True diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 084dd94..6d0b199 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -24,16 +24,15 @@ async def test_empty_config(hass: HomeAssistant): @pytest.mark.parametrize( - "power_consumption,power_production,sell_cost,buy_cost, battery_soc", + "power_consumption,power_production,sell_cost,buy_cost", itertools.product( ["sensor.power_consumption", "input_number.power_consumption"], ["sensor.power_production", "input_number.power_production"], ["sensor.sell_cost", "input_number.sell_cost"], ["sensor.buy_cost", "input_number.buy_cost"], - ["sensor.battery_soc", "input_number.battery_soc"], ), ) -async def test_config_inputs_with_battery( +async def test_config_inputs_wo_battery( hass: HomeAssistant, init_solar_optimizer_with_2_devices_power_not_power, init_solar_optimizer_entry, @@ -41,7 +40,6 @@ async def test_config_inputs_with_battery( power_production, sell_cost, buy_cost, - battery_soc ): _result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": "user"} @@ -52,13 +50,12 @@ async def test_config_inputs_with_battery( assert _result["errors"] is None user_input = { - "refresh_period_sec": 300, - "power_consumption_entity_id": power_consumption, - "power_production_entity_id": power_production, - "sell_cost_entity_id": sell_cost, - "buy_cost_entity_id": buy_cost, - "sell_tax_percent_entity_id": "input_number.tax_percent", - "battery_soc_entity_id": battery_soc, + "refresh_period_sec": 300, + "power_consumption_entity_id": power_consumption, + "power_production_entity_id": power_production, + "sell_cost_entity_id": sell_cost, + "buy_cost_entity_id": buy_cost, + "sell_tax_percent_entity_id": "input_number.tax_percent", } result = await hass.config_entries.flow.async_configure( @@ -74,27 +71,32 @@ async def test_config_inputs_with_battery( for key, value in user_input.items(): assert data.get(key) == value + assert data.get("battery_soc_entity_id") is None + assert data["smooth_production"] assert result["title"] == "SolarOptimizer" + @pytest.mark.parametrize( - "power_consumption,power_production,sell_cost,buy_cost", + "power_consumption,power_production,sell_cost,buy_cost, battery_soc", itertools.product( ["sensor.power_consumption", "input_number.power_consumption"], ["sensor.power_production", "input_number.power_production"], ["sensor.sell_cost", "input_number.sell_cost"], ["sensor.buy_cost", "input_number.buy_cost"], + ["sensor.battery_soc", "input_number.battery_soc"], ), ) -async def test_config_inputs_wo_battery( +async def test_config_inputs_with_battery( hass: HomeAssistant, - init_solar_optimizer_with_2_devices_power_not_power, + init_solar_optimizer_with_2_devices_power_not_power_battery, init_solar_optimizer_entry, power_consumption, power_production, sell_cost, - buy_cost + buy_cost, + battery_soc, ): _result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": "user"} @@ -105,12 +107,13 @@ async def test_config_inputs_wo_battery( assert _result["errors"] is None user_input = { - "refresh_period_sec": 300, - "power_consumption_entity_id": power_consumption, - "power_production_entity_id": power_production, - "sell_cost_entity_id": sell_cost, - "buy_cost_entity_id": buy_cost, - "sell_tax_percent_entity_id": "input_number.tax_percent", + "refresh_period_sec": 300, + "power_consumption_entity_id": power_consumption, + "power_production_entity_id": power_production, + "sell_cost_entity_id": sell_cost, + "buy_cost_entity_id": buy_cost, + "sell_tax_percent_entity_id": "input_number.tax_percent", + "battery_soc_entity_id": battery_soc, } result = await hass.config_entries.flow.async_configure( @@ -126,8 +129,6 @@ async def test_config_inputs_wo_battery( for key, value in user_input.items(): assert data.get(key) == value - assert data.get("battery_soc_entity_id") is None - assert data["smooth_production"] assert result["title"] == "SolarOptimizer"