480 lines
16 KiB
Python
480 lines
16 KiB
Python
""" 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,
|
||
},
|
||
)
|