Projects/Lidl Balkonkraftwerk Hacking: verschil tussen versies
| (24 tussenliggende versies door dezelfde gebruiker niet weergegeven) | |||
| Regel 4: | Regel 4: | ||
|URL=https://www.lidl.nl/p/tronic-balkon-zonnepanelen-starterset-400-w/p100387536 | |URL=https://www.lidl.nl/p/tronic-balkon-zonnepanelen-starterset-400-w/p100387536 | ||
|contact=Polyfloyd | |contact=Polyfloyd | ||
|info= | |info=Lohnt sich | ||
|status= | |status=Production | ||
|Picture=Lidl-balkonkraftwerk.jpg | |Picture=Lidl-balkonkraftwerk.jpg | ||
}} | }} | ||
| Regel 28: | Regel 28: | ||
== Getting In == | == Getting In == | ||
[[File:Lidl-balkonkraftwerk-6.jpg|200px|thumb| | [[File:Lidl-balkonkraftwerk-6.jpg|200px|thumb|Test clamps hooked up]] | ||
The pad on the UART header farthest from the ESP is connected to a large copper plane. With a continuity test from a multimeter I was able to conclude that this is a ground plane. The two middle pins have thin traces leading to the ESP which means that these are the RX/TX lines. The last pad is probably VCC, I did not measure it. | The pad on the UART header farthest from the ESP is connected to a large copper plane. With a continuity test from a multimeter I was able to conclude that this is a ground plane. The two middle pins have thin traces leading to the ESP which means that these are the RX/TX lines. The last pad is probably VCC, I did not measure it. | ||
| Regel 34: | Regel 34: | ||
The board is powered from the DC/solar side. The Lidl Tuya app reports the DC voltage when the sun is hitting the panels, which usually is around 26 volts in slightly overcast weather conditions. The EWAY specs state that the maximum input voltage for this device is 60 volts. Hooking up some probes to the neatly exposed cable connections to a lab power supply with 20V made the board power up. | The board is powered from the DC/solar side. The Lidl Tuya app reports the DC voltage when the sun is hitting the panels, which usually is around 26 volts in slightly overcast weather conditions. The EWAY specs state that the maximum input voltage for this device is 60 volts. Hooking up some probes to the neatly exposed cable connections to a lab power supply with 20V made the board power up. | ||
[[File:Lidl-balkonkraftwerk-3-with-pads-marked.jpg|200px|thumb| | [[File:Lidl-balkonkraftwerk-3-with-pads-marked.jpg|200px|thumb|UART pads marked]] | ||
Connecting the UART to a USB-dongle gave ASCII output! Hurray! The baudrate is 115200, which is the default for ESP devices. | Connecting the UART to a USB-dongle gave ASCII output! Hurray! The baudrate is 115200, which is the default for ESP devices. | ||
| Regel 50: | Regel 50: | ||
It turned out that really the only kind of IO this device ever does is communicating over its UART1 peripheral (this is distinct from its debugging interface which is UART0). | It turned out that really the only kind of IO this device ever does is communicating over its UART1 peripheral (this is distinct from its debugging interface which is UART0). | ||
The peripheral connected to UART1 is wired as GPIO6=TX, GPIO7=RX. | |||
There is something connected to GPIO4 which according to the firmware dump seems to be a LED. However, there is no LED visible on the top side of the PCB. The only LED at all on this device is a status LED controlled by the MPTT which is multi-color. | |||
== Protocol == | |||
A basic communication frame consists of a 1 byte header that is 0xFA, followed by the contents and is terminated by a 1 byte inverted XOR-sum checksum and 0xFB. | |||
The communication is rather unreliable, validating the checksum is a must. | |||
=== Retrieving hardware/software info === | |||
<pre> | |||
>>> FA:01:06:00:F8:FB | |||
^^ checksum | |||
^^ ^^ cmd | |||
<<< FA:01:06:0B:04:00:20:1E:17:DE:FB | |||
^^ checksum | |||
^^ software version | |||
^^ hardware version | |||
^^ rated power * 100 Watt | |||
</pre> | |||
=== Reading sensor values === | |||
<pre> | |||
>>> FA:10:01:00:EE:FB | |||
^^ checksum | |||
^^ ^^ command | |||
<<< FA:10:14:12:09:F4:01:01:0B:52:04:29:01:84:01:D7:00:C2:00:00:00:00:00:F1:FB | |||
^^ checksum | |||
^^ ^^ ^^ ^^ energy produced since power-up, in watt-hours | |||
^^ ^^ temperature * 10 | |||
^^ ^^ dc current * 100, | |||
^^ ^^ dc voltage * 10 | |||
^^ ^^ dc power * 10 | |||
^^ operational status (0 = off, 8 = starting, 11 = active) | |||
^^ ^^ ac frequency * 10 | |||
^^ ^^ ac voltage * 10 | |||
</pre> | |||
== Esphome == | |||
[[File:Lidl-Balkonkraftwerk-HASS-Sensors.png|200px|thumb|Stats shown in Home Assistant]] | |||
The most up-to-date config that I use [https://git.polyfloyd.net/polyfloyd/esphome-config/src/branch/main/lidl-balkonkraftwerk.yaml can be found here] | |||
<pre> | |||
esphome: | |||
name: lidl-balkonkraftwerk | |||
friendly_name: Lidl Balkonkraftwerk | |||
platformio_options: | |||
board_build.flash_mode: dio | |||
esp32: | |||
variant: esp32c3 | |||
board: esp32-c3-devkitc-02 | |||
framework: | |||
type: esp-idf | |||
logger: | |||
hardware_uart: UART0 | |||
wifi: | |||
ssid: !secret wifi_ssid | |||
password: !secret wifi_password | |||
ap: | |||
web_server: | |||
captive_portal: | |||
ota: | |||
platform: esphome | |||
password: !secret ota_password | |||
mqtt: | |||
broker: mqtt.local | |||
discovery: false | |||
api: | |||
uart: | |||
- id: uart_microinv | |||
rx_pin: 7 | |||
tx_pin: 6 | |||
baud_rate: 115200 | |||
parity: none | |||
data_bits: 8 | |||
stop_bits: 1 | |||
debug: | |||
direction: BOTH | |||
text_sensor: | |||
- platform: template | |||
name: "Firmware Version" | |||
id: microinv_sfv | |||
icon: mdi:alpha-v | |||
- platform: template | |||
name: "Hardware Version" | |||
id: microinv_hdv | |||
icon: mdi:alpha-v | |||
- platform: template | |||
name: "Status" | |||
id: microinv_status | |||
icon: mdi:cog | |||
sensor: | |||
- platform: template | |||
id: microinv_cmd_0x0106 | |||
update_interval: 60s | |||
lambda: |- | |||
const char *TAG = "microinv_cmd_0x0106"; | |||
auto uart = id(uart_microinv); | |||
uint8_t discard; | |||
while (uart->available()) uart->read_byte(&discard); | |||
const uint8_t buf[] = {0xfa, 0x01, 0x06, 0x00, 0xf8, 0xfb}; | |||
uart->write_array(buf, sizeof(buf)); | |||
uart->flush(); | |||
uint8_t recv[11] = {0}; | |||
size_t nread = uart->read_array(recv, sizeof(recv)); | |||
if (memcmp(recv, "\xfa\x01\x06", 3)) { | |||
ESP_LOGW(TAG, "response header invalid"); | |||
return {}; | |||
} | |||
uint8_t chk = 0; | |||
for (int i = 1; i < sizeof(recv)-2; i++) { | |||
chk = chk ^ recv[i]; | |||
} | |||
chk = ~chk; | |||
if (chk != recv[sizeof(recv)-2]) { | |||
ESP_LOGW(TAG, "invalid checksum"); | |||
return {}; | |||
} | |||
id(microinv_sfv_rating).publish_state(recv[4] * 100); | |||
char ss[0xff] = {0}; | |||
int sfv = recv[8]; | |||
snprintf(ss, sizeof(ss), "%d.%d.%d", sfv / 100 + 1, (sfv / 10) % 10, sfv % 10); | |||
id(microinv_sfv).publish_state(std::string(ss)); | |||
int hdv = recv[7]; | |||
snprintf(ss, sizeof(ss), "%d.%d.%d", hdv / 100 + 1, (hdv / 10) % 10, hdv % 10); | |||
id(microinv_hdv).publish_state(std::string(ss)); | |||
return {}; | |||
- platform: template | |||
id: microinv_cmd_0x1001 | |||
update_interval: 5s | |||
lambda: |- | |||
const char *TAG = "microinv_cmd_0x1001"; | |||
auto uart = id(uart_microinv); | |||
uint8_t discard; | |||
while (uart->available()) uart->read_byte(&discard); | |||
const uint8_t buf[] = {0xfa, 0x10, 0x01, 0x00, 0xee, 0xfb}; | |||
uart->write_array(buf, sizeof(buf)); | |||
uart->flush(); | |||
uint8_t recv[25] = {0}; | |||
size_t nread = uart->read_array(recv, sizeof(recv)); | |||
if (memcmp(recv, "\xfa\x10", 2)) { | |||
ESP_LOGW(TAG, "response header invalid"); | |||
return {}; | |||
} | |||
uint8_t chk = 0; | |||
for (int i = 1; i < sizeof(recv)-2; i++) { | |||
chk = chk ^ recv[i]; | |||
} | |||
chk = ~chk; | |||
if (chk != recv[sizeof(recv)-2]) { | |||
ESP_LOGW(TAG, "invalid checksum"); | |||
return {}; | |||
} | |||
// recv[2] is unused | |||
float ac_voltage = (float)(recv[4] << 8 | recv[3]) / 10.0; | |||
float ac_frequency = (float)(recv[6] << 8 | recv[5]) / 10.0; | |||
// recv[7] is always 1 and in the original fw some kind of length indicator of following data. The number of DC inputs? | |||
uint8_t status = recv[8]; | |||
float dc_power = (float)(recv[10] << 8 | recv[9]) / 10.0; | |||
float dc_voltage = (float)(recv[12] << 8 | recv[11]) / 10.0; | |||
float dc_current = (float)(recv[14] << 8 | recv[13]) / 100.0; | |||
float temperature = (float)(recv[16] << 8 | recv[15]) / 10.0; | |||
int energy = recv[20] << 24 | recv[19] << 16 | recv[18] << 8 | recv[17]; | |||
// recv[21] and recv[22]: online or error status codes? | |||
id(microinv_ac_voltage).publish_state(ac_voltage); | |||
id(microinv_ac_frequency).publish_state(ac_frequency); | |||
id(microinv_dc_power).publish_state(dc_power); | |||
id(microinv_dc_voltage).publish_state(dc_voltage); | |||
id(microinv_dc_current).publish_state(dc_current); | |||
id(microinv_temperature).publish_state(temperature); | |||
id(microinv_energy).publish_state(energy); | |||
char status_unk[16] = {0}; | |||
snprintf(status_unk, sizeof(status_unk), "0x%02x", status); | |||
id(microinv_status).publish_state( | |||
status == 0x00 ? "Standby" : | |||
status == 0x08 ? "Testing" : | |||
status == 0x0b ? "Active" : | |||
status_unk); | |||
return {}; | |||
- platform: uptime | |||
name: Uptime | |||
- platform: template | |||
name: "Rated Capacity" | |||
id: microinv_sfv_rating | |||
device_class: power | |||
unit_of_measurement: W | |||
- platform: template | |||
name: "AC Voltage" | |||
id: microinv_ac_voltage | |||
state_class: "measurement" | |||
device_class: voltage | |||
unit_of_measurement: V | |||
icon: mdi:transmission-tower | |||
- platform: template | |||
name: "AC Frequency" | |||
id: microinv_ac_frequency | |||
state_class: "measurement" | |||
device_class: frequency | |||
unit_of_measurement: Hz | |||
icon: mdi:current-ac | |||
- platform: template | |||
name: "DC Power" | |||
id: microinv_dc_power | |||
state_class: "measurement" | |||
device_class: power | |||
unit_of_measurement: W | |||
- platform: template | |||
name: "DC Voltage" | |||
id: microinv_dc_voltage | |||
state_class: "measurement" | |||
device_class: voltage | |||
unit_of_measurement: V | |||
icon: mdi:flash | |||
- platform: template | |||
name: "DC Current" | |||
id: microinv_dc_current | |||
state_class: "measurement" | |||
device_class: current | |||
unit_of_measurement: A | |||
icon: mdi:flash | |||
- platform: template | |||
name: "Temperature" | |||
id: microinv_temperature | |||
state_class: "measurement" | |||
device_class: temperature | |||
unit_of_measurement: °C | |||
- platform: template | |||
name: "Energy Produced" | |||
id: microinv_energy | |||
state_class: total_increasing | |||
device_class: energy | |||
unit_of_measurement: Wh | |||
</pre> | |||
Huidige versie van 12 nov 2025 17:51
| Project Lidl Balkonkraftwerk Hacking | |
|---|---|
| Naam | Lidl Balkonkraftwerk Hacking |
| Beschrijving | Lohnt sich |
| Website | https://www.lidl.nl/p/tronic-balkon-zonnepanelen-starterset-400-w/p100387536 |
| Start | 2025-10-25 |
| Contact | Polyfloyd |
| Status | Production |
I bought a solar power set from the Lidl in Germany for just 200 euros. A decent deal for 370 Wp panels, a microinverter and some mounting brackets.
On problem: the microinverter runs Tuya, which is notoriously connected to Chinese clouds. The inverter can be used without setting up network connectivity and the Tuya app, but I would like to have stats from this device in Home Assistant.
So let's hack it!
Teardown
The OEM manufacturer and model is EWAY-VNV6204. Eway makes more power electronics such as car chargers. There are a couple more models in this series such as the EWAY-VNV6208 where the last number corresponds to the wattage.
What is really neat is that the MCU responsible for network connectivity is an ESP32-C3 which I am familiar with. Moreover, it has a 4-pin header right next to it which is most likely UART and a physical push button labeled "BURN" which may very well be a button to put the ESP in flashing mode.
Getting In

The pad on the UART header farthest from the ESP is connected to a large copper plane. With a continuity test from a multimeter I was able to conclude that this is a ground plane. The two middle pins have thin traces leading to the ESP which means that these are the RX/TX lines. The last pad is probably VCC, I did not measure it.
The board is powered from the DC/solar side. The Lidl Tuya app reports the DC voltage when the sun is hitting the panels, which usually is around 26 volts in slightly overcast weather conditions. The EWAY specs state that the maximum input voltage for this device is 60 volts. Hooking up some probes to the neatly exposed cable connections to a lab power supply with 20V made the board power up.

Connecting the UART to a USB-dongle gave ASCII output! Hurray! The baudrate is 115200, which is the default for ESP devices.
Holding the BURN button while power cycling triggers the ESP to enter its ROM flashing mode, with which I was able to make a dump of the whole 4M firmware.
Reverse Engineering the ROM
Shoutout to Shiz from Revspace for helping me to get started with Ghidra! o7
I then plowed through the ROM for a few days. I had never done reverse engineering for binaries, so this was a first fun project to discover that. It's quite fun to do. Every function and variable that I identify gives a little dopamine hit. It's a flow-state zen which is similar to programming. I hit a few dead ends in the firmware until I figured out the best way to approach this:
- Find the ESP-IDF functions by searching for their log strings and printfs.
- Find out which of those IDF functions are doing some kind of IO
- Find out what kind of routines are making those function calls
It turned out that really the only kind of IO this device ever does is communicating over its UART1 peripheral (this is distinct from its debugging interface which is UART0).
The peripheral connected to UART1 is wired as GPIO6=TX, GPIO7=RX.
There is something connected to GPIO4 which according to the firmware dump seems to be a LED. However, there is no LED visible on the top side of the PCB. The only LED at all on this device is a status LED controlled by the MPTT which is multi-color.
Protocol
A basic communication frame consists of a 1 byte header that is 0xFA, followed by the contents and is terminated by a 1 byte inverted XOR-sum checksum and 0xFB.
The communication is rather unreliable, validating the checksum is a must.
Retrieving hardware/software info
>>> FA:01:06:00:F8:FB
^^ checksum
^^ ^^ cmd
<<< FA:01:06:0B:04:00:20:1E:17:DE:FB
^^ checksum
^^ software version
^^ hardware version
^^ rated power * 100 Watt
Reading sensor values
>>> FA:10:01:00:EE:FB
^^ checksum
^^ ^^ command
<<< FA:10:14:12:09:F4:01:01:0B:52:04:29:01:84:01:D7:00:C2:00:00:00:00:00:F1:FB
^^ checksum
^^ ^^ ^^ ^^ energy produced since power-up, in watt-hours
^^ ^^ temperature * 10
^^ ^^ dc current * 100,
^^ ^^ dc voltage * 10
^^ ^^ dc power * 10
^^ operational status (0 = off, 8 = starting, 11 = active)
^^ ^^ ac frequency * 10
^^ ^^ ac voltage * 10
Esphome

The most up-to-date config that I use can be found here
esphome:
name: lidl-balkonkraftwerk
friendly_name: Lidl Balkonkraftwerk
platformio_options:
board_build.flash_mode: dio
esp32:
variant: esp32c3
board: esp32-c3-devkitc-02
framework:
type: esp-idf
logger:
hardware_uart: UART0
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
ap:
web_server:
captive_portal:
ota:
platform: esphome
password: !secret ota_password
mqtt:
broker: mqtt.local
discovery: false
api:
uart:
- id: uart_microinv
rx_pin: 7
tx_pin: 6
baud_rate: 115200
parity: none
data_bits: 8
stop_bits: 1
debug:
direction: BOTH
text_sensor:
- platform: template
name: "Firmware Version"
id: microinv_sfv
icon: mdi:alpha-v
- platform: template
name: "Hardware Version"
id: microinv_hdv
icon: mdi:alpha-v
- platform: template
name: "Status"
id: microinv_status
icon: mdi:cog
sensor:
- platform: template
id: microinv_cmd_0x0106
update_interval: 60s
lambda: |-
const char *TAG = "microinv_cmd_0x0106";
auto uart = id(uart_microinv);
uint8_t discard;
while (uart->available()) uart->read_byte(&discard);
const uint8_t buf[] = {0xfa, 0x01, 0x06, 0x00, 0xf8, 0xfb};
uart->write_array(buf, sizeof(buf));
uart->flush();
uint8_t recv[11] = {0};
size_t nread = uart->read_array(recv, sizeof(recv));
if (memcmp(recv, "\xfa\x01\x06", 3)) {
ESP_LOGW(TAG, "response header invalid");
return {};
}
uint8_t chk = 0;
for (int i = 1; i < sizeof(recv)-2; i++) {
chk = chk ^ recv[i];
}
chk = ~chk;
if (chk != recv[sizeof(recv)-2]) {
ESP_LOGW(TAG, "invalid checksum");
return {};
}
id(microinv_sfv_rating).publish_state(recv[4] * 100);
char ss[0xff] = {0};
int sfv = recv[8];
snprintf(ss, sizeof(ss), "%d.%d.%d", sfv / 100 + 1, (sfv / 10) % 10, sfv % 10);
id(microinv_sfv).publish_state(std::string(ss));
int hdv = recv[7];
snprintf(ss, sizeof(ss), "%d.%d.%d", hdv / 100 + 1, (hdv / 10) % 10, hdv % 10);
id(microinv_hdv).publish_state(std::string(ss));
return {};
- platform: template
id: microinv_cmd_0x1001
update_interval: 5s
lambda: |-
const char *TAG = "microinv_cmd_0x1001";
auto uart = id(uart_microinv);
uint8_t discard;
while (uart->available()) uart->read_byte(&discard);
const uint8_t buf[] = {0xfa, 0x10, 0x01, 0x00, 0xee, 0xfb};
uart->write_array(buf, sizeof(buf));
uart->flush();
uint8_t recv[25] = {0};
size_t nread = uart->read_array(recv, sizeof(recv));
if (memcmp(recv, "\xfa\x10", 2)) {
ESP_LOGW(TAG, "response header invalid");
return {};
}
uint8_t chk = 0;
for (int i = 1; i < sizeof(recv)-2; i++) {
chk = chk ^ recv[i];
}
chk = ~chk;
if (chk != recv[sizeof(recv)-2]) {
ESP_LOGW(TAG, "invalid checksum");
return {};
}
// recv[2] is unused
float ac_voltage = (float)(recv[4] << 8 | recv[3]) / 10.0;
float ac_frequency = (float)(recv[6] << 8 | recv[5]) / 10.0;
// recv[7] is always 1 and in the original fw some kind of length indicator of following data. The number of DC inputs?
uint8_t status = recv[8];
float dc_power = (float)(recv[10] << 8 | recv[9]) / 10.0;
float dc_voltage = (float)(recv[12] << 8 | recv[11]) / 10.0;
float dc_current = (float)(recv[14] << 8 | recv[13]) / 100.0;
float temperature = (float)(recv[16] << 8 | recv[15]) / 10.0;
int energy = recv[20] << 24 | recv[19] << 16 | recv[18] << 8 | recv[17];
// recv[21] and recv[22]: online or error status codes?
id(microinv_ac_voltage).publish_state(ac_voltage);
id(microinv_ac_frequency).publish_state(ac_frequency);
id(microinv_dc_power).publish_state(dc_power);
id(microinv_dc_voltage).publish_state(dc_voltage);
id(microinv_dc_current).publish_state(dc_current);
id(microinv_temperature).publish_state(temperature);
id(microinv_energy).publish_state(energy);
char status_unk[16] = {0};
snprintf(status_unk, sizeof(status_unk), "0x%02x", status);
id(microinv_status).publish_state(
status == 0x00 ? "Standby" :
status == 0x08 ? "Testing" :
status == 0x0b ? "Active" :
status_unk);
return {};
- platform: uptime
name: Uptime
- platform: template
name: "Rated Capacity"
id: microinv_sfv_rating
device_class: power
unit_of_measurement: W
- platform: template
name: "AC Voltage"
id: microinv_ac_voltage
state_class: "measurement"
device_class: voltage
unit_of_measurement: V
icon: mdi:transmission-tower
- platform: template
name: "AC Frequency"
id: microinv_ac_frequency
state_class: "measurement"
device_class: frequency
unit_of_measurement: Hz
icon: mdi:current-ac
- platform: template
name: "DC Power"
id: microinv_dc_power
state_class: "measurement"
device_class: power
unit_of_measurement: W
- platform: template
name: "DC Voltage"
id: microinv_dc_voltage
state_class: "measurement"
device_class: voltage
unit_of_measurement: V
icon: mdi:flash
- platform: template
name: "DC Current"
id: microinv_dc_current
state_class: "measurement"
device_class: current
unit_of_measurement: A
icon: mdi:flash
- platform: template
name: "Temperature"
id: microinv_temperature
state_class: "measurement"
device_class: temperature
unit_of_measurement: °C
- platform: template
name: "Energy Produced"
id: microinv_energy
state_class: total_increasing
device_class: energy
unit_of_measurement: Wh