Smart Cooler

Hello. Everyone. If anyone is interested, I’ve migrated my Arduino MKR 1000-based code to esphome.

Designed as a controller for a cold room or walk-in refrigerator. It’s similar in concept to a CoolBot unit that tricks a standard window AC unit into getting colder than it normally will (shuts off at 60°F). It does this by turning on a small heater (a 5w power resistor) strapped to the main temp sensor on the AC unit. By turning this heater on, it tricks the AC into thinking the room is warmer than it actually is, and will run the AC down to whatever temp you set. There’s an additional temp sensor on the fins of the AC unit to sense if they are icing up, and to defrost them if necessary. This will chill down to 34°, if desired. A set of two small fans that vent outside air into the room, and a small fan that vents air out. If it’s cold enough outside, these two fans instead of the more expensive to run AC unit.

The code is designed to Config is below. The project utilizes a 5v 2-relay board, 3 Dallas Temperature sensors and a SSD1306 display.

# === Substitutions ===
# Define reusable variables for the project name and type to simplify configuration updates.
substitutions:
  name: "smart-cooler"
  type: ESP

# === ESPHome Configuration ===
# Basic settings for the ESPHome integration, defining the device name and its user-friendly display name.
esphome:
  name: "${name}"
  friendly_name: "smart-cooler"
  comment: Smart Cooler - Cold Room Control System

# === ESP32 Board and Framework ===
# Specify the board and framework type for the ESP32 device.
esp32:
  board: esp32-c3-devkitm-1
  framework:
    type: arduino

# === Global Variables ===
# Define global variables to store and manage device states.
globals:
  - id: ac_state # Tracks whether the AC (or heater) is active.
    type: bool
    initial_value: 'false'
  - id: fan_state # Tracks whether the fan is active.
    type: bool
    initial_value: 'false'
  - id: deadband_state  # Manages the deadband logic to prevent rapid toggling.
    type: bool
    initial_value: 'false'
  - id: freeze_recovery # Indicates if freeze recovery mode is active.
    type: bool
    initial_value: 'false'
  - id: hysteresis # Temperature hysteresis range to avoid frequent toggling.
    type: float
    initial_value: '1.0'
  - id: margin # Safety margin to prevent fins from freezing.
    type: float
    initial_value: '4.0'

# === Logging Configuration ===
# Enable logging to monitor the device's behavior.
logger:
  level: WARN

# === API Configuration ===
# Enable the API for integration with Home Assistant, including encryption for secure communication.
api:
  encryption:
    key: "p97Kk5PMQqd+PrebH7TEIzNhwPrxHVqRQnxLKiWZhq0="

# === OTA Updates ===
# Allow Over-The-Air updates with a secure password.
ota:
  - platform: esphome
    password: "994a754fd02223e9e8143b409cd1ec76"

# === Wi-Fi Configuration ===
# Configure Wi-Fi settings, including a fallback hotspot in case of connection failure.
wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  power_save_mode: none
  reboot_timeout: 0s
  
  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: "smart-cooler"
    password: "BnHdWV9EqyDt"

# === Text Sensors ===
# Provide network-related information (e.g., IP, SSID, MAC address) for diagnostics.
text_sensor:
  - platform: wifi_info
    ip_address:
      name: "IP Address"
      icon: "mdi:ip"
    ssid:
      name: "Connected SSID"
      icon: "mdi:wifi"
    bssid:
      name: "Connected BSSID"
      icon: "mdi:wifi"
    mac_address:
      name: "Mac Wifi Address"
      icon: "mdi:lan-connect"
    dns_address:
      name: "DNS Address"
      icon: "mdi:ip"
  - platform: template
    name: " Currently Running"
    id: currently_running
    icon: "mdi:earth"
    lambda: |-
      if (id(ac_state)) {
        return {"AC"};
      } else if (id(fan_state)) {
        return {"FANS"};
      } else if (id(deadband_state)) {
        return {"DEADBAND"};
      } else if (id(freeze_recovery)) {
        return {"FREEZE"};
      } else {
        return {"OFF"};
      }
    update_interval: 5s
    web_server:
      sorting_group_id: state_settings

