6666DEFAULT_DIFFERENCE = 100
6767DEFAULT_PWM = '00:15:00'
6868DEFAULT_MIN_CYCLE_DURATION = '00:00:00'
69+ DEFAULT_TOLERANCE = 0.3
6970DEFAULT_KP = 100
7071DEFAULT_KI = 0
7172DEFAULT_KD = 0
7980CONF_MIN_TEMP = "min_temp"
8081CONF_MAX_TEMP = "max_temp"
8182CONF_TARGET_TEMP = "target_temp"
83+ CONF_HOT_TOLERANCE = "hot_tolerance"
84+ CONF_COLD_TOLERANCE = "cold_tolerance"
8285CONF_AC_MODE = "ac_mode"
8386CONF_MIN_CYCLE_DURATION = "min_cycle_duration"
8487CONF_MIN_OFF_CYCLE_DURATION = "min_off_cycle_duration"
115118 vol .Optional (CONF_NAME , default = DEFAULT_NAME ): cv .string ,
116119 vol .Optional (CONF_UNIQUE_ID , default = 'none' ): cv .string ,
117120 vol .Optional (CONF_TARGET_TEMP ): vol .Coerce (float ),
121+ vol .Optional (CONF_HOT_TOLERANCE , default = DEFAULT_TOLERANCE ): vol .Coerce (float ),
122+ vol .Optional (CONF_COLD_TOLERANCE , default = DEFAULT_TOLERANCE ): vol .Coerce (float ),
118123 vol .Optional (CONF_MIN_CYCLE_DURATION , default = DEFAULT_MIN_CYCLE_DURATION ): vol .All (
119124 cv .time_period , cv .positive_timedelta ),
120125 vol .Optional (CONF_MIN_OFF_CYCLE_DURATION ): vol .All (
121126 cv .time_period , cv .positive_timedelta ),
122127 vol .Required (CONF_KEEP_ALIVE ): vol .All (cv .time_period , cv .positive_timedelta ),
123128 vol .Optional (CONF_SAMPLING_PERIOD , default = DEFAULT_SAMPLING_PERIOD ): vol .All (
124129 cv .time_period , cv .positive_timedelta ),
125- vol .Optional (CONF_INITIAL_HVAC_MODE , default = HVAC_MODE_OFF ): vol .In (
130+ vol .Optional (CONF_INITIAL_HVAC_MODE ): vol .In (
126131 [HVAC_MODE_COOL , HVAC_MODE_HEAT , HVAC_MODE_OFF ]
127132 ),
128133 vol .Optional (CONF_AWAY_TEMP ): vol .Coerce (float ),
@@ -168,6 +173,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
168173 'min_temp' : config .get (CONF_MIN_TEMP ),
169174 'max_temp' : config .get (CONF_MAX_TEMP ),
170175 'target_temp' : config .get (CONF_TARGET_TEMP ),
176+ 'hot_tolerance' : config .get (CONF_HOT_TOLERANCE ),
177+ 'cold_tolerance' : config .get (CONF_COLD_TOLERANCE ),
171178 'ac_mode' : config .get (CONF_AC_MODE ),
172179 'min_cycle_duration' : config .get (CONF_MIN_CYCLE_DURATION ),
173180 'min_off_cycle_duration' : config .get (CONF_MIN_OFF_CYCLE_DURATION ),
@@ -206,6 +213,13 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
206213 },
207214 "async_set_pid" ,
208215 )
216+ platform .async_register_entity_service ( # type: ignore
217+ "set_pid_mode" ,
218+ {
219+ vol .Required ("mode" ): vol .In (['auto' , 'off' ]),
220+ },
221+ "async_set_pid_mode" ,
222+ )
209223 platform .async_register_entity_service ( # type: ignore
210224 "set_preset_temp" ,
211225 {
@@ -240,7 +254,7 @@ def __init__(self, **kwargs):
240254 self ._ac_mode = kwargs .get ('ac_mode' )
241255 self ._keep_alive = kwargs .get ('keep_alive' )
242256 self ._sampling_period = kwargs .get ('sampling_period' ).seconds
243- self ._hvac_mode = kwargs .get ('initial_hvac_mode' )
257+ self ._hvac_mode = kwargs .get ('initial_hvac_mode' , None )
244258 self ._saved_target_temp = kwargs .get ('target_temp' , None ) or kwargs .get ('away_temp' , None )
245259 self ._temp_precision = kwargs .get ('precision' )
246260 self ._target_temperature_step = kwargs .get ('target_temp_step' )
@@ -298,7 +312,9 @@ def __init__(self, **kwargs):
298312 self ._lookback = kwargs .get ('lookback' ).seconds
299313 self ._noiseband = kwargs .get ('noiseband' )
300314 self ._sensor_entity_id = kwargs .get ('sensor_entity_id' )
301- self ._time_changed = time .time ()
315+ self ._cold_tolerance = abs (kwargs .get ('cold_tolerance' ))
316+ self ._hot_tolerance = abs (kwargs .get ('hot_tolerance' ))
317+ self ._time_changed = 0
302318 self ._last_sensor_update = time .time ()
303319 if self ._autotune != "none" :
304320 self ._pidController = None
@@ -311,7 +327,8 @@ def __init__(self, **kwargs):
311327 else :
312328 _LOGGER .debug ("PID Gains: kp = %s, ki = %s, kd = %s" , self ._kp , self ._ki , self ._kd )
313329 self ._pidController = pid_controller .PID (self ._kp , self ._ki , self ._kd , self ._minOut ,
314- self ._maxOut , self ._sampling_period )
330+ self ._maxOut , self ._sampling_period ,
331+ self ._cold_tolerance , self ._hot_tolerance )
315332 self ._pidController .mode = "AUTO"
316333
317334 async def async_added_to_hass (self ):
@@ -349,9 +366,6 @@ def _async_startup(event):
349366 self ._target_temp )
350367 else :
351368 self ._target_temp = float (old_state .attributes .get (ATTR_TEMPERATURE ))
352- else :
353- if old_state .attributes .get (ATTR_TEMPERATURE ) is not None :
354- self ._target_temp = float (old_state .attributes .get (ATTR_TEMPERATURE ))
355369 for preset_mode in ['away_temp' , 'eco_temp' , 'boost_temp' , 'comfort_temp' , 'home_temp' ,
356370 'sleep_temp' , 'activity_temp' ]:
357371 if old_state .attributes .get (preset_mode ) is not None :
@@ -362,7 +376,7 @@ def _async_startup(event):
362376 self ._pidController is not None :
363377 self ._i = float (old_state .attributes .get ('pid_i' ))
364378 self ._pidController .integral = self ._i
365- if old_state .state in self ._hvac_list :
379+ if self . _hvac_mode is None and old_state .state in self ._hvac_list :
366380 self ._hvac_mode = old_state .state
367381 if old_state .attributes .get ('Kp' ) is not None and self ._pidController is not None :
368382 self ._kp = float (old_state .attributes .get ('Kp' ))
@@ -373,6 +387,8 @@ def _async_startup(event):
373387 if old_state .attributes .get ('Kd' ) is not None and self ._pidController is not None :
374388 self ._kd = float (old_state .attributes .get ('Kd' ))
375389 self ._pidController .set_pid_param (kd = self ._kd )
390+ if old_state .attributes .get ('pid_mode' ) is not None and self ._pidController is not None :
391+ self ._pidController .mode = old_state .attributes .get ('pid_mode' )
376392
377393 else :
378394 # No previous state, try and restore defaults
@@ -386,6 +402,7 @@ def _async_startup(event):
386402 # Set default state to off
387403 if not self ._hvac_mode :
388404 self ._hvac_mode = HVAC_MODE_OFF
405+ await self ._async_control_heating (calc_pid = True )
389406
390407 @property
391408 def should_poll (self ):
@@ -517,7 +534,7 @@ def pid_control_output(self):
517534 return self ._control_output
518535
519536 @property
520- def device_state_attributes (self ):
537+ def extra_state_attributes (self ):
521538 """attributes to include in entity"""
522539 device_state_attributes = {
523540 'away_temp' : self ._away_temp ,
@@ -534,6 +551,7 @@ def device_state_attributes(self):
534551 }
535552 if self ._autotune != "none" :
536553 device_state_attributes .update ({
554+ "pid_mode" : 'off' ,
537555 "pid_p" : 0 ,
538556 "pid_i" : 0 ,
539557 "pid_d" : 0 ,
@@ -547,6 +565,7 @@ def device_state_attributes(self):
547565 })
548566 else :
549567 device_state_attributes .update ({
568+ "pid_mode" : self ._pidController .mode .lower (),
550569 "pid_p" : self .pid_control_p ,
551570 "pid_i" : self .pid_control_i ,
552571 "pid_d" : self .pid_control_d ,
@@ -572,6 +591,11 @@ async def async_set_hvac_mode(self, hvac_mode):
572591 self ._hvac_mode = HVAC_MODE_OFF
573592 if self ._is_device_active :
574593 await self ._async_heater_turn_off (force = True )
594+ # Clear the samples to avoid integrating the off period
595+ self ._previous_temp = None
596+ self ._previous_temp_time = None
597+ if self ._pidController is not None :
598+ self ._pidController .clear_samples ()
575599 else :
576600 _LOGGER .error ("Unrecognized hvac mode: %s" , hvac_mode )
577601 return
@@ -589,7 +613,6 @@ async def async_set_temperature(self, **kwargs):
589613 self ._force_off = True
590614 self ._target_temp = temperature
591615 await self ._async_control_heating (calc_pid = True )
592- await self .async_update_ha_state ()
593616
594617 async def async_set_pid (self , ** kwargs ):
595618 """Set PID parameters."""
@@ -604,7 +627,13 @@ async def async_set_pid(self, **kwargs):
604627 self ._kd = float (kd )
605628 self ._pidController .set_pid_param (self ._kp , self ._ki , self ._kd )
606629 await self ._async_control_heating (calc_pid = True )
607- await self .async_update_ha_state ()
630+
631+ async def async_set_pid_mode (self , ** kwargs ):
632+ """Set PID parameters."""
633+ mode = kwargs .get ('mode' , None )
634+ if str (mode ).upper () in ['AUTO' , 'OFF' ] and self ._pidController is not None :
635+ self ._pidController .mode = str (mode ).upper ()
636+ await self ._async_control_heating (calc_pid = True )
608637
609638 async def async_set_preset_temp (self , ** kwargs ):
610639 """Set the presets modes temperatures."""
@@ -630,7 +659,6 @@ async def async_set_preset_temp(self, **kwargs):
630659 if activity_temp is not None :
631660 self ._activity_temp = float (activity_temp )
632661 await self ._async_control_heating (calc_pid = True )
633- await self .async_update_ha_state ()
634662
635663 async def clear_integral (self , ** kwargs ):
636664 """Clear the integral value."""
@@ -668,7 +696,6 @@ async def _async_sensor_changed(self, entity_id, old_state, new_state):
668696 "(before %s)" , self ._cur_temp_time , self ._previous_temp_time ,
669697 self ._current_temp , self ._previous_temp )
670698 await self ._async_control_heating (calc_pid = True )
671- await self .async_update_ha_state ()
672699
673700 @callback
674701 def _async_switch_changed (self , entity_id , old_state , new_state ):
@@ -696,6 +723,7 @@ async def _async_control_heating(self, time_func=None, calc_pid=False):
696723 self ._current_temp , self ._target_temp )
697724
698725 if not self ._active or self ._hvac_mode == HVAC_MODE_OFF :
726+ await self .async_update_ha_state ()
699727 return
700728
701729 if calc_pid or self ._sampling_period != 0 :
@@ -704,6 +732,7 @@ async def _async_control_heating(self, time_func=None, calc_pid=False):
704732 # sensor not updated for more than 3 hours, considered as stall, set to 0 for safety
705733 self ._control_output = 0
706734 await self .set_control_value ()
735+ await self .async_update_ha_state ()
707736
708737 @property
709738 def _is_device_active (self ):
@@ -756,10 +785,10 @@ async def async_set_preset_mode(self, preset_mode: str):
756785 self ._target_temp = self .presets [preset_mode ]
757786 self ._attr_preset_mode = preset_mode
758787 await self ._async_control_heating (calc_pid = True )
759- await self .async_update_ha_state ()
760788
761789 async def calc_output (self ):
762790 """calculate control output and handle autotune"""
791+ update = False
763792 if self ._previous_temp_time is None :
764793 self ._previous_temp_time = time .time ()
765794 if self ._cur_temp_time is None :
@@ -784,26 +813,30 @@ async def calc_output(self):
784813 self ._ki , self ._kd )
785814 self ._pidController = pid_controller .PID (self ._kp , self ._ki , self ._kd ,
786815 self ._minOut , self ._maxOut ,
787- self ._sampling_period )
816+ self ._sampling_period ,
817+ self ._cold_tolerance ,
818+ self ._hot_tolerance )
788819 self ._autotune = "none"
789820 self ._control_output = self ._pidAutotune .output
790821 self ._p = self ._i = self ._d = error = dt = 0
791822 else :
792823 if self ._pidController .sampling_period == 0 :
793- self ._control_output = self ._pidController .calc (self ._current_temp ,
794- self ._target_temp ,
795- self ._cur_temp_time ,
796- self ._previous_temp_time )
824+ self ._control_output , update = self ._pidController .calc (self ._current_temp ,
825+ self ._target_temp ,
826+ self ._cur_temp_time ,
827+ self ._previous_temp_time )
797828 else :
798- self ._control_output = self ._pidController .calc (self ._current_temp ,
799- self ._target_temp )
829+ self ._control_output , update = self ._pidController .calc (self ._current_temp ,
830+ self ._target_temp )
800831 self ._p = self ._pidController .P
801832 self ._i = self ._pidController .I
802833 self ._d = self ._pidController .D
803834 error = self ._pidController .error
804835 dt = self ._pidController .dt
805- _LOGGER .debug ("Obtained current control output. %.2f (error = %.2f, dt = %.2f, p=%.2f, "
806- "i=%.2f, d=%.2f)" , self ._control_output , error , dt , self ._p , self ._i , self ._d )
836+ if update :
837+ _LOGGER .debug ("New PID control output. %.2f (error = %.2f, dt = %.2f, p=%.2f, "
838+ "i=%.2f, d=%.2f)" , self ._control_output , error , dt , self ._p , self ._i ,
839+ self ._d )
807840
808841 async def set_control_value (self ):
809842 """Set Output value for heater"""
0 commit comments