How to get sub-degree accuracy in home temperature, using a dumb A/C and ESPHome

published Aug 16, 2023, last modified Feb 17, 2024

The secret sauce for harmony at home is a PID controller and some grade school math.

How to get sub-degree accuracy in home temperature, using a dumb A/C and ESPHome

It's summer.  For summer, I have a variable compressor mini-split A/C unit which works great — it can cool the place rather quickly, so we don't have to suffer 35 degrees Celsius in our home.

The mini-split is driven using an extremely cheap infrared ESP device, running a custom component for ESPHome which I wrote.  It's nice to change temperatures and fan speeds, or shut the unit off completely, directly from our Home Assistant dashboards.  All code is available here, as well as an explanation of what it does.

The problem

One issue remained: the girlfriend and I were routinely arguing about the temperature.  During regular summer days, if we set the unit to 25 degrees, it's perfect for me, but it's too chilly for her.  If we set the unit to 26 degrees, I'm sweating, but she's happy.  If the fan of the unit is set to low, it won't appreciably cool the home (especially if it's  too warm outside).  If the fan of the unit is set to high, it's too noisy to sleep.

To compound the issue, we would have the tendency to lower the A/C setpoint on extra warm days — just because we felt the air was warmer.  Which, of course, would make for unbearably cold evenings, when the Sun stopped hitting hard.

These problems usually boil down to data.  Looking at the graphs of temperature while the unit is running,  it's clear the unit doesn't reach its target, when we compare it to our living space thermometers — if we set it to 25, it will get the place to 26, and fluctuate down to one whole degree from that, while sometimes going up to 26.5.  Horribly inaccurate!  To compound the issue, the A/C doesn't really know the actual temperature of the place; it really only knows the temperature of the air it's ingesting, which is not representative of the whole living space.  Finally, on hot days, the A/C was not "running hard enough" — we could see the indoor temperature sensors go up measurably, without a corresponding increase in compressor workload — which led to us needing to monkey around with the setpoint, just to get that compressor to output what it's supposed to.

Stressful and annoying.  We're not cavemen.  We don't need to be doing any of this.

What if, instead of letting the A/C's thermostat control the operation of the A/C, we instead had sub-degree (tenth-of-a-degree) automatic control of the unit?  What if we don't have to fiddle with the thermostat at all?  Maybe then we can select a temperature that both of us are happy with — and even reduce the fan noise when the place is at a nice, comfortable temperature already.

That's exactly what I did.  And it solved our problem, I'm happy to report.  With a setpoint of 26.7 degrees Celsius, the girlfriend and I are very happy with the temperature (which oscillates 95% of the time no more than 0.15 degrees around the setpoint!).  Furthermore, the unit cools extremely fast when we get home, the home is warm, and the unit is turned on.

How did I solve the issue?

I use a PID controller climate entity (technically, a proportional/integral controller), plus some simple math to take facts of reality into account.

The code for the complete solution is provided below, of course.

What's a PID controller?

A PID controller is a cybernetic control mechanism.  A complete explanation of PID controllers, which helped me, is in this YouTube video.

The PID controller takes an input parameter (e.g. a temperature), and a target parameter (e.g. another temperature) and it produces an output (say, a percentage of speed that a motor must run at).  The output is calculated by summing up the proportional, integral and derivative terms... wait, what's that mean?

  • The proportional term is an indication of how far off your system is from the target right now.
    • The proportional term is useful to rapidly correct a system that is off-track.
    • Mathematically, it's simply the difference between the input and the target, multiplied by a proportional factor.
    • With a factor of 1, a current temperature of 26, and a target temperature of 25, the proportional term would be -1 because (25 -26) * 1 = -1.
    • In the provided sketch, the proportional factor is called initial_kp.
  • The integral term is an indication of how far off your system has been from the target so far.
    • The integral term helps maintain a correction factor once the system has hit its target.
    • Mathematically, the controller calculates the difference between the input and the target, multiplies it by the time difference between the current reading and the last reading, multiplies it by an integral factor, and then sums the result to the old integral term value.
    • With a factor of 1, a current temperature of 26, and a target temperature of 25, every five seconds your integral factor would go down by -5.
    • In practice, many PID implementations will constrain the range that the integral term can visit — otherwise it may "accumulate to infinity" and cause the output to be permanently stuck at max or at min "forever".
    • In the provided sketch, the integral factor is called initial_ki.  Since the temperature is sampled every 5 seconds, the integral is accumulated every 5 seconds.
  • The derivative term I'm not using, so I can't really explain.
    • In the provided sketch, the derivative factor is called initial_kd.
  • The deadband is a range of inputs encompassing the target, within which each term factor is usually scaled down by some amount (the deadband multiplier).
    • When the input lies within the deadband, because of the multipliers, changes in the input have a reduced effect on the output.  The integral term also accumulates more slowly (depending on the multiplier).
    • The deadband helps the system be more stable once it's close to its target.
    • In the provided sketch, these are controlled by variables deadband_kp_multiplier, deadband_ki_multiplier and deadband_kd_multiplier.