# === Captive Portal ===
# Enable a fallback interface for device setup if Wi-Fi fails.
captive_portal:

# === Web Server ===
# Set up a web server for direct control and monitoring, with authentication.
web_server:
  version: 3
  auth:
    username: saintmoor
    password: saintmoor
  log: true
  sorting_groups:
    - id: relay_settings
      name: "Relays"
      sorting_weight: -25
    - id: temperature_settings
      name: "Temperature"
      sorting_weight: -20
    - id: set_point_settings
      name: "Temperature SetPoints"
      sorting_weight: -15
    - id: state_settings
      name: "Status"
      sorting_weight: -10

# === HTTP Request ===
# Enable HTTP requests for external integrations.
http_request:
  useragent: esphome/device
  timeout: 10s
  verify_ssl: false

# === Time Synchronization ===
# Synchronize the device time with NTP servers.
time:
  - platform: sntp
    id: sntp_time
    timezone: America/New_York
    servers:
     - 0.pool.ntp.org
     - 1.pool.ntp.org
     - 2.pool.ntp.org

# === Number Controls ===
# Define configurable settings for temperature setpoints and hysteresis.
number:
  - platform: template
    name: "Target Temp" # Target room temperature.
    id: target_temp
    unit_of_measurement: "°F"
    icon: "mdi:temperature-fahrenheit"
    optimistic: true
    min_value: 34
    max_value: 121
    step: 1
    initial_value: 34
    restore_value: true
    web_server:
      sorting_group_id: set_point_settings
  - platform: template
    name: "Hysteresis Range" # Range for temperature hysteresis.
    id: hysteresis_range
    unit_of_measurement: "°F"
    icon: "mdi:temperature-fahrenheit"
    optimistic: true
    min_value: 0.0
    max_value: 2.0
    step: 0.5
    initial_value: 0.5
    restore_value: true
    web_server:
      sorting_group_id: set_point_settings
  - platform: template
    name: "Freeze Recovery Margin" # Range for freezing temperature value addition.
    id: safety_margin
    unit_of_measurement: "°F"
    icon: "mdi:temperature-fahrenheit"
    optimistic: true
    min_value: 0
    max_value: 4
    step: 1
    initial_value: 4
    restore_value: true
    web_server:
      sorting_group_id: set_point_settings

# === 1-Wire Sensors ===
# Configure temperature sensors for monitoring room, fins, and outside temperatures.
one_wire:
  - platform: gpio
    pin: GPIO10
    id: dallas_1

# === i2c Sensors ===
# Configure temperature sensors for monitoring room, fins, and outside temperatures.  
i2c:
  sda: 9
  scl: 7
  scan: true
  id: bus_a
  frequency: 400kHz

# === LED Control ===
# Configure an onboard LED with color effects for status indication.
light:
  - platform: neopixelbus
    type: GRB
    variant: WS2811
    pin: GPIO08
    name: "Onboard LED"
    id: onboard_led
    icon: "mdi:led-strip"
    num_leds: 1
    restore_mode: ALWAYS_ON
    on_turn_on:
      then:
        - light.control:
            brightness: 25%
            id: onboard_led
            effect: Green
    effects: # Predefined LED effects for visual feedback.
      - addressable_color_wipe: # Solid color effects.
          name: Red
          colors:
            - red: 100%
              green: 0%
              blue: 0%
              num_leds: 1
          reverse: false
      - addressable_color_wipe:
          name: Green
          colors:
            - red: 0%
              green: 100%
              blue: 0%
              num_leds: 1
          reverse: false
      - addressable_color_wipe:
          name: Blue
          colors:
            - red: 0%
              green: 0%
              blue: 100%
              num_leds: 1
          reverse: false
      - addressable_color_wipe:
          name: White
          colors:
            - red: 100%
              green: 100%
              blue: 100%
              num_leds: 1
          reverse: false
      - pulse: # Blinking effects.
          name: "Fast Pulse"
          transition_length: 0.5s
          update_interval: 0.5s
          min_brightness: 0%
          max_brightness: 100%
      - pulse:
          name: "Slow Pulse"
          transition_length: 1s      # defaults to 1s
          update_interval: 2s

