Thermostat with PID controller

"""Adds support for smart (PID) thermostat units.
For more details about this platform, please refer to the documentation at
https://github.com/fabiannydegger/custom_components/

Home Assistant version 0.96 has breaking changes to the climate component
*Replaced operation_mode with hvac_mode
*Property names have been aligned, anything ending with “_list” is now named “_modes”.
*

"""
import asyncio
import logging
import time
import custom_components.smart_thermostat.pid_controller as pid_controller

import voluptuous as vol

from homeassistant.const import (
    ATTR_ENTITY_ID, ATTR_TEMPERATURE, CONF_NAME, PRECISION_HALVES,
    PRECISION_TENTHS, PRECISION_WHOLE, SERVICE_TURN_OFF, SERVICE_TURN_ON,
    STATE_OFF, STATE_ON, STATE_UNKNOWN)
from homeassistant.core import DOMAIN as HA_DOMAIN, callback
from homeassistant.helpers import condition
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.event import (
    async_track_state_change, async_track_time_interval)
from homeassistant.helpers.restore_state import RestoreEntity

from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateDevice
from homeassistant.components.climate.const import (
    ATTR_PRESET_MODE, ATTR_HVAC_MODE, HVAC_MODE_HEAT_COOL, HVAC_MODE_COOL, HVAC_MODE_HEAT,
    CURRENT_HVAC_IDLE, SUPPORT_PRESET_MODE,
    SUPPORT_TARGET_TEMPERATURE)

_LOGGER = logging.getLogger(__name__)

DEFAULT_TOLERANCE = 0.3
DEFAULT_NAME = 'Smart Thermostat'
#To Do: set default for pt1
DEFAULT_DIFFERENCE = 100
DEFAULT_PWM = 0
DEFAULT_KP = 0
DEFAULT_KI = 0
DEFAULT_KD = 0
DEFAULT_AUTOTUNE = "none"
DEFAULT_NOISEBAND = 0.5

CONF_HEATER = 'heater'
CONF_SENSOR = 'target_sensor'
CONF_MIN_TEMP = 'min_temp'
CONF_MAX_TEMP = 'max_temp'
CONF_TARGET_TEMP = 'target_temp'
CONF_AC_MODE = 'ac_mode'
CONF_MIN_DUR = 'min_cycle_duration'
CONF_COLD_TOLERANCE = 'cold_tolerance'
CONF_HOT_TOLERANCE = 'hot_tolerance'
CONF_KEEP_ALIVE = 'keep_alive'
CONF_INITIAL_HVAC_MODE = 'initial_HVAC_MODE'
CONF_AWAY_TEMP = 'away_temp'
CONF_PRECISION = 'precision'
CONF_DIFFERENCE = 'difference'
CONF_KP = 'kp'
CONF_KI = 'ki'
CONF_KD = 'kd'
CONF_PWM = 'pwm'
CONF_AUTOTUNE = 'autotune'
CONF_NOISEBAND = 'noiseband'

SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE)

PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
    vol.Required(CONF_HEATER): cv.entity_id,
    vol.Required(CONF_SENSOR): cv.entity_id,
    vol.Optional(CONF_AC_MODE): cv.boolean,
    vol.Optional(CONF_MAX_TEMP): vol.Coerce(float),
    vol.Optional(CONF_MIN_DUR): vol.All(cv.time_period, cv.positive_timedelta),
    vol.Optional(CONF_MIN_TEMP): vol.Coerce(float),
    vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
    vol.Optional(CONF_COLD_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce(
        float),
    vol.Optional(CONF_HOT_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce(
        float),
    vol.Optional(CONF_TARGET_TEMP): vol.Coerce(float),
    vol.Required(CONF_KEEP_ALIVE): vol.All(
        cv.time_period, cv.positive_timedelta),
    vol.Optional(CONF_INITIAL_HVAC_MODE):
        vol.In([HVAC_MODE_HEAT_COOL, STATE_OFF]),
    vol.Optional(CONF_AWAY_TEMP): vol.Coerce(float),
    vol.Optional(CONF_PRECISION): vol.In(
        [PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE]),
    vol.Optional(CONF_DIFFERENCE, default=DEFAULT_DIFFERENCE): vol.Coerce(float),
    vol.Optional(CONF_KP, default=DEFAULT_KP): vol.Coerce(float),
    vol.Optional(CONF_KI, default=DEFAULT_KI): vol.Coerce(float),
    vol.Optional(CONF_KD, default=DEFAULT_KD): vol.Coerce(float),
    vol.Optional(CONF_PWM, default=DEFAULT_PWM): vol.Coerce(float),
    vol.Optional(CONF_AUTOTUNE, default=DEFAULT_AUTOTUNE): cv.string,
    vol.Optional(CONF_NOISEBAND, default=DEFAULT_NOISEBAND): vol.Coerce(float)
})


async def async_setup_platform(hass, config, async_add_entities,
                               discovery_info=None):
    """Set up the generic thermostat platform."""
    name = config.get(CONF_NAME)
    heater_entity_id = config.get(CONF_HEATER)
    sensor_entity_id = config.get(CONF_SENSOR)
    min_temp = config.get(CONF_MIN_TEMP)
    max_temp = config.get(CONF_MAX_TEMP)
    target_temp = config.get(CONF_TARGET_TEMP)
    ac_mode = config.get(CONF_AC_MODE)
    min_cycle_duration = config.get(CONF_MIN_DUR)
    cold_tolerance = config.get(CONF_COLD_TOLERANCE)
    hot_tolerance = config.get(CONF_HOT_TOLERANCE)
    keep_alive = config.get(CONF_KEEP_ALIVE)
    initial_HVAC_MODE = config.get(CONF_INITIAL_HVAC_MODE)
    away_temp = config.get(CONF_AWAY_TEMP)
    precision = config.get(CONF_PRECISION)
    difference = config.get(CONF_DIFFERENCE)
    kp = config.get(CONF_KP)
    ki = config.get(CONF_KI)
    kd = config.get(CONF_KD)
    pwm = config.get(CONF_PWM)
    autotune = config.get(CONF_AUTOTUNE)
    noiseband = config.get(CONF_NOISEBAND)

    async_add_entities([SmartThermostat(
        hass, name, heater_entity_id, sensor_entity_id, min_temp, max_temp,
        target_temp, ac_mode, min_cycle_duration, cold_tolerance,
        hot_tolerance, keep_alive, initial_HVAC_MODE, away_temp,
        precision, difference, kp, ki, kd, pwm, autotune, noiseband)])


