Adax WiFi Thermostat

I have created a configuration for an Adax WiFi thermostat, board version 5.2a. It uses an ESP32-WROOM-32D module, and the board has test pads for easy programming. I have these thermostats in Adax Neo radiators, but I think they can be used in other models too.

These boards do not measure current, so I have implemented an energy consumption estimation based on the radiator’s power. Also, I don’t know what type of NTC these radiators use, so the type is currently just a “guesstimate” :smiley: (any help on identifying the type would be appreciated).

Just posting here in case someone is interested. If there is interest, I can make some kind of documentation awailable.

Hopefully not too late - would be very interested to see any configuration/documentation you might have as I’d like to bring my 2 neo heaters into esphome (they have become very slow to respond via the cloud API).

Any hints on how to open up the control box part of the heater?

Thanks and regards

Sorry, I haven’t been checking here for a while.

The case opens by pushing the outer shell “out” with a small flathead screwdriver between the parts at the clips.

Here is the pinout for the test pads.

And here is the latest configuration I am using.

substitutions:
  DEFAULT_CURRENT_IDLE: "0.033"
  DEFAULT_CURRENT_HEATING: "0.033"
  DEFAULT_VOLTAGE: "230"
  NTC_SELF_HEATING_COMPENSATION: "-0.2"

  MIN_TEMPERATURE: "5.0"
  MAX_TEMPERATURE: "25.0"

  DISPLAY_TIMEOUT: "10000"

  DISPLAY_MODE_OFF: "Off"
  DISPLAY_MODE_INTERACT: "Interact"
  DISPLAY_MODE_ON: "On"

  DISPLAY_STATE_NONE: "0"
  DISPLAY_STATE_CURRENT_TEMP: "1"
  DISPLAY_STATE_TARGET_TEMP: "2"
  DISPLAY_STATE_TURN_ON: "3"
  DISPLAY_STATE_TURN_OFF: "4"

esphome:
  name: "adax-wifi"
  friendly_name: "Adax Wifi"
  name_add_mac_suffix: True

esp32:
  board: esp32dev
  flash_size: 8MB
  framework:
    type: arduino

logger:
  level: INFO

api:
  id: api_id

ota:
  - platform: esphome

wifi:
  id: wifi_id
  ap:
    password: "12345678"

captive_portal:

web_server:
  port: 80
  version: 3

time:
  - platform: homeassistant
    id: hassTime

external_components:
  source: github://asergunov/7segment_gpio

globals:
  - id: interactTimeout
    type: int
    initial_value: "0"
  - id: displayState
    type: int
    initial_value: ${DISPLAY_STATE_NONE}
  - id: displayFractional
    type: bool
    initial_value: "false"

number:
  - platform: template
    id: currentIdle
    name: "Current Idle"
    icon: mdi:flash-off-outline
    mode: BOX
    unit_of_measurement: "A"
    disabled_by_default: True
    entity_category: CONFIG
    min_value: 0
    max_value: 1
    step: 0.001
    restore_value: True
    initial_value: ${DEFAULT_CURRENT_IDLE}
    optimistic: True
    set_action: 
      then:
        - component.update: currentPower
  - platform: template
    id: currentHeating
    name: "Current Heating"
    icon: mdi:flash-outline
    mode: BOX
    unit_of_measurement: "A"
    disabled_by_default: True
    entity_category: CONFIG
    min_value: 0
    max_value: 12
    step: 0.001
    restore_value: True
    initial_value: ${DEFAULT_CURRENT_HEATING}
    optimistic: True
    set_action: 
      then:
        - component.update: currentPower
  - platform: template
    id: voltage
    name: "Voltage"
    icon: mdi:sine-wave
    mode: BOX
    unit_of_measurement: "V"
    disabled_by_default: True
    entity_category: CONFIG
    min_value: 100
    max_value: 300
    step: 0.1
    restore_value: False
    initial_value: ${DEFAULT_VOLTAGE}
    optimistic: True
    set_action: 
      then:
        - component.update: currentPower
  - platform: template
    id: temperatureCompensation
    name: "Temperature Compensation"
    icon: mdi:thermometer-minus
    mode: BOX
    unit_of_measurement: "°C"
    disabled_by_default: True
    entity_category: CONFIG
    min_value: -10
    max_value: 10
    step: 0.01
    restore_value: True
    initial_value: 0
    optimistic: True

