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…
McTB
February 8, 2026, 11:15pm
22
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
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.
McTB
April 6, 2026, 10:04pm
26
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);
McTB
April 14, 2026, 3:19pm
29
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.