class SmartThermostat(ClimateDevice, RestoreEntity):
    """Representation of a Smart Thermostat device."""

    def __init__(self, hass, name, heater_entity_id, sensor_entity_id,
                 min_temp, max_temp, target_temp, ac_mode, min_cycle_duration,
                 cold_tolerance, hot_tolerance, keep_alive,
                 initial_HVAC_MODE, away_temp, precision,
                 difference, kp, ki, kd, pwm, autotune, noiseband):
        """Initialize the thermostat."""
        self.hass = hass
        self._name = name
        self.heater_entity_id = heater_entity_id
        self.ac_mode = ac_mode
        self.min_cycle_duration = min_cycle_duration
        self._cold_tolerance = cold_tolerance
        self._hot_tolerance = hot_tolerance
        self._keep_alive = keep_alive
        self._initial_HVAC_MODE = initial_HVAC_MODE
        self._saved_target_temp = target_temp if target_temp is not None \
            else away_temp
        self._temp_precision = precision
        if self.ac_mode:
            self._current_operation = HVAC_MODE_COOL
            self._operation_mode = [HVAC_MODE_COOL, STATE_OFF]
            self.minOut = -difference
            self.maxOut = 0
        else:
            self._current_operation = HVAC_MODE_HEAT
            self._operation_mode = [HVAC_MODE_HEAT, STATE_OFF]
            self.minOut = 0
            self.maxOut = difference
        if initial_HVAC_MODE == STATE_OFF:
            self._enabled = False
            self._current_operation = STATE_OFF
        else:
            self._enabled = True
        self._active = False
        self._cur_temp = None
        self._temp_lock = asyncio.Lock()
        self._min_temp = min_temp
        self._max_temp = max_temp
        self._target_temp = target_temp
        self._unit = hass.config.units.temperature_unit
        self._support_flags = SUPPORT_FLAGS
        if away_temp is not None:
            self._support_flags = SUPPORT_FLAGS | SUPPORT_PRESET_MODE
        self._away_temp = away_temp
        self._is_away = False
        self.difference = difference
        self.kp = kp
        self.ki = ki
        self.kd = kd
        self.pwm = pwm
        self.autotune = autotune
        self.sensor_entity_id = sensor_entity_id
        self.time_changed = time.time()
        if self.autotune != "none":
            self.pidAutotune = pid_controller.PIDAutotune(self._target_temp, self.difference,
            self._keep_alive.seconds, self._keep_alive.seconds, self.minOut, self.maxOut,
            noiseband, time.time)
            _LOGGER.warning("Autotune will run with the next Setpoint Value you set."
            "changes, submited after doesn't have any effekt until it's finished")
        else:
            self.pidController = pid_controller.PIDArduino(self._keep_alive.seconds,
            self.kp, self.ki, self.kd, self.minOut, self.maxOut, time.time)

        async_track_state_change(
            hass, sensor_entity_id, self._async_sensor_changed)
        async_track_state_change(
            hass, heater_entity_id, self._async_switch_changed)

        if self._keep_alive:
            async_track_time_interval(
                hass, self._async_control_heating, self._keep_alive)

        sensor_state = hass.states.get(sensor_entity_id)
        if sensor_state and sensor_state.state != STATE_UNKNOWN:
            self._async_update_temp(sensor_state)

    async def async_added_to_hass(self):
        """Run when entity about to be added."""
        await super().async_added_to_hass()
        # Check If we have an old state
        old_state = await self.async_get_last_state()
        if old_state is not None:
            # If we have no initial temperature, restore
            if self._target_temp is None:
                # If we have a previously saved temperature
                if old_state.attributes.get(ATTR_TEMPERATURE) is None:
                    if self.ac_mode:
                        self._target_temp = self.max_temp
                    else:
                        self._target_temp = self.min_temp
                    _LOGGER.warning("Undefined target temperature,"
                                    "falling back to %s", self._target_temp)
                else:
                    self._target_temp = float(
                        old_state.attributes[ATTR_TEMPERATURE])
            if old_state.attributes.get(ATTR_PRESET_MODE) is "away":
                self._is_away = str(
                    old_state.attributes[ATTR_PRESET_MODE]) == "away"
            if (self._initial_HVAC_MODE is None and
                    old_state.attributes[ATTR_HVAC_MODE] is not None):
                self._current_operation = \
                    old_state.attributes[ATTR_HVAC_MODE]
                self._enabled = self._current_operation != STATE_OFF

        else:
            # No previous state, try and restore defaults
            if self._target_temp is None:
                if self.ac_mode:
                    self._target_temp = self.max_temp
                else:
                    self._target_temp = self.min_temp
            _LOGGER.warning("No previously saved temperature, setting to %s",
                            self._target_temp)

    @property
    def state(self):
        """Return the current state."""
        if self._is_device_active:
            return self.current_operation
        if self._enabled:
            return CURRENT_HVAC_IDLE
        return STATE_OFF

    @property
    def should_poll(self):
        """Return the polling state."""
        return False

    @property
    def name(self):
        """Return the name of the thermostat."""
        return self._name

    @property
    def precision(self):
        """Return the precision of the system."""
        if self._temp_precision is not None:
            return self._temp_precision
        return super().precision

    @property
    def temperature_unit(self):
        """Return the unit of measurement."""
        return self._unit

    @property
    def current_temperature(self):
        """Return the sensor temperature."""
        return self._cur_temp

    @property
    def current_operation(self):
        """Return current operation."""
        return self._current_operation

    @property
    def target_temperature(self):
        """Return the temperature we try to reach."""
        return self._target_temp

    @property
    def operation_mode(self):
        """List of available operation modes."""
        return self._operation_mode

    @property
    def pid_parm(self):
        """Return the pid parameters of the thermostat."""
        return (self.kp, self.ki, self.kd)

    @property
    def pid_control_output(self):
        """Return the pid control output of the thermostat."""
        return self.control_output

    async def async_set_HVAC_MODE(self, HVAC_MODE):
        """Set operation mode."""
        if HVAC_MODE == HVAC_MODE_HEAT:
            self._current_operation = HVAC_MODE_HEAT
            self._enabled = True
            await self._async_control_heating(force=True)
        elif HVAC_MODE == HVAC_MODE_COOL:
            self._current_operation = HVAC_MODE_COOL
            self._enabled = True
            await self._async_control_heating(force=True)
        elif HVAC_MODE == STATE_OFF:
            self._current_operation = STATE_OFF
            self._enabled = False
            if self._is_device_active:
                await self._async_heater_turn_off()
        else:
            _LOGGER.error("Unrecognized operation mode: %s", HVAC_MODE)
            return
        # Ensure we update the current operation after changing the mode
        self.schedule_update_ha_state()

    async def async_turn_on(self):
        """Turn thermostat on."""
        await self.async_set_HVAC_MODE(self.operation_mode[0])

    async def async_turn_off(self):
        """Turn thermostat off."""
        await self.async_set_HVAC_MODE(STATE_OFF)

    async def async_set_temperature(self, **kwargs):
        """Set new target temperature."""
        temperature = kwargs.get(ATTR_TEMPERATURE)
        if temperature is None:
            return
        self._target_temp = temperature
        await self._async_control_heating(force=True)
        await self.async_update_ha_state()

    @asyncio.coroutine
    def async_set_pid(self, kp, ki, kd):
        """Set PID parameters."""

        self.kp = kp
        self.ki = ki
        self.kd = kd
        self._async_control_heating()
        yield from self.async_update_ha_state()
		
    @property
    def min_temp(self):
        """Return the minimum temperature."""
        if self._min_temp:
            return self._min_temp

        # get default temp from super class
        return super().min_temp

    @property
    def max_temp(self):
        """Return the maximum temperature."""
        if self._max_temp:
            return self._max_temp

        # Get default temp from super class
        return super().max_temp

    async def _async_sensor_changed(self, entity_id, old_state, new_state):
        """Handle temperature changes."""
        if new_state is None:
            return

        self._async_update_temp(new_state)
        #await self._async_control_heating()
        await self.async_update_ha_state()

    @callback
    def _async_switch_changed(self, entity_id, old_state, new_state):
        """Handle heater switch state changes."""
        if new_state is None:
            return
        self.async_schedule_update_ha_state()

    @callback
    def _async_update_temp(self, state):
        """Update thermostat with latest state from sensor."""
        try:
            self._cur_temp = float(state.state)
        except ValueError as ex:
            _LOGGER.error("Unable to update from sensor: %s", ex)

    async def _async_control_heating(self, time=None, force=False):
        """Run PID controller, optional autotune for faster integration"""
        async with self._temp_lock:
            if not self._active and None not in (self._cur_temp,
                                                 self._target_temp):
                self._active = True
                _LOGGER.info("Obtained current and target temperature. "
                             "Smart thermostat active. %s, %s",
                             self._cur_temp, self._target_temp)

            if not self._active or not self._enabled:
                return

            #if not force and time is None:
            #    # If the `force` argument is True, we
            #    # ignore `min_cycle_duration`.
            #    # If the `time` argument is not none, we were invoked for
            #    # keep-alive purposes, and `min_cycle_duration` is irrelevant.
            #    if self.min_cycle_duration:
            #        if self._is_device_active:
            #            current_state = STATE_ON
            #        else:
            #            current_state = STATE_OFF
            #        long_enough = condition.state(
            #            self.hass, self.heater_entity_id, current_state,
            #            self.min_cycle_duration)
            #        if not long_enough:
            #            return

            self.calc_output()

    @property
    def _is_device_active(self):
        """If the toggleable device is currently active."""
        return self.hass.states.is_state(self.heater_entity_id, STATE_ON)

    @property
    def supported_features(self):
        """Return the list of supported features."""
        return self._support_flags

    async def _async_heater_turn_on(self):
        """Turn heater toggleable device on."""
        data = {ATTR_ENTITY_ID: self.heater_entity_id}
        await self.hass.services.async_call(HA_DOMAIN, SERVICE_TURN_ON, data)

    async def _async_heater_turn_off(self):
        """Turn heater toggleable device off."""
        data = {ATTR_ENTITY_ID: self.heater_entity_id}
        await self.hass.services.async_call(HA_DOMAIN, SERVICE_TURN_OFF, data)

    @property
    def is_away_mode_on(self):
        """Return true if away mode is on."""
        return self._is_away

    async def async_set_preset_mode(self, new_mode):
        """Turn away mode on by setting it on away hold indefinitely."""
        if new_mode == "away":
            if self._is_away:
                return
            self._is_away = True
            self._saved_target_temp = self._target_temp
            self._target_temp = self._away_temp
            await self._async_control_heating(force=True)
            await self.async_update_ha_state()
        else:        
            """Turn away off."""
            if not self._is_away:
                return
            self._is_away = False
            self._target_temp = self._saved_target_temp
            await self._async_control_heating(force=True)
            await self.async_update_ha_state()

    def calc_output(self):
        """calculate controll output and handle autotune"""
        if self.autotune != "none" :
            if self.pidAutotune.run(self._cur_temp):
                params = self.pidAutotune.get_pid_parameters(self.autotune)
                self.kp = params.Kp
                self.ki = params.Ki
                self.kd = params.Kd
                _LOGGER.info("Set Kd, Ki, Kd. "
                             "Smart thermostat now runs on PID Controller. %s,  %s,  %s",
                             self.kp , self.ki, self.kd)
                self.pidController = pid_controller.PIDArduino(self._keep_alive.seconds, self.kp, self.ki,
                self.kd, self.minOut, self.maxOut, time.time)
                self.autotune = "none"
            self.control_output = self.pidAutotune.output
        else:
            self.control_output = self.pidController.calc(self._cur_temp,
            self._target_temp)
        _LOGGER.info("Obtained current control output. %s", self.control_output)
        self.set_controlvalue();

    def set_controlvalue(self):
        """Set Outputvalue for heater"""
        if self.pwm:
            if self.control_output == self.difference or self.control_output == -self.difference:
                if not self._is_device_active:
                    _LOGGER.info("Turning on AC %s", self.heater_entity_id)
                    self._heater_turn_on()
                    self.time_changed = time.time()
            elif self.control_output > 0:
                self.pwm_switch(self.pwm * self.control_output / self.maxOut, self.pwm * (self.maxOut - self.control_output) / self.maxOut, time.time() - self.time_changed)
            elif self.control_output < 0:
                self.pwm_switch(self.pwm * self.control_output / self.minOut, self.pwm * self.minOut / self.control_outpu, time.time() - self.time_changedt)
            else:
                if self._active:
                    _LOGGER.info("Turning off heater %s", self.heater_entity_id)
                    self._heater_turn_off()
                    self.time_changed = time.time()
        else:
            _LOGGER.info("Change state off heater %s to %s", self.heater_entity_id, self.control_output)
            self.hass.states.async_set(self.heater_entity_id, self.control_output)

    def pwm_switch(self, time_on, time_off, time_passed):
        """turn off and on the heater proportionally to controlvalue."""
        if self._is_device_active:
            if time_on < time_passed:
                _LOGGER.info("Turning off AC %s", self.heater_entity_id)
                self._heater_turn_off()
                self.time_changed = time.time()
            else:
                _LOGGER.info("Time until %s turns off: %s sec", self.heater_entity_id, time_on - time_passed)
        else:
            if time_off < time_passed:
                _LOGGER.info("Turning on AC %s", self.heater_entity_id)
                self._heater_turn_on()
                self.time_changed = time.time()
            else:
                _LOGGER.info("Time until %s turns on: %s sec", self.heater_entity_id, time_off - time_passed)

