Reverse-Engineering a Solar Power Station During War: Our Survival Tech Story
Hi, I’m Dmytro Novoselskyi, and this is a story about me and my good friend Alex Gorbachenko from Ukraine.
We used to be neighbors in a high-rise building in Odesa. When the war started, our lives changed overnight.
The shelling made living there unbearable – not only because of the constant danger but also because, as IT geeks, we were trapped in extreme offline boredom.
We lived without stable electricity, without internet, sometimes without any light except candles.
Then one day, after pooling together what little resources we had, we decided to build something that would keep us going – a rooftop solar power station.
Building a Solar Station From Scratch (Because No One Else Could)
Due to the war, we couldn’t rely on any external contractors or delivery services. So we built everything ourselves — every panel, every cable, every connection.
In the end, we managed to assemble:
4 kWh solar station on the roof
26 kWh lithium battery bank (120 kg!)
Controlled by an unknown Chinese inverter
Connected to Wi-Fi via a hacked smart bulb device (Espressif IoT chip)
Yes, you read that right – our inverter had a serial port, and the only way we could connect it to our home network was by using the Wi-Fi chip from a smart light bulb.
The Challenge: Real-Time Power Alerts on Minimal Energy
We lived on the 10th floor, and the elevator only worked when there was grid power. So we needed a system that could:
Notify us instantly when grid power returned.
Warn us about inverter errors, overheating, over-discharge, or excessive load.
Consume minimal energy (ideally Raspberry Pi-level or lower).
Running Espressif’s full framework for such simple tasks was wasteful, so we started reverse-engineering the smart bulb protocol and our inverter’s data output.
The inverter spoke a strange, undocumented serial protocol. The smart bulb firmware wasn’t designed for such work. And we had almost no internet – most of the coding and reverse-engineering was done completely offline, on an laptop.
Finding byte sequences, decoding voltage readings, and figuring out checksums felt like being in the 1980s — only with air raid sirens outside.
Our Debugging Console
To track system status, we wrote a custom debugging console that parsed inverter data and pushed notifications to Telegram.
Some highlights of what it could do:
Calculate battery % from cell voltage curves
Estimate runtime left at current load
Detect and notify on:
Grid power loss / restore
Battery charge milestones
Overloads and overheating
Communication failures
It wasn’t fancy – just PHP scripts talking over UDP sockets – but it worked reliably in the worst possible conditions.
Here’s a small snippet from the code that handled data reading and parsing:
$invertorResponse = readInvertor($client, $inverter, $port, $oPort, $timeout);
$data['battery_charge'] = calculateBattery($data['battery_volt'], $batterySerial, $cellCurve);
$data['pv_power'] = $data['pv_current'] * $data['pv_volt'];
push('<b>' . $pushMsg . '%0A</b><pre>' . $pushData . '</pre>');
function readInvertor($client, $inverter, $port, $oPort, $timeout)
{
//bind $oPort to $inverter
$socket = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
socket_set_option($socket, SOL_SOCKET, SO_RCVTIMEO, ["sec" => $timeout, "usec" => 0]);
socket_bind($socket, $client, $oPort);
//send hello (important part of inverter binding)
$send = "Are You Espressif IOT Smart Device?";
echo '<br>send: ' . $send;
socket_sendto($socket, $send, strlen($send), 0, $inverter, $port);
//receive hello (skip for performance)
socket_recvfrom($socket, $receive, 2048, 0, $inverter, $oPort);
echo '<br>receive: ' . $receive;
//send command
$send = urldecode("%51%50%49%47%53%b7%a9%0d");
echo '<br>send: ' . $send;
socket_sendto($socket, $send, strlen($send), 0, $inverter, $port);
//receive data
socket_recvfrom($socket, $receive, 2048, 0, $inverter, $oPort);
echo '<br>receive: ' . $receive;
socket_close($socket);
$qpigs = substr($receive, 1, 106);
return explode(" ", $qpigs);
}
We even wrote the battery charge calculation from scratch, interpolating between voltage curve points.
What We Learned
This project wasn’t about creating the most elegant code or following perfect design patterns. It was about solving a survival problem with whatever tools we had.
*We learned that:
*
Reverse-engineering without the internet is slow, but when you have endless time stuck indoors, you can go deep.
Smart home devices can be repurposed for very unexpected jobs.
A system doesn’t have to be pretty to be mission critical.
This little station still working.
And if there’s one takeaway, it’s this:
When resources are scarce, creativity becomes your most powerful tool.
Source code:
<?php
//Config
$client = '192.168.2.10';
$inverter = '192.168.2.20';
$port = '1025';
$oPort = '60005';
$timeout = '5';
$telKey = '---';
$telChatId = '---';
$invertorResponse = readInvertor($client, $inverter, $port, $oPort, $timeout);
//urldecode("%51%4d%4e%bb%64%0d"); //QMN
//urldecode("%51%50%49%47%53%b7%a9%0d"); //QPIGS
//urldecode("%51%50%49%52%49%f8%54%0d"); //QPIRIT
//urldecode("%51%4d%4f%44%49%c1%0d"); //QMODI
//urldecode("%51%50%49%57%53%b4%da%0d"); //QPIWS
//urldecode("%51%45%44%32%30%32%33%30%31%31%38%33%79%0d"); //QED202301183y
$data['checksum'] = $invertorResponse[17] . ';' . $invertorResponse[18];
$data['grid_volt'] = $invertorResponse[0];//V
$data['grid_fre'] = $invertorResponse[1];//Hz
$data['output_volt'] = $invertorResponse[2];//V
$data['output_fre'] = $invertorResponse[3];//Hz
$data['output_apparent_power'] = $invertorResponse[4]; //VA
$data['output_active_power'] = $invertorResponse[5]; //W
$data['battery_volt'] = $invertorResponse[8];
$data['battery_charging_current'] = $invertorResponse[9];
$data['battery_discharge_current'] = $invertorResponse[15];
$data['pv_current'] = $invertorResponse[12]; //A
$data['output_load_percent'] = $invertorResponse[6]; //%
$data['bus_voltage'] = $invertorResponse[7];
$data['battery_capacity_percent'] = $invertorResponse[10];
$data['sys_temp'] = $invertorResponse[11];
$data['pv_volt'] = $invertorResponse[13]; //V
$data['pv_charging_power'] = $invertorResponse[19]; //W
$batterySerial = 15;
$cellCurve[0] = '3.167';
$cellCurve[5] = '3.413';
$cellCurve[10] = '3.446';
$cellCurve[15] = '3.488';
$cellCurve[20] = '3.537';
$cellCurve[25] = '3.571';
$cellCurve[30] = '3.593';
$cellCurve[35] = '3.610';
$cellCurve[40] = '3.625';
$cellCurve[45] = '3.642';
$cellCurve[50] = '3.663';
$cellCurve[55] = '3.695';
$cellCurve[60] = '3.755';
$cellCurve[65] = '3.799';
$cellCurve[70] = '3.846';
$cellCurve[75] = '3.895';
$cellCurve[80] = '3.945';
$cellCurve[85] = '3.997';
$cellCurve[90] = '4.051';
$cellCurve[95] = '4.108';
$cellCurve[100] = '4.166';
$battery_power = '26160';
$battery_power_cutoff_low = '0';
$battery_power_cutoff_hight = '100';
$data['battery_charge'] = calculateBattery($data['battery_volt'], $batterySerial, $cellCurve); //%
$data['battery_charge'] = round($data['battery_charge'], 2);
$data['pv_power'] = $data['pv_current'] * $data['pv_volt'];
$data['battery_discharge_power'] = $data['battery_discharge_current'] * $data['battery_volt'];
$data['battery_charging_power'] = $data['battery_charging_current'] * $data['battery_volt'];
$data['grid_power'] = $data['output_apparent_power'] + $data['battery_charging_power'] - $data['pv_power'] - $data['battery_discharge_power'];
if($data['grid_volt'] < 100) {
$data['grid_power'] = '0';
}
$data['battery_charge_available'] = $data['battery_charge'] - $battery_power_cutoff_low;
$data['battery_wh_available'] = $battery_power / 100 * $data['battery_charge_available'];
if($data['battery_discharge_power'] > 0) {
$data['battery_estimate_lifetime'] = ceil(($data['battery_wh_available'] / $data['battery_discharge_power']) * 60);
$data['battery_estimate_lifetime'] = floor($data['battery_estimate_lifetime'] / 60) . ':' . ($data['battery_estimate_lifetime'] - floor($data['battery_estimate_lifetime'] / 60) * 60);
} else {
$data['battery_estimate_lifetime'] = ceil(($data['battery_wh_available'] / $data['output_active_power']) * 60);
$data['battery_estimate_lifetime'] = floor($data['battery_estimate_lifetime'] / 60) . ':' . ($data['battery_estimate_lifetime'] - floor($data['battery_estimate_lifetime'] / 60) * 60);
}
if($data['checksum'] == '00;00') {
echo "<br>";
foreach($data as $k => $v) {
echo "<br>" . $k . "=<b>" . $v . "</b>";
}
$lastStatus = file_get_contents('status.txt');
if($data['grid_volt'] < 100) {
if($lastStatus != 'b2' and $lastStatus != 'b3' and $lastStatus != 'b4' and $lastStatus != 'b5' and $lastStatus != 'b6') {
$thisStatus = 'b1';
}
if($data['battery_charge'] < 50 and $lastStatus != 'b3' and $lastStatus != 'b4' and $lastStatus != 'b5' and $lastStatus != 'b6') {
$thisStatus = 'b2';
}
if($data['battery_charge'] < 40 and $lastStatus != 'b4' and $lastStatus != 'b5' and $lastStatus != 'b6') {
$thisStatus = 'b3';
}
if($data['battery_charge'] < 30 and $lastStatus != 'b5' and $lastStatus != 'b6') {
$thisStatus = 'b4';
}
if($data['battery_charge'] < 20 and $lastStatus != 'b6') {
$thisStatus = 'b5';
}
if($data['battery_charge'] < 12) {
$thisStatus = 'b6';
}
} else {
if($lastStatus != 'g2' and $lastStatus != 'g3' and $lastStatus != 'g4' and $lastStatus != 'g5' and $lastStatus != 'g6') {
$thisStatus = 'g1';
}
if($data['battery_charge'] > 79 and $lastStatus != 'g3' and $lastStatus != 'g4' and $lastStatus != 'g5' and $lastStatus != 'g6') {
$thisStatus = 'g2';
}
if($data['battery_charge'] > 85 and $lastStatus != 'g4' and $lastStatus != 'g5' and $lastStatus != 'g6') {
$thisStatus = 'g3';
}
if($data['battery_charge'] > 90 and $lastStatus != 'g5' and $lastStatus != 'g6') {
$thisStatus = 'g4';
}
if($data['battery_charge'] > 95 and $lastStatus != 'g6') {
$thisStatus = 'g5';
}
if($data['battery_charge'] > 100) {
$thisStatus = 'g6';
}
}
foreach($data as $k => $v) {
$pushData .= "" . $k . "=" . $v . "%0A";
}
if($lastStatus != $thisStatus and isset($thisStatus)) {
file_put_contents('status.txt', $thisStatus);
if($thisStatus == 'b1') {
$pushMsg = '🗿 Grid offline. Battery: ' . $data['battery_charge'] . '%, estimate lifetime: ' . $data['battery_estimate_lifetime'];
}
if($thisStatus == 'b2') {
$pushMsg = '🪫 Battery: ' . $data['battery_charge'] . '%, estimate lifetime: ' . $data['battery_estimate_lifetime'];
}
if($thisStatus == 'b3') {
$pushMsg = '🪫 Battery: ' . $data['battery_charge'] . '%, estimate lifetime: ' . $data['battery_estimate_lifetime'];
}
if($thisStatus == 'b4') {
$pushMsg = '🪫 Battery: ' . $data['battery_charge'] . '%, estimate lifetime: ' . $data['battery_estimate_lifetime'];
}
if($thisStatus == 'b5') {
$pushMsg = '🪫 Battery: ' . $data['battery_charge'] . '%, estimate lifetime: ' . $data['battery_estimate_lifetime'];
}
if($thisStatus == 'b6') {
$pushMsg = '🪫 Battery: ' . $data['battery_charge'] . '%, estimate lifetime: ' . $data['battery_estimate_lifetime'];
}
if($thisStatus == 'g1') {
$pushMsg = '🔌 Grid online. Battery: ' . $data['battery_charge'] . '%, estimate lifetime: ' . $data['battery_estimate_lifetime'];
}
if($thisStatus == 'g2') {
$pushMsg = '🔋 Full charge. Battery: ' . $data['battery_charge'] . '%, estimate lifetime: ' . $data['battery_estimate_lifetime'];
}
if($thisStatus == 'g3') {
$pushMsg = '🔋 Full charge. Battery: ' . $data['battery_charge'] . '%, estimate lifetime: ' . $data['battery_estimate_lifetime'];
}
if($thisStatus == 'g4') {
$pushMsg = '🔋 Extra full charge. Battery: ' . $data['battery_charge'] . '%, estimate lifetime: ' . $data['battery_estimate_lifetime'];
}
if($thisStatus == 'g5') {
$pushMsg = '🔋 Extra full charge. Battery: ' . $data['battery_charge'] . '%, estimate lifetime: ' . $data['battery_estimate_lifetime'];
}
if($thisStatus == 'g6') {
$pushMsg = '🔋 DANGER CHARGE LEVEL! STOP CHARGING IMMEDIATELY. Battery: ' . $data['battery_charge'] . '%, estimate lifetime: ' . $data['battery_estimate_lifetime'];
}
push('<b>' . $pushMsg . '%0A</b><pre>' . $pushData . '</pre>');
}
if($data['output_active_power'] > '5300') {
push('<b>🆘 Grid Overload ' . $data['output_active_power'] . ' W%0A</b>');
}
if($data['grid_volt'] < 100 and $data['output_active_power'] > '3500') {
push('<b>📈 Battery Overload ' . $data['output_active_power'] . ' W. Battery: ' . $data['battery_charge'] . '%, estimate lifetime: ' . $data['battery_estimate_lifetime'] . '</b>');
}
if($data['sys_temp'] > 60) {
push('<b>🔥 Overheating ' . $data['sys_temp'] . ' C. Battery: ' . $data['battery_charge'] . '%, estimate lifetime: ' . $data['battery_estimate_lifetime'] . '</b>');
}
} else {
push('<b>Communication fail</b>');
}
function readInvertor($client, $inverter, $port, $oPort, $timeout)
{
//bind $oPort to $inverter
$socket = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
socket_set_option($socket, SOL_SOCKET, SO_RCVTIMEO, ["sec" => $timeout, "usec" => 0]);
socket_bind($socket, $client, $oPort);
//send hello (important part of inverter binding)
$send = "Are You Espressif IOT Smart Device?";
echo '<br>send: ' . $send;
socket_sendto($socket, $send, strlen($send), 0, $inverter, $port);
//receive hello (skip for performance)
socket_recvfrom($socket, $receive, 2048, 0, $inverter, $oPort);
echo '<br>receive: ' . $receive;
//send command
$send = urldecode("%51%50%49%47%53%b7%a9%0d");
echo '<br>send: ' . $send;
socket_sendto($socket, $send, strlen($send), 0, $inverter, $port);
//receive data
socket_recvfrom($socket, $receive, 2048, 0, $inverter, $oPort);
echo '<br>receive: ' . $receive;
socket_close($socket);
$qpigs = substr($receive, 1, 106);
return explode(" ", $qpigs);
}
function push($msg)
{
global $telKey, $telChatId;
file_get_contents('https://api.telegram.org/bot' . $telKey . "/sendmessage?chat_id=" . $telChatId . "&parse_mode=HTML&text=" . $msg);
return;
}
function calculateBattery($batteryVolt, $batterySerial, $cellCurve)
{
$moreThan = '0';
$lessThan = '0';
$actualVoltage = $batteryVolt / $batterySerial;
foreach($cellCurve as $cellPercent => $cellVoltage) {
if($actualVoltage > $cellVoltage) {
$moreThan = $cellPercent;
}
if(isset($moreThan)) {
if($actualVoltage < $cellVoltage and !$lessThan) {
$lessThan = $cellPercent;
}
}
}
if(isset($moreThan) and isset($lessThan)) {
$percentToCalculate = $lessThan - $moreThan;
$voltageToCalculate = $cellCurve[$lessThan] - $cellCurve[$moreThan];
$restVoltage = $actualVoltage - $cellCurve[$moreThan];
$deltaPercent = 100 / $voltageToCalculate * $restVoltage;
$addPercent = $percentToCalculate / 100 * $deltaPercent;
$realPercent = $moreThan + $addPercent;
}
return $realPercent;
}