isno.fr

OpenXtraflame

Complete reverse engineering of the Extraflame Black Label T009_3 Wi-Fi module, to replace its dependency on the Italian Omnyvore cloud with local control over MQTT.

Code: github.com/Isno-Open/OpenXtraflame

OpenXtraflame
Extraflame Black Label T009_3 Wi-Fi module, outside view with the cover closed. The "Compatible only with APP Total Control 2.0" marking is visible at the bottom.
Contents
  1. Context
  2. Command glossary
  3. Extraction
  4. Analysis
  5. Firmware
  6. Flash
  7. What’s next

Context

The Extraflame Teodora Evo pellet stove is controlled remotely through the official Total Control 2 mobile app (published by Extraflame on iOS and Android). Under the hood, an ESP32-based Black Label T009_3 Wi-Fi module talks to a proprietary cloud backend hosted by Omnyvore srl. The module sells for 289 € incl. VAT at Extraflame and had never been reversed publicly.

Two problems justify the effort:

The existing community projects (philibertc/micronova_controller, Jorre05/micronova) work around the problem by wiring an external ESP to the stove’s serial bus. Elegant, but the original module stays next to it and keeps talking to the cloud. Goal: reflash the original module to keep everything in place, with no visible modification.

Command glossary

A recap before the details. What every tool invoked further down does, with the real arguments used.

Identify the chip:

python -m esptool --port COM3 --before no-reset --after no-reset chip-id

Returns ESP32-D0WDQ6 rev v1.0 here and confirms the UART wiring works. --before no-reset / --after no-reset keep esptool from attempting a DTR/RTS reset that would take the module out of its manual download mode.

Read the chip’s protections (before dumping):

python -m espefuse --port COM3 summary

Shows the eFuses: FLASH_CRYPT_CNT (flash encryption), ABS_DONE_0/1 (Secure Boot), UART_DOWNLOAD_DIS, JTAG_DISABLE. Critical step: if the flash were encrypted, the dump would be unusable binary noise.

Full 4 MB dump:

python -m esptool --port COM3 --baud 460800 --before no-reset --after no-reset read-flash 0 0x400000 dump.bin

Reads the 4 MB of flash in one pass at 460,800 baud (about 100 s). This is the original firmware, to be locked away before anything else.

Restore from the dump:

python -m esptool --port COM3 --baud 460800 write-flash 0 dump.bin

Safety line: at any time, this puts the module back in factory state.

Full erase before the first custom flash:

python -m esptool --port COM3 --baud 460800 erase-flash

Erases the whole flash (partition table, apps, NVS, everything). Do it once before flashing the custom firmware, to start clean.

Set the ESP-IDF target:

idf.py set-target esp32

Configures the project to build for the classic esp32 chip (as opposed to esp32s3, esp32c3, and so on).

Build for the M5Stack ATOM Lite (devkit validation):

idf.py -DOPENXFLAME_TARGET=external build

Build for the Extraflame Black Label module (production):

idf.py -DOPENXFLAME_TARGET=blacklabel build

Flash and open the serial monitor:

idf.py -p COM3 -b 460800 flash monitor

Writes the binary to the chip, then shows the live logs. On Linux replace COM3 with /dev/ttyUSB0.

Serial monitor only:

screen /dev/ttyUSB0 115200            # Linux
python -m serial.tools.miniterm COM3 115200   # Windows

On Linux, quit with Ctrl-A K. On Windows, PuTTY in Serial mode does the job too.

Extraction

Hardware identification

Module disassembled. Green double-sided PCB with the COD.T009_3 silkscreen. Components identified under a loupe.

ESP32-WROOM-32 seen from above, the module's main chip

In the middle: the ESP32-WROOM-32 module (Espressif dual-core Xtensa LX6). This is the brain, and the part we are going to reprogram. Around it:

The 4 white SMD LEDs in a row and the chrome tactile reset button

No JTAG connector and no debug pad identified. The UART pads and IO0 (needed to put the chip into ROM UART boot mode) are not exposed on the surface. We will have to go straight to the ESP32 pins.

