Thermostat with PID controller

That’s great, thanks for clarifying!

I looked at Nest but was put off by the number of outages they’ve had over the last few years… Your new project sounds great!

Hi @fabian.n

I have updated to 0.84.3 and now I have this error

2018-12-18 09:42:51 ERROR (MainThread) [homeassistant.loader] Error loading custom_components.climate.smart_thermostat. Make sure all dependencies are installed Traceback (most recent call last): File "/usr/local/lib/python3.6/site-packages/homeassistant/loader.py", line 92, in get_component module = importlib.import_module(path) File "/usr/local/lib/python3.6/importlib/__init__.py", line 126, in import_module return _bootstrap._gcd_import(name[level:], package, level) File "<frozen importlib._bootstrap>", line 994, in _gcd_import File "<frozen importlib._bootstrap>", line 971, in _find_and_load File "<frozen importlib._bootstrap>", line 955, in _find_and_load_unlocked File "<frozen importlib._bootstrap>", line 665, in _load_unlocked File "<frozen importlib._bootstrap_external>", line 678, in exec_module File "<frozen importlib._bootstrap>", line 219, in _call_with_frames_removed File "/config/custom_components/climate/smart_thermostat.py", line 28, in <module> from homeassistant.helpers.restore_state import async_get_last_state ImportError: cannot import name 'async_get_last_state' 2018-12-18 09:42:51 ERROR (MainThread) [homeassistant.loader] Unable to find component climate.smart_thermostat 

Do you know if the last version breaks the component?

Thanks

I’ll test it, haven’t worked with this version in a while now. Are you sure everything is still where it belongs (file order). It look like it can’t find smart_thermostat anymore but it’s defined in your conf. The file probably got deleted or moved.
Can you open a issue on github?

No changes after updating and when I reverse to 0.83.3 everything started to work again.
Issue opened in GitHub

Hi. Any idea of when you can reinsert min_cycle_duration? Is there an old commit that I can use to understand how that should be implemented?

1 Like

Hi Laffez, I’m sry for all the waiting. I hope I can fix it this week. It’s complicated to integrate, so it works fine. You for example don’t want it to turn on for 30min when the PID out is just on 1%.

Hello,
I was able to add and test it. It should work as intended, also the pwm and min_duration is now really in minutes. Tell me if something doesn’t work, Thanks.

1 Like

How do I go about installing this in hassio? As far as I know I can only ssh into the core.

Would it be possible to do this in appdaemon?

I do not seen the issue in providing it as a component in trunk. It is already a custom component.
Not everybody use hassio or want to start another daemon (appdaemon) while has already has a performant core with many many interesting components

How did you manage to get it to work? :slight_smile:

I followed the instructions but depending on your version of hass you may change the name of the py file to init.py The bad point is I uninstalled it as it didn’t worked as I was expecting because of the behavior of my sensors not reporting at temperature changes but on fixed times (every 15 minutes)

Is there anyone still working on this? I have taken a stab at updating the smart_thermostat to fix the breaking changes that came with 0.96 but I’m not having any luck getting the python tracebacks. The only error in the log is

Setup failed for smart_thermostat: No setup function defined.

My logger is set up as follows:

logger:
  default: error
  logs:
    homeassistant.core: fatal
    homeassistant.components.mqtt: warning
    custom_components.smart_thermostat: debug

Merlin, do you have your changes somewhere accessable? I don’t want to start from scratch if you already started updating the code.

I have my floor heating system now running with HA and the generic thermostat is overshooting the temperature, so would like to introduce this PID controller.

I have it at home but I have been traveling for work a lot lately. I’ll try to get it today.

I’m not going to say it’s beautiful, and it definitely doesn’t work yet, but I’ll post what I have done so far.

"""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)