The important thing is that, once these terms are added, they will add up to some value, which in many applications is understood as the percentage of power input that should be applied to your control system (a car engine, a heating system, a rudder, an air conditioner).

Many PID implementations will constrain the output to a number between a minimum such as -1.0 and a maximum such as 1.0, since one usually can't drive a control device beyond 100%, or perhaps below -100%.

The details of the solution

  • The PID controller climate (called living_space in the code) has its traditional target temperature (which can be set to sub-degree accuracy).
  • It also has a process input (current temperature, called living_space_ambient_temperature), which is fed from a Home Assistant sensor (that ultimately uses multiple temperature probes around the home).
    • To ensure temporary (within less than a minute) changes in input temperature, the input temperature is smoothed using an exponential moving average.  This eliminates noise from the input temperature, which is rare, but happens.  No noise means no odd fluctuations in the output (discussed below) of the PID controller.
  • The output of the PID controller is a variable (living_space_pid_output) which ranges from 100% to -100% (in practice, from 0% to -100%).  I don't directly use the output provided by the climate PID controller my design, except as an event to drive the recalculation of the living_space_pid_output variable.
    • There is a deadband set up in the PID controller; if the current temperature is within the deadband, sudden changes in input temperature cause changes to the output to be slow over time.
  • This output is then used to recalculate the target temperature (temperature_for_novamatic_cl_1590), with a mapping formula that more or less means:
    • 0% -> target temperature must be set to current temperature
    • -100% -> target temperature must be set to current temperature minus "max delta from setpoint" (at home, this is 4 degrees).
  • When the target temperature changes, we puppet the actual A/C climate entity to correspond to it.
    • Since the output is a floating point number, and the A/C unit only accepts integers, the control code rounds the value up to the nearest integer.
    • However, there is an important detail here: we can't just use the rounded value as is, because that can cause the unit to flicker between temperature instantly when the process input temperature changes even minutely.  To aid with this, we have a stiction parameter — this makes the current setting stick to its guns, unless the delta between the new target and the old target differs by more than the stiction.  E.g. if the current target is 26, and the new target is 26.5, without stiction it would immediately change to 27 — but with a stiction of 0.1, a change to 27 would require the new target to go to 26.6.  This prevents wear and tear on the unit when temperatures indoors fluctuate.
    • Fan speeds are also adjusted here.  If the temperature is close to the "medium speed" threshold, the fan is adjusted from high to medium.  If then the temperature progresses to the "low speed" threshold, the fan is then set to low.  There's a stiction parameter for the fan as well, which prevents the fan from changing rapidly when the input temperature changes rapidly too.

Show me the code!

Here is the full sketch I am using.  The sketch has some substitutions that let you rename the entities easily.  Many parameters of the PID model in this sketch can be altered at runtime by way of input numbers (with sensible defaults) which you can tweak using sliders in Home Assistant.  This way you can see the effect these parameters have in the PID output and target temperature sensors.

substitutions:
# Tunables. These values can be changed using number entities in HA.
initial_kp: "0.5"
initial_ki: "0.01"
initial_kd: "0.0"
initial_temperature_stiction: "0.1"
initial_fan_stiction: "0.1"
initial_medium_fan_threshold: "0.2"
initial_high_fan_threshold: "1.5"
initial_max_delta_from_setpoint: "5.0"
# Hard-coded. These values can only be changed in the sketch.
min_integral: "-0.3"
max_integral: "0.05"
deadband_low: -0.1°C
deadband_high: 0.2°C
deadband_kp_multiplier: "1.0"
deadband_ki_multiplier: "0.05"
deadband_kd_multiplier: "0.0"
# Names of entities.
pid_climate_id: "living_space"
pid_climate_name: "Living space"
target_climate_id: "novamatic_cl_1590"
target_climate_name: "Novamatic CL 1590"
target_climate_min_temp: "16.0"
target_climate_max_temp: "30.0"
source_temperature_sensor: "living_space_ambient_temperature"
source_temperature_sampling_alpha: "0.1"
# skip_first_temperature_samples: "6"