Ok thanks, will have a go at it. Just upgraded to the latest HA version, so bug fix time anyway

1 Like

If anyone gets this working, please post here with the changes.

I’m having a stab at it myself later, but have already tried on multiple occasions without much luck. It’s the only thing holding me back from updating to latest.

I’ll see if I can get it done this Weekend should have the time for it

I managed to get it working last night with the new climate system. Might need a few changes but it loads and doesn’t throw any errors. Will post the code as soon as I get to my laptop.

Not sure about the turn_on and turn_off from the PWM section… Just kinda copied them from the generic thermostat, unsure if its done properly, but it seems to work for my use case. I’m sure someone else can fix any small mistakes I’ve made, but atleast it loads now.

import asyncio
import logging
import time
import custom_components.smart_thermostat.pid_controller as pid_controller

import voluptuous as vol

from homeassistant.const import (
    ATTR_ENTITY_ID, ATTR_TEMPERATURE, CONF_NAME, EVENT_HOMEASSISTANT_START,
    PRECISION_HALVES, PRECISION_TENTHS, PRECISION_WHOLE, SERVICE_TURN_OFF,
    SERVICE_TURN_ON, STATE_ON, STATE_UNKNOWN)
from homeassistant.core import DOMAIN as HA_DOMAIN, callback
from homeassistant.helpers import condition
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.event import (
    async_track_state_change, async_track_time_interval)
