↓ Skip to main content
  1. Posts/

Reverse-Engineering the Platinum 3100S solar inverter's RS485 Protocol

·1796 words·9 mins
David Steeman
Author
David Steeman

My roof has a Platinum 3100S solar inverter (made by Diehl AKO). I recently installed a Solar-Log 500 to read its production and feed the numbers into Home Assistant, it works, but it’s a black box I don’t control, and I’d rather read the inverter directly with an ESP32 and cut out the middleman.

The catch: the Platinum speaks a proprietary serial protocol. There’s no Modbus register map, no public documentation, nothing. So this turned into a reverse-engineering project, and this post is the honest story of an attempt that, so far, has hit a wall. I’m publishing the full dataset so anyone else can have a go.

Two ports, two protocols
#

The inverter exposes two serial ports, and they’re completely different animals:

  • A DB9 RS-232 “service port” (true ±12 V). A developer named stendec reverse-engineered this one back in 2014 (from the SolarControl PC app, against a Platinum 3800S) and published the C source. I verified its CRC against a real capture, it’s correct. This is the documented, easy path.
  • An RS-485 bus on a screw terminal, which is what the Solar-Log actually uses. Totally undocumented, and, as I’d find out, nothing like the RS-232 one.

The sensible plan is the RS-232 port. But that needs a MAX3232 level shifter, which I had to order. While waiting for it to arrive, I had a Raspberry Pi and an FT232 USB-to-RS485 dongle sitting idle… so I decided to passively sniff the Solar-Log ↔ inverter RS-485 bus and see if I could crack it.

The setup
#

A Raspberry Pi 2B, the FT232 dongle wired across the bus receive-only (it never transmits, important, you do not want to corrupt the inverter’s comms), and a small Python script reading /dev/ttyUSB0 at 9600 baud.

I did have one thing going for me, though: the answer key was already on my network. The Solar-Log exposes its readings over HTTP at /getjp, so every ~15 seconds I could pair each raw bus frame with the known power and voltage values the Solar-Log had just computed from it. If a byte in the response encoded, say, DC voltage, the correlation would jump right out. Or so I thought.

# the Solar-Log's ground-truth API
curl -X POST http://192.168.1.146/getjp -H 'Content-Type: application/json' \
  -d '{"801":{"170":null}}'
# → {"801":{"170":{"101":1089,"102":1150,"103":240,"104":328,...}}}
#    101=Pac(W)  102=Pdc(W)  103=Uac(V)  104=Udc(V) ...

The bus, characterised
#

The Solar-Log sends the exact same 16-byte request every single cycle:

01 01 4f 03 63 a1 00 04 03 01 00 04 03 02 81 fd

and the inverter replies with a 16-byte (sometimes 17-byte) response that varies. That’s the entire conversation, one request, one response, every 15 seconds. Whatever the Solar-Log displays, it derives from that one 16-byte reply.

(First sanity check, and I’m glad I did it: is the baud even right? I swept 4800 / 9600 / 19200 / 38400 and only 9600 reproduced that fixed request cleanly. A wrong baud gives you garbage, not a stable repeating frame. 9600 confirmed.)

The “breakthrough” that wasn’t
#

Early on, staring at correlations between response bytes and the known voltages, I saw numbers like r = 0.94. DC voltage tracking a byte at 0.94? I thought I’d cracked it. I started writing up the win.

I was wrong, and the reason is embarrassing in hindsight. My capture ran overnight: and at dusk and dawn the inverter is connected to the bus but producing zero watts (Pac = Uac = Udc = 0). That off↔on step made every voltage correlate with every response byte at 0.6–0.94. It was a confound, not a signal. The moment I filtered to frames where the inverter was actually producing (Pac > 100), every single correlation collapsed to noise (~0.2).

Lesson one, loudly: always check what your “signal” really correlates with. A day/night cycle will fool you every time.

Getting serious
#

So I let the capture run for 28 hours, 6701 poll cycles, each paired with its ground-truth /getjp reading. I rewrote the capture to be cleaner (prefix-anchored on the fixed request, so noise and split frames get discarded) and ended up with 3878 solid “inverter is producing” frames spanning Pac from 100 to 2380 W and DC voltage from 308 to 396 V. A proper dataset.

Then I threw everything at it, ten encoding hypotheses, one after another. Here’s what each was, why it was worth trying, and what actually happened.

1. Plain integer. The obvious starting point: maybe the value just sits in the bytes as a number. I took every single byte and every adjacent pair (both big- and little-endian) and correlated each against the known Pac, Pdc, Uac and Udc. Best result: about r = 0.2, noise.

2. BCD (binary-coded decimal). Industrial meters love BCD: each nibble holds one decimal digit. I decoded every run of three and four nibbles (most-significant-digit first) at every offset and correlated. Nothing rose above the noise floor.