esphome:
name: ir-transceiver-1
friendly_name: IR transceiver 1

external_components:
- source:
type: git
url: https://github.com/Rudd-O/novamatic_climate
ref: master
components:
- novamatic_climate
# refresh: 60s # https://esphome.io/components/external_components.html#refresh

globals:
- id: mode_changed
type: bool
restore_value: no
initial_value: "false"

esp8266:
# ESP8285 IR transceiver
board: esp01_1m
restore_from_flash: true

# Enable logging
logger:
level: info

# Enable Home Assistant API
api:
encryption:
key: <put your own key here>
services:
- service: send_pronto
variables:
data: string
then:
- remote_transmitter.transmit_pronto:
data: !lambda 'return data;'

ota:
password: <put your own password here>

wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password

button:
- platform: restart
name: Restart
entity_category: diagnostic
icon: mdi:restart
- platform: safe_mode
name: Safe mode restart
entity_category: diagnostic
icon: mdi:restart-alert
- platform: template
name: Reset PID integral value of ${pid_climate_name}
entity_category: diagnostic
on_press:
- then:
- climate.pid.reset_integral_term: ${pid_climate_id}
- platform: factory_reset
name: Factory reset
entity_category: diagnostic
icon: mdi:restore-alert

binary_sensor:
- platform: status
name: API connected
id: api_connected
entity_category: diagnostic

- platform: template
name: "${pid_climate_name} in deadband"
id: ${pid_climate_id}_in_deadband
disabled_by_default: true
entity_category: "diagnostic"

climate:
- platform: novamatic_climate
name: ${target_climate_name}
id: ${target_climate_id}
transmitter_id: transmitter
icon: mdi:air-conditioner
sensor: ${source_temperature_sensor}

- platform: pid
name: "${pid_climate_name}"
id: "${pid_climate_id}"
sensor: sampled_temperature_for_${pid_climate_id}
default_target_temperature: 24°C
cool_output: ${pid_climate_id}_output
control_parameters:
kp: ${initial_kp}
ki: ${initial_ki}
kd: ${initial_kd}
min_integral: ${min_integral}
max_integral: ${max_integral}
deadband_parameters:
threshold_low: ${deadband_low}
threshold_high: ${deadband_high}
kp_multiplier: ${deadband_kp_multiplier}
ki_multiplier: ${deadband_ki_multiplier}
  kd_multiplier: ${deadband_kd_multiplier}
on_control:
- globals.set:
id: mode_changed
value: "true"
- delay: 1s
- script.execute:
id: actuate_target_climate

# Obsolete comment below.
# FIXME the integral should not be reset every time the A/C is controlled.
# It should only be reset when the climate call against this PID unit
# contains a get_mode().value() of CLIMATE_MODE_COOL and perhaps
# when the current mode of the climate device is not CLIMATE_MODE_COOL.
# Requires: https://github.com/esphome/esphome/pull/5028
# - logger.log:
# format: Resetting the integral term due to incoming change in state
# tag: PID
# level: info
# With the new setup, it does not appear to be necessary to
# reset the PID integral when settings are changed.
# - climate.pid.reset_integral_term: living_space