from homeassistant.helpers.restore_state import RestoreEntity

from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateDevice
from homeassistant.components.climate.const import (
    ATTR_PRESET_MODE, CURRENT_HVAC_COOL, CURRENT_HVAC_HEAT, CURRENT_HVAC_IDLE,
    CURRENT_HVAC_OFF, HVAC_MODE_COOL, HVAC_MODE_HEAT, HVAC_MODE_OFF,
    PRESET_AWAY, SUPPORT_PRESET_MODE, SUPPORT_TARGET_TEMPERATURE)

_LOGGER = logging.getLogger(__name__)

DEFAULT_TOLERANCE = 0.3
DEFAULT_NAME = 'Smart Thermostat'
#To Do: set default for pt1
DEFAULT_DIFFERENCE = 100
DEFAULT_PWM = 0
DEFAULT_KP = 0
DEFAULT_KI = 0
DEFAULT_KD = 0
DEFAULT_AUTOTUNE = "none"
DEFAULT_NOISEBAND = 0.5

CONF_HEATER = 'heater'
CONF_SENSOR = 'target_sensor'
CONF_MIN_TEMP = 'min_temp'
CONF_MAX_TEMP = 'max_temp'
CONF_TARGET_TEMP = 'target_temp'
CONF_AC_MODE = 'ac_mode'
CONF_MIN_DUR = 'min_cycle_duration'
CONF_COLD_TOLERANCE = 'cold_tolerance'
CONF_HOT_TOLERANCE = 'hot_tolerance'
CONF_KEEP_ALIVE = 'keep_alive'
CONF_INITIAL_HVAC_MODE = 'initial_hvac_mode'
CONF_AWAY_TEMP = 'away_temp'
CONF_PRECISION = 'precision'
CONF_DIFFERENCE = 'difference'
CONF_KP = 'kp'
CONF_KI = 'ki'
CONF_KD = 'kd'
CONF_PWM = 'pwm'
CONF_AUTOTUNE = 'autotune'
CONF_NOISEBAND = 'noiseband'

SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE

PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
    vol.Required(CONF_HEATER): cv.entity_id,
    vol.Required(CONF_SENSOR): cv.entity_id,
    vol.Optional(CONF_AC_MODE): cv.boolean,
    vol.Optional(CONF_MAX_TEMP): vol.Coerce(float),
    vol.Optional(CONF_MIN_DUR): vol.All(cv.time_period, cv.positive_timedelta),
    vol.Optional(CONF_MIN_TEMP): vol.Coerce(float),
    vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
    vol.Optional(CONF_COLD_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce(
        float),
    vol.Optional(CONF_HOT_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce(
        float),
    vol.Optional(CONF_TARGET_TEMP): vol.Coerce(float),
    vol.Required(CONF_KEEP_ALIVE): vol.All(
        cv.time_period, cv.positive_timedelta),
    vol.Optional(CONF_INITIAL_HVAC_MODE):
        vol.In([HVAC_MODE_COOL, HVAC_MODE_HEAT, HVAC_MODE_OFF]),
    vol.Optional(CONF_AWAY_TEMP): vol.Coerce(float),
    vol.Optional(CONF_PRECISION): vol.In(
        [PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE]),
    vol.Optional(CONF_DIFFERENCE, default=DEFAULT_DIFFERENCE): vol.Coerce(float),
    vol.Optional(CONF_KP, default=DEFAULT_KP): vol.Coerce(float),
    vol.Optional(CONF_KI, default=DEFAULT_KI): vol.Coerce(float),
    vol.Optional(CONF_KD, default=DEFAULT_KD): vol.Coerce(float),
    vol.Optional(CONF_PWM, default=DEFAULT_PWM): vol.Coerce(float),
    vol.Optional(CONF_AUTOTUNE, default=DEFAULT_AUTOTUNE): cv.string,
    vol.Optional(CONF_NOISEBAND, default=DEFAULT_NOISEBAND): vol.Coerce(float)
})


async def async_setup_platform(hass, config, async_add_entities,
                               discovery_info=None):
    """Set up the generic thermostat platform."""
    name = config.get(CONF_NAME)
    heater_entity_id = config.get(CONF_HEATER)
    sensor_entity_id = config.get(CONF_SENSOR)
    min_temp = config.get(CONF_MIN_TEMP)
    max_temp = config.get(CONF_MAX_TEMP)
    target_temp = config.get(CONF_TARGET_TEMP)
    ac_mode = config.get(CONF_AC_MODE)
    min_cycle_duration = config.get(CONF_MIN_DUR)
    cold_tolerance = config.get(CONF_COLD_TOLERANCE)
    hot_tolerance = config.get(CONF_HOT_TOLERANCE)
    keep_alive = config.get(CONF_KEEP_ALIVE)
    initial_hvac_mode = config.get(CONF_INITIAL_HVAC_MODE)
    away_temp = config.get(CONF_AWAY_TEMP)
    precision = config.get(CONF_PRECISION)
    unit = hass.config.units.temperature_unit
    difference = config.get(CONF_DIFFERENCE)
    kp = config.get(CONF_KP)
    ki = config.get(CONF_KI)
    kd = config.get(CONF_KD)
    pwm = config.get(CONF_PWM)
    autotune = config.get(CONF_AUTOTUNE)
    noiseband = config.get(CONF_NOISEBAND)

    async_add_entities([SmartThermostat(
        name, heater_entity_id, sensor_entity_id, min_temp, max_temp,
        target_temp, ac_mode, min_cycle_duration, cold_tolerance,
        hot_tolerance, keep_alive, initial_hvac_mode, away_temp,
        precision, unit, difference, kp, ki, kd, pwm, autotune, noiseband)])