# === Binary Sensors ===
# Monitor various states, such as AC, fan, and freeze recovery status.
binary_sensor:
  - platform: status
    name: "Status"
    icon: "mdi:state-machine"
  - platform: template
    name: "AC State"
    id: ac_state_value
    icon: "mdi:earth"
    lambda: |-
      if (id(ac_state)) {
        return true;      
      } else {
        return false;
      }
    web_server:
      sorting_group_id: state_settings
  - platform: template
    name: "FAN State"
    id: fan_state_value
    icon: "mdi:earth"
    lambda: |-
      if (id(fan_state)) {
        return true;      
      } else {
        return false;
      }
    web_server:
      sorting_group_id: state_settings
  - platform: template
    name: "DEADBAND State"
    id: deadband_state_value
    icon: "mdi:earth"
    lambda: |-
      if (id(deadband_state)) {
        return true;      
      } else {
        return false;
      }
    web_server:
      sorting_group_id: state_settings
  - platform: template
    name: "Freeze Recovery"
    id: freeze_recovery_value
    icon: "mdi:earth"
    lambda: |-
      if (id(freeze_recovery)) {
        return true;      
      } else {
        return false;
      }
    web_server:
      sorting_group_id: state_settings

#=====CONFIG FOR OLED====#
# Control display output for sensor and state information.
font:
  - file: 'fonts/arial.ttf'
    id: arial_medium
    size: 14
  - file: "fonts/OpenSans-Regular.ttf"
    id: opensans_medium
    size: 12
  - file: "fonts/OpenSans-Regular.ttf"
    id: opensans_small
    size: 10
  - file: "gfonts://Roboto" # gfonts://family[@weight]
    id: roboto
    size: 20
  - file: "gfonts://Roboto"
    id: roboto_medium
    size: 15
  - file: "gfonts://Roboto"
    id: roboto_small
    size: 12
  - file: "gfonts://Roboto"
    id: roboto_smallest
    size: 10
  - file: 'fonts/BebasNeue-Regular.ttf'
    id: bebasneue_large
    size: 48
  - file: 'fonts/BebasNeue-Regular.ttf'
    id: bebasneue_medium
    size: 32
  - file: 'fonts/Silkscreen-Regular.ttf'
    id: silkscreen_medium
    size: 10
  - file: 'fonts/Silkscreen-Regular.ttf'
    id: silkscreen_small
    size: 8
  - file: 'fonts/arial.ttf'
    id: arial_large
    size: 16

image:
  - file: mdi:alert-outline
    id: alert_image
    resize: 60x60
  - file: mdi:snowflake
    id: snowflake_image
    resize: 60x60
  - file: mdi:radiator
    id: radiator_image
    resize: 60x60
  - file: mdi:power-off
    id: power_off_image
    resize: 60x60
  - file: mdi:power-on
    id: power_on_image
    resize: 60x60

display:
  - platform: ssd1306_i2c
    model: "SSD1306 128x64"
    address: 0x3C
    i2c_id: bus_a
#    reset_pin: D0
    id: oled_display
    auto_clear_enabled: True
    show_test_card: true
