Skip to content

Commit 83b05ee

Browse files
authored
Merge pull request #44 from ScratMan/development
v2021.12.2
2 parents 16f366e + 82dae84 commit 83b05ee

4 files changed

Lines changed: 162 additions & 53 deletions

File tree

README.md

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -117,35 +117,52 @@ database's is corrupted.
117117

118118
### Services
119119
Services can be used in Home Assistant to configure the thermostat.\
120-
The following services are available:\
120+
The following services are available:
121121

122122
**Set PID gains:** `smart_thermostat.set_pid_gain`\
123123
Use this service to adjust the PID gains without requiring a restart of Home
124-
Assistant. Values are saved to Home Assistant database and restored after a restart. Please consider saving
125-
the final gain parameters in YAML configuration file when satisfied to keep it safe in case of database corruption.
126-
Optional parameters : kp, ki and kd, as float.
124+
Assistant. Values are saved to Home Assistant database and restored after a restart.\
125+
Please consider saving the final gain parameters in YAML configuration file when satisfied to keep
126+
it safe in case of database corruption.\
127+
Optional parameters : kp, ki and kd, as float.\
127128
Example:
128129
```
129130
service: smart_thermostat.set_pid_gain
130131
data:
131132
kp: 11.8
132133
ki: 0.00878
133134
target:
134-
entity_id: climate.salle_de_bain
135+
entity_id: climate.smart_thermostat_example
136+
```
137+
138+
**Set PID mode:** `smart_thermostat.set_pid_mode`\
139+
Use this service to set the PID mode to either 'auto' or 'off'.\
140+
When in auto, the PID will modulate the heating based on temperature value and variation. When in
141+
off, the PID output will be 0% if temperature is above the set point, and 100% if temperature is
142+
below the set point.\
143+
Mode is saved to Home Assistant database and restored after a restart.\
144+
Required parameter : mode as a string in ['auto', 'off'].\
145+
Example:
146+
```
147+
service: smart_thermostat.set_pid_mode
148+
data:
149+
mode: 'off'
150+
target:
151+
entity_id: climate.smart_thermostat_example
135152
```
136153

137154
**Set preset modes temperatures:** `smart_thermostat.set_preset_temp`\
138155
Use this service to set the temperatures for the preset modes. It can be adjusted
139-
for all preset modes, if a preset mode is not enabled through YAML, it will be enabled. You can use any preset temp
140-
parameter available in smart thermostat settings.
156+
for all preset modes, if a preset mode is not enabled through YAML, it will be enabled. You can use
157+
any preset temp parameter available in smart thermostat settings.\
141158
Example:
142159
```
143160
service: smart_thermostat.set_preset_temp
144161
data:
145162
away_temp: 14.6
146163
boost_temp: 22.5
147164
target:
148-
entity_id: climate.salle_de_bain
165+
entity_id: climate.smart_thermostat_example
149166
```
150167

151168
**Clear the integral part:** `smart_thermostat.clear_integral`\
@@ -186,6 +203,14 @@ and 1.0 for Fahrenheit)
186203
* **max_temp** (Optional): Set maximum set point available (default: 35).
187204
* **target_temp** (Optional): Set initial target temperature. If not set target temperature will be set to null on
188205
startup.
206+
* **cold_tolerance** (Optional): When PID is off, set a minimum amount of difference between the temperature read by
207+
the sensor specified in the target_sensor option and the target temperature that must change prior to being switched
208+
on. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will start when the sensor
209+
equals or goes below 24.5 (float, default 0.3).
210+
* **hot_tolerance** (Optional): When PID is off, set a minimum amount of difference between the temperature read by
211+
the sensor specified in the target_sensor option and the target temperature that must change prior to being switched
212+
off. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will stop when the sensor
213+
equals or goes above 25.5 (float, default 0.3).
189214
* **ac_mode** (Optional): Set the switch specified in the heater option to be treated as a cooling device instead of a
190215
heating device. Should be a boolean (default: false).
191216
* **away_temp** (Optional): Set the temperature used by the "Away" preset. If this is not specified, away_mode feature will not be available.
@@ -195,6 +220,8 @@ heating device. Should be a boolean (default: false).
195220
* **home_temp** (Optional): Set the temperature used by the "Home" preset. If this is not specified, home feature will not be available.
196221
* **sleep_temp** (Optional): Set the temperature used by the "Sleep" preset. If this is not specified, sleep feature will not be available.
197222
* **activity_temp** (Optional): Set the temperature used by the "Activity" preset. If this is not specified, activity feature will not be available.
223+
* **initial_hvac_mode** (Optional): Forces the operation mode after Home Assistant is restarted. If not specified, the thermostat will restore the
224+
previous operation mode.
198225
* **noiseband** (Optional): set noiseband for autotune (float): Determines by how much the input value
199226
must overshoot/undershoot the set point before the state changes (default : 0.5).
200227
* **lookback** (Optional): length of the autotune buffer for the signal analysis to detect peaks, can

custom_components/smart_thermostat/climate.py

Lines changed: 56 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
DEFAULT_DIFFERENCE = 100
6767
DEFAULT_PWM = '00:15:00'
6868
DEFAULT_MIN_CYCLE_DURATION = '00:00:00'
69+
DEFAULT_TOLERANCE = 0.3
6970
DEFAULT_KP = 100
7071
DEFAULT_KI = 0
7172
DEFAULT_KD = 0
@@ -79,6 +80,8 @@
7980
CONF_MIN_TEMP = "min_temp"
8081
CONF_MAX_TEMP = "max_temp"
8182
CONF_TARGET_TEMP = "target_temp"
83+
CONF_HOT_TOLERANCE = "hot_tolerance"
84+
CONF_COLD_TOLERANCE = "cold_tolerance"
8285
CONF_AC_MODE = "ac_mode"
8386
CONF_MIN_CYCLE_DURATION = "min_cycle_duration"
8487
CONF_MIN_OFF_CYCLE_DURATION = "min_off_cycle_duration"
@@ -115,14 +118,16 @@
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

Comments
 (0)