Step-by-step wiring (beginners welcome)

Good news: no soldering required. The module has an accessible 3-pin serial connector (the one that links it to the stove) and one exposed pin, which is enough to extract the whole firmware. Female Dupont wires held against the pins do the job. If you are new to this, test on an M5Stack ATOM Lite first (same ESP32 family, finished case, about 12 €): same procedure, less risk.

1. Identify the TWO connectors on the module

The module has two distinct connectors that must not be confused:

CN2, the stove connector (4 pins):

CN2 connector on the PCB with the 4 wires of the stove cable: green, brown, white, yellow

Close-up of the connector alone, cable unplugged, to see the plastic key and the 4 pins in a row:

Macro of CN2 unplugged: vertical black plastic housing with a D-shaped key, 4 gold pins clearly visible side by side

The other end of the cable plugs into the stove’s TA socket (Termostato Ambiente):

TA socket on the stove side where the 4 wires go in

Zoom on the 4 wires leaving the cable just before the TA socket, colour code still readable after a few years of use:

Stove cable close-up: grey sheath opened, 4 inner wires green (GND), brown, white, yellow (power), before the stove's TA socket

CN5, the 3-pin debug header (the one we dump from):

3-pin CN5 header next to the ESP32, the one used to connect the CH340G

Closer, the CN5 silkscreen is clearly visible right next to the ESP32-WROOM:

Macro of the 3-pin CN5 header: the CN5 silkscreen is visible at the bottom, the ESP32-WROOM just to the left

And a super macro on the 3 pins themselves (white triangle on the left = pin 1 marker, the GND pin):

Extreme zoom on the 3 CN5 pins: the silkscreened triangle on the left marks pin 1, GND

CN5 pinout:

PinModule roleNote
1GNDpin marked by the silkscreened ▲ triangle
2TXgoes to the CH340G’s RXD
3RXreceives from the CH340G’s TXD

Nice surprise of the reverse: the three signals needed for the dump (GND, RX and TX) are already on CN5. Nothing to solder on the PCB.

2. Power the module (with its own supply, not through the CH340G)

The module needs two things at the same time so we can dump it:

  1. Power, supplied by its original mains adapter (the one that usually powers it when connected to the stove). The module has a dedicated DC jack for this. It stays plugged in for the whole dump.
  2. A serial link, provided by the CH340G connected to the PC over USB.

Why not power it straight from the CH340G? Because the CH340G can only source a few tens of mA on its 3.3 V rail, while the Extraflame module draws more (internal regulation, LEDs, resistors). In practice: if you remove the original supply and try to power the module from the CH340G’s 3.3 V pin, the module does not start, or starts unreliably. Keep the original adapter plugged in.

Mental picture (what it looks like on the desk):

   230 V mains
       │
       ▼
  ┌─────────┐         ┌──────────────┐
  │ original│ DC jack │              │  3-pin serial connector
  │ power   │────────►│    MODULE    │◄────────────── Dupont ──────┐
  │ adapter │         │  Extraflame  │                              │
  └─────────┘         │              │                              │
                      └──────────────┘                              │
                                                          ┌────────▼────────┐
                                                          │     CH340G      │
                                                          │  (3.3 V mode)   │
                                                          └────────┬────────┘
                                                                   │ USB
                                                                   ▼
                                                                  PC

3. Wire the CH340G in 3.3 V mode (mandatory)

⚠️ The CH340G must be in 3.3 V mode, otherwise nothing works properly. On most modules there is a jumper to move (a small plastic shunt between two pins), or a solder jumper on the compact models. Some have a mini switch. It is the first thing to check before wiring anything.

(To do: add a photo of the 3.3 V jumper on my CH340G, to be taken at the next teardown.)

What happens when the logic level is wrong:

Three female Dupont wires for the UART (plus a 4th for IO0↔GND, detailed in step 4):

CH340GCN5 pinWire in my photo (adapt to your kit)
GND1 (module GND)red
RXD2 (module TX)yellow
TXD3 (module RX)blue

