Adax WiFi Thermostat

Great work!

Did you power the board by applying 3.3v on the pin marked 3.3v in your image or by some orher way?

Did you check the power requirements if powering from the programming header, i.e. can I power directly from e.g. an FTDI serial adapter or should I use external power?
The FTDI adapter can typically source around 75mA…

Yes 3.3v to the board. The maximum power draw is higher than 75mA. Most of the time the programming still works without “external” power as the wlan is not used during the process. Usually I am just too lazy to wire “external” power :slight_smile:

Thank you!
I’ll give this a try when the summer comes as I cannot get to the summer house during winter.

BIG thank you to all but especially to McTB and Linda!
I have now replaced the firmware of all my 5 radiators and made them locally controlled. If I had done this before the winter I would have saved several thousand swedish kronor (several hundred dollars) as my radiators went from off to 20 degree heating when the network went down during the coldest part of the winter.
Now everything is locally controlled with a persistant entity to know if only turn on the radiators when there is a risk of dew on the walls, or if we are here and they should stay on 20 degrees.

I have a few issues though but nothing I cannot live with.

  • Four of the radiators where of the original version (2016) equipped with ESP8266EX chipset and 1MByte flash. These radiators, with Lindas code, don’t react on the buttons but central controls works like a charm.
  • one of the radiators where newer with a ESP32-WROOM-32D module on board. I used McTB’s code and both central control as well as buttons work great but ESPHome think the radiator is offline even though it is connected and controlled by Home Assistant.

I will perhaps look into this in the summer vacation, and perhaps replace the 1MByte flash with a 2 or 4MByte dito at the same time to make OTA work.

Again: BIG thank you!

An update of my remaining issues:
The radiator not shown as connected in ESPHome Builder was not found because there was a setting adding the three last bytes from the MAC address in the naming. The ESPHome Builder tried to find the device named without the MAC bytes suffixed in the name and thus it was not present. The setting I removed was: “name_add_mac_suffix: True”

Anyone have a clue why the local buttons don’t work is welcome with suggestions. I have measured that they are connected to the pins assigned in the code.

Maybe a dumb question but is the child lock enabled?

No questions are dumb questions if you ask me :o)
I’m not at the site right now but can remotely access the web of each radiotor and the child lock is not activated on any of them.

I post my used code here if useful for anyone or if it gives a clue why the buttons are not working. This code is for an early ADAX radiator with native antennas and the ESP8266 chip directly on the main PCB, not on a separate module. PCB version 4.8 from 2016.
I don’t take the credit for this at all, it’s mostly the copy from above with some very minor changes.

substitutions:
  UNIT_NAME: "adax-heater-1"
  UNIT_PASSWORD: "xxxxxxxxxxxx"
  DEFAULT_CURRENT_IDLE: "0.009"
  DEFAULT_CURRENT_HEATING: "1.700"
  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"

  API_ID: "adax_api_id"
  WIFI_ID: "adax_wifi_id"

esphome:
  name: ${UNIT_NAME}
  friendly_name: ${UNIT_NAME}


esp8266:
  board: esp01_1m
 #   type: arduino
 #   type: esp-idf

logger:
  level: INFO

api:
  id: ${API_ID}

ota:
  - platform: esphome


wifi:
  id: ${WIFI_ID}
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  ap:
    ssid: ${UNIT_NAME}
    password: ${UNIT_PASSWORD}

i2c:
  sda: GPIO2
  scl: GPIO14
  scan: True    

pcf8574:
  - id: 'pcf8574_hub'
    address: 0x20
    pcf8575: False

captive_portal:

web_server:
  port: 80
  version: 3

time:
  - platform: homeassistant
    id: hassTime

globals:
  - id: interactTimeout
    type: int
    initial_value: "0"

  - id: displayText
    type: std::string
    initial_value: '"--"'
  - id: displayDigit
    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_ON}
  - 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: GPIO1
      mode: output
      inverted: True

###
  - platform: gpio
    id: heatingLed
    pin:
      pcf8574: pcf8574_hub
      number: 7