3. Scaled or offset (÷10 and friends). The RS-232 spec warned that scale factors were inconsistent, voltages in 0.1 V, currents in 0.1 A. Worth checking, except it’s already settled by a mathematical fact: Pearson correlation is invariant under scaling and offset. If a value were stored as f(bytes) × k + c in any units at all, the correlation would be a perfect 1.0 regardless of k. A reading of 0.2 means it’s genuinely not a linear encoding, not a units problem. That single insight retires hypotheses 1–3 in one stroke.

4. Timing lag. I pair each bus frame with the /getjp reading taken four seconds later. But what if the Solar-Log reports a value from the previous cycle, or the next? I swept lags from −3 to +3 cycles. Correlation peaked at lag zero and fell off symmetrically on both sides, a genuinely shifted field would spike sharply at one non-zero lag. Not a timing problem.

5. Fixed XOR keystream. Maybe the payload is XORed with a repeating key, common in proprietary protocols, and a difficult one, because XOR is bitwise and correlation can’t see through it at all. I tested directly: for every value and every byte-pair I computed cipher XOR value across all frames and checked whether it came out as one constant key. I validated the method first with a synthetic XORed field, which scored 100% and recovered the exact key. On the real data the best consistency was ~6%, chance. No fixed key.

6. Multiplexing by a selector byte. The header bytes take only a handful of values (01, 08, 09 and the like), which look like page selectors. Maybe each cycle’s response carries a different value, chosen by one of those bytes, in which case each value would appear in only a fraction of frames and the average correlation would smear to nothing. I binned frames by each candidate selector and re-correlated inside every bin. Still ~0.2.

7. Round-robin multiplexing. The same idea with no selector byte at all: the inverter cycles through a sequence of data pages on each identical poll. This would explain everything I was seeing, so it was worth testing carefully. I binned frames by their position in the poll sequence (modulo N, for N from 2 to 10, using both frame index and wall-clock time) and correlated within each phase. No phase reached |r| above 0.5 for any value. Not it.

8. An energy counter. Some Solar-Log setups compute power from a cumulative energy counter rather than reading instantaneous power, which would explain why Pac refuses to correlate (it would be the derivative of something, not stored directly). For each byte-pair I computed the change between consecutive cycles and correlated that against Pac, using rank correlation so the occasional corrupted frame wouldn’t wreck it. Best result ~0.12, and no field was even strongly monotonic, a real counter marches upward at ~0.99. Not a counter.

9. Nibble-swap and bit-reversal. Two more transforms correlation can’t see through: swapping the two nibbles in each byte, and reversing the bit order (an MSB/LSB quirk). I applied each, then re-ran the whole correlation sweep. Best |r| about 0.29, still noise.

10. The checksum. Byte 15 of the response is unmistakably a checksum, the most variable byte in the frame, changing every cycle. Cracking it would settle two things at once: validate frame integrity (so I could finally trust the bytes were clean) and confirm which bytes are payload. I tried sum8, xor8, two’s-complement sum, CRC-8 with several polynomials, and CRC-16 (CCITT, Modbus and friends), over a range of byte windows. Best match: 0.9% of frames. The checksum is non-standard and I couldn’t crack it. And an uncrackable checksum is itself a sign that the protocol is deliberately obscure.

Where it stands
#

The bytes in that response do carry live data. They change with the inverter’s operating state. But it is not the power and voltage values the Solar-Log reports, in any form I could recover. My best guess is that they’re status or event codes, or raw internal quantities the Solar-Log maps through some table or formula I don’t have.

This is exactly the “from-scratch reverse-engineering job” the smart money always said RS-485 would be. I’m parking it. The documented RS-232 service port, the one stendec already cracked, is the real path forward, and the moment my MAX3232 arrives that’s what I’ll be working on. I’ll write that up when it works.

Want to try?
#

I’ve published the full dataset, all 6701 frames plus the paired ground-truth readings, with a README explaining the format:

📦 platinum-3100s-rs485-capture.zip , frames + ground truth + README

The README explains how to join the two files (frames.t == solarlog.frame_t), what each field means, and, importantly, warns you about that day/night confound so you don’t fall into the same trap I did. If you crack the checksum, or figure out where the watts are hiding, let me know.

Lessons
#

  • Beware the off/on confound. A device that reports zero at night will make every byte look meaningful. Filter to active frames first.
  • Correlation is invariant to scale. If a value were stored as f(bytes)×k+c, you’d see |r| = 1.0 regardless of units. Seeing 0.2 means it’s genuinely not a linear encoding, not a units problem.
  • An uncrackable checksum is a bad sign. It usually means the protocol is deliberately non-standard, and you can’t trust that your captured bytes are intact.
  • Sometimes the documented path is the right one. I spent days on RS-485 anyway. The RS-232 port was already solved.

Resources
#

  • Download the dataset , 6701 frames + ground truth + README
  • WebSolarLog , an open Solar-Log alternative; its Diehl driver is Ethernet/HTTP rather than serial, but a useful reference for which values exist
  • stendec’s RS_diehl.c, the reverse-engineered RS-232 service-port protocol (the documented path I’m ultimately taking); originally shared on the WebSolarLog Google Group in 2014