select:
  - platform: template
    id: displayMode
    name: "Display Mode"
    icon: mdi:counter
    disabled_by_default: True
    entity_category: CONFIG
    optimistic: True
    restore_value: True
    options:
      - ${DISPLAY_MODE_OFF}
      - ${DISPLAY_MODE_INTERACT}
      - ${DISPLAY_MODE_ON}
    initial_option: ${DISPLAY_MODE_INTERACT}
  - platform: template
    id: ledMode
    name: "Led Mode"
    icon: mdi:led-on
    disabled_by_default: True
    entity_category: CONFIG
    optimistic: True
    restore_value: True
    options:
      - ${DISPLAY_MODE_OFF}
      - ${DISPLAY_MODE_INTERACT}
      - ${DISPLAY_MODE_ON}
    initial_option: ${DISPLAY_MODE_ON}

switch:
  - platform: factory_reset
    id: factoryReset
    internal: True
  - platform: gpio
    id: connectionLed
    pin:
      number: GPIO18
      mode: output
      inverted: True
  - platform: gpio
    id: heatingLed
    pin:
      number: GPIO19
      mode: output
      inverted: True
  - platform: gpio
    id: heatingControl
    pin:
      number: GPIO27
      mode: output
    on_turn_on:
      then:
        - component.update: currentPower
    on_turn_off:
      then:
        - component.update: currentPower
  - platform: template
    id: childLock
    name: "Child Lock"
    icon: mdi:lock
    disabled_by_default: True
    entity_category: CONFIG
    optimistic: True
    restore_mode: RESTORE_DEFAULT_OFF

button:
  - platform: restart
    name: "Restart"
    disabled_by_default: True
    entity_category: DIAGNOSTIC
  - platform: factory_reset
    name: "Factory Reset"
    disabled_by_default: True
    entity_category: DIAGNOSTIC

binary_sensor:
  - platform: gpio
    pin:
      number: GPIO22
      mode:
        input: True
      inverted: True
    id: minusButton
    on_multi_click:
      - timing:
        - ON for at most 1s
        then:
          - lambda: id(modTargetTemp)->execute(false, false);
  - platform: gpio
    pin:
      number: GPIO35
      mode:
        input: True
      inverted: True
    id: plusButton
    on_multi_click:
      - timing:
        - ON for at most 1s
        then:
          - lambda: id(modTargetTemp)->execute(true, false);
  - platform: gpio
    pin:
      number: GPIO34
      mode:
        input: True
      inverted: True
    id: okButton
    on_multi_click:
      - timing:
        - ON for at most 1s
        then:
          - lambda: id(setDisplayState)->execute(${DISPLAY_STATE_CURRENT_TEMP}, false);
      - timing:
        - ON for 3s to 10s
        then:
          - lambda: |-
              if (!id(childLock).state) {
                auto call = id(climateControl).make_call();
                if (id(climateControl).mode == CLIMATE_MODE_OFF) {
                  call.set_mode("HEAT");
                } else {
                  call.set_mode("OFF");
                }
                call.perform();
                if (id(climateControl).mode == CLIMATE_MODE_OFF) {
                  id(setDisplayState)->execute(${DISPLAY_STATE_TURN_OFF}, false);
                } else {
                  id(setDisplayState)->execute(${DISPLAY_STATE_TURN_ON}, false);
                }
              }
      - timing:
        - ON for at least 10s
        then:
          - lambda: if (!id(childLock).state) { id(factoryReset).turn_on(); }
  - platform: template
    name: "Heating"
    icon: mdi:radiator
    disabled_by_default: True
    condition:
      switch.is_on:
        id: heatingControl