number:
- platform: template
name: "${pid_climate_name} PID Kp"
id: ${pid_climate_id}_pid_kp
min_value: 0
max_value: 50
step: 0.000001
initial_value: ${initial_kp}
optimistic: true
entity_category: config
restore_value: true
on_value:
then:
- climate.pid.set_control_parameters:
id: living_space
kp: !lambda "return x;"
ki: !lambda "return id(living_space).get_ki();"
kd: !lambda "return id(living_space).get_kd();"
icon: mdi:chart-line-variant
- platform: template
name: "${pid_climate_name} PID Ki"
id: ${pid_climate_id}_pid_ki
min_value: 0
max_value: 5
step: 0.000001
initial_value: ${initial_ki}
optimistic: true
entity_category: config
restore_value: true
on_value:
then:
- climate.pid.set_control_parameters:
id: living_space
kp: !lambda "return id(living_space).get_kp();"
ki: !lambda "return x;"
kd: !lambda "return id(living_space).get_kd();"
icon: mdi:math-integral-box
- platform: template
name: "${pid_climate_name} PID Kd"
id: ${pid_climate_id}_pid_kd
min_value: 0
max_value: 50
step: 0.000001
initial_value: ${initial_kd}
optimistic: true
entity_category: config
restore_value: true
on_value:
then:
- climate.pid.set_control_parameters:
id: living_space
kp: !lambda "return id(living_space).get_kp();"
ki: !lambda "return id(living_space).get_ki();"
kd: !lambda "return x;"
icon: mdi:delta
- platform: template
name: "${pid_climate_name} maximum delta from setpoint"
id: ${pid_climate_id}_maximum_delta_from_setpoint
min_value: 0
max_value: 10
step: 0.1
initial_value: ${initial_max_delta_from_setpoint}
optimistic: true
entity_category: config
restore_value: true
icon: mdi:delta
- platform: template
name: "${target_climate_name} temperature stiction"
id: ${target_climate_id}_temperature_stiction
min_value: 0
max_value: 1
step: 0.01
initial_value: ${initial_temperature_stiction}
optimistic: true
entity_category: config
restore_value: true
icon: mdi:car-brake-temperature
- platform: template
name: "${target_climate_name} fan stiction"
id: ${target_climate_id}_fan_stiction
min_value: 0
max_value: 1
step: 0.01
initial_value: ${initial_fan_stiction}
optimistic: true
entity_category: config
restore_value: true
icon: mdi:car-brake-temperature
- platform: template
name: "${target_climate_name} high fan threshold"
id: ${target_climate_id}_high_fan_threshold
min_value: 0
max_value: 5
step: 0.01
initial_value: ${initial_high_fan_threshold}
optimistic: true
entity_category: config
restore_value: true
icon: mdi:fan-speed-3
unit_of_measurement: "°C"
- platform: template
name: "${target_climate_name} medium fan threshold"
id: ${target_climate_id}_medium_fan_threshold
min_value: 0
max_value: 5
step: 0.01
initial_value: ${initial_medium_fan_threshold}
optimistic: true
entity_category: config
restore_value: true
icon: mdi:fan-speed-2
unit_of_measurement: "°C"

remote_receiver:
id: receiver
pin:
number: GPIO14
inverted: True
dump: pronto

remote_transmitter:
id: transmitter
pin: GPIO4
carrier_duty_percent: 50%

# Dummy output needed by PID climate.
# We compute our own PID output which is allowed
# to be nonzero when the PID climate entity is off
# since we must compute the target temperature
# independently of the mode of the PID climate.
output:
- platform: template
id: ${pid_climate_id}_output
type: float
write_action:
- lambda: |-
ESP_LOGD("PID", "Written to output: %.2f", state);
- component.update: ${pid_climate_id}_pid_output
- binary_sensor.template.publish:
id: ${pid_climate_id}_in_deadband
state: !lambda 'return id(${pid_climate_id}).in_deadband();'

sensor:
- platform: homeassistant
entity_id: sensor.${source_temperature_sensor}
id: ${source_temperature_sensor}
device_class: temperature
state_class: measurement
unit_of_measurement: "°C"
internal: true

- platform: template
id: sampled_temperature_for_${pid_climate_id}
lambda: |-
auto val = id(${source_temperature_sensor}).state;
if (!isnan(val)) {
return val;
} else {
return {};
}
update_interval: 5s
internal: true
filters:
# - skip_initial: ${skip_first_temperature_samples}
- exponential_moving_average:
alpha: ${source_temperature_sampling_alpha}
send_every: 1
# - throttle_average: 30s
# - heartbeat: 30s

- platform: pid
name: "${pid_climate_name} PID P"
id: ${pid_climate_id}_pid_p
type: PROPORTIONAL
disabled_by_default: true
entity_category: "diagnostic"
- platform: pid
name: "${pid_climate_name} PID I"
id: ${pid_climate_id}_pid_i
type: INTEGRAL
disabled_by_default: true
entity_category: "diagnostic"
- platform: pid
name: "${pid_climate_name} PID D"
id: ${pid_climate_id}_pid_d
type: DERIVATIVE
disabled_by_default: true
entity_category: "diagnostic"
- platform: pid
name: "${pid_climate_name} PID error"
id: ${pid_climate_id}_error
type: ERROR
disabled_by_default: true
entity_category: "diagnostic"
- platform: template
name: "${pid_climate_name} PID output"
id: ${pid_climate_id}_pid_output
update_interval: never
disabled_by_default: true
unit_of_measurement: "%"
entity_category: "diagnostic"
lambda: |-
return id(${pid_climate_id}_pid_p).state + id(${pid_climate_id}_pid_i).state + id(${pid_climate_id}_pid_d).state;
on_value:
- component.update: temperature_for_${target_climate_id}

