Status: Stable since 2026-07-29 — the only driver in this project at that level. Reconstructed
from the reference implementation, verified against a real TL3000-20 from 2026-07-19, and promoted
after eight consecutive unassisted sunrises: the one bug this protocol carried appeared only at the
night-to-morning transition, so a reboot hid it and no bench test could ever close it. Hardware findings are marked as such;
two captured frames are stored byte-for-byte in tools/gen_fixtures.py, which
verifies itself against the captures. Where something is unknown, that is stated explicitly — no
fields have been invented.
Checking this document against a running bridge no longer needs a USB-RS485 tap and a laptop at the inverter. Health → "Record the driver's own conversation" records the requests and replies as they happen, with a direction on each; hold them next to the frame layouts below. See adding-a-device.md § 6.5.
| Reference | https://github.com/solmoller/eversolar-monitor |
| Commit | 784c2fc2f6b6ed4e70bf7519d0939ec1195a1813 (2026-02-21) |
| License | MIT — © 2021 Henrik Møller Jørgensen |
| Original basis | Steve Cliffe <steve@sjcnet.id.au>, Eversolar PMU logger |
MIT is permissive; reuse of protocol knowledge and reimplementation in C++ is allowed provided the copyright notice and license text are retained. Concretely:
LICENSE-THIRD-PARTY.mdin the repo root gets the full MIT text of eversolar-monitor.- Every file in
src/drivers/eversolar_legacy/gets a header stating that the protocol knowledge is derived from eversolar-monitor (MIT) and from Steve Cliffe's PMU logger. - No Perl code is translated or copied; only the protocol is reimplemented. The application structure (sqlite, pvoutput, FTP upload) is out of scope.
From serial_connect() (eversolar.pl:559-576) — hardcoded, not configurable:
| Parameter | Value |
|---|---|
| Baud rate | 9600 |
| Data bits | 8 |
| Parity | none |
| Stop bits | 1 |
| Handshake | none |
This is the only relevant serial profile for this driver. The driver descriptor therefore
specifies exactly one SerialProfile: 9600 8N1. No other baud rates are tried.
From the header comment (eversolar.pl:102-110) and send_request() (:395-408):
Offset Length Field
------ ------ --------------------------------------------------
0 2 Header: 0xAA 0x55
2 2 Source address
4 2 Destination address
6 1 Control code
7 1 Function code
8 1 Data length N
9 N Data
9+N 2 Checksum (uint16, big-endian)
------ ------ --------------------------------------------------
Total: 11 + N bytes
The PMU (our bridge) is the master. From send_request():
@tmp_packet = ( 0xAA, 0x55, OUR_ADDRESS, 0x00, 0x00, $destination_address, ... );
# ^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^
# bron = 0x01 0x00 bestemming = 0x00 <addr>| Value | |
|---|---|
| PMU source address | 0x01 0x00 (OUR_ADDRESS = 0x01, eversolar.pl:268) |
| Broadcast destination | 0x00 0x00 |
| Inverter destination | 0x00 <assigned address> |
| First assigned address | 0x10 (START_INVERTER_ADDRESS, :267) |
The PMU assigns addresses itself, incrementing from 0x10. This is therefore not a fixed
slave address like in Modbus: the address is the result of a registration procedure.
From send_request() (:403-406) and validate_checksum() (:750-760):
checksum = (sum of all bytes from offset 0 through 8+N) mod 65536
A simple 16-bit sum over all preceding bytes, including the 0xAA 0x55 header, written big-endian. No CRC-16, no Modbus CRC.
The reference adds one validation (:759):
return ( $csum > 0 ) && ( $csum == ( ( $packet[$len-2] << 8 ) + $packet[$len-1] ) );"packets with all zeroes pass checksum test, but are invalid"
A frame of all zeros has checksum 0 and would therefore appear valid — precisely the case that would otherwise be published as a "valid zero reading".
We deliberately do not adopt this csum > 0 guard. It only exists because the reference
never checks the AA 55 header anywhere. We do check it, and as a result the byte sum of a
valid frame is always at least 0xAA + 0x55 = 255: an all-zero frame already fails earlier, on
a stronger ground (BadHeader).
Worse, the guard is slightly wrong: with a real header the sum can still come out to exactly
65536 and truncate to 0. That is then a correct checksum, and the guard would incorrectly
reject such a frame — roughly 1 in 65536 frames. Both cases are captured as a test in
test_eversolar_checksum.
From %CTRL_FUNC_CODES (eversolar.pl:190-223):
| Control | Function | Name | Request → Response |
|---|---|---|---|
0x10 REGISTER |
0x00 |
Offline query | 10 00 → 10 80 |
0x10 REGISTER |
0x01 |
Send register address | 10 01 → 10 81 |
0x10 REGISTER |
0x04 |
Re-register | 10 04 → (no response) |
0x11 READ |
0x00 |
Query description | 11 00 → 11 80 |
0x11 READ |
0x02 |
Query normal info | 11 02 → 11 82 |
0x11 READ |
0x03 |
Query inverter ID | 11 03 → 11 83 |
0x12 WRITE |
— | empty in reference | — |
0x13 EXECUTE |
— | empty in reference | — |
Responses set bit 7 of the function code (0x02 → 0x82).
0x12 WRITE and 0x13 EXECUTE are empty in the reference. There is therefore no known
write operation for this inverter at all. This fits seamlessly with the read-only MVP: the
EverSolar driver returns Unsupported for every command, and that is not a temporary
limitation but a property of what we know about this protocol.
All registration traffic goes to broadcast (0x00).
1. RE_REGISTER (10 04) → broadcast, repeated 8x, no response expected
eversolar.pl:582-594. The comment says "3 times as per protocol specs",
the code sends 8. All inverters forget their registration.
2. OFFLINE_QUERY (10 00) → broadcast
Response (10 80): serial number as ASCII bytes, variable length.
eversolar.pl:602-608
3. SEND_REGISTER_ADDRESS (10 01) → broadcast
Data = <serial-number bytes> + <1 byte address to assign>
Response (10 81): 1 byte ACK. Expected: 0x06 (ASCII ACK).
eversolar.pl:610-620
4. QUERY_INVERTER_ID (11 03) → <assigned address>
Response (11 83): ASCII identification string.
eversolar.pl:625-628
After that, QUERY_NORMAL_INFO (11 02) is sent periodically to the assigned address.
The reference repeats steps 2-4 every minute to find new inverters (:1037-1040).
All four steps are read-only. Nothing is configured in the inverter except for
a volatile bus address, which the inverter itself forgets when it loses power. This procedure
is therefore safe as a probe (see docs/architecture.md, discovery).
Everything above is read from eversolar.pl. This is what this firmware actually sends,
captured on a Relay-6CH with no inverter attached at all — a driver capture needs no device
to answer, which is what makes the outgoing half of a handshake characterisable on a desk.
t (ms) gap bytes decoded
5052 5052 AA 55 01 00 00 00 10 00 00 01 10 10 00 offline query -> broadcast
6063 1011 AA 55 01 00 00 00 10 04 00 01 14 10 04 re-register
6075 12 AA 55 01 00 00 00 10 04 00 01 14 10 04
6086 11 AA 55 01 00 00 00 10 04 00 01 14 10 04
6098 12 AA 55 01 00 00 00 10 00 00 01 10 10 00 offline query -> broadcast
65070 58972 ... the same five frames again
Three things worth having written down:
The re-register runs three times, not eight. kReRegisterRepeats = 3 in
eversolar_driver.cpp, chosen because eversolar.pl's own comment says "3 times as per
protocol specs" while its code sends 8 — see the note in the sequence above. That decision was
made from a comment in a Perl script; this is the first evidence that what leaves the UART
matches it.
An attempt is five frames in ~1 s, and attempts are ~60 s apart. The three re-registers sit
11-12 ms apart, and the whole cycle repeats a minute later. That matches the reference repeating
steps 2-4 every minute (:1037-1040).
A driver that never registers is nearly silent, and that is a diagnostic trap. Ten frames in
two minutes, and between attempts the poll loop short-circuits on not_registered without
touching the bus at all. A 30-second capture on a bus with nothing on it can therefore land
entirely in a gap and record zero frames — which looks exactly like a broken capture and is
not one. Read rs485_timeout_total before and after the window: if it did not move, nothing was
transmitted and there was nothing to record.
No response is expected to any of these, so every record here is tx and every one is cut by
idle_gap rather than by a direction change. On a bus where an inverter does answer, the same
frames appear cut by direction_change instead — the reply is what ends them.
The payload is an array of 16-bit big-endian words (parse_packet(), :766-777):
$data[$j++] = ( $packet[$i] << 8 ) + $packet[$i+1];The indices below are therefore word indices, not byte offsets.
eversolar.pl:226-241
| Word | Field | Type | Scale | Unit | Measurement ID |
|---|---|---|---|---|---|
| 0 | TEMP | int16 | ÷10 | °C | inverter.temperature |
| 1 | E_TODAY | uint16 | ÷100 | kWh | energy.today |
| 2 | VPV | uint16 | ÷10 | V | dc.mppt_1.voltage |
| 3 | IPV | uint16 | ÷10 | A | dc.mppt_1.current |
| 4 | IAC | uint16 | ÷10 | A | ac.phase_l1.current |
| 5 | VAC | uint16 | ÷10 | V | ac.phase_l1.voltage |
| 6 | FREQUENCY | uint16 | ÷100 | Hz | ac.frequency |
| 7 | PAC | uint16 | ×1 | W | ac.power.total |
| 8 | IMPEDANCE | uint16 | ? | ? | not published, see below |
| 9 | E_TOTAL_HI | uint16 | — | — | high word of energy.total |
| 10 | E_TOTAL_LO | uint16 | ÷10 | kWh | energy.total |
| 11 | NA_2 | uint16 | ? | ? | unknown, not published |
| 12 | HOURS_UP | uint16 | ×1 | h | inverter.operating_hours |
| 13 | OP_MODE | uint16 | — | — | status_code |
eversolar.pl:244-261
| Word | Field | Measurement ID |
|---|---|---|
| 0 | TEMP | inverter.temperature |
| 1 | E_TODAY | energy.today |
| 2 | VPV | dc.mppt_1.voltage |
| 3 | VPV2 | dc.mppt_2.voltage |
| 4 | IPV | dc.mppt_1.current |
| 5 | IPV2 | dc.mppt_2.current |
| 6 | IAC | ac.phase_l1.current |
| 7 | VAC | ac.phase_l1.voltage |
| 8 | FREQUENCY | ac.frequency |
| 9 | PAC | ac.power.total |
| 10 | NA_0 | unknown |
| 11 | E_TOTAL_HI | high word |
| 12 | E_TOTAL_LO | energy.total |
| 13 | NA_2 | unknown |
| 14 | HOURS_UP | inverter.operating_hours |
| 15 | OP_MODE | status_code |
Note: the order of IAC/VAC and the position of PAC differ between the two layouts.
A wrong layout choice therefore produces plausible-looking but completely wrong values.
The reference lets the user choose the layout via an ini option (strings = 1 or 2,
eversolar.ini:8) and dies on any other value. We instead derive the layout from the
payload length:
| Payload length | Words | Layout |
|---|---|---|
| 28 bytes | 14 | 1 string |
| 32 bytes | 16 | 2 strings |
| otherwise | — | discard frame, do not publish data |
This is a hypothesis, not an established fact. It follows from the two index maps of 14 and 16 entries in the reference. But there is counter-evidence: if the length unambiguously determines the layout, why is it then a config option?
Two explanations, and we don't yet know which one is correct:
- The author didn't think of it. Plausible: the reference validates almost nothing — no
header check, no length check, and with a wrong
strings=Perl silently indexes out of bounds (undef→ 0) instead of failing. - The length is not distinguishing and only the interpretation differs. In that case the detection is broken.
The real 1-string TL3000-20 sends 44 bytes: the 14 single-string words followed by an
8-word tail, meaning unknown. Not 28. The values in the first 14
words have been verified against reality (38.6°C, 346.4 V × 2.0 A DC ≈ 655 W AC,
50.03 Hz, 35,445.9 kWh lifetime — mutually consistent and physically plausible; frame stored
as kRespNormalInfoCaptured in the fixtures).
This paragraph used to describe that tail as "zeros and 0xFFFF". It is not only those two
values. Three consecutive replies, captured on the production bridge, carry the same eight words
byte for byte:
word 14 15 16 17 18 19 20 21
0000 0000 FFFF 0000 0000 0000 FF00 0000
Word 20 is FF00 — neither zero nor 0xFFFF, and stable across all three frames, so it is a
constant rather than noise. Still unknown, and still not decoded: nothing here reads past word
13. What changed is only that the description now matches the bytes.
How this was found is the point worth recording. The 2026-07-19 entry above needed a USB-RS485
tap and a laptop at the inverter — which is why the tail got a one-line summary and no
transcript. This came from the bridge itself: a driver capture (mode=driver, 30 s) records
the driver's own request/response pairs while polling continues, so checking this document
against the wire no longer needs any hardware beyond the bridge that is already there. Three
request/reply pairs, six frames, all six with a valid AA55 checksum, decoded by hand and
cross-checked against the same bridge's published measurements — 333.9 V × 5.2 A DC against
1671 W AC is 96.3% efficiency, which is what a string inverter does.
That feature exists because the older PASSIVE capture cannot do this: it takes the bus away from the driver for the whole window, so pointing it at a working inverter records nothing at all. See adding-a-device.md § 6.5.
That resolves the question about the config option in the reference: eversolar.pl indexes
words and ignores everything after them, so the payload length was never relevant to the
author — and therefore never decisive either. Explanation 2 from the previous revision of this
document was the correct one.
Auto now accepts both 28 and 44 as single (44 is hardware truth, not a guess), 32 as dual, and
rejects everything else. Whether 2-string firmware really sends 32 (or 44, or something else)
remains unvalidatable on this hardware; the dual path remains constructed. LayoutSelection
remains the escape hatch.
Because the hypothesis is unproven, decodeNormalInfo() has a LayoutSelection:
| Selection | Behavior |
|---|---|
Auto |
28 → single, 32 → dual, otherwise UnknownLayout (default) |
ForceSingleString |
interprets as 1 string; requires len >= 28 |
ForceDualString |
interprets as 2 strings; requires len >= 32 |
A forced layout only requires that the payload is long enough, not that the length matches
exactly. This way a device that contradicts the hypothesis remains readable without a firmware
change. A too-short payload gives LayoutMismatch — the buffer is never read out of bounds.
This is exposed as a generic driver option, not as a field in the config model:
{ "driver": { "id": "eversolar_legacy", "options": { "layout": "auto" } } }The driver declares the key layout with allowed values auto|single|dual in its
descriptor; validateDriverOptions() validates against that and the web interface renders it
generically. This keeps Configuration free of brand-specific fields. In line with §19 "manual
configuration is always possible". See docs/architecture.md.
- TEMP is signed.
eversolar.pl:1059-1061:if ($data >= 0x8000) { $data -= 0x10000 }, with comment "Temperature is signed, -0.1 = 0xFFFF". All other fields are treated as unsigned. - PAC has no scale factor — the raw word is watts. (
:1058,:1097) - E_TODAY is ÷100, while E_TOTAL is ÷10. Different resolutions, easy to get wrong.
eversolar.pl:1057:
my $e_total = $data[E_TOTAL] / 10 + $data[NA_1] * 65535 / 10;The high word is multiplied by 65535. The correct value is 65536 (1 << 16). The
correct calculation is a plain uint32:
energy.total [kWh] = ((E_TOTAL_HI << 16) | E_TOTAL_LO) / 10.0
Consequence for hardware validation (Phase 3/§42): our value systematically deviates from
eversolar-monitor by E_TOTAL_HI × 0.1 kWh. At a total around 18,452.7 kWh,
E_TOTAL_HI = 2, so our reading comes out 0.2 kWh higher. This is not a bug in our
implementation, and the acceptance criterion "energy exactly within scale factor" must be
adjusted for this: expect a deviation of exactly HI × 0.1 kWh, and verify that the
difference is exactly that value.
That NA_1/E_TOTAL together form a uint32 is a reverse-engineering conclusion of the
original author (comment :256: "NA_1 is actually upper bytes of total production"),
not a documented specification. Confidence: high but to be confirmed — it can only be
confirmed on hardware once E_TOTAL_LO rolls over past 0xFFFF, or by checking that
HI matches the cumulative total on the inverter's display.
IMPEDANCE (word 8, only in the 1-string layout) is logged by the reference but
without a unit or scale factor; presumably the insulation resistance in mΩ or Ω. NA_0 and
NA_2 are entirely unknown.
For these fields the rule from the assignment applies: unknown values are not published as zero. They are logged at TRACE level in Phase 3 so that their meaning can potentially still be determined, but they do not appear in the canonical measurement model as long as unit and scale are unknown.
The reference reads OP_MODE and publishes it as a raw number, without a value table.
No table is invented here — but two codes have now been measured on a real
TL3000-20 (day/night cycle via HA history, 2026-07-19 through 2026-07-26):
| Code | Meaning (measured) | Evidence |
|---|---|---|
| 1 | Grid-connected (normal) | Full production days show code 1; independently confirmed by the calibrated Zeversolar 2000s capture (ha-zeversolar-modbus) |
| 0 | Standby (not feeding) | Five independent events: dusk on July 19/20/21 (last minutes before shutdown show code 0) and two dawns — July 22 at 06:01 (four minutes of code 0, then code 1 at first production) and July 26 at 06:20:42 (≈5 minutes of code 0, then code 1 at 06:25:46, AC power 0 W→7 W then ramping). The July 26 dawn is a clean self-recovery on firmware 0.12.0: unknown straight to code 0 the moment the inverter re-registered, no stuck poll: timeout in between — the failure the sunrise-registration fix targets (see eversolar_driver.h, the 2026-07-21 note). |
Mapping in eversolar_parser.cpp::opModeText(); every other code honestly remains
"Unknown (<n>)" until it has actually been observed (candidate: an error code during a
grid fault).
Approach:
- The MVP publishes
status_codeas a raw uint16 and setsstatus_textto"Unknown (<n>)". - In Phase 3, the value is logged over a full day (night → startup → production → shutdown). The actually occurring codes follow from that sequence.
- Only after that is a mapping added, with the measurement as the source.
The same applies to error_code: the protocol has no separate error code field that
is read out in the reference. DeviceState::errorCode therefore stays at 0 with
supported = false; no fictitious field is filled in.
The reference validates minimally (send_request(), :456-467): only checksum +
control/function code. Source and destination address are not checked, and
$len = length($response) - 11 assumes there is exactly one frame in the buffer.
Our parser validates more strictly:
| Check | Rule |
|---|---|
| Minimum length | >= 11 bytes |
| Maximum length | 11 + 255; buffer capped at 300 bytes |
| Header | 0xAA 0x55 |
| Destination | must be 0x01 0x00 (= us) |
| Source | addressed queries: 0x00 <assigned address>. Registration broadcasts: also 0x00 0x00 — see below |
| Data length | N must match the actual frame length |
| Checksum | sum over [0 .. 8+N], and > 0 |
| Control/function | must be the expected response code |
| Payload length | per function: 28/32/44 for 11 82 (see layout section) |
Hardware correction (2026-07-19): an unregistered inverter answers the offline query from
source 00 00 — it doesn't have an address yet; that is precisely what the query is there to
arrange. The original assumption (answer from the not-yet-assigned address) was a constructed
guess, and the reference couldn't tell us because it doesn't check source addresses at all.
The registration steps therefore accept both sources; the ACK source (10 81) has not yet
been observed on hardware (this device registered in one go via the 00 00 path). Addressed
queries (11 02, 11 03) remain strictly on the assigned address — confirmed on hardware:
the normal-info response comes cleanly from 00 10.
If any check fails, the frame is discarded and invalid_frame_total /
checksum_error_total is incremented. No partial or corrected data is published.
The reference does sleep 1 after each request and then blindly reads 256 bytes
(:430-446). That is crude but proves that the inverter responds well within a second.
Our transport layer does it properly: read until the frame is complete (length follows from
byte 8), with a response timeout of 1000 ms and a 3000 ms ceiling on the whole
transaction, so a device that trickles bytes forever still ends. Both are compile-time constants
in src/protocols/pmu/pmu_transaction.h (kResponseTimeoutMs, kTransactionDeadlineMs), shared
with the other PMU-family driver.
There are no retries at this layer. An earlier version of this paragraph said "3 retries,
set in the driver's SerialProfile and configurable", which was wrong in all three parts: no
retry loop exists in the poll path, the SerialProfile::retries field it named was never read by
anything (it has since been removed), and nothing ever exposed it as a setting. A failed
transaction is counted and the next poll comes round on its own schedule; recovery is per-poll,
not per-transaction.
Supported, and untested against real hardware. Nobody involved has two EverSolar inverters, so everything below is reasoned from the protocol and from the reference implementation, which does serve several inverters on one loop. Read the pitfalls before you wire a second one.
Give each inverter its own assigned bus address, counting up from 16 (0x10): the first
device keeps the default, the second gets 17, and so on. The address is not stored in the
inverter — it is handed out at registration and forgotten on power loss.
A registered inverter ignores the broadcast offline query. So when the second driver instance sends that query, the first inverter stays silent and the next unregistered one answers. Instances start sequentially from the configuration, and that ordering is the whole mechanism.
The reference does the same thing from a timer instead of from a config list: register_inverter()
handles exactly one inverter per pass — its own comment says "register inverter that responds
first" (eversolar.pl:599-645) — and is called once a minute (:1038). We call it once per
configured device instead.
RE_REGISTER is a bus operation, not a device one. It tells every inverter on the line to
forget its address, so only the instance holding the first address (16) broadcasts it. The
reference is the same: one call, from inverter_connect() (:822). An unconditional broadcast
per instance is what made a second device knock out the first (#63).
A cold start has both inverters answering at once. After RE_REGISTER nothing on the line has
an address, so the first offline query is answered by every inverter simultaneously — on half
duplex. Our parser rejects the result (checksum, header and destination are all checked), and the
poll retries. The protocol defines no backoff, so if two inverters answer with identical timing
this could in principle repeat. The reference has exactly the same exposure and works in the
field, which suggests real devices differ enough in response latency for the frames to queue
rather than overlap; flushInput() before each request discards the surplus. This is the one
behaviour that genuinely needs two inverters to confirm.
Which config row gets which inverter is not fixed across reboots. Whichever answers first gets address 16. Device ids are derived from the serial number, so they stay stable per inverter — but anything keyed on the configuration slot can swap. The Modbus TCP unit ids are keyed that way. If you poll those, do not assume unit 1 is the same physical inverter after a restart.
Two devices sharing one address will not work, and will look like an intermittent inverter rather than a configuration mistake. The settings page warns when two configured devices share an address, which is why the option is named the same as the sibling drivers'.
Discovery still assigns 16. The wizard configures one device. A second inverter is added by hand afterwards, with its own address.
Recovery still needs the broadcast. An inverter holding an address from an earlier session ignores the offline query forever; only RE_REGISTER breaks that deadlock. That is why the first instance still sends it on every start, and why a single-inverter bridge behaves exactly as it did before this change.
Please report what happens on #82 — including "it did not work". The startup logs and the
checksum_error_total / invalid_frame_total counters say immediately whether the cold-start
collision above is real.
| Unknown | Impact | Approach |
|---|---|---|
| Does 2-string firmware really send 32 bytes? | Auto-detection could guess wrong | Not validatable on this hardware; LayoutSelection as escape hatch |
Meaning of OP_MODE codes |
status_text remains "Unknown (n)" |
Log over a full day, Phase 3 |
Meaning of IMPEDANCE, NA_0, NA_2 |
Not published | TRACE log, Phase 3 |
Format of QUERY_DESCRIPTION (11 00) |
Model info possibly derivable from this | Try it out in Phase 3 (read-only, safe) |
Format of QUERY_INVERTER_ID (11 03) |
ASCII, structure unknown | Log raw first, then parse |
Confirmation of uint32 energy.total |
0.1 kWh precision | Compare with display |
| Behavior at night | Timeouts vs. error frames | Phase 9 |
For none of these points is code written in Phase 2 that hardcodes an assumption.