From aeb130a2f28c430c226aff39b006633b0efccfe8 Mon Sep 17 00:00:00 2001 From: Jean-Marc Collin Date: Sat, 17 Jun 2023 11:02:40 +0000 Subject: [PATCH] With config_flow, listen state_change, switch instead of binary_sensor, click to toggle --- .devcontainer/configuration.yaml | 25 ++- custom_components/solar_optimizer/__init__.py | 36 +++- .../solar_optimizer/binary_sensor.py | 88 -------- .../solar_optimizer/config_flow.py | 65 ++++++ custom_components/solar_optimizer/const.py | 2 +- .../solar_optimizer/coordinator.py | 42 ++-- .../solar_optimizer/managed_device.py | 6 +- .../solar_optimizer/manifest.json | 2 +- custom_components/solar_optimizer/sensor.py | 55 +++-- .../simulated_annealing_algo.py | 18 +- .../solar_optimizer/strings.json | 28 +++ custom_components/solar_optimizer/switch.py | 194 ++++++++++++++++++ .../solar_optimizer/translations/en.json | 28 +++ .../solar_optimizer/translations/fr.json | 28 +++ 14 files changed, 464 insertions(+), 153 deletions(-) delete mode 100644 custom_components/solar_optimizer/binary_sensor.py create mode 100644 custom_components/solar_optimizer/config_flow.py create mode 100644 custom_components/solar_optimizer/strings.json create mode 100644 custom_components/solar_optimizer/switch.py create mode 100644 custom_components/solar_optimizer/translations/en.json create mode 100644 custom_components/solar_optimizer/translations/fr.json diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml index 39327dd..e75a80c 100644 --- a/.devcontainer/configuration.yaml +++ b/.devcontainer/configuration.yaml @@ -46,6 +46,9 @@ input_boolean: fake_device_i: name: "Equipement I (20 W)" icon: mdi:radiator + device_h_enable: + name: "Enable device H" + icon: mdi:check switch: - platform: template @@ -138,7 +141,7 @@ solar_optimizer: entity_id: "input_boolean.fake_device_a" power_max: 1000 check_usable_template: "{{ True }}" - duration_sec: 20 + duration_min: 0.3 action_mode: "service_call" activation_service: "input_boolean/turn_on" deactivation_service: "input_boolean/turn_off" @@ -146,7 +149,7 @@ solar_optimizer: entity_id: "input_boolean.fake_device_b" power_max: 500 check_usable_template: "{{ True }}" - duration_sec: 40 + duration_min: 0.6 action_mode: "service_call" activation_service: "input_boolean/turn_on" deactivation_service: "input_boolean/turn_off" @@ -154,7 +157,7 @@ solar_optimizer: entity_id: "input_boolean.fake_device_c" power_max: 800 check_usable_template: "{{ True }}" - duration_sec: 60 + duration_min: 1 action_mode: "service_call" activation_service: "input_boolean/turn_on" deactivation_service: "input_boolean/turn_off" @@ -162,7 +165,7 @@ solar_optimizer: entity_id: "input_boolean.fake_device_d" power_max: 2100 check_usable_template: "{{ True }}" - duration_sec: 30 + duration_min: 0.5 action_mode: "service_call" activation_service: "input_boolean/turn_on" deactivation_service: "input_boolean/turn_off" @@ -170,7 +173,7 @@ solar_optimizer: entity_id: "input_boolean.fake_device_e" power_max: 120 check_usable_template: "{{ True }}" - duration_sec: 125 + duration_min: 2 action_mode: "service_call" activation_service: "input_boolean/turn_on" deactivation_service: "input_boolean/turn_off" @@ -178,7 +181,7 @@ solar_optimizer: entity_id: "input_boolean.fake_device_f" power_max: 500 check_usable_template: "{{ True }}" - duration_sec: 10 + duration_min: 0.2 action_mode: "service_call" activation_service: "input_boolean/turn_on" deactivation_service: "input_boolean/turn_off" @@ -186,7 +189,7 @@ solar_optimizer: entity_id: "input_boolean.fake_device_g" power_max: 1200 check_usable_template: "{{ True }}" - duration_sec: 90 + duration_min: 90 action_mode: "service_call" activation_service: "input_boolean/turn_on" deactivation_service: "input_boolean/turn_off" @@ -197,9 +200,9 @@ solar_optimizer: power_max: 3960 power_step: 660 # check_active_template: "{{ not is_state('input_select.fake_tesla_1', '0 A') }}" - check_usable_template: "{{ True }}" - duration_sec: 60 - duration_power_sec: 10 + check_usable_template: "{{ is_state('input_boolean.device_h_enable', 'on') }}" + duration_min: 1 + duration_power_min: 0.1 action_mode: "service_call" activation_service: "input_boolean/turn_on" deactivation_service: "input_boolean/turn_off" @@ -209,7 +212,7 @@ solar_optimizer: entity_id: "switch.fake_switch_1" power_max: 20 check_usable_template: "{{ True }}" - duration_sec: 10 + duration_min: 0.1 action_mode: "service_call" activation_service: "switch/turn_on" deactivation_service: "switch/turn_off" diff --git a/custom_components/solar_optimizer/__init__.py b/custom_components/solar_optimizer/__init__.py index 2a23549..0bfe603 100644 --- a/custom_components/solar_optimizer/__init__.py +++ b/custom_components/solar_optimizer/__init__.py @@ -9,7 +9,7 @@ from .const import DOMAIN, PLATFORMS from .coordinator import SolarOptimizerCoordinator from .sensor import async_setup_entry as async_setup_entry_sensor -from .binary_sensor import async_setup_entry as async_setup_entry_binary_sensor +from .switch import async_setup_entry as async_setup_entry_switch _LOGGER = logging.getLogger(__name__) @@ -22,7 +22,7 @@ async def async_setup( "Initializing %s integration with plaforms: %s with config: %s", DOMAIN, PLATFORMS, - config, + config.get(DOMAIN), ) hass.data.setdefault(DOMAIN, {}) @@ -30,20 +30,36 @@ async def async_setup( # L'argument config contient votre fichier configuration.yaml solar_optimizer_config = config.get(DOMAIN) - hass.data[DOMAIN]["coordinator"] = coordinator = SolarOptimizerCoordinator( + hass.data[DOMAIN]["coordinator"] = SolarOptimizerCoordinator( hass, solar_optimizer_config ) - await async_setup_entry_sensor(hass) - await async_setup_entry_binary_sensor(hass) + # await async_setup_entry_sensor(hass) + # await async_setup_entry_switch(hass) + # + # # refresh data on startup + # async def _internal_startup(*_): + # await coordinator.async_config_entry_first_refresh() + # + # hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _internal_startup) - # refresh data on startup - async def _internal_startup(*_): - await coordinator.async_config_entry_first_refresh() + # Return boolean to indicate that initialization was successful. + return True - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _internal_startup) - # Return boolean to indicate that initialization was successful. +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, {}) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True diff --git a/custom_components/solar_optimizer/binary_sensor.py b/custom_components/solar_optimizer/binary_sensor.py deleted file mode 100644 index c5a2a84..0000000 --- a/custom_components/solar_optimizer/binary_sensor.py +++ /dev/null @@ -1,88 +0,0 @@ -""" A bonary sensor entity that holds the state of each managed_device """ -import logging -from homeassistant.core import callback, HomeAssistant -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.components.binary_sensor import ( - BinarySensorEntity, - DOMAIN as BINARY_SENSOR_DOMAIN, -) -from .const import DOMAIN, name_to_unique_id, get_tz -from .coordinator import SolarOptimizerCoordinator -from .managed_device import ManagedDevice - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_entry(hass: HomeAssistant) -> None: - """Setup the entries of type Binary sensor, one for each ManagedDevice""" - coordinator: SolarOptimizerCoordinator = hass.data[DOMAIN]["coordinator"] - - entities = [] - for _, device in enumerate(coordinator.devices): - entity = ManagedDeviceBinarySensor( - coordinator, hass, device.name, name_to_unique_id(device.name) - ) - if entity is not None: - entities.append(entity) - - component: EntityComponent[BinarySensorEntity] = hass.data.get(BINARY_SENSOR_DOMAIN) - if component is None: - component = hass.data[BINARY_SENSOR_DOMAIN] = EntityComponent[ - BinarySensorEntity - ](_LOGGER, BINARY_SENSOR_DOMAIN, hass) - await component.async_add_entities(entities) - - -class ManagedDeviceBinarySensor(CoordinatorEntity, BinarySensorEntity): - """The entity holding the algorithm calculation""" - - def __init__(self, coordinator, hass, name, idx): - super().__init__(coordinator, context=idx) - self._hass = hass - self.idx = idx - self._attr_name = name - self._attr_unique_id = "solar_optimizer_" + idx - - self._attr_is_on = None - - 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_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(), - } - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - device: ManagedDevice = self.coordinator.data[self.idx] - if not device: - return - self._attr_is_on = device.is_active - 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 - } diff --git a/custom_components/solar_optimizer/config_flow.py b/custom_components/solar_optimizer/config_flow.py new file mode 100644 index 0000000..9d14b19 --- /dev/null +++ b/custom_components/solar_optimizer/config_flow.py @@ -0,0 +1,65 @@ +""" Le Config Flow """ + +import logging +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, FlowResult +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 + +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=[INPUT_NUMBER_DOMAIN]) + ), + vol.Required("buy_cost_entity_id"): selector.EntitySelector( + selector.EntitySelectorConfig(domain=[INPUT_NUMBER_DOMAIN]) + ), + vol.Required("sell_tax_percent_entity_id"): selector.EntitySelector( + selector.EntitySelectorConfig(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) diff --git a/custom_components/solar_optimizer/const.py b/custom_components/solar_optimizer/const.py index eee63b9..02e3e8f 100644 --- a/custom_components/solar_optimizer/const.py +++ b/custom_components/solar_optimizer/const.py @@ -6,7 +6,7 @@ from homeassistant.util import dt as dt_util DOMAIN = "solar_optimizer" -PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR] +PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH] DEFAULT_REFRESH_PERIOD_SEC = 300 diff --git a/custom_components/solar_optimizer/coordinator.py b/custom_components/solar_optimizer/coordinator.py index f0ec744..42dcae3 100644 --- a/custom_components/solar_optimizer/coordinator.py +++ b/custom_components/solar_optimizer/coordinator.py @@ -7,11 +7,11 @@ from homeassistant.core import HomeAssistant # callback from homeassistant.helpers.update_coordinator import ( - # CoordinatorEntity, DataUpdateCoordinator, - # UpdateFailed, ) +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 @@ -35,20 +35,19 @@ class SolarOptimizerCoordinator(DataUpdateCoordinator): _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 _algo: SimulatedAnnealingAlgorithm def __init__(self, hass: HomeAssistant, config): """Initialize the coordinator""" - # TODO mettre un Voluptuous schema pour verifier la config dans __init__ - refresh_period_sec = ( - config.get("refresh_period_sec") or DEFAULT_REFRESH_PERIOD_SEC - ) super().__init__( hass, _LOGGER, name="Solar Optimizer", - update_interval=timedelta(seconds=refresh_period_sec), + # update_interval=timedelta(seconds=refresh_period_sec), ) # pylint : disable=line-too-long self._devices = [] try: @@ -61,17 +60,6 @@ def __init__(self, hass: HomeAssistant, config): "Your 'devices' configuration is wrong. SolarOptimizer will not be operational until you fix it" ) raise err - self._power_consumption_entity_id = config.get("power_consumption_entity_id") - # if self._power_consumption_entity_id is None: - # err = HomeAssistantError( - # "Your 'power_consumption_entity_id' configuration is wrong. SolarOptimizer will not be operational until you fix it" - # ) - # _LOGGER.error(err) - # raise err - self._power_production_entity_id = config.get("power_production_entity_id") - self._sell_cost_entity_id = config.get("sell_cost_entity_id") - self._buy_cost_entity_id = config.get("buy_cost_entity_id") - self._sell_tax_percent_entity_id = config.get("sell_tax_percent_entity_id") algo_config = config.get("algorithm") self._algo = SimulatedAnnealingAlgorithm( @@ -82,6 +70,24 @@ def __init__(self, hass: HomeAssistant, config): ) 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") + + await self.async_config_entry_first_refresh() + async def _async_update_data(self): _LOGGER.info("Refreshing Solar Optimizer calculation") diff --git a/custom_components/solar_optimizer/managed_device.py b/custom_components/solar_optimizer/managed_device.py index 41b60f7..570409e 100644 --- a/custom_components/solar_optimizer/managed_device.py +++ b/custom_components/solar_optimizer/managed_device.py @@ -135,9 +135,9 @@ def __init__(self, hass: HomeAssistant, device_config): ) self._current_power = self._requested_power = 0 - self._duration_sec = int(device_config.get("duration_sec")) - self._duration_power_sec = int( - device_config.get("duration_power_sec") or self._duration_sec + self._duration_sec = round(float(device_config.get("duration_min")) * 60) + self._duration_power_sec = round( + float(device_config.get("duration_power_min") or self._duration_sec) * 60 ) if device_config.get("check_usable_template"): diff --git a/custom_components/solar_optimizer/manifest.json b/custom_components/solar_optimizer/manifest.json index 85656f5..abbdfe1 100644 --- a/custom_components/solar_optimizer/manifest.json +++ b/custom_components/solar_optimizer/manifest.json @@ -4,7 +4,7 @@ "codeowners": [ "@jmcollin78" ], - "config_flow": false, + "config_flow": true, "documentation": "https://github.com/jmcollin78/solar_optimizer", "integration_type": "device", "iot_class": "local_polling", diff --git a/custom_components/solar_optimizer/sensor.py b/custom_components/solar_optimizer/sensor.py index 85d7d4c..69a9c77 100644 --- a/custom_components/solar_optimizer/sensor.py +++ b/custom_components/solar_optimizer/sensor.py @@ -2,29 +2,52 @@ import logging from homeassistant.core import callback, HomeAssistant from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.components.sensor import SensorEntity, DOMAIN as SENSOR_DOMAIN +from homeassistant.components.sensor import SensorEntity + +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) -> None: +# async def async_setup_entry(hass: HomeAssistant) -> None: +# """Setup the entries of type Sensor""" +# entity1 = SolarOptimizerSensorEntity( +# hass.data[DOMAIN]["coordinator"], hass, "best_objective" +# ) +# entity2 = SolarOptimizerSensorEntity( +# hass.data[DOMAIN]["coordinator"], hass, "total_power" +# ) +# +# component: EntityComponent[SensorEntity] = hass.data.get(SENSOR_DOMAIN) +# if component is None: +# component = hass.data[SENSOR_DOMAIN] = EntityComponent[SensorEntity]( +# _LOGGER, SENSOR_DOMAIN, hass +# ) +# await component.async_add_entities([entity1, entity2]) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Setup the entries of type Sensor""" - entity1 = SolarOptimizerSensorEntity( - hass.data[DOMAIN]["coordinator"], hass, "best_objective" - ) - entity2 = SolarOptimizerSensorEntity( - hass.data[DOMAIN]["coordinator"], hass, "total_power" - ) - - component: EntityComponent[SensorEntity] = hass.data.get(SENSOR_DOMAIN) - if component is None: - component = hass.data[SENSOR_DOMAIN] = EntityComponent[SensorEntity]( - _LOGGER, SENSOR_DOMAIN, hass - ) - await component.async_add_entities([entity1, entity2]) + + # 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") + + async_add_entities([entity1, entity2], False) + + await coordinator.configure(entry) class SolarOptimizerSensorEntity(CoordinatorEntity, SensorEntity): diff --git a/custom_components/solar_optimizer/simulated_annealing_algo.py b/custom_components/solar_optimizer/simulated_annealing_algo.py index 8019bb7..1a64b19 100644 --- a/custom_components/solar_optimizer/simulated_annealing_algo.py +++ b/custom_components/solar_optimizer/simulated_annealing_algo.py @@ -100,18 +100,26 @@ def recuit_simule( self._equipements = [] for _, device in enumerate(devices): + 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, - # Initial Requested power is the current power - "requested_power": device.current_power, + "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": device.is_active, + "state": force_state, "is_usable": device.is_usable, - "is_waiting": device.is_waiting, + "is_waiting": waiting, "can_change_power": device.can_change_power, } ) diff --git a/custom_components/solar_optimizer/strings.json b/custom_components/solar_optimizer/strings.json new file mode 100644 index 0000000..68246ab --- /dev/null +++ b/custom_components/solar_optimizer/strings.json @@ -0,0 +1,28 @@ +{ + "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" + }, + "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)" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/solar_optimizer/switch.py b/custom_components/solar_optimizer/switch.py new file mode 100644 index 0000000..13ccdbd --- /dev/null +++ b/custom_components/solar_optimizer/switch.py @@ -0,0 +1,194 @@ +""" 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.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 +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) + + # component: EntityComponent[SwitchEntity] = hass.data.get(SWITCH_DOMAIN) + # if component is None: + # component = hass.data[SWITCH_DOMAIN] = EntityComponent[SwitchEntity]( + # _LOGGER, SWITCH_DOMAIN, hass + # ) + # await component.async_add_entities(entities) + async_add_entities(entities) + + +class ManagedDeviceSwitch(CoordinatorEntity, SwitchEntity): + """The entity holding the algorithm calculation""" + + 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 = coordinator.data.get(self.idx) + if device: + self._attr_is_on = device.is_active + + async def async_added_to_hass(self) -> None: + """The entity have been added to hass, listen to state change of the underlying entity""" + # 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) + + @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 self.coordinator or not self.coordinator.data: + return + + device: ManagedDevice = self.coordinator.data.get(self.idx) + if not device: + 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.warning("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_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(), + } + + @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.warning("No coordinator found ...") + return + device: ManagedDevice = self.coordinator.data.get(self.idx) + if not device: + _LOGGER.warning("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) + device: ManagedDevice = self.coordinator.data.get(self.idx) + if not device: + 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) + device: ManagedDevice = self.coordinator.data.get(self.idx) + if not device: + 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 + } diff --git a/custom_components/solar_optimizer/translations/en.json b/custom_components/solar_optimizer/translations/en.json new file mode 100644 index 0000000..68246ab --- /dev/null +++ b/custom_components/solar_optimizer/translations/en.json @@ -0,0 +1,28 @@ +{ + "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" + }, + "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)" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/solar_optimizer/translations/fr.json b/custom_components/solar_optimizer/translations/fr.json new file mode 100644 index 0000000..68246ab --- /dev/null +++ b/custom_components/solar_optimizer/translations/fr.json @@ -0,0 +1,28 @@ +{ + "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" + }, + "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)" + } + } + } + } +} \ No newline at end of file