Home Assistant Git Exporter

This commit is contained in:
root
2024-08-09 06:45:02 +02:00
parent 60abdd866c
commit 80fc630f5e
624 changed files with 27739 additions and 4497 deletions

View 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)

View 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)

View 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)

View 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

View File

@@ -0,0 +1,7 @@
{
"name": "Solar Optimizer",
"content_in_root": false,
"render_readme": true,
"hide_default_branch": false,
"homeassistant": "2023.6.1"
}

View 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,
},
)

View 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"
}

View 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

View File

@@ -0,0 +1,3 @@
reload:
name: Reload
description: Reload Solar Optimizer configuration

View File

@@ -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

View 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"
}
}
}
}
}

View 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)

View 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"
}
}
}
}
}

View 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"
}
}
}
}
}