⚠️ Classic UART crossover: the CH340G’s TXD goes to the module’s RX, the CH340G’s RXD goes to the module’s TX. It is trap number one in TTL serial. Just remember that TX talks to RX and vice versa.

Push the female Dupont wires straight into the module’s connector. They hold by themselves.

Three female Dupont wires held on CN5 during the dump: red on pin 1 GND, yellow on pin 2 module TX, blue on pin 3 module RX; ESP32-WROOM-32 label readable right next to them

Overview of the setup with both sources connected in parallel: the DC jack of the original adapter (black cable entering the module from below), and the CH340G on USB-C (on the right) bringing the serial link through the 3 red/yellow/blue Dupont wires (same colours on both ends, no visual crossover to remember).

Complete setup during the dump: Extraflame module on the left with the power jack connected, CH340G on the right on USB-C, 3 Dupont wires red (GND) / yellow (module TX = CH340G RXD) / blue (module RX = CH340G TXD) between them; the module's green LED lit = power OK

First test before reaching for esptool: open a serial monitor on the CH340G (screen /dev/ttyUSB0 115200 on Linux, PuTTY on Windows in Serial mode, COM3 at 115200 8N1), then unplug and replug the module’s power while keeping the IO0↔GND wire of step 4 held in place.

What I actually saw scrolling in the monitor on the first successful attempt:

rst:0x1 (POWERON_RESET),boot:0x3 (DOWNLOAD_BOOT(UART0/UART1/SDIO_REI_REO_V2))
waiting for download

Two lines only, then silence. That is the ESP32 ROM bootloader saying “I am in download mode, send your commands”. On another reset I also saw the equivalent code boot:0xf (uart_boot(uart0)); the ESP32 varies with the OTP configuration and the chip revision, and both mean the same thing: download mode ready.

Symptoms seen when the wiring was wrong:

Once the two lines boot:0x3 and waiting for download show up, quit the serial monitor (Ctrl-A K in screen, close PuTTY on Windows) to free the COM3 / /dev/ttyUSB0 port, then run esptool (next step).

4. Put the module into “download” mode (IO0↔GND wire required)

To flash or dump an ESP32, it has to boot in download mode (instead of the normal mode that starts the firmware). On most consumer ESP32 boards (NodeMCU, WROOM devkits, and so on) there is a BOOT button to press during the reset: that button ties a special ESP32 pin (GPIO0) to ground, which tells the chip “no firmware, enter download mode”.

On the Extraflame module, no BOOT button is wired and GPIO0 is not exposed on a header. So you need to put an extra Dupont wire directly on the IO0 pad of the ESP32-WROOM and hold it against GND during the reset. Without it, the module boots normally (boot:0x13 SPI_FAST_FLASH_BOOT) and the Extraflame firmware starts: esptool cannot talk to it.

Complete download mode setup: yellow Dupont wire placed on the ESP32-WROOM's IO0 pad at the top right, its free female end to be tied to GND during the reset; the 3 other UART wires (red/yellow/blue) stay on CN5 at the bottom, original power DC jack on the left, CH340G on USB-C on the right

Zoom on the exact move: the male Dupont pin of the yellow wire is placed against the IO0 pad of the ESP32-WROOM32 (flush with the left side of the chip), and the other end of the same wire is pushed into pin 1 (GND) of the CN5/CN6 connector at the bottom left. It is a temporary contact, you can hold it by hand during the hard reset.

Macro view of the ESP32-WROOM32: the yellow wire's male Dupont pin placed directly against the IO0 pad (left side of the chip), the wire going back down to CN6 pin 1 GND at the bottom left, the red/yellow/blue CN5 UART wiring visible below