sensor:
  - platform: adc
    id: tempSensorVoltage
    pin: A0
    filters:
      - sliding_window_moving_average: 
          window_size: 24
          send_every: 12
    update_interval: 5s
  - platform: resistance
    id: tempSensorResistance
    sensor: tempSensorVoltage
    configuration: DOWNSTREAM
    resistor: 91kOhm
    reference_voltage: 2.925
  - platform: ntc
    id: currentTemp
    name: "Current Temperature"
    icon: mdi:thermometer
    unit_of_measurement: "°C"
    device_class: temperature
    disabled_by_default: True
    accuracy_decimals: 1
    sensor: tempSensorResistance
    calibration:
      reference_temperature: 25°C
      reference_resistance: 10kOhm
      b_constant: 3950
    filters:
      - lambda: return x + ${NTC_SELF_HEATING_COMPENSATION} + id(temperatureCompensation).state;
  - platform: template
    name: "Current Power"
    id: currentPower
    unit_of_measurement: "W"
    accuracy_decimals: 1
    device_class: power
    disabled_by_default: True
    lambda: |-
      if (id(heatingControl).state) {
        return id(currentHeating).state * id(voltage).state;
      } else {
        return id(currentIdle).state * id(voltage).state;
      }
  - platform: total_daily_energy
    name: "Energy Consumption"
    power_id: currentPower
    unit_of_measurement: "kWh"
    device_class: energy
    disabled_by_default: True
    method: left
    accuracy_decimals: 3
    filters:
      - multiply: 0.001
  - platform: wifi_signal
    name: "Signal dB"
    icon: mdi:wifi-strength-3
    disabled_by_default: True
    entity_category: DIAGNOSTIC
  - platform: template
    id: targetTemperature
    name: "Target temperature"
    icon: mdi:thermometer-lines
    unit_of_measurement: "°C"
    device_class: temperature
    disabled_by_default: True
    accuracy_decimals: 1
    lambda: return id(climateControl).target_temperature;

text_sensor:
  - platform: wifi_info
    ip_address:
      name: "IP Address"
      icon: mdi:ip
      disabled_by_default: True
      entity_category: DIAGNOSTIC
    ssid:
      name: "Network SSID"
      icon: mdi:wifi
      disabled_by_default: True
      entity_category: DIAGNOSTIC
    mac_address:
      name: "MAC Address"
      icon: mdi:numeric
      disabled_by_default: True
      entity_category: DIAGNOSTIC

climate:
  - platform: thermostat
    id: climateControl
    name: "Thermostat"
    visual:
      min_temperature: ${MIN_TEMPERATURE}
      max_temperature: ${MAX_TEMPERATURE}
      temperature_step: 0.5
    sensor: currentTemp
    min_heating_off_time: 10s
    min_heating_run_time: 10s
    min_idle_time: 10s
    heat_deadband: 0.4
    heat_overrun: 0.0
    heat_action:
      - switch.turn_on: heatingControl
    idle_action:
      - switch.turn_off: heatingControl
    target_temperature_change_action: 
      then:
        - lambda: |-
            id(targetTemperature).publish_state(id(climateControl).target_temperature);
    on_control:
      - lambda: |-
          if (id(climateControl).mode == CLIMATE_MODE_HEAT) {
            if (x.get_mode() == CLIMATE_MODE_OFF) {
              id(setDisplayState)->execute(${DISPLAY_STATE_TURN_OFF}, false);
            } else if (id(climateControl).target_temperature != x.get_target_temperature()) {
              id(setDisplayState)->execute(${DISPLAY_STATE_TARGET_TEMP}, false);
            }
          } else {
            if (x.get_mode() == CLIMATE_MODE_HEAT) {
              id(setDisplayState)->execute(${DISPLAY_STATE_TURN_ON}, false);
            } else if (id(climateControl).target_temperature != x.get_target_temperature()) {
              id(setDisplayState)->execute(${DISPLAY_STATE_TARGET_TEMP}, false);
            }
          }

display:
- platform: 7segment_gpio
  id: lcd
  digit_pins: [14, 13]
  iterate_digits: True
  segment_pins: [4, 32, 33, 26, 25, 21, 23]
  compensate_brightness: True
  intensity: 0
  update_interval: 200ms

interval:
  - interval: 60s
    then:
      - lambda: id(currentPower).publish_state(id(currentPower).state);
  - interval: 200ms
    then:
      - lambda: |-
          unsigned long _Millis = millis();
          id(setLedStates)->execute(_Millis);
          if (_Millis - id(interactTimeout) > ${DISPLAY_TIMEOUT}) {
            id(displayState) = ${DISPLAY_STATE_NONE};
          }
          id(updateDisplay)->execute(_Millis);