- platform: template
name: Temperature for ${target_climate_name}
id: temperature_for_${target_climate_id}
update_interval: never
device_class: temperature
unit_of_measurement: "°C"
accuracy_decimals: 2
disabled_by_default: true
entity_category: "diagnostic"
lambda: |-
// The setpoint is the temperature you set in the PID climate.
float setpoint = id(${pid_climate_id}).target_temperature;
// The output is the PID output, ranging from zero to -anything,
// but in practice this will be clamped to +100% — -100% below.
float output = id(${pid_climate_id}_pid_output).state / 100.0;
// This is the maximum deviation of the temperature that will
// be set on the target climate unit.
float max_delta_from_setpoint = id(${pid_climate_id}_maximum_delta_from_setpoint).state;
// These are the target unit's maximum boundaries.
float hard_minimum = ${target_climate_min_temp};
float hard_maximum = ${target_climate_max_temp};
float max_from_setpoint = min(hard_maximum, setpoint + max_delta_from_setpoint);
float min_from_setpoint = max(hard_minimum, setpoint - max_delta_from_setpoint);
// The value is computed by adding the setpoint to the output
// multiplied by the maximum delta from setpoint. E.g. if
// the PID output is -50% (-0.5), the setpoint is 22, and the max
// delta from setpoint is 4, then the value will be 22 + -0.5 * 4
// => in other words = 20.
float val = setpoint + output * max_delta_from_setpoint;
// And here we do the clamping, so even if the PID output is -200%
// we still only permit a minimum equivalent to
// setpoint - min_from_setpoint which takes into account the
// max deviation allowed as well as the hard minimum. In practice,
// if the PID output is -150% (-1.5), the setpoint is 22, and the max
// delta from setpoint is 4, then the value will be 22 + -1.5 * 4
// => 16, but clamped to 18, in other words = 18.
float clamped = max(min(val, max_from_setpoint), min_from_setpoint);
return clamped;
on_value:
- script.execute:
id: actuate_target_climate

- platform: wifi_signal
name: "Wi-Fi signal strength"
update_interval: 5s
entity_category: "diagnostic"

script:
- id: actuate_target_climate
then:
- lambda: |-
bool mc = id(mode_changed);
id(mode_changed) = false;

auto master_ac = id(${pid_climate_id});
auto slave_ac = id(${target_climate_id});
auto pid_mode = master_ac->mode;
auto slave_mode = slave_ac->mode;

if (pid_mode == esphome::climate::CLIMATE_MODE_OFF) {
if (slave_mode != esphome::climate::CLIMATE_MODE_OFF) {
esphome::climate::ClimateCall *c = new esphome::climate::ClimateCall(slave_ac);
c->set_mode(esphome::climate::CLIMATE_MODE_OFF);
ESP_LOGI("PID", "Turning off slave A/C because master A/C was turned off.");
c->perform();
delete c;
}
return;
}

/* From this point on, pid_mode is always CLIMATE_MODE_COOL. */

float new_slave_target_temperature = id(temperature_for_${target_climate_id}).state;
auto current_temperature = id(sampled_temperature_for_${pid_climate_id}).state;

if (new_slave_target_temperature >= current_temperature) {
if (slave_mode != esphome::climate::CLIMATE_MODE_OFF) {
esphome::climate::ClimateCall *c = new esphome::climate::ClimateCall(slave_ac);
c->set_mode(esphome::climate::CLIMATE_MODE_OFF);
ESP_LOGI("PID",
"Turning off slave A/C because target temperature %.2f is above current temperature %.2f.",
new_slave_target_temperature, current_temperature);
c->perform();
delete c;
}
return;
}

/* From this point on, new_slave_target_temperature is always < than current_temperature. */

auto slave_target_temperature = slave_ac->target_temperature;
esphome::climate::ClimateCall *c = new esphome::climate::ClimateCall(slave_ac);
bool perform_call = false;