class SmartThermostat(ClimateDevice, RestoreEntity):
    """Representation of a Smart Thermostat device."""

    def __init__(self, name, heater_entity_id, sensor_entity_id,
                 min_temp, max_temp, target_temp, ac_mode, min_cycle_duration,
                 cold_tolerance, hot_tolerance, keep_alive,
                 initial_hvac_mode, away_temp, precision, unit,
                 difference, kp, ki, kd, pwm, autotune, noiseband):
        """Initialize the thermostat."""
        self._name = name
        self.heater_entity_id = heater_entity_id
        self.sensor_entity_id = sensor_entity_id
        self.ac_mode = ac_mode
        self.min_cycle_duration = min_cycle_duration
        self._cold_tolerance = cold_tolerance
        self._hot_tolerance = hot_tolerance
        self._keep_alive = keep_alive
        self._hvac_mode = initial_hvac_mode
        self._saved_target_temp = target_temp or away_temp
        self._temp_precision = precision
        if self.ac_mode:
            self._hvac_list = [HVAC_MODE_COOL, HVAC_MODE_OFF]
            self.minOut = -difference
            self.maxOut = 0
        else:
            self._hvac_list = [HVAC_MODE_HEAT, HVAC_MODE_OFF]
            self.minOut = 0
            self.maxOut = difference
        self._active = False
        self._cur_temp = None
        self._temp_lock = asyncio.Lock()
        self._min_temp = min_temp
        self._max_temp = max_temp
        self._target_temp = target_temp
        self._unit = unit
        self._support_flags = SUPPORT_FLAGS
        if away_temp:
            self._support_flags = SUPPORT_FLAGS | SUPPORT_PRESET_MODE
        self._away_temp = away_temp
        self._is_away = False
        self.difference = difference
        self.kp = kp
        self.ki = ki
        self.kd = kd
        self.pwm = pwm
        self.autotune = autotune
        self.sensor_entity_id = sensor_entity_id
        self.time_changed = time.time()
        if self.autotune != "none":
            self.pidAutotune = pid_controller.PIDAutotune(self._target_temp, self.difference,
            self._keep_alive.seconds, self._keep_alive.seconds, self.minOut, self.maxOut,
            noiseband, time.time)
            _LOGGER.warning("Autotune will run with the next Setpoint Value you set."
            "changes, submited after doesn't have any effekt until it's finished")
        else:
            self.pidController = pid_controller.PIDArduino(self._keep_alive.seconds,
            self.kp, self.ki, self.kd, self.minOut, self.maxOut, time.time)

    async def async_added_to_hass(self):
        """Run when entity about to be added."""
        await super().async_added_to_hass()

        # Add listener
        async_track_state_change(
            self.hass, self.sensor_entity_id, self._async_sensor_changed)
        async_track_state_change(
            self.hass, self.heater_entity_id, self._async_switch_changed)

        if self._keep_alive:
            async_track_time_interval(
                self.hass, self._async_control_heating, self._keep_alive)

        @callback
        def _async_startup(event):
            """Init on startup."""
            sensor_state = self.hass.states.get(self.sensor_entity_id)
            if sensor_state and sensor_state.state != STATE_UNKNOWN:
                self._async_update_temp(sensor_state)

        self.hass.bus.async_listen_once(
            EVENT_HOMEASSISTANT_START, _async_startup)


        # Check If we have an old state
        old_state = await self.async_get_last_state()
        if old_state is not None:
            # If we have no initial temperature, restore
            if self._target_temp is None:
                # If we have a previously saved temperature
                if old_state.attributes.get(ATTR_TEMPERATURE) is None:
                    if self.ac_mode:
                        self._target_temp = self.max_temp
                    else:
                        self._target_temp = self.min_temp
                    _LOGGER.warning("Undefined target temperature,"
                                    "falling back to %s", self._target_temp)
                else:
                    self._target_temp = float(
                        old_state.attributes[ATTR_TEMPERATURE])
            if old_state.attributes.get(ATTR_PRESET_MODE) == PRESET_AWAY:
                self._is_away = True
            if not self._hvac_mode and old_state.state:
                self._hvac_mode = old_state.state

        else:
            # No previous state, try and restore defaults
            if self._target_temp is None:
                if self.ac_mode:
                    self._target_temp = self.max_temp
                else:
                    self._target_temp = self.min_temp
            _LOGGER.warning("No previously saved temperature, setting to %s",
                            self._target_temp)

        # Set default state to off
        if not self._hvac_mode:
            self._hvac_mode = HVAC_MODE_OFF

    @property
    def should_poll(self):
        """Return the polling state."""
        return False

    @property
    def name(self):
        """Return the name of the thermostat."""
        return self._name

    @property
    def precision(self):
        """Return the precision of the system."""
        if self._temp_precision is not None:
            return self._temp_precision
        return super().precision

    @property
    def temperature_unit(self):
        """Return the unit of measurement."""
        return self._unit

    @property
    def current_temperature(self):
        """Return the sensor temperature."""
        return self._cur_temp

    @property
    def hvac_mode(self):
        """Return current operation."""
        return self._hvac_mode

    @property
    def hvac_action(self):
        """Return the current running hvac operation if supported.
        Need to be one of CURRENT_HVAC_*.
        """
        if self._hvac_mode == HVAC_MODE_OFF:
            return CURRENT_HVAC_OFF
        if not self._is_device_active:
            return CURRENT_HVAC_IDLE
        if self.ac_mode:
            return CURRENT_HVAC_COOL
        return CURRENT_HVAC_HEAT
    @property
    def target_temperature(self):
        """Return the temperature we try to reach."""
        return self._target_temp

    @property
    def hvac_modes(self):
        """List of available operation modes."""
        return self._hvac_list

    @property
    def preset_mode(self):
        """Return the current preset mode, e.g., home, away, temp."""
        if self._is_away:
            return PRESET_AWAY
        return None

    @property
    def preset_modes(self):
        """Return a list of available preset modes."""
        if self._away_temp:
            return [PRESET_AWAY]
        return None

    @property
    def pid_parm(self):
        """Return the pid parameters of the thermostat."""
        return (self.kp, self.ki, self.kd)

    @property
    def pid_control_output(self):
        """Return the pid control output of the thermostat."""
        return self.control_output

    async def async_set_hvac_mode(self, hvac_mode):
        """Set hvac mode."""
        if hvac_mode == HVAC_MODE_HEAT:
            self._hvac_mode = HVAC_MODE_HEAT
            await self._async_control_heating(force=True)
        elif hvac_mode == HVAC_MODE_COOL:
            self._hvac_mode = HVAC_MODE_COOL
            await self._async_control_heating(force=True)
        elif hvac_mode == HVAC_MODE_OFF:
            self._hvac_mode = HVAC_MODE_OFF
            if self._is_device_active:
                await self._async_async_heater_turn_off()
        else:
            _LOGGER.error("Unrecognized hvac mode: %s", hvac_mode)
            return
        # Ensure we update the current operation after changing the mode
        self.schedule_update_ha_state()

    async def async_set_temperature(self, **kwargs):
        """Set new target temperature."""
        temperature = kwargs.get(ATTR_TEMPERATURE)
        if temperature is None:
            return
        self._target_temp = temperature
        await self._async_control_heating(force=True)
        await self.async_update_ha_state()

    @asyncio.coroutine
    def async_set_pid(self, kp, ki, kd):
        """Set PID parameters."""

        self.kp = kp
        self.ki = ki
        self.kd = kd
        self._async_control_heating()
        yield from self.async_update_ha_state()

    @property
    def min_temp(self):
        """Return the minimum temperature."""
        if self._min_temp:
            return self._min_temp

        # get default temp from super class
        return super().min_temp

    @property
    def max_temp(self):
        """Return the maximum temperature."""
        if self._max_temp:
            return self._max_temp

        # Get default temp from super class
        return super().max_temp

    async def _async_sensor_changed(self, entity_id, old_state, new_state):
        """Handle temperature changes."""
        if new_state is None:
            return

        self._async_update_temp(new_state)
        #await self._async_control_heating()
        await self.async_update_ha_state()

    @callback
    def _async_switch_changed(self, entity_id, old_state, new_state):
        """Handle heater switch state changes."""
        if new_state is None:
            return
        self.async_schedule_update_ha_state()

    @callback
    def _async_update_temp(self, state):
        """Update thermostat with latest state from sensor."""
        try:
            self._cur_temp = float(state.state)
        except ValueError as ex:
            _LOGGER.error("Unable to update from sensor: %s", ex)

    async def _async_control_heating(self, time=None, force=False):
        """Run PID controller, optional autotune for faster integration"""
        async with self._temp_lock:
            if not self._active and None not in (self._cur_temp,
                                                 self._target_temp):
                self._active = True
                _LOGGER.info("Obtained current and target temperature. "
                             "Smart thermostat active. %s, %s",
                             self._cur_temp, self._target_temp)

            if not self._active or self._hvac_mode == HVAC_MODE_OFF:
                return

            #if not force and time is None:
            #    # If the `force` argument is True, we
            #    # ignore `min_cycle_duration`.
            #    # If the `time` argument is not none, we were invoked for
            #    # keep-alive purposes, and `min_cycle_duration` is irrelevant.
            #    if self.min_cycle_duration:
            #        if self._is_device_active:
            #            current_state = STATE_ON
            #        else:
            #            current_state = STATE_OFF
            #        long_enough = condition.state(
            #            self.hass, self.heater_entity_id, current_state,
            #            self.min_cycle_duration)
            #        if not long_enough:
            #            return

            # self.calc_output()
            await self.calc_output()

    @property
    def _is_device_active(self):
        """If the toggleable device is currently active."""
        return self.hass.states.is_state(self.heater_entity_id, STATE_ON)

    @property
    def supported_features(self):
        """Return the list of supported features."""
        return self._support_flags

    async def _async_heater_turn_on(self):
        """Turn heater toggleable device on."""
        data = {ATTR_ENTITY_ID: self.heater_entity_id}
        await self.hass.services.async_call(HA_DOMAIN, SERVICE_TURN_ON, data)

    async def _async_heater_turn_off(self):
        """Turn heater toggleable device off."""
        data = {ATTR_ENTITY_ID: self.heater_entity_id}
        await self.hass.services.async_call(HA_DOMAIN, SERVICE_TURN_OFF, data)

    async def async_set_preset_mode(self, preset_mode: str):
        """Set new preset mode.
        This method must be run in the event loop and returns a coroutine.
        """
        if preset_mode == PRESET_AWAY and not self._is_away:
            self._is_away = True
            self._saved_target_temp = self._target_temp
            self._target_temp = self._away_temp
            await self._async_control_heating(force=True)
        elif not preset_mode and self._is_away:
            self._is_away = False
            self._target_temp = self._saved_target_temp
            await self._async_control_heating(force=True)


        await self.async_update_ha_state()

    async def calc_output(self):
        """calculate control output and handle autotune"""
        if self.autotune != "none" :
            if self.pidAutotune.run(self._cur_temp):
                params = self.pidAutotune.get_pid_parameters(self.autotune)
                self.kp = params.Kp
                self.ki = params.Ki
                self.kd = params.Kd
                _LOGGER.info("Set Kd, Ki, Kd. "
                             "Smart thermostat now runs on PID Controller. %s,  %s,  %s",
                             self.kp , self.ki, self.kd)
                self.pidController = pid_controller.PIDArduino(self._keep_alive.seconds, self.kp, self.ki,
                self.kd, self.minOut, self.maxOut, time.time)
                self.autotune = "none"
            self.control_output = self.pidAutotune.output
        else:
            self.control_output = self.pidController.calc(self._cur_temp,
            self._target_temp)
        _LOGGER.info("Obtained current control output. %s", self.control_output)
        await self.set_controlvalue();

    async def set_controlvalue(self):
        """Set Outputvalue for heater"""
        if self.pwm:
            if self.control_output == self.difference or self.control_output == -self.difference:
                if not self._is_device_active:
                    _LOGGER.info("Turning on AC %s", self.heater_entity_id)
                    await self._async_heater_turn_on()
                    self.time_changed = time.time()
            elif self.control_output > 0:
                await self.pwm_switch(self.pwm * self.control_output / self.maxOut, self.pwm * (self.maxOut - self.control_output) / self.maxOut, time.time() - self.time_changed)
            elif self.control_output < 0:
                await self.pwm_switch(self.pwm * self.control_output / self.minOut, self.pwm * self.minOut / self.control_outpu, time.time() - self.time_changedt)
            else:
                if self._active:
                    _LOGGER.info("Turning off heater %s", self.heater_entity_id)
                    await self._async_heater_turn_off()
                    self.time_changed = time.time()
        else:
            _LOGGER.info("Change state of heater %s to %s", self.heater_entity_id, self.control_output)
            self.hass.states.async_set(self.heater_entity_id, self.control_output)

    async def pwm_switch(self, time_on, time_off, time_passed):
        """turn off and on the heater proportionally to controlvalue."""
        if self._is_device_active:
            if time_on < time_passed:
                _LOGGER.info("Turning off AC %s", self.heater_entity_id)
                await self._async_heater_turn_off()
                self.time_changed = time.time()
            else:
                _LOGGER.info("Time until %s turns off: %s sec", self.heater_entity_id, time_on - time_passed)
        else:
            if time_off < time_passed:
                _LOGGER.info("Turning on AC %s", self.heater_entity_id)
                await self._async_heater_turn_on()
                self.time_changed = time.time()
            else:
                _LOGGER.info("Time until %s turns on: %s sec", self.heater_entity_id, time_off - time_passed)

