Invertor and lithium

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.

Invertor and lithium

Solar station

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

Similar Posts