script:
  - id: setHeatingLedState
    parameters:
      state: bool
    then: 
      - lambda: |-
          if (state) {
            if (!id(heatingLed).state) {
              id(heatingLed).turn_on();
            }
          } else {
            if (id(heatingLed).state) {
              id(heatingLed).turn_off();
            }
          }
  - id: setConnectionLedState
    parameters:
      state: bool
    then: 
      - lambda: |-
          if (state) {
            if (!id(connectionLed).state) {
              id(connectionLed).turn_on();
            }
          } else {
            if (id(connectionLed).state) {
              id(connectionLed).turn_off();
            }
          }
  - id: setLedStates
    parameters:
      millis: int
    then: 
      - lambda: |-
          auto _LedMode = id(ledMode).active_index();
          if ((_LedMode == 2) || ((_LedMode == 1) && ((millis - id(interactTimeout)) < ${DISPLAY_TIMEOUT}))) {
            bool _LedOn = (millis / 1000) % 2 == 0;
            id(setHeatingLedState)->execute(id(heatingControl).state || ((id(climateControl).mode == CLIMATE_MODE_HEAT) && _LedOn));
            id(setConnectionLedState)->execute(id(wifi_id).is_connected() && (id(api_id).is_connected() || _LedOn));
          } else {
            id(setHeatingLedState)->execute(false);
            id(setConnectionLedState)->execute(false);
          }
  - id: updateDisplay
    parameters:
      millis: int
    then: 
      - lambda: |-
          auto _displayMode = id(displayMode).active_index();
          int _displayState = id(displayState);
          if (_displayMode == 0) {
            _displayState = ${DISPLAY_STATE_NONE};
          } else {
            if (millis - id(interactTimeout) > ${DISPLAY_TIMEOUT}) {
              if (_displayMode == 1) {
                _displayState = ${DISPLAY_STATE_NONE};
              } else {
                _displayState = ${DISPLAY_STATE_CURRENT_TEMP};
              }
            }
          }
          switch(_displayState) {
            case ${DISPLAY_STATE_CURRENT_TEMP}:
            case ${DISPLAY_STATE_TARGET_TEMP}:
              float val;
              if (_displayState == ${DISPLAY_STATE_CURRENT_TEMP}) {
                val = id(climateControl).current_temperature;
              } else {
                val = id(climateControl).target_temperature;
              }
              val = round(val * 10.0f) / 10.0f;
              if (val < 0) {
                lcd->print("-0");
              } else {
                if ((((millis - id(interactTimeout)) / 1000) % 2) == id(displayFractional)) {
                  lcd->printf(0, "%02d", static_cast<int>(val));
                } else {
                  lcd->printf(0, "_%d", static_cast<int>(round(val * 10.0f)) % 10);
                }
              }
              break;
            case ${DISPLAY_STATE_TURN_ON}:
              lcd->print("ON");
              break;
            case ${DISPLAY_STATE_TURN_OFF}:
              lcd->print("OF");
              break;
            default:
              lcd->print("  ");
          }
  - id: setDisplayState
    parameters: 
      state: int
      fractional: bool
    then:
      - lambda: |-
          unsigned long _Millis = millis();
          id(displayFractional) = fractional;
          id(interactTimeout) = _Millis;
          id(displayState) = state;
          id(updateDisplay)->execute(_Millis);
  - id: modTargetTemp
    parameters: 
      up: bool
      large: bool
    then:
      - lambda: |-
          bool _displayFractional = false;
          if (id(displayState) == ${DISPLAY_STATE_TARGET_TEMP}) {
            if (!id(childLock).state) {
              double _current = id(climateControl).target_temperature;
              double _value = _current + (up ? (large ? 1.0f : 0.5f) : (large ? -1.0f : -0.5f));
              _value = round(_value * 10.0f) / 10.0f;
              _value = std::min(${MAX_TEMPERATURE}, std::max(${MIN_TEMPERATURE}, _value));
              _displayFractional = floor(_value) == floor(_current);
              auto call = id(climateControl).make_call();
              call.set_target_temperature(_value);
              call.perform();
            }
          }
          id(setDisplayState)->execute(${DISPLAY_STATE_TARGET_TEMP}, _displayFractional);

There is also a PCF8563 Real-time clock/calendar ic connected on gpio16/sda and gpio17/scl. I have not tested this as there is no use for me with it.