great project. I started reading about this. I started using it :smiley: let’s see how it behaves :smiley:

I planed to use this PID Thermostat. Do you think it will include hass soon ?

@fabian.n Nice work. easy to install and setup.

Will it be possible to make a more detailed step by step guide on how to use autotune? I am unable to find the logger.info and also not sure how to make autotune run for the first time and how to disable it when values are secured.

Thanks again.

I tried to use it. I have floor heating and it just doesn’t work for me because I get some division by zero error. I tried fixing it but I don’t know the math behind it… I need to read more about formula and how I can apply it…

@larsbach You have to enable the logging for info in home assistant. Right now default is warning and the pid logging is info

At https://github.com/home-assistant/home-assistant/pull/27833 a new thermostat is proposed. Maybe we can also bring the PWM/PID functions in this new thermostat?

Is there somebody who can give me a little bit more context on how this works? Does it work well with underfloor heating? Can somebody give a review on how it compares to the original generic_thermostat?

Thanks in advance,
Ronald

I’m working on making this easier to use for everyone. Basically the normal Thermostat uses a very simple “if colder then 20°C, heat” A PID controller uses 3 different methodes Potential, Integral and Differential. Basically if set up correctly it predicts the Temperatur change over time and controls the heating accordingly. This eliminats the overshoot that happens if you heat until it e.g. reaches 22°C. Therefor it can safe you a lot of energy. Especially floor heating has a very large overshoot because of the mass.
However, PID controller are not “plug and play”, set up wrong they don’t work.
Small note, floor heating can explode if you set the temperatur too high.