#    update_interval: 5s
    pages:
      - id: page1 
        lambda: |-
          // it.printf(X, Y,.. (X (Row) and Y (Column)) 
          // Print "Smart Cooler" in top center.
          it.printf(it.get_width() / 2, 8, id(roboto_medium), TextAlign::TOP_CENTER, "Smart Cooler");
          // Print time in HH:MM format
          it.strftime(0, 60, id(bebasneue_large), TextAlign::BASELINE_LEFT, "%H:%M", id(sntp_time).now());
          // Print ROOM temperature
          if (id(room_temp).has_state()) {
          it.printf(127, 23, id(arial_medium), TextAlign::TOP_RIGHT, "%.1f°", id(room_temp).state);
          }
          // Print Target temperature
          if (id(target_temp).has_state()) {
          it.printf(127, 60, id(arial_medium), TextAlign::BASELINE_RIGHT, "%.1f°", id(target_temp).state);
          }
      - id: page2
        lambda: |-
          // Print "Currently Running" in the top center
          it.printf(it.get_width() / 2, 8, id(roboto_medium), TextAlign::TOP_CENTER, "Currently Running");
          // Determine the text to display
          std::string status_text;
          if (id(ac_state)) {
          status_text = "AC";
          } else if (id(fan_state)) {
          status_text = "FANS";
          } else if (id(deadband_state)) {
          status_text = "DEADBAND";
          } else if (id(freeze_recovery)) {
          status_text = "FREEZE";
          } else {
          status_text = "OFF";
          }
          // Print the status text centered at the bottom
          it.printf(it.get_width() / 2, it.get_height() - 4, id(bebasneue_medium), TextAlign::BASELINE_CENTER, "%s", status_text.c_str());
      - id: page3 
        lambda: |-
          // Print "Target Temp" in top center.
          it.printf(it.get_width() / 2, 8, id(roboto_medium), TextAlign::TOP_CENTER, "Target Temp");
          // Print Target temperature in baseline center
          if (id(target_temp).has_state()) {
          it.printf(it.get_width() / 2, it.get_height() - 4, id(bebasneue_large), TextAlign::BASELINE_CENTER, "%.1f°", id(target_temp).state);
          }
      - id: page4
        lambda: |-
          // Print "Room Temp" in top center.
          it.printf(it.get_width() / 2, 8, id(roboto_medium), TextAlign::TOP_CENTER, "Room Temp");
          // Print Room temperature in baseline center
          if (id(room_temp).has_state()) {
          it.printf(it.get_width() / 2, it.get_height() - 4, id(bebasneue_large), TextAlign::BASELINE_CENTER, "%.1f°", id(room_temp).state);
          }
      - id: page5
        lambda: |-
          // Print "FINS Temp" in top center.
          it.printf(it.get_width() / 2, 8, id(roboto_medium), TextAlign::TOP_CENTER, "Fins Temp");
          // Print Room temperature in baseline center
          if (id(fins_temp).has_state()) {
          it.printf(it.get_width() / 2, it.get_height() - 4, id(bebasneue_large), TextAlign::BASELINE_CENTER, "%.1f°", id(fins_temp).state);
          }
      - id: page6
        lambda: |-
          // Print "Outside Temp" in top center.
          it.printf(it.get_width() / 2, 8, id(roboto_medium), TextAlign::TOP_CENTER, "Outside Temp");
          // Print Outside temperature in baseline center
          if (id(ext_temp).has_state()) {
          it.printf(it.get_width() / 2, it.get_height() - 4, id(bebasneue_large), TextAlign::BASELINE_CENTER, "%.1f°", id(ext_temp).state);
          }
      - id: page7
        lambda: |-
          // Print "Hysteresis" in top center.
          it.printf(it.get_width() / 2, 8, id(roboto_medium), TextAlign::TOP_CENTER, "Hysteresis");
          // Print Hystereis Range in baseline center
          if (id(hysteresis_range).has_state()) {
          it.printf(it.get_width() / 2, it.get_height() - 4, id(bebasneue_large), TextAlign::BASELINE_CENTER, "%.1f°", id(hysteresis_range).state);
          }
      - id: page8
        lambda: |-
          // Print "Recovery Margin" in top center.
          it.printf(it.get_width() / 2, 8, id(roboto_medium), TextAlign::TOP_CENTER, "Recovery Margin");
          // Print Freeze Recovery Margin in baseline center
          if (id(safety_margin).has_state()) {
          it.printf(it.get_width() / 2, it.get_height() - 4, id(bebasneue_large), TextAlign::BASELINE_CENTER, "%.1f°", id(safety_margin).state);
          }
      - id: starting
        lambda: |-
          it.printf(it.get_width() / 2, 8, id(roboto), TextAlign::TOP_CENTER, "Smart Cooler");
      - id: snowflake
        lambda: |-
          // Draw the image snowflake at position [x=0,y=0]
          it.image(64, 60, id(snowflake_image));
      - id: alert
        lambda: |-
          // Draw the image alert at position [x=0,y=0]
          it.image(0, 0, id(alert_image));
      - id: power_off
        lambda: |-
          // Draw the image power_off at position [x=0,y=0]
          it.image(0, 0, id(power_off_image));
      - id: power_on
        lambda: |-
          // Draw the image power_on at position [x=0,y=0]
          it.image(0, 0, id(power_on_image));
      - id: radiator
        lambda: |-
          // Draw the image radiator at position [x=0,y=0]
          it.image(0, 0, id(radiator_image));