In practice, to enter download mode with this module:

  1. The CH340G is connected in 3.3 V mode to CN5 for the 3 UART signals (step 3 done).
  2. Add a 4th Dupont wire: one end placed and held on the IO0 pad of the ESP32-WROOM (at the edge of the chip), the other tied to GND (the rail shared with CN5 pin 1, or any other ground point on the PCB).
  3. The serial monitor is closed (COM3 / /dev/ttyUSB0 free).
  4. Keeping IO0 held at GND, unplug and replug the module’s original power adapter (hard reset).
  5. Check in the serial monitor that the line boot:0x3 (DOWNLOAD_BOOT…) (or the equivalent boot:0xf uart_boot) and then waiting for download appear. Once that trace is seen, you can release the IO0 wire: the chip stays in download mode until the next power cut.
  6. The module is ready for esptool.

Symptom if you forget the IO0 wire: you see boot:0x13 SPI_FAST_FLASH_BOOT and then the Extraflame Black Label T009_3 firmware boot logs scroll. Download mode was not armed. Cut the power again, holding IO0 at GND this time.

5. The complete boot log of the original firmware

If you forget the IO0 wire during the reset (or just out of curiosity), the module boots normally and the Extraflame firmware scrolls in the serial monitor. That log is valuable: it reveals the ESP-IDF version used, the partition table and the application task names, all without opening Ghidra.

Normal boot mode (boot:0x13 SPI_FAST_FLASH_BOOT, Extraflame firmware starting):

$ screen /dev/ttyUSB0 115200
rst:0x1 (POWERON_RESET),boot:0x13 (SPI_FAST_FLASH_BOOT)
configsip: 0, SPIWP:0xee
clk_drv:0x00,q_drv:0x00,d_drv:0x00,cs0_drv:0x00,hd_drv:0x00,wp_drv:0x00
mode:DIO, clock div:2
load:0x3fff0018,len:4
load:0x3fff001c,len:5992
load:0x40078000,len:14352
load:0x40080400,len:3648
entry 0x400806a4
I (30) boot: ESP-IDF v4.3-dirty 2nd stage bootloader
I (30) boot: compile time 09:14:41
I (30) boot: Enabling RNG early entropy source...
I (35) boot: SPI Speed      : 40MHz
I (39) boot: SPI Mode       : DIO
I (43) boot: SPI Flash Size : 4MB
I (47) boot: Partition Table:
I (51) boot: ## Label            Usage          Type ST Offset   Length
I (58) boot:  0 nvs              WiFi data        01 02 00009000 00005000
I (66) boot:  1 otadata          OTA data         01 00 0000e000 00002000
I (73) boot:  2 phy_init         RF data          01 01 00010000 00001000
I (81) boot:  3 factory          factory app      00 00 00020000 00180000
I (88) boot:  4 ota_0            OTA app          00 10 001a0000 00180000
I (96) boot:  5 ota_1            OTA app          00 11 00320000 00080000
I (103) boot:  6 nvs_key          NVS keys         01 04 003a0000 00001000
I (111) boot: End of partition table
I (115) esp_image: segment 0: paddr=0001a020 vaddr=3f400020 size=1c1e8h
I (2100) cpu_start: Starting scheduler on APP CPU.
I (2110) MAIN: Extraflame Black Label T009_3 firmware boot
I (2110) MAIN: Loading credentials from NVS...
I (2120) MAIN: secure_code loaded, stove_model loaded
I (2130) WIFI: STA connecting to SSID '<configured>'...
I (2140) MICRONOVA: UART_INIT: SERIAL_UART, 38400 bps, 8N1

These are native ESP-IDF logs (“I (millis) TAG: message” format), not ESPHome logs. The Extraflame application uses the Espressif logging API directly.

This output is valuable:

  1. It confirms the boot mode (boot:0x13 = normal SPI flash, boot:0xf = UART download)
  2. It reveals the ESP-IDF version (v4.3-dirty = v4.3 base plus proprietary patches)
  3. It lists the partition table in clear (no need to rebuild it from the dump)
  4. It shows the application logs (task names, events) that help map the firmware

Accidental reset before flashing (a risk to know): if you are in boot mode and press reset, the ESP32 falls back to the ROM UART bootloader after a few seconds (waiting for download shows up again). As long as the CH340G stays properly connected, you can run esptool.py as many times as you like.

Firmware extraction