#      number: GPIO19
      mode: output
      inverted: True
  - platform: gpio
    id: heatingControl
    pin:
      number: GPIO15
      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: GPIO0
      mode: input
      inverted: True
    id: minusButton
    on_multi_click:
      - timing:
          - ON for at most 1s
        then:
          - lambda: |-
              id(modTargetTemp)->execute(false, false);
  - platform: gpio
    pin:
      number: GPIO3
      mode: input
      inverted: True
    id: plusButton
    on_multi_click:
      - timing:
          - ON for at most 1s
        then:
          - lambda: |-
              id(modTargetTemp)->execute(true, false);
  - platform: gpio
    pin:
      number: GPIO4
      mode: input
      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
    state_class: measurement
    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
    state_class: measurement
    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: 1
    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);
            }
          };


interval:
  - interval: 60s
    then:
      - lambda: |-
          id(currentPower).publish_state(id(currentPower).state);


  - interval: 200ms
    then:
      - lambda: |-
          unsigned long _Millis = millis();
          id(setLedStates)->execute(_Millis);
          id(updateDisplay)->execute(_Millis);



  - interval: 10ms
    then:
      - lambda: |-
          unsigned long _Millis = millis();
          id(driveDisplay)->execute(_Millis);

output:
  - platform: gpio
    id: "displaySegmentA"
    pin:
      pcf8574: pcf8574_hub
#      number: 2
      number: 0
      inverted: True
  - platform: gpio
    id: "displaySegmentB"
    pin:
      pcf8574: pcf8574_hub
      number: 1
      inverted: True
  - platform: gpio
    id: "displaySegmentC"
    pin:
      pcf8574: pcf8574_hub
      number: 2
#      number: 0
      inverted: True
  - platform: gpio
    id: "displaySegmentD"
    pin:
      pcf8574: pcf8574_hub
#      number: 4
      number: 3
      inverted: True
  - platform: gpio
    id: "displaySegmentE"
    pin:
      pcf8574: pcf8574_hub
#      number: 3
      number: 4
      inverted: True
  - platform: gpio
    id: "displaySegmentF"
    pin:
      pcf8574: pcf8574_hub
      number: 5
      inverted: True
  - platform: gpio
    id: "displaySegmentG"
    pin:
      pcf8574: pcf8574_hub
      number: 6
      inverted: True
  - platform: gpio
    id: "displayDigit1"
    pin:
      number: 13
      inverted: True
  - platform: gpio
    id: "displayDigit2"
    pin:
      number: 12
      inverted: True

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(setConnectionLedState)->execute(id(${WIFI_ID}).is_connected() && (id(${API_ID}).is_connected() || _LedOn));
          } else {
            id(setHeatingLedState)->execute(false);
            id(setConnectionLedState)->execute(false);
          };



  - id: driveDisplay
    parameters:
      millis: int
    then:
      - lambda: |-
          auto get_segments = [](char ch) -> uint8_t {
            switch(ch) {
              case '0': return 0b01111110;
              case '1': return 0b00110000;
              case '2': return 0b01101101;
              case '3': return 0b01111001;
              case '4': return 0b00110011;
              case '5': return 0b01011011;
              case '6': return 0b01011111;
              case '7': return 0b01110000;
              case '8': return 0b01111111;
              case '9': return 0b01111011;
              case '-': return 0b00000001;
              case '_': return 0b00001000;
              case 'F': return 0b01000111;
              case 'N': return 0b01110110;
              case 'O': return 0b01111110;
              default:  return 0b00000000;
            }
          };
          auto set_digit = [&](int digit, uint8_t ch) {
            id(displayDigit1).set_state(0);
            id(displayDigit2).set_state(0);
            id(displaySegmentA).set_state(0);
            id(displaySegmentB).set_state(0);
            id(displaySegmentC).set_state(0);
            id(displaySegmentD).set_state(0);
            id(displaySegmentE).set_state(0);
            id(displaySegmentF).set_state(0);
            id(displaySegmentG).set_state(0);
            id(displayDigit1).set_state(digit == 0);
            id(displayDigit2).set_state(digit == 1);
            uint8_t segments = get_segments(ch);
            id(displaySegmentA).set_state(segments & 0b01000000);
            id(displaySegmentB).set_state(segments & 0b00100000);
            id(displaySegmentC).set_state(segments & 0b00010000);
            id(displaySegmentD).set_state(segments & 0b00001000);
            id(displaySegmentE).set_state(segments & 0b00000100);
            id(displaySegmentF).set_state(segments & 0b00000010);
            id(displaySegmentG).set_state(segments & 0b00000001);
          };
          std::string text = id(displayText);
          while (text.length() < 2) {
            text = " " + text;
          }
          set_digit(id(displayDigit), text[id(displayDigit)]);
          id(displayDigit)++;
          if (id(displayDigit) > 1) {
            id(displayDigit) = 0;
          }
  - id: updateDisplay
    parameters:
      millis: int
    then: 
      - lambda: |-
         // Always-on display logic, no decimals
         if (id(displayMode).active_index() == 0) {
           // Display OFF
           id(displayText) = "  ";
           return;
         }

         // Heating OFF → show "--"
         if (id(climateControl).mode != CLIMATE_MODE_HEAT) {
           id(displayText) = "--";
           return;
         }

         // Heating ON → show current temperature (integer)
         float temp = id(climateControl).target_temperature;
         int val = static_cast<int>(round(temp));

         // Clamp just in case
         val = std::max(-9, std::min(99, val));

         char buf[3];
         snprintf(buf, sizeof(buf), "%02d", val);
         id(displayText) = buf;



  - 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);