# === Sensors ===
# Read and display temperature sensor values.
sensor:
  - platform: uptime
    name: "Uptime" # Device uptime in seconds
  - platform: internal_temperature
    name: "Internal Temperature" # ESP32 internal temperature in °F
    id: "internal_temperature_f"
    unit_of_measurement: "°F"
    icon: "mdi:temperature-fahrenheit"
    accuracy_decimals: 0
    device_class: "temperature"
    state_class: "measurement"
    filters:
      - lambda: return x * (9.0/5.0) + 32.0; # Convert Celsius to Fahrenheit
      - filter_out: nan
  - platform: dallas_temp
    address: 0x690417a1115aff28 # ROOM_SENSOR
    one_wire_id: dallas_1
    name: "Room Temperature" 
    id: room_temp
    icon: "mdi:thermometer"
    device_class: "temperature"
    state_class: "measurement"
    accuracy_decimals: 1
    update_interval: 10s
    resolution: 9
    filters:
      - throttle: 1s
      - heartbeat: 5s
      - debounce: 0.1s
      - lambda: return x * (9.0/5.0) + 32.0; # Convert temperature readings to Fahrenheit.
      - filter_out: nan
    unit_of_measurement: "°F"
    on_value_range:
      - above: 125
        then:
          - logger.log:
              level: ERROR
              format: "Sensor reading too high! Value: %.2f"
              args: [x]
          - delay: 30s
          - logger.log: "Retrying sensor..."
    web_server:
      sorting_group_id: temperature_settings
  - platform: dallas_temp
    address: 0x990417a10ca3ff28 # FINS_SENSOR
    one_wire_id: dallas_1
    name: "Fins Temperature" 
    id: fins_temp
    icon: "mdi:thermometer"
    device_class: "temperature"
    state_class: "measurement"
    accuracy_decimals: 1
    update_interval: 10s
    resolution: 9
    filters:
      - throttle: 1s
      - heartbeat: 5s
      - debounce: 0.1s
      - lambda: return x * (9.0/5.0) + 32.0; # Convert temperature readings to Fahrenheit.
      - filter_out: nan
    unit_of_measurement: "°F"
    on_value_range:
      - above: 125
        then:
          - logger.log:
              level: ERROR
              format: "Sensor reading too high! Value: %.2f"
              args: [x]
          - delay: 30s
          - logger.log: "Retrying sensor..."
    web_server:
      sorting_group_id: temperature_settings
  - platform: dallas_temp
    address: 0x0e0417a116ffff28 # EXT_SENSOR
    one_wire_id: dallas_1
    name: "Outside Temperature" 
    id: ext_temp
    icon: "mdi:thermometer"
    device_class: "temperature"
    state_class: "measurement"
    accuracy_decimals: 1
    update_interval: 10s
    resolution: 9
    filters:
      - throttle: 1s
      - heartbeat: 5s
      - debounce: 0.1s
      - lambda: return x * (9.0/5.0) + 32.0; # Convert temperature readings to Fahrenheit.
      - filter_out: nan
    unit_of_measurement: "°F"
    on_value_range:
      - above: 125
        then:
          - logger.log:
              level: ERROR
              format: "Sensor reading too high! Value: %.2f"
              args: [x]
          - delay: 30s
          - logger.log: "Retrying sensor..."
    web_server:
      sorting_group_id: temperature_settings

