usda-hass-config/custom_components/pid_controller/sensor.py

840 lines
26 KiB
Python

#
# Copyright (c) 2022, Diogo Silva "Soloam"
# Creative Commons BY-NC-SA 4.0 International Public License
# (see LICENSE.md or https://creativecommons.org/licenses/by-nc-sa/4.0/)
#
"""
PID Controller.
For more details about this sensor, please refer to the documentation at
https://github.com/soloam/ha-pid-controller/
"""
from __future__ import annotations
import logging
from math import floor, ceil
from typing import Any, Mapping, Optional
import voluptuous as vol
from _sha1 import sha1
from homeassistant.components.sensor import SensorEntity, SensorDeviceClass
from homeassistant.const import (
CONF_ENTITY_ID,
CONF_NAME,
CONF_ICON,
CONF_UNIQUE_ID,
EVENT_HOMEASSISTANT_START,
STATE_UNAVAILABLE,
CONF_MINIMUM,
CONF_MAXIMUM,
CONF_UNIT_OF_MEASUREMENT,
CONF_DEVICE_CLASS,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import TemplateError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.config_validation import PLATFORM_SCHEMA
from homeassistant.helpers.event import async_track_state_change
from homeassistant.helpers.template import result_as_boolean
# pylint: disable=wildcard-import, unused-wildcard-import
from .const import *
from .pidcontroller import PIDController as PID
_LOGGER = logging.getLogger(__name__)
PLATFORM_SCHEMA = vol.All(
PLATFORM_SCHEMA.extend(
{
vol.Optional(CONF_ENABLED, default=DEFAULT_ENABLED): cv.template,
vol.Optional(CONF_ICON, default=DEFAULT_ICON): cv.template,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_UNIQUE_ID): cv.string,
vol.Required(CONF_SETPOINT): cv.template,
vol.Optional(CONF_PROPORTIONAL, default=0): cv.template,
vol.Optional(CONF_INTEGRAL, default=0): cv.template,
vol.Optional(CONF_DERIVATIVE, default=0): cv.template,
vol.Required(CONF_ENTITY_ID): cv.entity_id,
vol.Optional(CONF_INVERT, default=False): cv.template,
vol.Optional(CONF_PRECISION, default=DEFAULT_PRECISION): cv.template,
vol.Optional(CONF_MINIMUM, default=DEFAULT_MINIMUM): cv.template,
vol.Optional(CONF_MAXIMUM, default=DEFAULT_MAXIMUM): cv.template,
vol.Optional(CONF_ROUND, default=DEFAULT_ROUND): cv.template,
vol.Optional(CONF_SAMPLE_TIME, default=DEFAULT_SAMPLE_TIME): cv.template,
vol.Optional(CONF_WINDUP, default=DEFAULT_WINDUP): cv.template,
vol.Optional(
CONF_UNIT_OF_MEASUREMENT, default=DEFAULT_UNIT_OF_MEASUREMENT
): cv.string,
vol.Optional(CONF_DEVICE_CLASS, default=DEFAULT_DEVICE_CLASS): cv.template,
}
)
)
# pylint: disable=unused-argument
async def async_setup_platform(
hass: HomeAssistant, config, async_add_entities, discovery_info=None
):
enabled = config.get(CONF_ENABLED)
icon = config.get(CONF_ICON)
set_point = config.get(CONF_SETPOINT)
proportional = config.get(CONF_PROPORTIONAL)
integral = config.get(CONF_INTEGRAL)
derivative = config.get(CONF_DERIVATIVE)
invert = config.get(CONF_INVERT)
precision = config.get(CONF_PRECISION)
minimum = config.get(CONF_MINIMUM)
maximum = config.get(CONF_MAXIMUM)
round_type = config.get(CONF_ROUND)
sample_time = config.get(CONF_SAMPLE_TIME)
windup = config.get(CONF_WINDUP)
device_class = config.get(CONF_DEVICE_CLASS)
## Process Templates.
for template in [
enabled,
icon,
set_point,
sample_time,
windup,
proportional,
integral,
derivative,
invert,
precision,
minimum,
maximum,
round_type,
device_class,
]:
if template is not None:
template.hass = hass
## Set up platform.
async_add_entities(
[
PidController(
hass,
config.get(CONF_UNIQUE_ID),
config.get(CONF_NAME),
enabled,
icon,
set_point,
config.get(CONF_UNIT_OF_MEASUREMENT),
device_class,
sample_time,
windup,
proportional,
integral,
derivative,
invert,
minimum,
maximum,
round_type,
precision,
config.get(CONF_ENTITY_ID),
)
]
)
# pylint: disable=r0902
class PidController(SensorEntity):
# pylint: disable=r0913
def __init__(
self,
hass: HomeAssistant,
unique_id,
name,
enabled,
icon,
set_point,
unit_of_measurement,
device_class,
sample_time,
windup,
proportional,
integral,
derivative,
invert,
minimum,
maximum,
round_type,
precision,
entity_id,
):
self._attr_name = name
self._attr_native_unit_of_measurement = unit_of_measurement
self._enabled_template = enabled
self._icon_template = icon
self._set_point_template = set_point
self._device_class_template = device_class
self._sample_time_template = sample_time
self._windup_template = windup
self._sensor_state = 0
self._proportional_template = proportional
self._integral_template = integral
self._derivative_template = derivative
self._invert_template = invert
self._minimum_template = minimum
self._maximum_template = maximum
self._round_template = round_type
self._precision_template = precision
self._entities = []
self._force_update = []
self._reset_pid = []
self._feedback_pid = []
self._pid = None
self._source = entity_id
self._tunning = False
self._updating = False
self._tunnig_calculating = False
self._tunning_data = {}
self._enabled_entities = []
self._p_entities = []
self._i_entities = []
self._d_entities = []
self._get_entities()
self._attr_unique_id = (
str(
sha1(
";".join(
[
str(set_point),
str(proportional),
str(integral),
str(derivative),
]
).encode("utf-8")
).hexdigest()
)
if unique_id == "__legacy__"
else unique_id
)
@property
def native_value(self):
"""Return the state of the sensor."""
if not self.enabled:
return self.minimum
state = 0
try:
state = float(self._sensor_state) / 100
except ValueError:
state = 0
if self.minimum > self.maximum:
state = 0
units = self.units * state
state = self.minimum + units
precision = pow(10, self.precision)
if self.round == ROUND_FLOOR:
state = floor(state * precision) / precision
elif self.round == ROUND_CEIL:
state = ceil(state * precision) / precision
else:
state = round(state, self.precision)
if self.precision == 0:
state = int(state)
return state if self.available else STATE_UNAVAILABLE
@property
def available(self) -> bool:
"""Return True if entity is available."""
return True
@property
def enabled(self) -> bool:
"""Enabled"""
if self._enabled_template is not None:
try:
enabled = self._enabled_template.async_render(parse_result=False)
except (TemplateError, TypeError) as ex:
self.show_template_exception(ex, CONF_ENABLED)
return DEFAULT_ENABLED
return bool(result_as_boolean(enabled))
return DEFAULT_ENABLED
@property
def tunning(self) -> bool:
"""Returns Tunning"""
return self._tunning
@property
def icon(self) -> str | None:
"""Returns Icon"""
icon = DEFAULT_ICON
if self._icon_template is not None:
try:
icon = self._icon_template.async_render(parse_result=False)
except (TemplateError, TypeError) as ex:
self.show_template_exception(ex, CONF_ICON)
icon = DEFAULT_ICON
return icon
@property
def units(self) -> float:
return self.maximum - self.minimum
@property
def raw_state(self) -> float:
"""Return the state of the sensor."""
state = 0
try:
state = float(self._sensor_state) / 100
except ValueError:
state = 0
return float(state) if self.available else 0
@property
def extra_state_attributes(self) -> Optional[Mapping[str, Any]]:
"""Return entity specific state attributes."""
state_attr = {}
for attr in ATTR_TO_PROPERTY:
try:
if getattr(self, attr) is not None:
state_attr[attr] = getattr(self, attr)
except AttributeError:
continue
return state_attr
@property
def source(self) -> float:
"""Returns Response"""
source_state = self.hass.states.get(self._source)
if not source_state:
return float(0)
try:
state = float(source_state.state)
except ValueError:
state = float(0)
return float(state)
@property
def set_point(self) -> float:
"""Returns Set Point"""
if self._set_point_template is not None:
try:
set_point = self._set_point_template.async_render(parse_result=False)
except (TemplateError, TypeError) as ex:
self.show_template_exception(ex, CONF_SETPOINT)
return float(0)
try:
set_point = float(set_point)
except (ValueError):
set_point = 0
return float(set_point)
return float(0)
@property
def device_class(self) -> SensorDeviceClass:
"""Returns Device Class"""
if self._device_class_template is not None:
try:
device_class = self._device_class_template.async_render(
parse_result=False
)
except (TemplateError, TypeError) as ex:
self.show_template_exception(ex, CONF_DEVICE_CLASS)
device_class = DEFAULT_DEVICE_CLASS
return device_class
@property
def sample_time(self) -> int:
"""Returns Sample Time"""
if self._sample_time_template is not None:
try:
sample_time = self._sample_time_template.async_render(
parse_result=False
)
except (TemplateError, TypeError) as ex:
self.show_template_exception(ex, CONF_SAMPLE_TIME)
return int(DEFAULT_SAMPLE_TIME)
try:
sample_time = int(float(sample_time))
except ValueError:
sample_time = int(DEFAULT_SAMPLE_TIME)
return int(sample_time)
return int(DEFAULT_SAMPLE_TIME)
@property
def windup(self) -> int:
"""Returns Windup"""
if self._windup_template is not None:
try:
windup = self._windup_template.async_render(parse_result=False)
except (TemplateError, TypeError) as ex:
self.show_template_exception(ex, CONF_WINDUP)
return int(DEFAULT_WINDUP)
try:
windup = int(float(windup))
except ValueError:
windup = int(DEFAULT_WINDUP)
return int(windup)
return int(DEFAULT_WINDUP)
@property
def proportional(self) -> float:
"""Returns Proportional Band"""
if self._proportional_template is not None:
try:
proportional = self._proportional_template.async_render(
parse_result=False
)
except (TemplateError, TypeError) as ex:
self.show_template_exception(ex, CONF_PROPORTIONAL)
return 0
try:
proportional = float(proportional)
except (ValueError):
proportional = 0
if self.invert:
proportional = proportional * -1
return float(proportional)
return float(0)
@property
def integral(self) -> float:
"""Returns Internal Band"""
if self._integral_template is not None:
try:
integral = self._integral_template.async_render(parse_result=False)
except (TemplateError, TypeError) as ex:
self.show_template_exception(ex, CONF_INTEGRAL)
return float(0)
try:
integral = float(integral)
except ValueError:
integral = 0
if self.invert:
integral = integral * -1
return float(integral)
return float(0)
@property
def derivative(self) -> float:
"""Returns Derivative Band"""
if self._derivative_template is not None:
try:
derivative = self._derivative_template.async_render(parse_result=False)
except (TemplateError, TypeError) as ex:
self.show_template_exception(ex, CONF_DERIVATIVE)
return float(0)
try:
derivative = float(derivative)
except ValueError:
derivative = 0
if self.invert:
derivative = derivative * -1
return float(derivative)
return float(0)
@property
def minimum(self) -> float:
"""Returns Minimum"""
if self._minimum_template is not None:
try:
minimum = self._minimum_template.async_render(parse_result=False)
except (TemplateError, TypeError) as ex:
self.show_template_exception(ex, CONF_MINIMUM)
return float(DEFAULT_MINIMUM)
try:
minimum = float(minimum)
except ValueError:
minimum = float(DEFAULT_MINIMUM)
return minimum
return DEFAULT_MINIMUM
@property
def maximum(self) -> float:
"""Returns Maximum"""
if self._maximum_template is not None:
try:
maximum = self._maximum_template.async_render(parse_result=False)
except (TemplateError, TypeError) as ex:
self.show_template_exception(ex, CONF_MAXIMUM)
return float(DEFAULT_MAXIMUM)
try:
maximum = float(maximum)
except ValueError:
maximum = float(DEFAULT_MAXIMUM)
return maximum
return DEFAULT_MAXIMUM
@property
def round(self) -> str:
"""Returns Round Type"""
if self._round_template is not None:
try:
round_type = self._round_template.async_render(parse_result=False)
except (TemplateError, TypeError) as ex:
self.show_template_exception(ex, CONF_ROUND)
return False
return str(round_type).lower()
return DEFAULT_ROUND
@property
def invert(self) -> bool:
"""Returns Inverted State"""
if self._invert_template is not None:
try:
invert = self._invert_template.async_render(parse_result=False)
except (TemplateError, TypeError) as ex:
self.show_template_exception(ex, CONF_INVERT)
return False
return bool(result_as_boolean(invert))
return False
@property
# pylint: disable=invalid-name
def p(self) -> float:
"""Calculated Proportional Gain"""
if not self._pid:
return float(0)
p = self._pid.p
return float(p)
@property
# pylint: disable=invalid-name
def i(self) -> float:
"""Calculated Integral Gain"""
if not self._pid:
return float(0)
i = self._pid.i
return float(i)
@property
# pylint: disable=invalid-name
def d(self) -> float:
"""Calculated Derivative Gain"""
if not self._pid:
return float(0)
d = self._pid.d
return float(d)
@property
def precision(self) -> int:
"""Returns Precision"""
if self._precision_template is not None:
try:
precision = self._precision_template.async_render(parse_result=False)
except (TemplateError, TypeError) as ex:
self.show_template_exception(ex, CONF_PRECISION)
return int(DEFAULT_PRECISION)
try:
precision = int(float(precision))
except ValueError:
precision = int(DEFAULT_PRECISION)
return int(precision)
return int(DEFAULT_PRECISION)
@staticmethod
def show_template_exception(ex, field) -> None:
"""Show Template Erros"""
if ex.args and ex.args[0].startswith("UndefinedError: 'None' has no attribute"):
# Common during HA startup - so just a warning
_LOGGER.warning(ex)
else:
_LOGGER.error('Error parsing template for field "%s": %s', field, ex)
def _get_entities(self) -> None:
self._entities = []
self._force_update = []
if self._icon_template is not None:
try:
info = self._icon_template.async_render_to_info()
except (TemplateError, TypeError) as ex:
self.show_template_exception(ex, CONF_ICON)
else:
self._entities += info.entities
if self._set_point_template is not None:
try:
info = self._set_point_template.async_render_to_info()
except (TemplateError, TypeError) as ex:
self.show_template_exception(ex, CONF_SETPOINT)
else:
self._entities += info.entities
self._reset_pid += info.entities
if self._device_class_template is not None:
try:
info = self._device_class_template.async_render_to_info()
except (TemplateError, TypeError) as ex:
self.show_template_exception(ex, CONF_DEVICE_CLASS)
else:
self._entities += info.entities
self._force_update += info.entities
if self._sample_time_template is not None:
try:
info = self._sample_time_template.async_render_to_info()
except (TemplateError, TypeError) as ex:
self.show_template_exception(ex, CONF_SAMPLE_TIME)
else:
self._entities += info.entities
if self._windup_template is not None:
try:
info = self._windup_template.async_render_to_info()
except (TemplateError, TypeError) as ex:
self.show_template_exception(ex, CONF_WINDUP)
else:
self._entities += info.entities
if self._enabled_template is not None:
try:
info = self._enabled_template.async_render_to_info()
except (TemplateError, TypeError) as ex:
self.show_template_exception(ex, CONF_ENABLED)
else:
self._entities += info.entities
self._reset_pid += info.entities
self._force_update += info.entities
self._enabled_entities += info.entities
if self._proportional_template is not None:
try:
info = self._proportional_template.async_render_to_info()
except (TemplateError, TypeError) as ex:
self.show_template_exception(ex, CONF_PROPORTIONAL)
else:
self._entities += info.entities
self._p_entities += info.entities
if self._integral_template is not None:
try:
info = self._integral_template.async_render_to_info()
except (TemplateError, TypeError) as ex:
self.show_template_exception(ex, CONF_INTEGRAL)
else:
self._entities += info.entities
self._i_entities += info.entities
if self._derivative_template is not None:
try:
info = self._derivative_template.async_render_to_info()
except (TemplateError, TypeError) as ex:
self.show_template_exception(ex, CONF_DERIVATIVE)
else:
self._entities += info.entities
self._d_entities += info.entities
if self._precision_template is not None:
try:
info = self._precision_template.async_render_to_info()
except (TemplateError, TypeError) as ex:
self.show_template_exception(ex, CONF_PRECISION)
else:
self._entities += info.entities
self._force_update += info.entities
if self._minimum_template is not None:
try:
info = self._minimum_template.async_render_to_info()
except (TemplateError, TypeError) as ex:
self.show_template_exception(ex, CONF_MINIMUM)
else:
self._entities += info.entities
self._force_update += info.entities
if self._maximum_template is not None:
try:
info = self._maximum_template.async_render_to_info()
except (TemplateError, TypeError) as ex:
self.show_template_exception(ex, CONF_MAXIMUM)
else:
self._entities += info.entities
self._force_update += info.entities
if self._round_template is not None:
try:
info = self._round_template.async_render_to_info()
except (TemplateError, TypeError) as ex:
self.show_template_exception(ex, CONF_ROUND)
else:
self._entities += info.entities
self._force_update += info.entities
if self._invert_template is not None:
try:
info = self._invert_template.async_render_to_info()
except (TemplateError, TypeError) as ex:
self.show_template_exception(ex, CONF_INVERT)
else:
self._entities += info.entities
self._force_update += info.entities
self._reset_pid += info.entities
self._entities += [self._source]
def reset_pid(self):
if self._pid:
self._pid.reset_pid()
def update(self) -> None:
"""Update the sensor state if it needed."""
self._update_sensor()
def _update_sensor(self, entity=None) -> None:
if entity in self._reset_pid:
self.reset_pid()
if not self.enabled:
return
source = self.source
set_point = self.set_point
if self.proportional == 0 and self.integral == 0 and self.derivative == 0:
if entity != self._source:
return
self._sensor_state = 0 if self.invert else 100
if source >= set_point:
self._sensor_state = 100 if self.invert else 0
else:
p_base = self.proportional
i_base = self.integral
d_base = self.derivative
if self._pid is None:
self._pid = PID(p_base, i_base, d_base, logger=_LOGGER)
else:
if p_base != self._pid.kp:
self._pid.kp = p_base
if i_base != self._pid.ki:
self._pid.ki = i_base
if d_base != self._pid.kd:
self._pid.kd = d_base
if self.sample_time != self._pid.sample_time:
self._pid.sample_time = self.sample_time
if self.windup != self._pid.windup:
self._pid.windup = self.windup
if set_point != self._pid.set_point:
self.reset_pid()
self._pid.set_point = set_point
if entity == self._source:
self._pid.update(source)
output = float(self._pid.output)
output = max(min(output, 100), 0)
self._sensor_state = output
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
# pylint: disable=unused-argument
@callback
def sensor_state_listener(entity, old_state, new_state):
"""Handle device state changes."""
last_state = self.state
self._update_sensor(entity=entity)
if last_state != self.state or entity in self._force_update:
self.async_schedule_update_ha_state(True)
# pylint: disable=unused-argument
@callback
def sensor_startup(event):
"""Update template on startup."""
self._update_sensor()
self.async_schedule_update_ha_state(True)
## process listners
for entity in self._entities:
async_track_state_change(self.hass, entity, sensor_state_listener)
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, sensor_startup)
def update_entity(self, entity_id, state):
entity = self.hass.states.get(entity_id)
if not entity:
return
self.hass.states.async_set(entity_id, state, entity.attributes)