Wiring in place, UART boot mode confirmed (boot:0x3 DOWNLOAD_BOOT then waiting for download in the serial monitor), we can move on to esptool. Real session under Windows PowerShell, done with esptool 5.3 installed through pip:

Zoom on the ESP32-WROOM with the contact wires: no soldering, everything holds by pressure

Step A: identify the chip (confirms the serial connection works):

My real session, warnings included (I was used to the old chip_id syntax with no_reset, esptool v5 politely reminds me):

PS> python -m esptool --port COM3 --baud 115200 --before no_reset --after no_reset chip_id

Warning: Deprecated: Choice 'no_reset' for option '--before' is deprecated. Use 'no-reset' instead.
Warning: Deprecated: Choice 'no_reset' for option '--after' is deprecated. Use 'no-reset' instead.
Warning: Deprecated: Command 'chip_id' is deprecated. Use 'chip-id' instead.
esptool v5.3.0
Note: Pre-connection option "no-reset" was selected. Connection may fail
if the chip is not in bootloader or flasher stub mode.
Connected to ESP32 on COM3:
Chip type:          ESP32-D0WDQ6 (revision v1.0)
Features:           Wi-Fi, BT, Dual Core + LP Core, 240MHz,
                    Vref calibration in eFuse, Coding Scheme None
Crystal frequency:  40MHz
MAC:                4c:eb:d6:xx:xx:xx

Stub flasher running.

Warning: ESP32 has no chip ID. Reading MAC address instead.
MAC:                4c:eb:d6:xx:xx:xx

Staying in bootloader.
PS>

Three things to note in this output:

The --before no-reset --after no-reset options are critical in our setup: by default esptool tries to reset the chip through DTR/RTS, but our Extraflame module is already in download mode by itself. If it attempts a reset, we leave the mode and have to cut the power again.

Chip identified: ESP32-D0WDQ6 revision v1.0, MAC 4c:eb:d6:xx:xx:xx.

Check that the firmware is not encrypted (critical step). Before dumping, we want to know whether the flash content is in clear or encrypted with Flash Encryption (an Espressif protection where every flash block is encrypted with a key stored in the eFuses, unreadable without that key). If it is encrypted, a dump is useless: we would get binary noise.

The espefuse summary command reads the chip’s eFuses and shows the state of every protection:

PS> python -m espefuse --port COM3 summary

espefuse v5.3.0
Connected to ESP32 on COM3:
Chip type:          ESP32-D0WDQ6 (revision v1.0)

Flash fuses:
FLASH_CRYPT_CNT (BLOCK0)     Flash encryption is enabled if this
                             field has an odd number of bits set  = 0 R/W (0b0000000)
FLASH_CRYPT_CONFIG (BLOCK0)  Flash encryption config              = 0 R/W (0x0)

Security fuses:
UART_DOWNLOAD_DIS (BLOCK0)   Disable UART download mode           = False R/W (0b0)
ABS_DONE_0 (BLOCK0)          Secure boot V1 is enabled            = False R/W (0b0)
ABS_DONE_1 (BLOCK0)          Secure boot V2 is enabled            = False R/W (0b0)
JTAG_DISABLE (BLOCK0)        Disable JTAG                         = False R/W (0b0)

Everything we want to see in this report:

Extraflame enabled none of the protections on this module, probably because the secure_code and stove_model in the NVS are enough for their cloud pairing logic, and securing the flash would have complicated their OTA. A gift for us: the dump will give a binary that can be analysed directly.

Had any of those flags been True, the story would have been very different (a hardware attack with a ChipWhisperer at about 2,000 €, or giving up).

Step B: full 4 MB dump at 460,800 baud:

PS> python -m esptool --port COM3 --baud 460800 --before no-reset --after no-reset read-flash 0 0x400000 ~\Desktop\extraflame_dump.bin

esptool v5.3.0
Connected to ESP32 on COM3:
Chip type:          ESP32-D0WDQ6 (revision v1.0)
MAC:                4c:eb:d6:xx:xx:xx

Stub flasher running.
Changing baud rate to 460800...
Changed.