1 Like

I really like the idea but in my head it misses support for other states. Outdoor temperature, illuminance, weather, sun azimuth and angle are all factors that have a great role in my house.

I already think for a long time, my thermostat should use deep reinforcement learning but I don’t have a clue on how to start :slight_smile:.

Exactly this it what takes me so long to programm. Because I want to include all that in a new version instead of fixing the old one. My expirence in building automation has shown me it’s acutally very easy to do. However, I’m not a big programmer so I have a hard time including this in the UI to make it accessable for others.
If someone else wants to work with me that has that expirence, I gladly do so.

Then we should perhaps team up!

I’ve got experience in the Lovelace frontend and know my way around some custom components.

I was missing that info as well so I’ll share my progress here…

I’ve been using it now for the last two weeks with floor heating and it keeps my temperature quite stable within limits.

My situation:
The house is a recently build (2017-18) 140m2 house with district heating (stadsverwarming) so I don’t have to control the heating system itself, I just have to open and close the main valve.

My config:

  • I have to say that I modified the climate.py to fix some typos and to match with the ‘generic-thermostat’
  • modified PID controller my wishes :wink: (added moving average D part (velocity) and changed some on the limits for the I part) … so my settings should be used I think with my code only.
  • PWM and PID settings tuned to roughly mimic honeywell behaviour
  • Visiualised in Lovelace by ‘simple-thermostat’

modified climate and PID settings

  - platform: smart_thermostat
    name: woonkamer
    heater: switch.hoofdklep
    target_sensor: sensor.temp_wk
    min_temp: 15
    max_temp: 23
    ac_mode: False
    target_temp: 18
    
    keep_alive: #time before update of PID controller
      seconds: 60
    away_temp: 16
    kp : 50
    ki : 0.005 #0.01
    kd : 150000 #0
    
    #duration of cycle
    pwm : 600
    # autotune : ziegler-nichols
    difference : 100
    noiseband : 0.5

However some generic settings for the default PID and floor heating could be. I think I used something like this at the beginning (use on your own risk!)

  - platform: smart_thermostat
    name: woonkamer
    heater: switch.hoofdklep
    target_sensor: sensor.temp_wk
    min_temp: 15
    max_temp: 23
    ac_mode: False
    target_temp: 18
    
    keep_alive: #time before update of PID controller
      seconds: 60
    away_temp: 16
    kp : 50
    ki : 0.01
    kd : 0
    
    #duration of cycle
    pwm : 600
    # autotune : ziegler-nichols
    difference : 100
    noiseband : 0.5

the critical note :slight_smile:
I still use it as a test. If I understand it correctly no or limited safety routines are included in the ‘generic thermostat’ for failing sensors etc. thus also not in this code! So for testing and not long periods away from home I’m using it but I’m not convinced I want to use it as standard. It has to be more robust. There is some development on a new code ’ virtual_thermostat’ which ,if I remember correctly, should be more robust, but I’m a too bad programmer to check-validate it.

and as last a graph of the temperature of before and after.

  • yellow is the PWM signal
  • green main floor temp
  • HA controlled when yellow line is present.
  • before yellow line Honeywell chronotherm

1 Like

Wauw, I like your way of doing stuff - especially the before and after graph… this is a data scientist at work.

My situation is similar: it’s a house from 1840 which we completely renovated and isolated. We have large windows on the south side and these interfere a lot with the generic thermostat.

As for heating, I have a Itho Daalderop Central Heater (instead of your stadsverwarming) and for each room, I can control the valves of the underfloor heating. All is controlled by HomeAssistant.

  • If any room is requesting heating, the valves of that room open and central heating is turned on.
  • If no room is requesting heating, the central heating is turned off.

The valves itself are thermo-electric (e.g. candlewax valves: https://en.wikipedia.org/wiki/Wax_motor). The response times of the valves is about 2-3 minutes to open and 10 minutes to close.

So, my gut feeling says:

  • The PWM won’t work as response time of the wax valves is too slow / I have fear the wax motors will die faster if I use PWM
  • The influence of the sun/weather is large due to the windows on the south side and should be taken into account

And to be honest: I don’t know anything about a PID thermostat :smirk:. I should put it on my todo-learning list soon but my gut feeling says it won’t work due to the external influences. Or am I wrong?

Thanks for your input, I’ll think of making it robust.
This shows me, I need to include safety, didn’t really think of that. Your graph doesn’t really look good. It should definitely be more stable. However it depends on your case and what happend.
If it’s ok for both of you. I’ll make a flowchart on everything with an regular floor heating as an example. And upload it here to get some feedback if it’s what you would want and if there’s anything missing?

1 Like