Skimmed through the config and didn’t find anything obviously wrong with it. Are the GPIO pins correct?

I measured the buttons connection to the GPIO pins and I think they are correct. I will go to the house this weekend and make more tests. I will also try to replace the 1MByte flash with 4MByte flash on at least one of the radiators to make OTA work. After that I can more easily test changes in the code.
It is OK for me that the buttons not work as I anyway set everything from the web interface and as the radiators now are independent of internet connection to ADAX this will always work.
I will probably take one of the radiators modules home after the weekend if I don’t get it to work or if I run out of time.

Strange… I have replaced the flash memory with a 4MByte flash of the same kind as the original. I have used:

esp8266:
  board: nodemcuv2

and in the logs when compiling the logs says:

Processing adax-heater-1-4mb (board: nodemcuv2; framework: arduino; platform: platformio/[email protected])
--------------------------------------------------------------------------------
HARDWARE: ESP8266 80MHz, 80KB RAM, 4MB Flash

Linking .pioenvs/adax-heater-1-4mb/firmware.elf
RAM:   [=====     ]  45.1% (used 36956 bytes from 81920 bytes)
Flash: [====      ]  44.6% (used 465657 bytes from 1044464 bytes)
========================= [SUCCESS] Took 3.81 seconds =========================
INFO Build Info: config_hash=0x3b2f9bcd build_time_str=2026-04-17 20:59:12 +0200
INFO Successfully compiled program.
INFO Connecting to 10.10.6.167 port 8266...
INFO Connected to 10.10.6.167
INFO Uploading /data/build/adax-heater-1-4mb/.pioenvs/adax-heater-1-4mb/firmware.bin (469808 bytes)
INFO Compressed to 328196 bytes
ERROR Error binary size: Error: ESP has been flashed with wrong flash size. Please choose the correct 'board' option (esp01_1m always works) and then flash over USB.

I don’t understand why I still connot update OTA and why the log says “Flash: [==== ] 44.6% (used 465657 bytes from 1044464 bytes)” even though it says 4MByte in the hardware info line above…
What else should I do to be able to update OTA? Is there some problem in the code?
I tested this by installing using USB-FTDI adapter, the borad boots as it should but update OTA don’t work when recompiling exactly the same code again but this time with wireless upload when the board booted normally and is connected to HA.