Configuring flash size...
Read 4194304 bytes from 0x00000000 in 100.1 seconds (335.3 kbit/s)
to '~\Desktop\extraflame_dump.bin'.

Staying in bootloader.

4,194,304 bytes (exactly 4 MB) extracted in 100.1 seconds at 335 kbit/s. The final Staying in bootloader confirms the chip stayed in download mode (thanks to --after no-reset); another command could follow without cutting the power.

Immediate backup before any manipulation: copy of extraflame_dump.bin to the NAS plus a USB stick. The rule is simple: never touch the original dump, always work on copies.

Note for Linux and macOS users: the commands are identical, except the port name (/dev/ttyUSB0 instead of COM3) and the destination path (~/Desktop/extraflame_dump.bin).

Analysis

Partition analysis

Classic ESP-IDF layout with two OTA partitions:

# Name,   Type, SubType,  Offset,   Size
nvs,      data, nvs,      0x9000,   0x5000
otadata,  data, ota,      0xe000,   0x2000
app0,     app,  ota_0,    0x10000,  0x180000
app1,     app,  ota_1,    0x190000, 0x180000
spiffs,   data, spiffs,   0x310000, 0xE0000
nvs_key,  data, nvs_keys, 0x3F0000, 0x1000

String extraction

The old reflex before Ghidra: strings on ota0. In 60 key lines:

Omnyvore cloud backend

The strings also reveal the backend that talks to the Total Control 2 app: mqtt.extraflame.it:8883 (TLS MQTT broker), operated by the Italian company Omnyvore srl. Every Black Label module authenticates with this broker using two values stored in the secret1 NVS partition:

The firmware also embeds an X.509 CA certificate used to validate mqtt.extraflame.it over TLS. Certificate analysis:

Subject : /C=IT/ST=Vicenza/L=Vicenza/O=Omnyvore/OU=Tech Ops/CN=omnyvore.com
Issuer  : same (self-signed)
Valid   : 2017 -> 2027

It is a self-signed CA whose private key stays at Omnyvore. That detail closes the door on the “local fake MQTT cloud without touching the module” idea:

Verdict: no clean fake cloud is possible without reflashing the module. In other words, if Omnyvore shuts down tomorrow, no DNS trick will save a module left on the original firmware. The only lasting way is the custom firmware (this project), which replaces the MQTT layer and leaves the Omnyvore loop once and for all.

The survival stake is real: if Omnyvore closes tomorrow (a small company, no service guarantee), every Extraflame sold since 2018 loses its remote control. A local firmware is also long-term insurance for the installed base.

Ghidra decompilation

Ghidra 12.x supports Xtensa natively (the processor of classic ESP32s), no more community plugin needed. Trick to recover ESP-IDF symbols: functions log their own name through ESP_LOGx(). If the string “uart_set_pin” is referenced by a function, that function IS uart_set_pin. That is anchoring by log strings.

Key functions located:

gpio_set_level      @ 0x400d98f4
uart_set_pin        @ 0x400d8740
uart_param_config   @ 0x400d88f4
uart_driver_install @ 0x400d90fc
uart_write_bytes    @ 0x400d8be8
uart_read_bytes     @ 0x400d8c64

QEMU emulation plus GDB

Rather than risking a brick on the real hardware, boot the firmware under QEMU-Xtensa and capture the low-level calls through GDB remote.

(gdb) break *0x400d8740        # uart_set_pin
(gdb) continue

Breakpoint hit at 0x400d8740
  a2 (uart_num) = 0x1
  a3 (tx_pin)   = 0x17    ; = GPIO 23
  a4 (rx_pin)   = 0x5     ; = GPIO 5

Result captured without opening the module:

Mapping the 4 LEDs

By cross-checking the runtime logs with the meaning of the Extraflame LEDs (the user manual names POWER, BLE, WI-FI, SERVER), and confirming every bit in the breakpoints on gpio_set_level(), we get the following mapping:

The module has 4 white SMD LEDs and 1 tactile reset button, nothing else on the front. The mapping:

LED silkscreenESP32 GPIOOriginal role
POWERGPIO 25lit as long as the module is powered
BLEGPIO 26Bluetooth (provisioning through the Total Control app)
WI-FIGPIO 32STA connected to the router
SERVERGPIO 33Omnyvore MQTT cloud connection active

Active high (gpio_set_level(pin, 1) lights the LED, confirmed by the patterns observed in QEMU at boot).

The 5th GPIO toggling in QEMU (GPIO 22) is the reset button, not a hidden LED: its direction is an input with a pull-up handled by the firmware, which looks like an output from the breakpoint’s point of view but has nothing to do with a light source.

The custom firmware reuses the same 4 LEDs with their original meaning (POWER always on, WIFI while STA is up, SERVER when MQTT to the local Mosquitto is up, BLE in SoftAP provisioning mode). A user who reflashes the module keeps a set of LEDs that makes sense, rather than an unreadable home-made pattern.

A first round of smoke: QEMU with no stove in front

Before having access to the real bus, I set breakpoints on the 2 functions in QEMU and let it run for 60 seconds:

(gdb) shell grep -c "READ hit" trace.log
706
(gdb) shell grep -c "WRITE hit" trace.log
0

706 reads for 0 writes. At first I thought the module was listening passively (a slave, waiting for the stove to talk). That was a partial reading: with no stove in front on the QEMU side, every uart_read returns empty, which looks like “passive listening” when in truth the module simply has nothing to decode. The full reverse plus the field contradict that interpretation: the module is the RWMS master, it polls actively, exactly like the community implementations (philibertc/micronova_controller, Jorre05/micronova, agua-iot). Details in the protocol section below.

The Micronova protocol, register side

Micronova is a generic mainboard used by several Italian pellet stove brands (Extraflame, EdilKamin, LAMINOX, Freepoint, Karmek One). After Ghidra reverse of the original firmware plus live validation on my Teodora Evo I_VENT, here is the real physical and logical layer of the bus:

Physical layer (to be respected in code, otherwise the board stays silent):

Role: the Wi-Fi module is the RWMS master (Read/Write Micronova Serial). It polls the stove cyclically, the stove answers. Passive listening yields 0 frames.

Frame format (RWMS protocol, additive checksum):

Register addresses (standard Micronova, documented by the community and validated empirically on the Teodora Evo):

AddressRegisterDirectionEncoding
0x01ambient tempread°C × 2 (raw / 2 for °C)
0x03water tempread°C × 2 (absent on air models)
0x21stove stateread/write0=OFF 1=Start 4=WORK 7=Standby 8=Alarm
0x34flame powerread%
0x3Cwater pressurereadbar × 10
0x3Efumes tempread°C raw
0x7Dtemp setpointread/write°C
0x7Fpower setpointread/writestep 1..5
0x9Dtemp activeread°C
0x9Fpower activereadstep 1..5

Commands = writes to register 0x21:

Reference document kept in sync with the firmware: docs/PROTOCOLE-MICRONOVA.md in the GitHub repo also covers the full SOTA2 protocol (SLIP framing, 8-byte esptool header, SYNC handshake, opcode table 0x02..0xDE), the state machine, the retry timings, the chrono and date EEPROM registers, and the reverse points still open.

Stove families: the original firmware handles several types through stove_type_t (I_CALD boiler, I_IDRO hydro, I_VENT air, plus variants 2/3/4/5). OpenXtraflame keeps the type at boot from the secret1 partition of the original dump, to stay compatible with the same registers filled by the stove.

Firmware

Custom firmware

The OpenXtraflame firmware implements today:

Still to write:

QEMU validation

QEMU-Xtensa boots OpenXtraflame. UART0 = console, UART1 = TCP socket 4449 simulating the Micronova bus. A Python simulator plays the master.

[Master] Test 1 - Read RAM_TAMB (0x30)
  TX: 00 30
  RX: 00 ff                → value=0, checksum ok ✓
[Master] Test 2 - Write RAM_TAMB (0x30) = 42
  TX: b0 2a d5
  RX: 2a d5                → ACK ok, value written ✓