// implement stiction on temperature control
// slave_target_temperature is always a rounded version of previously-set new_slave_target_temperature val
auto diff = abs(slave_target_temperature - new_slave_target_temperature);
auto stiction = id(${target_climate_id}_temperature_stiction).state;
if (diff >= 0.5 + stiction || (mc && slave_mode != esphome::climate::CLIMATE_MODE_COOL)) {
int wanttmp_int = floor(new_slave_target_temperature + 0.5);
if (mc && slave_mode != esphome::climate::CLIMATE_MODE_COOL) {
ESP_LOGI("PID",
"Setting temp of ${target_climate_name} to %d (current temp %.2f) due to ${pid_climate_name} turning on.",
wanttmp_int, current_temperature);
}
else {
ESP_LOGI("PID",
"Temp of ${target_climate_name} is %.2f, diff is %.2f, changing it to %d (current temp %.2f).",
slave_target_temperature, diff, wanttmp_int, current_temperature);
}
c->set_target_temperature(wanttmp_int);
if (wanttmp_int >= current_temperature) {
ESP_LOGI("PID", "The target temperature is higher than the current temperature, turning ${target_climate_id} off.");
c->set_mode(esphome::climate::CLIMATE_MODE_OFF);
perform_call = true;
} else {
ESP_LOGI("PID", "Turning ${target_climate_id} on.");
c->set_mode(esphome::climate::CLIMATE_MODE_COOL);
perform_call = true;
}
}

auto slave_fan_mode = esphome::climate::CLIMATE_FAN_AUTO;
auto high_medium_boundary = id(${target_climate_id}_high_fan_threshold).state;
auto medium_low_boundary = id(${target_climate_id}_medium_fan_threshold).state;
auto fan_stiction = id(${target_climate_id}_fan_stiction).state;
if (slave_ac->fan_mode.has_value()) {
slave_fan_mode = slave_ac->fan_mode.value();
}
float delta = master_ac->target_temperature - new_slave_target_temperature;
/*
truth table
stiction h/m m/l
-> 0.1 >2.1 <=2.1 >2 <=2 >1.9 <=1.9 ↔ >1.1 <=1.1 >1 <=1 >0.9 <=0.9
if high 3 3 3 3 3 2 2 2 2 2 1 1 1
if med 3 2 2 2 2 2 2 2 2 2 2 2 1
if low 3 3 3 2 2 2 2 2 1 1 1 1 1
*/
if (slave_fan_mode == esphome::climate::CLIMATE_FAN_HIGH) {
if (delta > high_medium_boundary - stiction) {
// do nothing
} else if (delta > medium_low_boundary) {
ESP_LOGI("PID", "Fan is %d, must be set to medium (delta %.2f).", slave_fan_mode, delta);
c->set_fan_mode(esphome::climate::CLIMATE_FAN_MEDIUM);
perform_call = true;
} else {
ESP_LOGI("PID", "Fan is %d, must be set to low (delta %.2f).", slave_fan_mode, delta);
c->set_fan_mode(esphome::climate::CLIMATE_FAN_LOW);
perform_call = true;
}
} else if (slave_fan_mode == esphome::climate::CLIMATE_FAN_MEDIUM) {
if (delta > high_medium_boundary + stiction) {
ESP_LOGI("PID", "Fan is %d, must be set to high (delta %.2f).", slave_fan_mode, delta);
c->set_fan_mode(esphome::climate::CLIMATE_FAN_HIGH);
perform_call = true;
} else if (delta <= high_medium_boundary + stiction
&&
delta >= medium_low_boundary - stiction) {
// do nothing
} else {
ESP_LOGI("PID", "Fan is %d, must be set to low (delta %.2f).", slave_fan_mode, delta);
c->set_fan_mode(esphome::climate::CLIMATE_FAN_LOW);
perform_call = true;
}
} else /* (slave_fan_mode == esphome::climate::CLIMATE_FAN_LOW) */ {
if (delta > high_medium_boundary) {
ESP_LOGI("PID", "Fan is %d, must be set to high (delta %.2f).", slave_fan_mode, delta);
c->set_fan_mode(esphome::climate::CLIMATE_FAN_HIGH);
perform_call = true;
} else if (delta > medium_low_boundary + stiction) {
ESP_LOGI("PID", "Fan is %d, must be set to medium (delta %.2f).", slave_fan_mode, delta);
c->set_fan_mode(esphome::climate::CLIMATE_FAN_MEDIUM);
perform_call = true;
} else {
// do nothing
}
}

if (perform_call) {
ESP_LOGI("PID", "Performing climate call to ${target_climate_id}.");
c->perform();
}
delete c;