Home Assistant Git Exporter
This commit is contained in:
143
config/custom_components/solar_optimizer/__init__.py
Normal file
143
config/custom_components/solar_optimizer/__init__.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""Initialisation du package de l'intégration HACS Tuto"""
|
||||
import logging
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_START
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers import selector
|
||||
from homeassistant.components.input_boolean import DOMAIN as INPUT_BOOLEAN_DOMAIN
|
||||
from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||
from homeassistant.components.humidifier import DOMAIN as HUMIDIFIER_DOMAIN
|
||||
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
|
||||
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN
|
||||
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN
|
||||
|
||||
# from homeassistant.helpers.entity_component import EntityComponent
|
||||
|
||||
|
||||
from .const import DOMAIN, PLATFORMS
|
||||
from .coordinator import SolarOptimizerCoordinator
|
||||
|
||||
# from .input_boolean import async_setup_entry as async_setup_entry_input_boolean
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
DOMAIN: vol.Schema(
|
||||
{
|
||||
"algorithm": vol.Schema(
|
||||
{
|
||||
vol.Required("initial_temp", default=1000): vol.Coerce(float),
|
||||
vol.Required("min_temp", default=0.1): vol.Coerce(float),
|
||||
vol.Required("cooling_factor", default=0.95): vol.Coerce(float),
|
||||
vol.Required(
|
||||
"max_iteration_number", default=1000
|
||||
): cv.positive_int,
|
||||
}
|
||||
),
|
||||
"devices": vol.All(
|
||||
[
|
||||
{
|
||||
vol.Required("name"): str,
|
||||
vol.Required("entity_id"): selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(
|
||||
domain=[INPUT_BOOLEAN_DOMAIN, SWITCH_DOMAIN, HUMIDIFIER_DOMAIN, CLIMATE_DOMAIN, BUTTON_DOMAIN]
|
||||
)
|
||||
),
|
||||
vol.Optional("power_entity_id"): selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(
|
||||
domain=[INPUT_NUMBER_DOMAIN, NUMBER_DOMAIN]
|
||||
)
|
||||
),
|
||||
vol.Required("power_max"): vol.Coerce(float),
|
||||
vol.Optional("power_min"): vol.Coerce(float),
|
||||
vol.Optional("power_step"): vol.Coerce(float),
|
||||
vol.Optional("check_usable_template"): str,
|
||||
vol.Optional("check_active_template"): str,
|
||||
vol.Optional("duration_min"): vol.Coerce(float),
|
||||
vol.Optional("duration_stop_min"): vol.Coerce(float),
|
||||
vol.Optional("duration_power_min"): vol.Coerce(float),
|
||||
vol.Optional("action_mode"): str,
|
||||
vol.Required("activation_service"): str,
|
||||
vol.Required("deactivation_service"): str,
|
||||
vol.Optional("change_power_service"): str,
|
||||
vol.Optional("convert_power_divide_factor"): vol.Coerce(
|
||||
float
|
||||
),
|
||||
vol.Optional("battery_soc_threshold", default=0): vol.Coerce(float),
|
||||
}
|
||||
]
|
||||
),
|
||||
}
|
||||
),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup(
|
||||
hass: HomeAssistant, config: ConfigType
|
||||
): # pylint: disable=unused-argument
|
||||
"""Initialisation de l'intégration"""
|
||||
_LOGGER.info(
|
||||
"Initializing %s integration with plaforms: %s with config: %s",
|
||||
DOMAIN,
|
||||
PLATFORMS,
|
||||
config.get(DOMAIN),
|
||||
)
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
|
||||
# L'argument config contient votre fichier configuration.yaml
|
||||
solar_optimizer_config = config.get(DOMAIN)
|
||||
|
||||
hass.data[DOMAIN]["coordinator"] = coordinator = SolarOptimizerCoordinator(
|
||||
hass, solar_optimizer_config
|
||||
)
|
||||
|
||||
hass.bus.async_listen_once("homeassistant_started", coordinator.on_ha_started)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Creation des entités à partir d'une configEntry"""
|
||||
|
||||
_LOGGER.debug(
|
||||
"Appel de async_setup_entry entry: entry_id='%s', data='%s'",
|
||||
entry.entry_id,
|
||||
entry.data,
|
||||
)
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
|
||||
# Enregistrement de l'écouteur de changement 'update_listener'
|
||||
entry.async_on_unload(entry.add_update_listener(update_listener))
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Fonction qui force le rechargement des entités associées à une configEntry"""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Handle removal of an entry."""
|
||||
if unloaded := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
pass
|
||||
# hass.data[DOMAIN].pop(entry.entry_id)
|
||||
return unloaded
|
||||
|
||||
|
||||
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Reload config entry."""
|
||||
await async_unload_entry(hass, entry)
|
||||
# await async_setup_entry(hass, entry)
|
||||
145
config/custom_components/solar_optimizer/config_flow.py
Normal file
145
config/custom_components/solar_optimizer/config_flow.py
Normal file
@@ -0,0 +1,145 @@
|
||||
""" Le Config Flow """
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
import copy
|
||||
from collections.abc import Mapping
|
||||
import voluptuous as vol
|
||||
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.config_entries import (
|
||||
ConfigFlow,
|
||||
FlowResult,
|
||||
OptionsFlow,
|
||||
ConfigEntry,
|
||||
)
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN
|
||||
from homeassistant.helpers import selector
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
solar_optimizer_schema = {
|
||||
vol.Required("refresh_period_sec", default=300): int,
|
||||
vol.Required("power_consumption_entity_id"): selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN])
|
||||
),
|
||||
vol.Required("power_production_entity_id"): selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN])
|
||||
),
|
||||
vol.Required("sell_cost_entity_id"): selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN])
|
||||
),
|
||||
vol.Required("buy_cost_entity_id"): selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN])
|
||||
),
|
||||
vol.Required("sell_tax_percent_entity_id"): selector.EntitySelector(
|
||||
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])
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class SolarOptimizerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""La classe qui implémente le config flow pour notre DOMAIN.
|
||||
Elle doit dériver de FlowHandler"""
|
||||
|
||||
# La version de notre configFlow. Va permettre de migrer les entités
|
||||
# vers une version plus récente en cas de changement
|
||||
VERSION = 1
|
||||
_user_inputs: dict = {}
|
||||
|
||||
async def async_step_user(self, user_input: dict | None = None) -> FlowResult:
|
||||
"""Gestion de l'étape 'user'. Point d'entrée de notre
|
||||
configFlow. Cette méthode est appelée 2 fois :
|
||||
1. une première fois sans user_input -> on affiche le formulaire de configuration
|
||||
2. une deuxième fois avec les données saisies par l'utilisateur dans user_input -> on sauvegarde les données saisies
|
||||
"""
|
||||
user_form = vol.Schema(solar_optimizer_schema)
|
||||
|
||||
if user_input is None:
|
||||
_LOGGER.debug(
|
||||
"config_flow step user (1). 1er appel : pas de user_input -> on affiche le form user_form"
|
||||
)
|
||||
return self.async_show_form(step_id="user", data_schema=user_form)
|
||||
|
||||
# 2ème appel : il y a des user_input -> on stocke le résultat
|
||||
self._user_inputs.update(user_input)
|
||||
_LOGGER.debug(
|
||||
"config_flow step2 (2). L'ensemble de la configuration est: %s",
|
||||
self._user_inputs,
|
||||
)
|
||||
|
||||
return self.async_create_entry(title="SolarOptimizer", data=self._user_inputs)
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry: ConfigEntry):
|
||||
"""Get options flow for this handler"""
|
||||
return SolarOptimizerOptionsFlow(config_entry)
|
||||
|
||||
|
||||
class SolarOptimizerOptionsFlow(OptionsFlow):
|
||||
"""The class which enable to modified the configuration"""
|
||||
|
||||
_user_inputs: dict = {}
|
||||
config_entry: ConfigEntry = None
|
||||
|
||||
def __init__(self, config_entry: ConfigEntry) -> None:
|
||||
"""Initialisation de l'option flow. On a le ConfigEntry existant en entrée"""
|
||||
self.config_entry = config_entry
|
||||
# On initialise les user_inputs avec les données du configEntry
|
||||
self._user_inputs = config_entry.data.copy()
|
||||
|
||||
async def async_step_init(self, user_input: dict | None = None) -> FlowResult:
|
||||
"""Gestion de l'étape 'user'. Point d'entrée de notre
|
||||
configFlow. Cette méthode est appelée 2 fois :
|
||||
1. une première fois sans user_input -> on affiche le formulaire de configuration
|
||||
2. une deuxième fois avec les données saisies par l'utilisateur dans user_input -> on sauvegarde les données saisies
|
||||
"""
|
||||
user_form = vol.Schema(solar_optimizer_schema)
|
||||
|
||||
if user_input is None:
|
||||
_LOGGER.debug(
|
||||
"config_flow step user (1). 1er appel : pas de user_input -> on affiche le form user_form"
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
data_schema=user_form,
|
||||
suggested_values=self._user_inputs,
|
||||
),
|
||||
)
|
||||
|
||||
# 2ème appel : il y a des user_input -> on stocke le résultat
|
||||
self._user_inputs.update(user_input)
|
||||
_LOGGER.debug(
|
||||
"config_flow step_user (2). L'ensemble de la configuration est: %s",
|
||||
self._user_inputs,
|
||||
)
|
||||
|
||||
# On appelle le step de fin pour enregistrer les modifications
|
||||
return await self.async_end()
|
||||
|
||||
async def async_end(self):
|
||||
"""Finalization of the ConfigEntry creation"""
|
||||
_LOGGER.info(
|
||||
"Recreation de l'entry %s. La nouvelle config est maintenant : %s",
|
||||
self.config_entry.entry_id,
|
||||
self._user_inputs,
|
||||
)
|
||||
|
||||
# Modification des data de la configEntry
|
||||
# (et non pas ajout d'un objet options dans la configEntry)
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self.config_entry, data=self._user_inputs
|
||||
)
|
||||
# Suppression de l'objet options dans la configEntry
|
||||
return self.async_create_entry(title=None, data=None)
|
||||
41
config/custom_components/solar_optimizer/const.py
Normal file
41
config/custom_components/solar_optimizer/const.py
Normal file
@@ -0,0 +1,41 @@
|
||||
""" Les constantes pour l'intégration Solar Optimizer """
|
||||
from slugify import slugify
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
DOMAIN = "solar_optimizer"
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH]
|
||||
|
||||
DEFAULT_REFRESH_PERIOD_SEC = 300
|
||||
|
||||
CONF_ACTION_MODE_SERVICE = "service_call"
|
||||
CONF_ACTION_MODE_EVENT = "event"
|
||||
|
||||
CONF_ACTION_MODES = [CONF_ACTION_MODE_SERVICE, CONF_ACTION_MODE_EVENT]
|
||||
|
||||
EVENT_TYPE_SOLAR_OPTIMIZER_CHANGE_POWER = "solar_optimizer_change_power_event"
|
||||
EVENT_TYPE_SOLAR_OPTIMIZER_STATE_CHANGE = "solar_optimizer_state_change_event"
|
||||
|
||||
EVENT_TYPE_SOLAR_OPTIMIZER_ENABLE_STATE_CHANGE = (
|
||||
"solar_optimizer_enable_state_change_event"
|
||||
)
|
||||
|
||||
|
||||
def get_tz(hass: HomeAssistant):
|
||||
"""Get the current timezone"""
|
||||
|
||||
return dt_util.get_time_zone(hass.config.time_zone)
|
||||
|
||||
|
||||
def name_to_unique_id(name: str) -> str:
|
||||
"""Convert a name to a unique id. Replace ' ' by _"""
|
||||
return slugify(name).replace("-", "_")
|
||||
|
||||
|
||||
class ConfigurationError(Exception):
|
||||
"""An error in configuration"""
|
||||
|
||||
def __init__(self, message):
|
||||
super().__init__(message)
|
||||
228
config/custom_components/solar_optimizer/coordinator.py
Normal file
228
config/custom_components/solar_optimizer/coordinator.py
Normal file
@@ -0,0 +1,228 @@
|
||||
""" The data coordinator class """
|
||||
import logging
|
||||
import math
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
from homeassistant.core import HomeAssistant # callback
|
||||
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
DataUpdateCoordinator,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
from .const import DEFAULT_REFRESH_PERIOD_SEC, name_to_unique_id
|
||||
from .managed_device import ManagedDevice
|
||||
from .simulated_annealing_algo import SimulatedAnnealingAlgorithm
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_safe_float(hass, entity_id: str):
|
||||
"""Get a safe float state value for an entity.
|
||||
Return None if entity is not available"""
|
||||
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
|
||||
|
||||
|
||||
class SolarOptimizerCoordinator(DataUpdateCoordinator):
|
||||
"""The coordinator class which is used to coordinate all update"""
|
||||
|
||||
_devices: list[ManagedDevice]
|
||||
_power_consumption_entity_id: str
|
||||
_power_production_entity_id: str
|
||||
_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
|
||||
|
||||
_algo: SimulatedAnnealingAlgorithm
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config):
|
||||
"""Initialize the coordinator"""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name="Solar Optimizer",
|
||||
# update_interval=timedelta(seconds=refresh_period_sec),
|
||||
) # pylint : disable=line-too-long
|
||||
self._devices = []
|
||||
try:
|
||||
for _, device in enumerate(config.get("devices")):
|
||||
_LOGGER.debug("Configuration of manageable device: %s", device)
|
||||
self._devices.append(ManagedDevice(hass, device))
|
||||
except Exception as err:
|
||||
_LOGGER.error(err)
|
||||
_LOGGER.error(
|
||||
"Your 'devices' configuration is wrong. SolarOptimizer will not be operational until you fix it"
|
||||
)
|
||||
raise err
|
||||
|
||||
algo_config = config.get("algorithm")
|
||||
self._algo = SimulatedAnnealingAlgorithm(
|
||||
float(algo_config.get("initial_temp")),
|
||||
float(algo_config.get("min_temp")),
|
||||
float(algo_config.get("cooling_factor")),
|
||||
int(algo_config.get("max_iteration_number")),
|
||||
)
|
||||
self.config = config
|
||||
|
||||
async def configure(self, config: ConfigEntry) -> None:
|
||||
"""Configure the coordinator from configEntry of the integration"""
|
||||
refresh_period_sec = (
|
||||
config.data.get("refresh_period_sec") or DEFAULT_REFRESH_PERIOD_SEC
|
||||
)
|
||||
self.update_interval = timedelta(seconds=refresh_period_sec)
|
||||
self._schedule_refresh()
|
||||
|
||||
self._power_consumption_entity_id = config.data.get(
|
||||
"power_consumption_entity_id"
|
||||
)
|
||||
self._power_production_entity_id = config.data.get("power_production_entity_id")
|
||||
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
|
||||
|
||||
# Do not calculate immediatly because switch state are not restored yet. Wait for homeassistant_started event
|
||||
# which is captured in onHAStarted method
|
||||
# await self.async_config_entry_first_refresh()
|
||||
|
||||
async def on_ha_started(self, _) -> None:
|
||||
"""Listen the homeassistant_started event to initialize the first calculation"""
|
||||
_LOGGER.info("First initialization of Solar Optimizer")
|
||||
await self.async_config_entry_first_refresh()
|
||||
|
||||
async def _async_update_data(self):
|
||||
_LOGGER.info("Refreshing Solar Optimizer calculation")
|
||||
|
||||
calculated_data = {}
|
||||
|
||||
# Add a device state attributes
|
||||
for _, device in enumerate(self._devices):
|
||||
# Initialize current power depending or reality
|
||||
device.set_current_power_with_device_state()
|
||||
|
||||
# Add a power_consumption and power_production
|
||||
power_production = get_safe_float(self.hass, self._power_production_entity_id)
|
||||
if not power_production:
|
||||
_LOGGER.warning(
|
||||
"Power production is not valued. Solar Optimizer will be disabled"
|
||||
)
|
||||
return None
|
||||
|
||||
if not self._smooth_production:
|
||||
calculated_data["power_production"] = power_production
|
||||
else:
|
||||
self._last_production = round(
|
||||
0.5 * self._last_production + 0.5 * power_production
|
||||
)
|
||||
calculated_data["power_production"] = self._last_production
|
||||
|
||||
calculated_data["power_production_brut"] = power_production
|
||||
|
||||
calculated_data["power_consumption"] = get_safe_float(
|
||||
self.hass, self._power_consumption_entity_id
|
||||
)
|
||||
|
||||
calculated_data["sell_cost"] = get_safe_float(
|
||||
self.hass, self._sell_cost_entity_id
|
||||
)
|
||||
|
||||
calculated_data["buy_cost"] = get_safe_float(
|
||||
self.hass, self._buy_cost_entity_id
|
||||
)
|
||||
|
||||
calculated_data["sell_tax_percent"] = get_safe_float(
|
||||
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é
|
||||
#
|
||||
best_solution, best_objective, total_power = self._algo.recuit_simule(
|
||||
self._devices,
|
||||
calculated_data["power_consumption"],
|
||||
calculated_data["power_production"],
|
||||
calculated_data["sell_cost"],
|
||||
calculated_data["buy_cost"],
|
||||
calculated_data["sell_tax_percent"],
|
||||
calculated_data["battery_soc"]
|
||||
)
|
||||
|
||||
calculated_data["best_solution"] = best_solution
|
||||
calculated_data["best_objective"] = best_objective
|
||||
calculated_data["total_power"] = total_power
|
||||
|
||||
# Uses the result to turn on or off or change power
|
||||
should_log = False
|
||||
for _, equipement in enumerate(best_solution):
|
||||
_LOGGER.debug("Dealing with best_solution for %s", equipement)
|
||||
name = equipement["name"]
|
||||
requested_power = equipement.get("requested_power")
|
||||
state = equipement["state"]
|
||||
device = self.get_device_by_name(name)
|
||||
if not device:
|
||||
continue
|
||||
is_active = device.is_active
|
||||
if is_active and not state:
|
||||
_LOGGER.debug("Extinction de %s", name)
|
||||
should_log = True
|
||||
await device.deactivate()
|
||||
elif not is_active and state:
|
||||
_LOGGER.debug("Allumage de %s", name)
|
||||
should_log = True
|
||||
await device.activate(requested_power)
|
||||
|
||||
# Send change power if state is now on and change power is accepted and (power have change or eqt is just activated)
|
||||
if (
|
||||
state
|
||||
and device.can_change_power
|
||||
and (device.current_power != requested_power or not is_active)
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"Change power of %s to %s",
|
||||
equipement["name"],
|
||||
requested_power,
|
||||
)
|
||||
should_log = True
|
||||
await device.change_requested_power(requested_power)
|
||||
|
||||
# Add updated data to the result
|
||||
calculated_data[name_to_unique_id(name)] = device
|
||||
|
||||
if should_log:
|
||||
_LOGGER.info("Calculated data are: %s", calculated_data)
|
||||
else:
|
||||
_LOGGER.debug("Calculated data are: %s", calculated_data)
|
||||
|
||||
return calculated_data
|
||||
|
||||
@property
|
||||
def devices(self) -> list[ManagedDevice]:
|
||||
"""Get all the managed device"""
|
||||
return self._devices
|
||||
|
||||
def get_device_by_name(self, name: str) -> ManagedDevice | None:
|
||||
"""Returns the device which name is given in argument"""
|
||||
for _, device in enumerate(self._devices):
|
||||
if device.name == name:
|
||||
return device
|
||||
return None
|
||||
|
||||
def get_device_by_unique_id(self, uid: str) -> ManagedDevice | None:
|
||||
"""Returns the device which name is given in argument"""
|
||||
for _, device in enumerate(self._devices):
|
||||
if device.unique_id == uid:
|
||||
return device
|
||||
return None
|
||||
7
config/custom_components/solar_optimizer/hacs.json
Normal file
7
config/custom_components/solar_optimizer/hacs.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "Solar Optimizer",
|
||||
"content_in_root": false,
|
||||
"render_readme": true,
|
||||
"hide_default_branch": false,
|
||||
"homeassistant": "2023.6.1"
|
||||
}
|
||||
479
config/custom_components/solar_optimizer/managed_device.py
Normal file
479
config/custom_components/solar_optimizer/managed_device.py
Normal file
@@ -0,0 +1,479 @@
|
||||
""" A ManagedDevice represent a device than can be managed by the optimisatiion algorithm"""
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.template import Template
|
||||
from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||
|
||||
from .const import (
|
||||
get_tz,
|
||||
name_to_unique_id,
|
||||
CONF_ACTION_MODE_SERVICE,
|
||||
CONF_ACTION_MODE_EVENT,
|
||||
CONF_ACTION_MODES,
|
||||
ConfigurationError,
|
||||
EVENT_TYPE_SOLAR_OPTIMIZER_CHANGE_POWER,
|
||||
EVENT_TYPE_SOLAR_OPTIMIZER_STATE_CHANGE,
|
||||
EVENT_TYPE_SOLAR_OPTIMIZER_ENABLE_STATE_CHANGE,
|
||||
)
|
||||
|
||||
ACTION_ACTIVATE = "Activate"
|
||||
ACTION_DEACTIVATE = "Deactivate"
|
||||
ACTION_CHANGE_POWER = "ChangePower"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def do_service_action(
|
||||
hass: HomeAssistant,
|
||||
entity_id,
|
||||
action_type,
|
||||
service_name,
|
||||
current_power,
|
||||
requested_power,
|
||||
convert_power_divide_factor,
|
||||
):
|
||||
"""Activate an entity via a service call"""
|
||||
_LOGGER.info("Calling service %s for entity %s", service_name, entity_id)
|
||||
|
||||
parties = service_name.split("/")
|
||||
if len(parties) != 2:
|
||||
raise ConfigurationError(
|
||||
f"Incorrect service declaration for entity {entity_id}. Service {service_name} should be formatted with: 'domain/service'"
|
||||
)
|
||||
|
||||
if action_type == ACTION_CHANGE_POWER:
|
||||
value = round(requested_power / convert_power_divide_factor)
|
||||
service_data = {"value": value}
|
||||
else:
|
||||
service_data = {}
|
||||
|
||||
target = {
|
||||
"entity_id": entity_id,
|
||||
}
|
||||
|
||||
await hass.services.async_call(
|
||||
parties[0], parties[1], service_data=service_data, target=target
|
||||
)
|
||||
|
||||
# Also send an event to inform
|
||||
do_event_action(
|
||||
hass,
|
||||
entity_id,
|
||||
action_type,
|
||||
current_power,
|
||||
requested_power,
|
||||
EVENT_TYPE_SOLAR_OPTIMIZER_STATE_CHANGE,
|
||||
)
|
||||
|
||||
|
||||
def do_event_action(
|
||||
hass: HomeAssistant,
|
||||
entity_id,
|
||||
action_type,
|
||||
current_power,
|
||||
requested_power,
|
||||
event_type: str,
|
||||
):
|
||||
"""Activate an entity via an event"""
|
||||
_LOGGER.info(
|
||||
"Sending event %s with action %s for entity %s with requested_power %s and current_power %s",
|
||||
event_type,
|
||||
action_type,
|
||||
entity_id,
|
||||
requested_power,
|
||||
current_power,
|
||||
)
|
||||
|
||||
hass.bus.fire(
|
||||
event_type=event_type,
|
||||
event_data={
|
||||
"action_type": action_type,
|
||||
"requested_power": requested_power,
|
||||
"current_power": current_power,
|
||||
"entity_id": entity_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class ManagedDevice:
|
||||
"""A Managed device representation"""
|
||||
|
||||
_name: str
|
||||
_unique_id: str
|
||||
_entity_id: str
|
||||
_power_entity_id: str
|
||||
_power_max: int
|
||||
_power_min: int
|
||||
_power_step: int
|
||||
_can_change_power: bool
|
||||
_current_power: int
|
||||
_requested_power: int
|
||||
_duration_sec: int
|
||||
_duration_stop_sec: int
|
||||
_duration_power_sec: int
|
||||
_check_usable_template: Template
|
||||
_check_active_template: Template
|
||||
_next_date_available: datetime
|
||||
_next_date_available_power: datetime
|
||||
_action_mode: str
|
||||
_activation_service: str
|
||||
_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"""
|
||||
self._hass = hass
|
||||
self._name = device_config.get("name")
|
||||
self._unique_id = name_to_unique_id(self._name)
|
||||
self._entity_id = device_config.get("entity_id")
|
||||
self._power_entity_id = device_config.get("power_entity_id")
|
||||
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._can_change_power = self._power_min >= 0
|
||||
self._convert_power_divide_factor = int(
|
||||
device_config.get("convert_power_divide_factor") or 1
|
||||
)
|
||||
|
||||
self._current_power = self._requested_power = 0
|
||||
duration_min = float(device_config.get("duration_min"))
|
||||
self._duration_sec = round(duration_min * 60)
|
||||
self._duration_power_sec = round(
|
||||
float(device_config.get("duration_power_min") or duration_min) * 60
|
||||
)
|
||||
|
||||
self._duration_stop_sec = round(
|
||||
float(device_config.get("duration_stop_min") or duration_min) * 60
|
||||
)
|
||||
|
||||
if device_config.get("check_usable_template"):
|
||||
self._check_usable_template = Template(
|
||||
device_config.get("check_usable_template"), hass
|
||||
)
|
||||
else:
|
||||
# If no template for usability, the device is supposed to be always usable
|
||||
self._check_usable_template = Template("{{ True }}", hass)
|
||||
if device_config.get("check_active_template"):
|
||||
self._check_active_template = Template(
|
||||
device_config.get("check_active_template"), hass
|
||||
)
|
||||
else:
|
||||
template_string = (
|
||||
"{{ is_state('" + self._entity_id + "', '" + STATE_ON + "') }}"
|
||||
)
|
||||
self._check_active_template = Template(template_string, hass)
|
||||
self._next_date_available_power = self._next_date_available = datetime.now(
|
||||
get_tz(hass)
|
||||
)
|
||||
self._action_mode = device_config.get("action_mode")
|
||||
self._activation_service = device_config.get("activation_service")
|
||||
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
|
||||
)
|
||||
|
||||
self._enable = True
|
||||
|
||||
async def _apply_action(self, action_type: str, requested_power=None):
|
||||
"""Apply an action to a managed device.
|
||||
This method is a generical method for activate, deactivate, change_requested_power
|
||||
"""
|
||||
_LOGGER.debug(
|
||||
"Applying action %s for entity %s. requested_power=%s",
|
||||
action_type,
|
||||
self._entity_id,
|
||||
requested_power,
|
||||
)
|
||||
if requested_power is not None:
|
||||
self._requested_power = requested_power
|
||||
|
||||
if self._action_mode == CONF_ACTION_MODE_SERVICE:
|
||||
method = None
|
||||
entity_id = self._entity_id
|
||||
if action_type == ACTION_ACTIVATE:
|
||||
method = self._activation_service
|
||||
self.reset_next_date_available(action_type)
|
||||
if self._can_change_power:
|
||||
self.reset_next_date_available_power()
|
||||
elif action_type == ACTION_DEACTIVATE:
|
||||
method = self._deactivation_service
|
||||
self.reset_next_date_available(action_type)
|
||||
elif action_type == ACTION_CHANGE_POWER:
|
||||
assert (
|
||||
self._can_change_power
|
||||
), f"Equipment {self._name} cannot change its power. We should not be there."
|
||||
method = self._change_power_service
|
||||
entity_id = self._power_entity_id
|
||||
self.reset_next_date_available_power()
|
||||
|
||||
await do_service_action(
|
||||
self._hass,
|
||||
entity_id,
|
||||
action_type,
|
||||
method,
|
||||
self._current_power,
|
||||
self._requested_power,
|
||||
self._convert_power_divide_factor,
|
||||
)
|
||||
elif self._action_mode == CONF_ACTION_MODE_EVENT:
|
||||
do_event_action(
|
||||
self._hass,
|
||||
self._entity_id,
|
||||
action_type,
|
||||
self._current_power,
|
||||
self._requested_power,
|
||||
EVENT_TYPE_SOLAR_OPTIMIZER_CHANGE_POWER,
|
||||
)
|
||||
else:
|
||||
raise ConfigurationError(
|
||||
f"Incorrect action_mode declaration for entity '{self._entity_id}'. Action_mode '{self._action_mode}' is not supported. Use one of {CONF_ACTION_MODES}"
|
||||
)
|
||||
|
||||
self._current_power = self._requested_power
|
||||
|
||||
async def activate(self, requested_power=None):
|
||||
"""Use this method to activate this ManagedDevice"""
|
||||
return await self._apply_action(ACTION_ACTIVATE, requested_power)
|
||||
|
||||
async def deactivate(self):
|
||||
"""Use this method to deactivate this ManagedDevice"""
|
||||
return await self._apply_action(ACTION_DEACTIVATE, 0)
|
||||
|
||||
async def change_requested_power(self, requested_power):
|
||||
"""Use this method to change the requested power of this ManagedDevice"""
|
||||
return await self._apply_action(ACTION_CHANGE_POWER, requested_power)
|
||||
|
||||
def reset_next_date_available(self, action_type):
|
||||
"""Incremente the next availability date to now + _duration_sec"""
|
||||
if action_type == ACTION_ACTIVATE:
|
||||
self._next_date_available = datetime.now(get_tz(self._hass)) + timedelta(
|
||||
seconds=self._duration_sec
|
||||
)
|
||||
else:
|
||||
self._next_date_available = datetime.now(get_tz(self._hass)) + timedelta(
|
||||
seconds=self._duration_stop_sec
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Next availability date for %s is %s", self._name, self._next_date_available
|
||||
)
|
||||
|
||||
def reset_next_date_available_power(self):
|
||||
"""Incremente the next availability date for power change to now + _duration_power_sec"""
|
||||
self._next_date_available_power = datetime.now(get_tz(self._hass)) + timedelta(
|
||||
seconds=self._duration_power_sec
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Next availability date for power change for %s is %s",
|
||||
self._name,
|
||||
self._next_date_available_power,
|
||||
)
|
||||
|
||||
# def init_power(self, power: int):
|
||||
# """Initialise current_power and requested_power to the given value"""
|
||||
# _LOGGER.debug(
|
||||
# "Initializing power for entity '%s' with %s value", self._name, power
|
||||
# )
|
||||
# self._requested_power = self._current_power = power
|
||||
|
||||
def set_current_power_with_device_state(self):
|
||||
"""Set the current power according to the real device state"""
|
||||
if not self.is_active:
|
||||
self._current_power = 0
|
||||
_LOGGER.debug(
|
||||
"Set current_power to 0 for device %s cause not active", self._name
|
||||
)
|
||||
return
|
||||
|
||||
if not self._can_change_power:
|
||||
self._current_power = self._power_max
|
||||
_LOGGER.debug(
|
||||
"Set current_power to %s for device %s cause active and not can_change_power",
|
||||
self._current_power,
|
||||
self._name,
|
||||
)
|
||||
return
|
||||
|
||||
amps = self._hass.states.get(self._power_entity_id)
|
||||
if not amps or amps.state in [None, STATE_UNKNOWN, STATE_UNAVAILABLE]:
|
||||
self._current_power = self._power_min
|
||||
_LOGGER.debug(
|
||||
"Set current_power to %s for device %s cause can_change_power but amps is %s",
|
||||
self._current_power,
|
||||
self._name,
|
||||
amps,
|
||||
)
|
||||
return
|
||||
|
||||
self._current_power = round(
|
||||
float(amps.state) * self._convert_power_divide_factor
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Set current_power to %s for device %s cause can_change_power and amps is %s",
|
||||
self._current_power,
|
||||
self._name,
|
||||
amps.state,
|
||||
)
|
||||
|
||||
def set_enable(self, enable: bool):
|
||||
"""Enable or disable the ManagedDevice for Solar Optimizer"""
|
||||
_LOGGER.info("%s - Set enable=%s", self.name, enable)
|
||||
self._enable = enable
|
||||
self.publish_enable_state_change()
|
||||
|
||||
@property
|
||||
def is_enabled(self) -> bool:
|
||||
"""return true if the managed device is enabled for solar optimisation"""
|
||||
return self._enable
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
"""Check if device is active by getting the underlying state of the device"""
|
||||
result = self._check_active_template.async_render(context={})
|
||||
if result:
|
||||
_LOGGER.debug("%s is active", self._name)
|
||||
|
||||
return result
|
||||
|
||||
@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 and if the battery_soc_threshold is >= battery_soc"""
|
||||
|
||||
context = {}
|
||||
now = datetime.now(get_tz(self._hass))
|
||||
result = self._check_usable_template.async_render(context) and (
|
||||
now >= self._next_date_available
|
||||
or (self._can_change_power and now >= self._next_date_available_power)
|
||||
)
|
||||
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
|
||||
def is_waiting(self):
|
||||
"""A device is waiting if the device is waiting for the end of its cycle"""
|
||||
now = datetime.now(get_tz(self._hass))
|
||||
result = now < self._next_date_available
|
||||
|
||||
if result:
|
||||
_LOGGER.debug("%s is waiting", self._name)
|
||||
|
||||
return result
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""The name of the ManagedDevice"""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""The id of the ManagedDevice"""
|
||||
return self._unique_id
|
||||
|
||||
@property
|
||||
def power_max(self):
|
||||
"""The power max of the managed device"""
|
||||
return self._power_max
|
||||
|
||||
@property
|
||||
def power_min(self):
|
||||
"""The power min of the managed device"""
|
||||
return self._power_min
|
||||
|
||||
@property
|
||||
def power_step(self):
|
||||
"""The power step of the managed device"""
|
||||
return self._power_step
|
||||
|
||||
@property
|
||||
def duration_sec(self) -> int:
|
||||
"""The duration a device is not available after a change of the managed device"""
|
||||
return self._duration_sec
|
||||
|
||||
@property
|
||||
def duration_stop_sec(self) -> int:
|
||||
"""The duration a device is not available after a change of the managed device to stop"""
|
||||
return self._duration_stop_sec
|
||||
|
||||
@property
|
||||
def duration_power_sec(self) -> int:
|
||||
"""The duration a device is not available after a change of the managed device for power change"""
|
||||
return self._duration_power_sec
|
||||
|
||||
@property
|
||||
def entity_id(self) -> str:
|
||||
"""The entity_id of the device"""
|
||||
return self._entity_id
|
||||
|
||||
@property
|
||||
def power_entity_id(self) -> str:
|
||||
"""The entity_id of the device which gives the current power"""
|
||||
return self._power_entity_id
|
||||
|
||||
@property
|
||||
def current_power(self) -> int:
|
||||
"""The current_power of the device"""
|
||||
return self._current_power
|
||||
|
||||
@property
|
||||
def requested_power(self) -> int:
|
||||
"""The requested_power of the device"""
|
||||
return self._requested_power
|
||||
|
||||
@property
|
||||
def can_change_power(self) -> bool:
|
||||
"""true is the device can change its power"""
|
||||
return self._can_change_power
|
||||
|
||||
@property
|
||||
def next_date_available(self) -> datetime:
|
||||
"""returns the next available date for state change"""
|
||||
return self._next_date_available
|
||||
|
||||
@property
|
||||
def next_date_available_power(self) -> datetime:
|
||||
"""return the next available date for power change"""
|
||||
return self._next_date_available_power
|
||||
|
||||
@property
|
||||
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"""
|
||||
|
||||
self._hass.bus.fire(
|
||||
event_type=EVENT_TYPE_SOLAR_OPTIMIZER_ENABLE_STATE_CHANGE,
|
||||
event_data={
|
||||
"device_unique_id": self._unique_id,
|
||||
"is_enabled": self.is_enabled,
|
||||
"is_active": self.is_active,
|
||||
"is_usable": self.is_usable,
|
||||
"is_waiting": self.is_waiting,
|
||||
},
|
||||
)
|
||||
14
config/custom_components/solar_optimizer/manifest.json
Normal file
14
config/custom_components/solar_optimizer/manifest.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"domain": "solar_optimizer",
|
||||
"name": "Solar Optimizer",
|
||||
"codeowners": [
|
||||
"@jmcollin78"
|
||||
],
|
||||
"config_flow": true,
|
||||
"documentation": "https://github.com/jmcollin78/solar_optimizer",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"issue_tracker": "https://github.com/jmcollin78/solar_optimizer/issues",
|
||||
"quality_scale": "silver",
|
||||
"version": "1.7.0"
|
||||
}
|
||||
112
config/custom_components/solar_optimizer/sensor.py
Normal file
112
config/custom_components/solar_optimizer/sensor.py
Normal file
@@ -0,0 +1,112 @@
|
||||
""" A sensor entity that holds the result of the recuit simule algorithm """
|
||||
import logging
|
||||
from homeassistant.const import UnitOfPower
|
||||
from homeassistant.core import callback, HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
SensorDeviceClass,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddEntitiesCallback,
|
||||
)
|
||||
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import SolarOptimizerCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
"""Setup the entries of type Sensor"""
|
||||
|
||||
# Sets the config entries values to SolarOptimizer coordinator
|
||||
coordinator: SolarOptimizerCoordinator = hass.data[DOMAIN]["coordinator"]
|
||||
|
||||
entity1 = SolarOptimizerSensorEntity(coordinator, hass, "best_objective")
|
||||
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, entity5], False)
|
||||
|
||||
await coordinator.configure(entry)
|
||||
|
||||
|
||||
class SolarOptimizerSensorEntity(CoordinatorEntity, SensorEntity):
|
||||
"""The entity holding the algorithm calculation"""
|
||||
|
||||
def __init__(self, coordinator, hass, idx):
|
||||
super().__init__(coordinator, context=idx)
|
||||
self._hass = hass
|
||||
self.idx = idx
|
||||
self._attr_name = idx
|
||||
self._attr_unique_id = "solar_optimizer_" + idx
|
||||
|
||||
self._attr_native_value = None
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
if (
|
||||
not self.coordinator
|
||||
or not self.coordinator.data
|
||||
or (value := self.coordinator.data.get(self.idx)) == None
|
||||
):
|
||||
_LOGGER.debug("No coordinator found or no data...")
|
||||
return
|
||||
|
||||
self._attr_native_value = value
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
# Retournez des informations sur le périphérique associé à votre entité
|
||||
return {
|
||||
"identifiers": {(DOMAIN, "solar_optimizer_device")},
|
||||
"name": "Solar Optimizer",
|
||||
# Autres attributs du périphérique ici
|
||||
}
|
||||
|
||||
@property
|
||||
def icon(self) -> str | None:
|
||||
if self.idx == "best_objective":
|
||||
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"
|
||||
|
||||
@property
|
||||
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
|
||||
|
||||
@property
|
||||
def state_class(self) -> SensorStateClass | None:
|
||||
if self.idx == "best_objective":
|
||||
return SensorStateClass.TOTAL
|
||||
else:
|
||||
return SensorStateClass.MEASUREMENT
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self) -> str | None:
|
||||
if self.idx == "best_objective":
|
||||
return "€"
|
||||
elif self.idx == "battery_soc":
|
||||
return "%"
|
||||
else:
|
||||
return UnitOfPower.WATT
|
||||
3
config/custom_components/solar_optimizer/services.yaml
Normal file
3
config/custom_components/solar_optimizer/services.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
reload:
|
||||
name: Reload
|
||||
description: Reload Solar Optimizer configuration
|
||||
@@ -0,0 +1,384 @@
|
||||
""" The Simulated Annealing (recuit simulé) algorithm"""
|
||||
import logging
|
||||
import random
|
||||
import math
|
||||
import copy
|
||||
|
||||
from .managed_device import ManagedDevice
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEBUG = False
|
||||
|
||||
|
||||
class SimulatedAnnealingAlgorithm:
|
||||
"""The class which implemenets the Simulated Annealing algorithm"""
|
||||
|
||||
# Paramètres de l'algorithme de recuit simulé
|
||||
_temperature_initiale: float = 1000
|
||||
_temperature_minimale: float = 0.1
|
||||
_facteur_refroidissement: float = 0.95
|
||||
_nombre_iterations: float = 1000
|
||||
_equipements: list[ManagedDevice]
|
||||
_puissance_totale_eqt_initiale: float
|
||||
_cout_achat: float = 15 # centimes
|
||||
_cout_revente: float = 10 # centimes
|
||||
_taxe_revente: float = 0.13 # pourcentage
|
||||
_consommation_net: float
|
||||
_production_solaire: float
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
initial_temp: float,
|
||||
min_temp: float,
|
||||
cooling_factor: float,
|
||||
max_iteration_number: int,
|
||||
):
|
||||
"""Initialize the algorithm with values"""
|
||||
self._temperature_initiale = initial_temp
|
||||
self._temperature_minimale = min_temp
|
||||
self._facteur_refroidissement = cooling_factor
|
||||
self._nombre_iterations = max_iteration_number
|
||||
_LOGGER.info(
|
||||
"Initializing the SimulatedAnnealingAlgorithm with initial_temp=%.2f min_temp=%.2f cooling_factor=%.2f max_iterations_number=%d",
|
||||
self._temperature_initiale,
|
||||
self._temperature_minimale,
|
||||
self._facteur_refroidissement,
|
||||
self._nombre_iterations,
|
||||
)
|
||||
|
||||
def recuit_simule(
|
||||
self,
|
||||
devices: list[ManagedDevice],
|
||||
power_consumption: float,
|
||||
solar_power_production: float,
|
||||
sell_cost: float,
|
||||
buy_cost: float,
|
||||
sell_tax_percent: float,
|
||||
battery_soc: float
|
||||
):
|
||||
"""The entrypoint of the algorithm:
|
||||
You should give:
|
||||
- devices: a list of ManagedDevices. devices that are is_usable false are not taken into account
|
||||
- power_consumption: the current power consumption. Can be negeative if power is given back to grid
|
||||
- solar_power_production: the solar production power
|
||||
- sell_cost: the sell cost of energy
|
||||
- buy_cost: the buy cost of energy
|
||||
- sell_tax_percent: a sell taxe applied to sell energy (a percentage)
|
||||
|
||||
In return you will have:
|
||||
- best_solution: a list of object in whitch name, power_max and state are set,
|
||||
- best_objectif: the measure of the objective for that solution,
|
||||
- total_power_consumption: the total of power consumption for all equipments which should be activated (state=True)
|
||||
"""
|
||||
if (
|
||||
len(devices) <= 0 # pylint: disable=too-many-boolean-expressions
|
||||
or power_consumption is None
|
||||
or solar_power_production is None
|
||||
or sell_cost is None
|
||||
or buy_cost is None
|
||||
or sell_tax_percent is None
|
||||
):
|
||||
_LOGGER.info(
|
||||
"Not all informations are available for Simulated Annealign algorithm to work. Calculation is abandoned"
|
||||
)
|
||||
return [], -1, -1
|
||||
|
||||
_LOGGER.debug(
|
||||
"Calling recuit_simule with power_consumption=%.2f, solar_power_production=%.2f sell_cost=%.2f, buy_cost=%.2f, tax=%.2f%% devices=%s",
|
||||
power_consumption,
|
||||
solar_power_production,
|
||||
sell_cost,
|
||||
buy_cost,
|
||||
sell_tax_percent,
|
||||
devices,
|
||||
)
|
||||
self._cout_achat = buy_cost
|
||||
self._cout_revente = sell_cost
|
||||
self._taxe_revente = sell_tax_percent
|
||||
self._consommation_net = power_consumption
|
||||
self._production_solaire = solar_power_production
|
||||
|
||||
self._equipements = []
|
||||
for _, device in enumerate(devices):
|
||||
if not device.is_enabled:
|
||||
_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
|
||||
force_state = (
|
||||
False
|
||||
if device.is_active and not usable and not waiting
|
||||
else device.is_active
|
||||
)
|
||||
self._equipements.append(
|
||||
{
|
||||
"power_max": device.power_max,
|
||||
"power_min": device.power_min,
|
||||
"power_step": device.power_step,
|
||||
"current_power": device.current_power, # if force_state else 0,
|
||||
# Initial Requested power is the current power if usable
|
||||
"requested_power": device.current_power, # if force_state else 0,
|
||||
"name": device.name,
|
||||
"state": force_state,
|
||||
"is_usable": device.is_usable,
|
||||
"is_waiting": waiting,
|
||||
"can_change_power": device.can_change_power,
|
||||
}
|
||||
)
|
||||
if DEBUG:
|
||||
_LOGGER.debug("enabled _equipements are: %s", self._equipements)
|
||||
|
||||
# Générer une solution initiale
|
||||
solution_actuelle = self.generer_solution_initiale(self._equipements)
|
||||
meilleure_solution = solution_actuelle
|
||||
meilleure_objectif = self.calculer_objectif(solution_actuelle)
|
||||
temperature = self._temperature_initiale
|
||||
|
||||
for _ in range(self._nombre_iterations):
|
||||
# Générer un voisin
|
||||
objectif_actuel = self.calculer_objectif(solution_actuelle)
|
||||
if DEBUG:
|
||||
_LOGGER.debug("Objectif actuel : %.2f", objectif_actuel)
|
||||
|
||||
voisin = self.permuter_equipement(solution_actuelle)
|
||||
|
||||
# Calculer les objectifs pour la solution actuelle et le voisin
|
||||
objectif_voisin = self.calculer_objectif(voisin)
|
||||
if DEBUG:
|
||||
_LOGGER.debug("Objectif voisin : %2.f", objectif_voisin)
|
||||
|
||||
# Accepter le voisin si son objectif est meilleur ou si la consommation totale n'excède pas la production solaire
|
||||
if objectif_voisin < objectif_actuel:
|
||||
_LOGGER.debug("---> On garde l'objectif voisin")
|
||||
solution_actuelle = voisin
|
||||
if objectif_voisin < self.calculer_objectif(meilleure_solution):
|
||||
_LOGGER.debug("---> C'est la meilleure jusque là")
|
||||
meilleure_solution = voisin
|
||||
meilleure_objectif = objectif_voisin
|
||||
else:
|
||||
# Accepter le voisin avec une certaine probabilité
|
||||
probabilite = math.exp(
|
||||
(objectif_actuel - objectif_voisin) / temperature
|
||||
)
|
||||
if (seuil := random.random()) < probabilite:
|
||||
solution_actuelle = voisin
|
||||
if DEBUG:
|
||||
_LOGGER.debug(
|
||||
"---> On garde l'objectif voisin car seuil (%.2f) inférieur à proba (%.2f)",
|
||||
seuil,
|
||||
probabilite,
|
||||
)
|
||||
else:
|
||||
if DEBUG:
|
||||
_LOGGER.debug("--> On ne prend pas")
|
||||
|
||||
# Réduire la température
|
||||
temperature *= self._facteur_refroidissement
|
||||
if DEBUG:
|
||||
_LOGGER.debug(" !! Temperature %.2f", temperature)
|
||||
if temperature < self._temperature_minimale:
|
||||
break
|
||||
|
||||
return (
|
||||
meilleure_solution,
|
||||
meilleure_objectif,
|
||||
self.consommation_equipements(meilleure_solution),
|
||||
)
|
||||
|
||||
def calculer_objectif(self, solution) -> float:
|
||||
"""Calcul de l'objectif : minimiser le surplus de production solaire
|
||||
rejets = 0 if consommation_net >=0 else -consommation_net
|
||||
consommation_solaire = min(production_solaire, production_solaire - rejets)
|
||||
consommation_totale = consommation_net + consommation_solaire
|
||||
"""
|
||||
|
||||
puissance_totale_eqt = self.consommation_equipements(solution)
|
||||
diff_puissance_totale_eqt = (
|
||||
puissance_totale_eqt - self._puissance_totale_eqt_initiale
|
||||
)
|
||||
|
||||
new_consommation_net = self._consommation_net + diff_puissance_totale_eqt
|
||||
new_rejets = 0 if new_consommation_net >= 0 else -new_consommation_net
|
||||
new_import = 0 if new_consommation_net < 0 else new_consommation_net
|
||||
new_consommation_solaire = min(
|
||||
self._production_solaire, self._production_solaire - new_rejets
|
||||
)
|
||||
new_consommation_totale = (
|
||||
new_consommation_net + new_rejets
|
||||
) + new_consommation_solaire
|
||||
if DEBUG:
|
||||
_LOGGER.debug(
|
||||
"Objectif : cette solution ajoute %.3fW a la consommation initial. Nouvelle consommation nette=%.3fW. Nouveaux rejets=%.3fW. Nouvelle conso totale=%.3fW",
|
||||
diff_puissance_totale_eqt,
|
||||
new_consommation_net,
|
||||
new_rejets,
|
||||
new_consommation_totale,
|
||||
)
|
||||
|
||||
cout_revente_impose = self._cout_revente * (1.0 - self._taxe_revente / 100.0)
|
||||
coef_import = (self._cout_achat) / (self._cout_achat + cout_revente_impose)
|
||||
coef_rejets = (cout_revente_impose) / (self._cout_achat + cout_revente_impose)
|
||||
|
||||
return coef_import * new_import + coef_rejets * new_rejets
|
||||
|
||||
def generer_solution_initiale(self, solution):
|
||||
"""Generate the initial solution (which is the solution given in argument) and calculate the total initial power"""
|
||||
self._puissance_totale_eqt_initiale = self.consommation_equipements(solution)
|
||||
return copy.deepcopy(solution)
|
||||
|
||||
def consommation_equipements(self, solution):
|
||||
"""The total power consumption for all active equipement"""
|
||||
return sum(
|
||||
equipement["requested_power"]
|
||||
for _, equipement in enumerate(solution)
|
||||
if equipement["state"]
|
||||
)
|
||||
|
||||
def calculer_new_power(
|
||||
self, current_power, power_step, power_min, power_max, can_switch_off
|
||||
):
|
||||
"""Calcul une nouvelle puissance"""
|
||||
choices = []
|
||||
if current_power > power_min or can_switch_off:
|
||||
choices.append(-1)
|
||||
if current_power < power_max:
|
||||
choices.append(1)
|
||||
|
||||
if len(choices) <= 0:
|
||||
# No changes
|
||||
return current_power
|
||||
|
||||
power_add = random.choice(choices) * power_step
|
||||
_LOGGER.debug("Adding %d power to current_power (%d)", power_add, current_power)
|
||||
requested_power = current_power + power_add
|
||||
_LOGGER.debug("New requested_power is %s", requested_power)
|
||||
return requested_power
|
||||
# if requested_power < power_min:
|
||||
# deactivate the equipment
|
||||
# requested_power = 0
|
||||
# elif requested_power > power_max:
|
||||
# Do nothing
|
||||
# requested_power = current_power
|
||||
|
||||
def permuter_equipement(self, solution):
|
||||
"""Permuter le state d'un equipement eau hasard"""
|
||||
voisin = copy.deepcopy(solution)
|
||||
|
||||
usable = [eqt for eqt in voisin if eqt["is_usable"]]
|
||||
|
||||
if len(usable) <= 0:
|
||||
return voisin
|
||||
|
||||
eqt = random.choice(usable)
|
||||
|
||||
# name = eqt["name"]
|
||||
state = eqt["state"]
|
||||
can_change_power = eqt["can_change_power"]
|
||||
is_waiting = eqt["is_waiting"]
|
||||
|
||||
# Current power is the last requested_power
|
||||
current_power = eqt.get("requested_power")
|
||||
power_max = eqt.get("power_max")
|
||||
power_step = eqt.get("power_step")
|
||||
if can_change_power:
|
||||
power_min = eqt.get("power_min")
|
||||
else:
|
||||
# If power is not manageable, min = max
|
||||
power_min = power_max
|
||||
|
||||
# On veut gérer le is_waiting qui interdit d'allumer ou éteindre un eqt usable.
|
||||
# On veut pouvoir changer la puissance si l'eqt est déjà allumé malgré qu'il soit waiting.
|
||||
# Usable veut dire qu'on peut l'allumer/éteindre OU qu'on peut changer la puissance
|
||||
|
||||
# if not can_change_power and is_waiting:
|
||||
# -> on ne fait rien (mais ne devrait pas arriver car il ne serait pas usable dans ce cas)
|
||||
#
|
||||
# if state and can_change_power and is_waiting:
|
||||
# -> change power mais sans l'éteindre (requested_power >= power_min)
|
||||
#
|
||||
# if state and can_change_power and not is_waiting:
|
||||
# -> change power avec extinction possible
|
||||
#
|
||||
# if not state and not is_waiting
|
||||
# -> allumage
|
||||
#
|
||||
# if state and not is_waiting
|
||||
# -> extinction
|
||||
#
|
||||
if (not can_change_power and is_waiting) or (
|
||||
not state and can_change_power and is_waiting
|
||||
):
|
||||
_LOGGER.debug("not can_change_power and is_waiting -> do nothing")
|
||||
return voisin
|
||||
|
||||
if state and can_change_power and is_waiting:
|
||||
# calculated a new power but do not switch off (because waiting)
|
||||
requested_power = self.calculer_new_power(
|
||||
current_power, power_step, power_min, power_max, False
|
||||
)
|
||||
assert (
|
||||
requested_power > 0
|
||||
), "Requested_power should be > 0 because is_waiting is True"
|
||||
|
||||
elif state and can_change_power and not is_waiting:
|
||||
# change power and accept switching off
|
||||
requested_power = self.calculer_new_power(
|
||||
current_power, power_step, power_min, power_max, True
|
||||
)
|
||||
if requested_power < power_min:
|
||||
# deactivate the equipment
|
||||
eqt["state"] = False
|
||||
requested_power = 0
|
||||
|
||||
elif not state and not is_waiting:
|
||||
# Allumage
|
||||
eqt["state"] = not state
|
||||
requested_power = power_min
|
||||
|
||||
elif state and not is_waiting:
|
||||
# Extinction
|
||||
eqt["state"] = not state
|
||||
requested_power = 0
|
||||
|
||||
elif "requested_power" not in locals():
|
||||
_LOGGER.error("We should not be there. eqt=%s", eqt)
|
||||
assert False, "Requested power n'a pas été calculé. Ce n'est pas normal"
|
||||
|
||||
eqt["requested_power"] = requested_power
|
||||
|
||||
# old code that was working
|
||||
# if not state or not can_change_power:
|
||||
# eqt["state"] = not state
|
||||
# # We always start at the min power
|
||||
# eqt["requested_power"] = power_min
|
||||
# else:
|
||||
# _LOGGER.debug("Managing a can_change_power eqt which is already Activated")
|
||||
# # Deactivate eqt or change power
|
||||
# power_add = random.choice([-1, 1]) * power_step
|
||||
# _LOGGER.debug(
|
||||
# "Adding %d power to current_power (%d)", power_add, current_power
|
||||
# )
|
||||
# requested_power = current_power + power_add
|
||||
# if requested_power < power_min:
|
||||
# # deactivate the equipment
|
||||
# eqt["state"] = False
|
||||
# requested_power = 0
|
||||
# elif requested_power > power_max:
|
||||
# # Do nothing
|
||||
# requested_power = current_power
|
||||
# _LOGGER.debug("New requested_power is %s for eqt %s", requested_power, name)
|
||||
# # Update the solution with current_power and
|
||||
# eqt["requested_power"] = requested_power
|
||||
|
||||
if DEBUG:
|
||||
_LOGGER.debug(
|
||||
" -- On permute %s puissance max de %.2f. Il passe à %s",
|
||||
eqt["name"],
|
||||
eqt["requested_power"],
|
||||
eqt["state"],
|
||||
)
|
||||
return voisin
|
||||
61
config/custom_components/solar_optimizer/strings.json
Normal file
61
config/custom_components/solar_optimizer/strings.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"title": "solar_optimizer",
|
||||
"config": {
|
||||
"flow_title": "Solar Optimizer configuration",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "General parameters",
|
||||
"description": "Give the general parameters",
|
||||
"data": {
|
||||
"refresh_period_sec": "Refresh period",
|
||||
"power_consumption_entity_id": "Net power consumption",
|
||||
"power_production_entity_id": "Solar power production",
|
||||
"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",
|
||||
"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",
|
||||
"power_consumption_entity_id": "the entity_id of the net power consumption sensor. Net power should be negative if power is exported to grid.",
|
||||
"power_production_entity_id": "the entity_id of the solar power production sensor.",
|
||||
"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",
|
||||
"battery_soc_entity_id": "Battery state of charge in %. If you don't have battery, keep it empty"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"flow_title": "Solar Optimizer options configuration",
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "General parameters",
|
||||
"description": "Give the general parameters",
|
||||
"data": {
|
||||
"refresh_period_sec": "Refresh period",
|
||||
"power_consumption_entity_id": "Net power consumption",
|
||||
"power_production_entity_id": "Solar power production",
|
||||
"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",
|
||||
"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",
|
||||
"power_consumption_entity_id": "the entity_id of the net power consumption sensor. Net power should be negative if power is exported to grid.",
|
||||
"power_production_entity_id": "the entity_id of the solar power production sensor.",
|
||||
"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",
|
||||
"battery_soc_entity_id": "Battery state of charge in %. If you don't have battery, keep it empty"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
337
config/custom_components/solar_optimizer/switch.py
Normal file
337
config/custom_components/solar_optimizer/switch.py
Normal file
@@ -0,0 +1,337 @@
|
||||
""" A bonary sensor entity that holds the state of each managed_device """
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_ON
|
||||
from homeassistant.core import callback, HomeAssistant, State, Event
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.components.switch import (
|
||||
SwitchEntity,
|
||||
)
|
||||
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddEntitiesCallback,
|
||||
)
|
||||
|
||||
from homeassistant.helpers.event import (
|
||||
async_track_state_change_event,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
name_to_unique_id,
|
||||
get_tz,
|
||||
EVENT_TYPE_SOLAR_OPTIMIZER_ENABLE_STATE_CHANGE,
|
||||
)
|
||||
from .coordinator import SolarOptimizerCoordinator
|
||||
from .managed_device import ManagedDevice
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, _, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
"""Setup the entries of type Binary sensor, one for each ManagedDevice"""
|
||||
_LOGGER.debug("Calling switch.async_setup_entry")
|
||||
|
||||
coordinator: SolarOptimizerCoordinator = hass.data[DOMAIN]["coordinator"]
|
||||
|
||||
entities = []
|
||||
for _, device in enumerate(coordinator.devices):
|
||||
entity = ManagedDeviceSwitch(
|
||||
coordinator,
|
||||
hass,
|
||||
device.name,
|
||||
name_to_unique_id(device.name),
|
||||
device.entity_id,
|
||||
)
|
||||
if entity is not None:
|
||||
entities.append(entity)
|
||||
|
||||
entity = ManagedDeviceEnable(hass, device)
|
||||
if entity is not None:
|
||||
entities.append(entity)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
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)
|
||||
self._hass: HomeAssistant = hass
|
||||
self.idx = idx
|
||||
self._attr_name = "Solar Optimizer " + name
|
||||
self._attr_unique_id = "solar_optimizer_" + idx
|
||||
self._entity_id = entity_id
|
||||
|
||||
# Try to get the state if it exists
|
||||
device: ManagedDevice = None
|
||||
if (device := coordinator.get_device_by_unique_id(self.idx)) is not None:
|
||||
self._attr_is_on = device.is_active
|
||||
else:
|
||||
self._attr_is_on = None
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""The entity have been added to hass, listen to state change of the underlying entity"""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
# Arme l'écoute de la première entité
|
||||
listener_cancel = async_track_state_change_event(
|
||||
self.hass,
|
||||
[self._entity_id],
|
||||
self._on_state_change,
|
||||
)
|
||||
# desarme le timer lors de la destruction de l'entité
|
||||
self.async_on_remove(listener_cancel)
|
||||
|
||||
# desarme le timer lors de la destruction de l'entité
|
||||
self.async_on_remove(
|
||||
self._hass.bus.async_listen(
|
||||
event_type=EVENT_TYPE_SOLAR_OPTIMIZER_ENABLE_STATE_CHANGE,
|
||||
listener=self._on_enable_state_change,
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
async def _on_enable_state_change(self, event: Event) -> None:
|
||||
"""Triggered when the ManagedDevice enable state have change"""
|
||||
|
||||
# is it for me ?
|
||||
if (
|
||||
not event.data
|
||||
or (device_id := event.data.get("device_unique_id")) != self.idx
|
||||
):
|
||||
return
|
||||
|
||||
# search for coordinator and device
|
||||
if not self.coordinator or not (
|
||||
device := self.coordinator.get_device_by_unique_id(device_id)
|
||||
):
|
||||
return
|
||||
|
||||
_LOGGER.info(
|
||||
"Changing enabled state for %s to %s", device_id, device.is_enabled
|
||||
)
|
||||
|
||||
self.update_custom_attributes(device)
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
async def _on_state_change(self, event: Event) -> None:
|
||||
"""The entity have change its state"""
|
||||
_LOGGER.info(
|
||||
"Appel de on_state_change à %s avec l'event %s", datetime.now(), event
|
||||
)
|
||||
|
||||
if not event.data:
|
||||
return
|
||||
|
||||
# search for coordinator and device
|
||||
if not self.coordinator or not (
|
||||
device := self.coordinator.get_device_by_unique_id(self.idx)
|
||||
):
|
||||
return
|
||||
|
||||
new_state: State = event.data.get("new_state")
|
||||
# old_state: State = event.data.get("old_state")
|
||||
|
||||
if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
|
||||
_LOGGER.debug("Pas d'état disponible. Evenement ignoré")
|
||||
return
|
||||
|
||||
# On recherche la date de l'event pour la stocker dans notre état
|
||||
new_state = new_state.state == STATE_ON
|
||||
if new_state == self._attr_is_on:
|
||||
return
|
||||
|
||||
self._attr_is_on = new_state
|
||||
# On sauvegarde le nouvel état
|
||||
self.update_custom_attributes(device)
|
||||
self.async_write_ha_state()
|
||||
|
||||
def update_custom_attributes(self, device):
|
||||
"""Add some custom attributes to the entity"""
|
||||
current_tz = get_tz(self._hass)
|
||||
self._attr_extra_state_attributes: dict(str, str) = {
|
||||
"is_enabled": device.is_enabled,
|
||||
"is_active": device.is_active,
|
||||
"is_waiting": device.is_waiting,
|
||||
"is_usable": device.is_usable,
|
||||
"can_change_power": device.can_change_power,
|
||||
"current_power": device.current_power,
|
||||
"requested_power": device.requested_power,
|
||||
"duration_sec": device.duration_sec,
|
||||
"duration_power_sec": device.duration_power_sec,
|
||||
"power_min": device.power_min,
|
||||
"power_max": device.power_max,
|
||||
"next_date_available": device.next_date_available.astimezone(
|
||||
current_tz
|
||||
).isoformat(),
|
||||
"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
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
_LOGGER.debug("Calling _handle_coordinator_update for %s", self._attr_name)
|
||||
|
||||
if not self.coordinator or not self.coordinator.data:
|
||||
_LOGGER.debug("No coordinator found or no data...")
|
||||
return
|
||||
|
||||
device: ManagedDevice = self.coordinator.data.get(self.idx)
|
||||
if not device:
|
||||
# it is possible to not have device in coordinator update (if device is not enabled)
|
||||
_LOGGER.debug("No device %s found ...", self.idx)
|
||||
return
|
||||
|
||||
self._attr_is_on = device.is_active
|
||||
self.update_custom_attributes(device)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the entity on."""
|
||||
if not self.coordinator or not self.coordinator.data:
|
||||
return
|
||||
|
||||
_LOGGER.info("Turn_on Solar Optimizer switch %s", self._attr_name)
|
||||
# search for coordinator and device
|
||||
if not self.coordinator or not (
|
||||
device := self.coordinator.get_device_by_unique_id(self.idx)
|
||||
):
|
||||
return
|
||||
|
||||
if not self._attr_is_on:
|
||||
await device.activate()
|
||||
self._attr_is_on = True
|
||||
self.update_custom_attributes(device)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the entity on."""
|
||||
if not self.coordinator or not self.coordinator.data:
|
||||
return
|
||||
|
||||
_LOGGER.info("Turn_on Solar Optimizer switch %s", self._attr_name)
|
||||
# search for coordinator and device
|
||||
if not self.coordinator or not (
|
||||
device := self.coordinator.get_device_by_unique_id(self.idx)
|
||||
):
|
||||
return
|
||||
|
||||
if self._attr_is_on:
|
||||
await device.deactivate()
|
||||
self._attr_is_on = False
|
||||
self.update_custom_attributes(device)
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
# Retournez des informations sur le périphérique associé à votre entité
|
||||
return {
|
||||
"identifiers": {(DOMAIN, "solar_optimizer_device")},
|
||||
"name": "Solar Optimizer",
|
||||
# Autres attributs du périphérique ici
|
||||
}
|
||||
|
||||
@property
|
||||
def get_attr_extra_state_attributes(self):
|
||||
"""Get the extra state attributes for the entity"""
|
||||
return self._attr_extra_state_attributes
|
||||
|
||||
|
||||
class ManagedDeviceEnable(SwitchEntity, RestoreEntity):
|
||||
"""The that enables the ManagedDevice optimisation with"""
|
||||
|
||||
_device: ManagedDevice
|
||||
|
||||
def __init__(self, hass: HomeAssistant, device: ManagedDevice):
|
||||
self._hass: HomeAssistant = hass
|
||||
self._device = device
|
||||
self._attr_name = "Enable Solar Optimizer " + device.name
|
||||
self._attr_unique_id = "solar_optimizer_enable_" + name_to_unique_id(
|
||||
device.name
|
||||
)
|
||||
self._attr_is_on = True
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
# Retournez des informations sur le périphérique associé à votre entité
|
||||
return {
|
||||
"identifiers": {(DOMAIN, "solar_optimizer_device")},
|
||||
"name": "Solar Optimizer",
|
||||
# Autres attributs du périphérique ici
|
||||
}
|
||||
|
||||
@property
|
||||
def icon(self) -> str | None:
|
||||
return "mdi:check"
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
await super().async_added_to_hass()
|
||||
|
||||
# Récupérer le dernier état sauvegardé de l'entité
|
||||
last_state = await self.async_get_last_state()
|
||||
|
||||
# Si l'état précédent existe, vous pouvez l'utiliser
|
||||
if last_state is not None:
|
||||
self._attr_is_on = last_state.state == "on"
|
||||
else:
|
||||
# Si l'état précédent n'existe pas, initialisez l'état comme vous le souhaitez
|
||||
self._attr_is_on = True
|
||||
|
||||
# this breaks the start of integration
|
||||
self.update_device_enabled()
|
||||
|
||||
@callback
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the entity on."""
|
||||
self._attr_is_on = True
|
||||
self.async_write_ha_state()
|
||||
self.update_device_enabled()
|
||||
|
||||
@callback
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the entity off."""
|
||||
self._attr_is_on = False
|
||||
self.async_write_ha_state()
|
||||
self.update_device_enabled()
|
||||
|
||||
def update_device_enabled(self) -> None:
|
||||
"""Update the device is enabled flag"""
|
||||
if not self._device:
|
||||
return
|
||||
|
||||
self._device.set_enable(self._attr_is_on)
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"title": "solar_optimizer",
|
||||
"config": {
|
||||
"flow_title": "Solar Optimizer configuration",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "General parameters",
|
||||
"description": "Give the general parameters",
|
||||
"data": {
|
||||
"refresh_period_sec": "Refresh period",
|
||||
"power_consumption_entity_id": "Net power consumption",
|
||||
"power_production_entity_id": "Solar power production",
|
||||
"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",
|
||||
"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",
|
||||
"power_consumption_entity_id": "the entity_id of the net power consumption sensor. Net power should be negative if power is exported to grid.",
|
||||
"power_production_entity_id": "the entity_id of the solar power production sensor.",
|
||||
"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",
|
||||
"battery_soc_entity_id": "Battery state of charge in %. If you don't have battery, keep it empty"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"flow_title": "Solar Optimizer options configuration",
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "General parameters",
|
||||
"description": "Give the general parameters",
|
||||
"data": {
|
||||
"refresh_period_sec": "Refresh period",
|
||||
"power_consumption_entity_id": "Net power consumption",
|
||||
"power_production_entity_id": "Solar power production",
|
||||
"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",
|
||||
"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",
|
||||
"power_consumption_entity_id": "the entity_id of the net power consumption sensor. Net power should be negative if power is exported to grid.",
|
||||
"power_production_entity_id": "the entity_id of the solar power production sensor.",
|
||||
"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",
|
||||
"battery_soc_entity_id": "Battery state of charge in %. If you don't have battery, keep it empty"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"title": "solar_optimizer",
|
||||
"config": {
|
||||
"flow_title": "Solar Optimizer configuration",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "General parameters",
|
||||
"description": "Give the general parameters",
|
||||
"data": {
|
||||
"refresh_period_sec": "Refresh period",
|
||||
"power_consumption_entity_id": "Net power consumption",
|
||||
"power_production_entity_id": "Solar power production",
|
||||
"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",
|
||||
"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",
|
||||
"power_consumption_entity_id": "the entity_id of the net power consumption sensor. Net power should be negative if power is exported to grid.",
|
||||
"power_production_entity_id": "the entity_id of the solar power production sensor.",
|
||||
"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",
|
||||
"battery_soc_entity_id": "Battery state of charge in %. If you don't have battery, keep it empty"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"flow_title": "Solar Optimizer options configuration",
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "General parameters",
|
||||
"description": "Give the general parameters",
|
||||
"data": {
|
||||
"refresh_period_sec": "Refresh period",
|
||||
"power_consumption_entity_id": "Net power consumption",
|
||||
"power_production_entity_id": "Solar power production",
|
||||
"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",
|
||||
"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",
|
||||
"power_consumption_entity_id": "the entity_id of the net power consumption sensor. Net power should be negative if power is exported to grid.",
|
||||
"power_production_entity_id": "the entity_id of the solar power production sensor.",
|
||||
"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",
|
||||
"battery_soc_entity_id": "Battery state of charge in %. If you don't have battery, keep it empty"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user