# == Other Sensors ==
# Configure target temp, and other sensors
  - platform: template
    name: "Target Temp"
    id: target_temp_value
    icon: "mdi:earth"
    lambda: |-
      return id(target_temp).state;
    on_value:
      then:
        - globals.set:
            id: target
            value: !lambda 'return x;'
    update_interval: 5s
    unit_of_measurement: "°F"
    accuracy_decimals: 0
    web_server:
      sorting_group_id: state_settings
  - platform: template
    name: "Hysteresis"
    id: hysteresis_range_value
    icon: "mdi:earth"
    lambda: |-
      return id(hysteresis_range).state;
    on_value:
      then:
        - globals.set:
            id: hysteresis
            value: !lambda 'return x;'
    update_interval: 5s
    unit_of_measurement: "°F"
    accuracy_decimals: 1
    web_server:
      sorting_group_id: state_settings
  - platform: template
    name: "Freeze Recovery Temp"
    id: recovery_temp
    icon: "mdi:earth"
    lambda: |-
      return 32.0 + id(safety_margin).state;
    on_value:
      then:
        - globals.set:
            id: margin
            value: !lambda 'return x;'
    update_interval: 5s
    unit_of_measurement: "°F"
    accuracy_decimals: 0
    web_server:
      sorting_group_id: state_settings

# === Switches ===
# Control relays for heater and outside fans.
switch:
  - platform: restart
    name: "Restart"
  - platform: gpio
    pin: GPIO04
    id: heater
    name: "Heater (AC)"
    icon: "mdi:radiator"
    restore_mode: ALWAYS_OFF
    web_server:
      sorting_group_id: relay_settings
  - platform: gpio
    pin: GPIO05
    id: ext_fans
    name: "Outside Fans"
    icon: "mdi:fan"
    restore_mode: ALWAYS_OFF
    web_server:
      sorting_group_id: relay_settings
  - platform: template
    name: " Power Switch"
    id: power_switch
    icon: "mdi:power"
    optimistic: True
    restore_mode: RESTORE_DEFAULT_OFF
    turn_on_action:
      - logger.log: "Power Turned On!"
      - light.turn_on: 
          id: onboard_led
          brightness: 25%
          effect: Green
    turn_off_action:
      - logger.log: "Power Turned Off!"
      - light.turn_on:
          id: onboard_led
          brightness: 25%
          effect: Red
    web_server:
      sorting_group_id: state_settings

# === Scripts ===
# Define scripts for handling specific tasks, like freeze recovery.
script:
  - id: freeze_recovery_script
    mode: queued
    then:
      - lambda: |-
          id(freeze_recovery) = true;
          ESP_LOGW("freeze_recovery", "Starting freeze recovery process.");
      - switch.turn_off: heater
      - switch.turn_off: ext_fans
      - delay: 60s
      - while:
          condition:
            lambda: "return id(fins_temp).state <= 32.0 + id(safety_margin).state;"
          then: # Logic to recover from fins freezing.
            - delay: 60s
      - lambda: |-
          id(freeze_recovery) = false;
          ESP_LOGI("freeze_recovery", "Recovery process complete.");