[Master] Test 3 - Re-read RAM_TAMB (0x30)
  TX: 00 30
  RX: 2a d5                → value=42, RAM shadow persists ✓

3 critical validation points confirmed: register addressing, frame format, UART timing.

Flash

Preliminary test on the M5Stack ATOM Lite (External target)

Before reflashing the original module, the whole firmware chain is validated on an M5Stack ATOM Lite (ESP32-PICO in a finished case, about 12 €, External target, TARGET=external). Wiring:

With the stove physically next to it, it can also be stubbed with a simple Python script playing the Micronova master on /dev/ttyUSB1 (the one from the QEMU validation above, reused on real hardware).

Build and flash:

$ idf.py set-target esp32
$ idf.py -DOPENXFLAME_TARGET=external build
$ idf.py -p /dev/ttyUSB0 -b 460800 flash monitor

What I check on the devkit before touching the Black Label:

Flashing the Black Label module

Once the External build is validated, same procedure on the original module with OPENXFLAME_TARGET=blacklabel. Two precautions:

  1. The extraflame_dump.bin saved at the very beginning stays in the drawer. If anything goes wrong, the factory firmware comes back with a simple esptool --port COM3 --baud 460800 write-flash 0 extraflame_dump.bin.
  2. The first boot of the custom firmware writes a fresh, empty NVS. The original secure_code and stove_model are lost on the Omnyvore cloud side (unrecoverable, but irrelevant since we will not talk to Omnyvore anymore). They remain in the binary dump, so they come back on a rollback.
$ idf.py -DOPENXFLAME_TARGET=blacklabel build
$ python -m esptool --port COM3 --baud 460800 erase-flash
$ idf.py -p COM3 -b 460800 flash monitor

Plugged back into the stove, the module then shows its 4 LEDs with the new meaning (POWER on, WI-FI in STA once the router is joined, SERVER when the local Mosquitto answers) and the stove appears in Home Assistant through MQTT Discovery.

Useful everyday commands

Once the module is in STA on the network, most operations happen from the web UI, and the CH340G is no longer needed. Here are the most used commands, worth a bookmark.

Follow the firmware logs live (from the PC, module still connected over serial):

python -m serial.tools.miniterm COM3 115200

The firmware logs with the classic ESP_LOGI/W/E. Every Micronova frame received plus the Wi-Fi and MQTT events scroll in real time.

Live Micronova log from the browser (once the module is in STA, without the CH340G):

OTA from a URL (without going through the PC):

OTA with curl (a script without a browser, useful for CI):

curl -X POST --data-binary @openextraflame.bin \
     -H "Content-Type: application/octet-stream" \
     http://192.168.20.11/ota/upload

Rollback to the previous version (if an OTA introduced a regression):

Factory reset (wipe the Wi-Fi and MQTT configuration and go back to the provisioning SoftAP):

Restore the original Extraflame firmware (last resort, with the dump from the beginning):

python -m esptool --port COM3 --baud 460800 write-flash 0 extraflame_dump.bin

What’s next

🎉 RC1 update, night of 8 to 9 July 2026

v0.1.0-rc1 published on GitHub with Blacklabel and External tarballs ready to flash. Since then, v0.1.0-rc3 adds the Maintenance tab, technical parameters Pr01 to Pr30 in read and write, alarm history, hopper refill prediction, and per-target OTA images.

Extraflame Omnyvore cloud reversed end to end (Black Label only):

The official TotalControl 2 app keeps seeing the stove online alongside the local MQTT bridge to HA. Verified end to end: command sent from the mobile app → Extraflame server → our ESP32 firmware → Micronova EEPROM → physical stove.

Safe mode plus boot loop detector:

New HA Discovery sensors: uptime, last boot, cloud enabled/connected, safe mode at next boot.

httpd wildcard fix along the way: POST /api/stove/power/N had always returned 404 (httpd_uri_match_wildcard missing), now OK.

Already in place

Done since the first write-up

Next features

Parts

Prices as observed at the time of the project. No affiliate links, I earn nothing.