Files
homeassistant_config/config/custom_components/solar_optimizer/managed_device.py
2024-08-09 06:45:02 +02:00

480 lines
16 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
""" 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,
},
)