# === Main Logic ===
# Control relays and temperature settings based on sensor data and state variables.
interval:
  - interval: 16s
    then:
      - display.page.show: page1
      - component.update: oled_display
      - delay: 3s
      - display.page.show: page2
      - component.update: oled_display
      - delay: 3s
      - display.page.show: page3
      - component.update: oled_display
      - delay: 2s
      - display.page.show: page4
      - component.update: oled_display
      - delay: 2s
      - display.page.show: page5
      - component.update: oled_display
      - delay: 2s
      - display.page.show: page6
      - component.update: oled_display
      - delay: 2s
      - display.page.show: page7
      - component.update: oled_display
      - delay: 2s
      - display.page.show: page8
      - component.update: oled_display
      - delay: 2s
  - interval: 15s
    then:
      - lambda: |-
          float upper_threshold = id(target) + id(hysteresis);
          float lower_threshold = id(target) - id(hysteresis);
          float outside_temp = id(ext_temp).state;
          float cooler_temp = id(room_temp).state;
          bool is_ac_active = id(ac_state);
          bool is_fan_active = id(fan_state);
          bool is_deadband_active = id(deadband_state);
          bool is_freezing = id(fins_temp).state <= 32.0;
          bool is_freeze_recovery_active = id(freeze_recovery);
          bool is_power_on = id(power_switch);
          bool is_ext_temp_cooling = outside_temp <= (cooler_temp - 20);
          bool is_room_above_upper = cooler_temp > upper_threshold;
          bool is_room_below_lower = cooler_temp <= lower_threshold;

          // Exterior Cooling Logic
          if (id(power_switch).state == 1) {
            if (is_ext_temp_cooling && is_room_above_upper && !is_freeze_recovery_active) {
              if (id(ac_state)) {
                ESP_LOGD("logic", "FANS are on; turning off heater.");
                id(heater).turn_off();
                id(ac_state) = 0;
              }
                id(ext_fans).turn_on();
                id(fan_state) = 1;
            } else {
              id(ext_fans).turn_off();
              id(fan_state) = 0;
            }
            // Heater Control Logic with Hysteresis
            if (is_deadband_active && !is_freezing && id(room_temp).state >= upper_threshold && !is_freeze_recovery_active)  {
              ESP_LOGW("logic", "Room temperature above upper threshold; turning on heater.");
              id(heater).turn_on();
              id(ac_state) = 1;
              // id(ext_fans).turn_off();
              // id(fan_state) = 0;
              id(deadband_state) = 0;
            } else if (!is_freezing && !is_deadband_active && id(room_temp).state > id(target_temp).state && !is_freeze_recovery_active) {
              ESP_LOGW("logic", "Room temperature above target; keeping heater on.");
              id(heater).turn_on();
              id(ac_state) = 1;
              // id(ext_fans).turn_off();
              // id(fan_state) = 0;
            } else if (is_room_below_lower && is_ac_active) {
              ESP_LOGW("logic", "Room temperature below lower threshold; turning off heater.");
              id(heater).turn_off();
              id(ac_state) = 0;
              id(deadband_state) = 1;
              id(ext_fans).turn_off();
              id(fan_state) = 0;
            } else if (is_room_below_lower && !is_ac_active) {
              ESP_LOGW("logic", "Room temperature below lower threshold; keeping heater off.");
              id(heater).turn_off();
              id(ac_state) = 0;
              id(deadband_state) = 1;
              id(ext_fans).turn_off();
              id(fan_state) = 0;
            }
          } else if (id(power_switch).state == 0) {
            ESP_LOGW("logic", "Power is off.  Controller is not running.");
              id(heater).turn_off();
              id(ac_state) = 0;
              id(deadband_state) = 0;
              id(ext_fans).turn_off();
              id(fan_state) = 0;
          }