Register Mapping the Ampinvt TEL-48502M100 solar inverter
TL;DR: I turned a 5kW Ampinvt TEL-48502M100 solar inverter from a box with an LCD on it in a shed into something I can poll, log, safely write setpoints to, and trust to route my homelab rack off real battery charge instead of the voltage guesswork it ships with. Getting there needed a Modbus register map, which the vendor doesn’t publish and which as far as I can tell nobody else ever has - it’s not SRNE, it’s not MUST, and the vendor’s own published spec is for a completely different product line - so I scanned the whole 16-bit address space and value-matched registers against photographs of the LCD until the map fell out. Then I had to go do the entire thing AGAIN over Bluetooth, because the one number I actually needed - how full is this battery, really - is the one number the inverter cannot tell you.
If you’re shopping for a hybrid inverter you can actually drive from a script, this one qualifies now. And if you already own one, everything below is the map, the traps, and the working setup, so you don’t have to go find them yourself.
Contents
- What the system looks like
- Talking to the inverter
- Building the map
- Don’t trust the 0x20xx block
- Deriving what the map doesn’t have
- Writing setpoints safely
- Measuring charge state: coulomb counting
- Ground truth: BLE into the BMS
- Fusing the two signals
- Closing the loop: routing the load
- How it’s deployed
- The full register map
- Limits worth knowing before you start
What the system looks like
The setup: solar array, two Eco-Worthy 48V/100Ah LFP packs in parallel (~10kWh, 200Ah at 48V), one Ampinvt TEL-48502M100 inverter/charger, and the rack it powers. All of it lives in the same shed in my back yard. The inverter has an LCD with numbers on it, which is handy right up until you want those numbers from indoors, or in a graph, or from ten minutes ago.
What it grew into is three processes on one small VM, I’ll swap it to a container later probably, talking over a local MQTT broker:
┌──────────────┐ Modbus-RTU ┌──────────┐ MQTT ┌──────────┐ REST ┌───────────┐
│ inverter │──────────────▶│ daemon │──────────▶│ shim │─────────▶│ dashboard │
│ (CH340T) │ 9600 8N1 s1 │ pyserial │ topics │ FastAPI │ /status │ rich │
└──────────────┘ └──────────┘ └──────────┘ └───────────┘
▲ ▲
┌──────────────┐ BLE / GATT ┌──────────┐ MQTT
│ BMS packs │──────────────▶│ ble │──────────────▶ broker network
│ (JBD 0xFF00) │ notify 0xFF01│ poller │ derived (HA / Grafana)
└──────────────┘ └──────────┘ topics
daemonowns the serial port. Polls registers, decodes scale and sentinels, publishes per-field MQTT topics, integrates the energy ledger and the coulomb counter, and is the only process allowed to write setpoints.ble pollerowns the Bluetooth adapter. Reads each battery pack’s BMS, publishes per-pack telemetry, and runs the load-routing guard.shimsubscribes to everything, caches the latest values, and serves them as JSON over HTTP for the dashboard and anything else that doesn’t speak MQTT.
Splitting the two pollers isn’t just for giggles. BLE is slow and flaky next to RS-485, and I didn’t want a Bluetooth timeout to be able to stall inverter polling, so they’re separate processes under separate units. The poller doesn’t write to the daemon’s state either - it subscribes to what the daemon publishes and puts its own derived topics back on the bus.
Talking to the inverter
The inverter has a USB-B port with an internal CH340T behind it, and it speaks bog-standard Modbus-RTU: function 0x03 to read holding registers, 0x06 to write one, 9600 8N1, slave address 1. That part takes ten minutes to establish.
Making it reliable took a lot longer, and comes down to three things the firmware cares about.
Chunk reads at 32 registers. Anything much over 32 registers times out. Not an error, not an exception response, just silence until the timeout expires. 88 fails, 32 is comfortable.
Leave ~50ms between requests. The firmware ignores follow-up reads that arrive too fast, and it does it silently. Enforce it inside the client instead of trusting callers, or someone eventually writes a tight loop and spends an afternoon wondering where half their reads went.
Pin the line flags off. rtscts, dsrdtr and xonxoff all explicitly False - the CH340 drops reads if pyserial so much as toggles RTS on open.
Each of those fails partially rather than cleanly, which is what made them expensive to find. If you’re pointing a stock Modbus library at this device and getting intermittent reads, it’s one of the three.
Above that, read_blocks() groups a sparse address set into ≤32-register runs and returns how many runs succeeded, so the caller can tell “the bus is down” from “here’s a snapshot” instead of an empty reading that looks like the real thing. One subtlety in the watchdog: a hung serial read blocks the poll thread while holding the lock, so the watchdog needs a way to close the FD without taking that lock, or it blocks on the thing it’s supposed to be rescuing.
Building the map
I scanned 0x0000 through 0xFFFF. Everything that responds lives in 0x0400 to 0x21xx, in three clusters:
| Region | Role |
|---|---|
0x0500-0x0510 |
Live electrical readings (battery, PV) |
0x1000-0x110f |
Settings: charge curve, currents, priority/charge modes, SoC thresholds |
0x2000-0x212c |
Status/snapshot cluster - treat with suspicion, see below |
There is nothing above 0x30FF. The SRNE family keeps daily PV/grid/load energy counters up at 0xF0xx; this box has no equivalent I could find, which quietly decides what you can and can’t measure for the rest of the project.
Finding addresses that respond is easy. Working out what they mean is the whole job, and only two approaches ever proved anything.
For settings: photograph the LCD. Walk every parameter screen [01] through [63], photograph each one, and value-match against a register dump. If LCD parameter [09] reads 58.4V and 0x1007 holds raw 146, you have the address and the scale from a single photo.
That’s also how the weirdest convention on this device falls out. Most values are raw × 0.1, but charge-curve setpoints are stored per-12V-cell at 0.1V resolution, so on a 48V (4×12V) config the scale is ×0.4:
raw 146 → 14.6 V/cell → 58.4 V system
Assume ×0.1 like everything else, get 14.6V, conclude it isn’t a voltage and move on. The photo is what breaks the tie.
For live values: the delta test. Change exactly one physical condition and watch which raw register moves. This is the only thing that counts as an identification.
The delta test is also what settles sign conventions, which on this device are not what you’d guess: battery_current is negative when charging (current into the pack), positive when discharging. Confirmed against an LCD showing an unsigned “12A” with the PV→Battery arrow lit while the register read -12.3A. A backwards sign in a control rule does the catastrophic opposite of what you intended, so this one lives in a comment, in the docs, and in the field definition itself.
The map is a list of frozen dataclasses - address, name, scale, unit, signed, sentinel, notes - and the decode is about as boring as it should be:
def decode(field: Field, raw: int) -> float | int | None:
"""Apply scale + sentinel handling. Returns None if raw is a sentinel."""
sentinels = {field.sentinel} if field.sentinel is not None else DEFAULT_SENTINELS
if raw in sentinels:
return None
if field.signed and raw >= 0x8000:
raw = raw - 0x10000
return raw * field.scale if field.scale != 1.0 else raw
Raw 4096 and 0xFFFF are the device’s “no value” sentinels and decode to None, which propagates as an empty MQTT payload instead of a plausible-looking zero. A zero meaning “no reading” and a zero meaning “no current” look identical on a dashboard, and only one of them should ruin your evening.
The notes field is the one that earns its keep. It carries the evidence for every identification: which LCD photo it matched, which delta test moved it, and what I had it confidently mislabeled as beforehand. It’s why I can still trust a field I confirmed two months ago and haven’t thought about since.
Don’t trust the 0x20xx block
This is the most useful thing to know before you point anything at this inverter. The 0x2000-0x212c region responds to reads and looks fantastic - a value that reads 8 when the load is light, one that reads 640W, a VA/W pair sitting adjacent at 0x2030/0x2031 exactly how other OEMs lay out apparent and active power, a battery SoC at 0x2049 that matched the app on the nose first time I looked.
Almost none of it is live. The numbers are plausible, stale, and in some cases just firmware defaults that never change. Every address in the known-dead list at the bottom of this post came out of that block, and I misidentified three of them before I worked out what was going on.
Two exceptions, and they’re the useful ones. 0x203c and 0x203d are live, and they’re the only place this inverter tells you where your load is actually running:
| on mains | on battery | |
|---|---|---|
0x203c |
3588.7 | 191.9 |
0x203d |
212.8 | 8.0 |
Across 771 settled samples - 610 on grid, 161 on battery, spanning three real transfers - the two value sets are completely disjoint. They lag the actual transfer by one poll, so allow a sample either side of a changeover before reading them. They hold those constants while battery power swings -1447→+1324 W and PV runs 0→1825 W, so they report state, not magnitude: compare against the constants, don’t read the number as a measurement.
Also worth knowing: 0x0509 is PV power in watts, not a DC bus voltage. It reads ~154 at 1540W, which makes a 48V→120V inverter look like it’s reporting a ~154V internal bus, and I had it mislabeled that way for weeks. It tracks pv_voltage × pv_current at r=1.000 over 118 distinct values and reads 0 whenever PV current is 0. There is no DC-bus-voltage register on this device.
If you want to check a candidate register yourself, the quick version is: log it every 20-30 seconds through a window where the real quantity is swinging hard - dawn recharge is ideal - then count how many distinct values it took. Live registers took 70-108 distinct values across 180 samples for me. The dead ones took one to four. There’s no gray zone, and it takes one morning.
A couple of cautions on that. A low count alone isn’t proof, because 0x203c above only ever takes two values and is perfectly live - if a register describes a state rather than a quantity, ask how many states the machine has. And a register that only changes when the inverter changes mode will look dead unless your window contains a mode change, which is how I had 0x204b pegged as the operating-state enum for two months before testing it across a real transfer.
Whatever you disprove, keep it written down with the evidence attached instead of deleting it:
Field(0x2127, "cand_pv_power_0x2127", scale=10.0, unit="W",
notes="STALE - frozen at 1700W across the 2026-05-25 capture "
"(read 1700W at dawn with PV≈0). Use derived "
"pv_voltage × pv_current instead."),
That note has stopped me re-investigating 0x2127 at least twice, because 1700 is such a believable number for PV power.
Deriving what the map doesn’t have
The direct consequence of the stale-cache block: the only trustworthy power numbers on this inverter are derived, not read.
pv_power = pv_voltage × pv_current
battery_power = battery_voltage × battery_current # signed, negative = charging
load_balance = pv_power + battery_power # what the load is drawing
Everything downstream - grid offset, charge, discharge, the per-day ledger priced against my time-of-use rates - comes off those two products. Nothing reads a “power” register, because every power register on this device is a fiction.
There’s a second consequence: total grid usage is not measurable from this inverter. No AC-input power or energy register exists and the likely-looking candidates are all stale. The only honest grid quantity available is grid→battery charging, max(0, battery_charge - PV). True household grid draw needs an external CT clamp, and the software says so instead of inventing a self-sufficiency percentage.
Writing setpoints safely
Reading is free. Writing to a reverse-engineered address on a device rated for 5kW, with a lithium bank hanging off it, deserves more care - so every write goes through five gates. Three of them are boring and I’ll do them quickly:
- An allow-list. A field is unwritable until someone registers it explicitly, with bounds and a note saying where those bounds came from.
- Engineering-unit bounds. The API and CLI take volts, amps and percent, and conversion to raw happens once via the field’s own scale, so nobody hand-computes a ×0.4 at midnight.
- An audit log. Every attempt appends to JSONL with timestamp, initiator and before/after values,
fsync‘d, because an audit line that only reached the page cache is not evidence. An unclean reboot right after a setpoint change is the moment you want to know what was written.
The other two are worth more words.
Cross-field rules are where the domain knowledge lives. float_voltage ≤ boost_voltage, so the bank is never parked at absorption. mains_to_battery_v > battery_to_mains_v, or the transfer thrashes. And a minimum band between the transfer and handback thresholds:
# A grid→battery handback threshold this close to the battery→grid threshold is
# crossed by IR rebound alone when the load drops away, so the transfer never
# settles. Measured 2026-07-11: the two sat 0.4V apart (one raw step) and the
# rack flipped ~95×/hour.
MIN_TRANSFER_BAND_V = 1.0
That 0.4V is smaller than the bank’s IR rebound when the load lifts, so grid takes the load, the voltage springs back over the handback point, and the load gets handed straight back. Ninety-five relay flips an hour is audible, which is a diagnostic technique I don’t recommend but which is how I first noticed.
Read-back verification is the gate I’d keep if you made me give up the other four, and it’s specific to reverse-engineered maps: Modbus 0x06 echoes the frame back whether or not the address is real. The inverter will happily “accept” a write to a register it doesn’t own, with a well-formed echo and a valid CRC, and the value simply doesn’t stick. The echo proves nothing, so every write reads itself back:
readback = self.read_holding(address, 1)[0]
if readback != value:
raise ModbusError(
f"write verify failed at 0x{address:04x}: "
f"wrote {value} (0x{value:04x}), read back {readback} (0x{readback:04x})"
)
Measuring charge state: coulomb counting
Everything else in this system is shaped by one physical fact: LFP has no voltage curve to speak of. From roughly 20% to 90% state of charge the cell just sits on a plateau. I measured it under a full-rack ~26A draw and the bank held at 51.8V, dead flat, for nine straight minutes while true per-pack SoC fell from 44% to 42%. Two percent of the bank drained and the terminal voltage did not move!
Which makes any voltage-derived SoC fiction across the entire range you care about, and the inverter’s single SoC register is voltage-derived. The only honest way to get the number is to integrate current - amp-hours in, amp-hours out, against a known capacity. That’s soc.py.
def _integrate_locked(self, battery_current: float, gap_s: float) -> None:
# negative current = charging (into pack); positive = discharging.
amp_hours = -(battery_current + self.parasitic_a) * (gap_s / SECONDS_PER_HOUR)
if amp_hours >= 0: # charging: derate by coulombic efficiency
self._charge_ah += amp_hours * self.charge_efficiency
else: # discharging: full coulombs leave
self._charge_ah += amp_hours
A raw integrator drifts forever, so it needs propping up.
Anchor it on physics. When the bank reaches float voltage (≥56.4V) and current has tapered into the CV-phase tail (<5A) and both hold for 10 minutes, the bank is full by definition, so snap to 100%. Standard LFP recalibration, fires on every real full charge.
Make that anchor condition something you continuously observe. If samples stop - daemon restart, inverter dropout - the “at full” hold clock has to reset, or an outage that happens to bridge one high-voltage sample counts the entire blind period as “held at full” and fires the anchor on a half-empty bank the moment samples resume. Took me a while to work out why the counter kept jumping to 100% after restarts. Same applies to the integration itself: gaps longer than max_gap get dropped rather than integrated against stale current. I’d rather under-count than invent amp-hours.
Meter what you’d otherwise clamp. The count clamps at capacity, obviously, but a bank that keeps absorbing charge after you’ve declared it 100% was never full. So instead of throwing that away:
# The clamp would bin the evidence that we were never actually full.
if self._charge_ah > self.capacity_ah:
self._overflow_ah += self._charge_ah - self.capacity_ah
That meter paid for itself almost immediately. It caught the bank absorbing 51.2Ah over 16 hours past a verified anchor, then 65.3Ah over 21.4 hours on the next cycle. A healthy 200Ah bank cannot do that. Two cycles, one division:
51.2 Ah / 16.0 h = 3.20 A
65.3 Ah / 21.4 h = 3.05 A
Roughly 3.1A that the battery_current register never sees, because it’s the inverter’s own DC-bus self-consumption - about 160W of overhead sitting upstream of the shunt. Left unmetered it was inflating SoC by 40-50Ah a day, a quarter of the bank, which explains a lot of the dusk deaths that happened at a comfortable-looking “23%”. It’s a config constant now, derating charge and inflating discharge during integration.
The estimator also refuses to claim a number it hasn’t earned: while it’s carrying unexplained overflow it caps itself at 99.5%, and only a real float-and-taper anchor clears the books.
It publishes battery_soc_last_full so the dashboard can show when the count was last anchored, and “never” means it’s still running on its initial seed and you shouldn’t believe it yet. A bare percentage looks equally confident whether it was anchored an hour ago or never, which is how you end up trusting one that’s been drifting since Tuesday.
Ground truth: BLE into the BMS
Counting coulombs at the bank level has one blind spot. A single measurement sees two parallel packs as one, and on the flat part of the LFP curve two packs at identical terminal voltage can be holding quite different amounts of charge. The weaker one’s BMS cuts out first and takes the rack with it, and none of that is visible from any register on the Modbus bus.
The packs know, though. Each has a BMS, and the BMS talks Bluetooth, because that’s how the phone app works. So: go around the inverter entirely. A TP-Link UB500 dongle (2357:0604, RTL8761BU) went straight into the Proxmox host and got passed through to the monitoring VM. The one gotcha is a spectacularly unhelpful error: the VM is minimal and has no linux-firmware, so hci0 refused to come up with rtl8761bu_fw.bin not found, error -2. Rather than drag a multi-gigabyte firmware package onto a small VM, drop in the two files it actually wants - rtl8761bu_fw.bin (44 KB) and rtl8761bu_config.bin (6 bytes, yes really, six) - reload btusb, and you have a controller.
A GATT dump confirmed these are JBD/Xiaoxiang boards, the most common Chinese LFP BMS family and well enough documented by the community to work from. Dump the table, don’t trust the brand on the label - Eco-Worthy is a case around somebody else’s board, and whose board it is turns out to be the only question that matters.
After Modbus-with-caveats it’s a genuinely pleasant protocol. Service 0xFF00, write commands to 0xFF02, replies arrive as notifications on 0xFF01. Same frame shape both directions:
DD A5 77
Two commands cover everything worth having: DDA50300FFFD77 for basic info and DDA50400FFFC77 for per-cell millivolts. The checksum is 0x10000 - sum(cmd, len), and len is 0 for reads.
Replies arrive chunked across MTU-sized notifications, so you buffer and ask “is that a whole frame yet” after each one. Check the declared length byte and not just the trailing 0x77, or the first payload containing a stray 0x77 will hand you a truncated frame that decodes perfectly and means nothing:
def frame_complete(buf: bytes) -> bool:
if len(buf) < 7 or buf[0] != 0xDD:
return False
payload_len = buf[3]
return len(buf) >= 4 + payload_len + 3 and buf[-1] == 0x77
The basic-info decode is the prize. SoC, true pack current, residual capacity, cycles, protection flags, FET state and temperatures, all out of one 0x03 reply:
def decode_basic(frame: bytes) -> dict:
d = _validate(frame, 0x03)
ntc = d[22]
temps = [
(struct.unpack(">H", d[23 + 2*i:25 + 2*i])[0] - 2731) / 10.0
for i in range(ntc)
]
return {
"voltage": struct.unpack(">H", d[0:2])[0] / 100.0,
"current": struct.unpack(">h", d[2:4])[0] / 100.0, # signed, +charge
"resid_ah": struct.unpack(">H", d[4:6])[0] / 100.0,
"nominal_ah": struct.unpack(">H", d[6:8])[0] / 100.0,
"cycles": struct.unpack(">H", d[8:10])[0],
"protection": struct.unpack(">H", d[16:18])[0],
"soc_pct": d[19],
"fet": d[20],
"ncell": d[21],
"temps_c": temps,
}
Temperatures are decikelvin, hence the 2731. SoC is a single byte at offset 19. Cell voltages come back from 0x04 as a big-endian u16 of millivolts per cell, which gets you cell spread for free - a useful early warning that a pack is coming apart internally.
One trap when you bolt this onto something that already exists: the BMS says positive is charging, the inverter says negative is charging. Same current, same bank, opposite signs. Rather than rely on remembering that at every call site, the reading object carries an explicit current_inverter_convention() that re-signs it, and the convention is written on the dataclass where it can’t be missed.
The frame decoders are pure functions with bleak deferred into the one async call that needs it, so the whole protocol layer unit-tests against captured frames on a machine with no Bluetooth stack at all.
Fusing the two signals
Per-pack telemetry gets you two things neither source produces alone.
The imbalance floor is min(pack_socs), not the average. The weak pack is the one that cuts out, so the weak pack is the number the system runs on. It publishes the spread too and warns above 10 points, because two packs on the same bus drifting apart is something you want to hear about early rather than at dusk.
The parasitic cross-check falls out for free. The BMS measures true current at the pack terminals; the inverter register can’t see the inverter’s own bus draw. Subtract one from the other and there it is. First time I ran it the register said -12.7A “charging” while the BMS said -10.0A at the terminals, and that 2.7A gap is the same self-consumption I’d fitted weeks earlier from overflow accumulation - completely different data, agreeing to within half an amp. I was quite pleased with that one!
It runs continuously now and warns if the implied value drifts more than 1.5A from the configured constant, so a change in the inverter’s idle draw shows up as a log line rather than as mysterious SoC drift three weeks later. Gated on |current| ≥ 3A, because at rest you’re dividing noise by noise.
Closing the loop: routing the load
With a trustworthy charge number, the last piece is acting on it: keeping the load on the battery while there’s charge, and moving it to grid before the bank gets low enough for a pack to cut out.
The obvious approach is to set the inverter’s own thresholds and let its routing logic get on with it. I spent weeks on that. It does not work on this hardware, and not for want of trying values:
| Lever | Why it doesn’t close the loop |
|---|---|
battery_to_mains_v |
Voltage-gated on a chemistry with no voltage slope. It can force the load to grid, but never hands it back. |
mains_to_battery_v |
Firmware-floored (must exceed battery_uv_recovery_v), and its return is a lagged level trigger of about a minute. Set it above the float ceiling and it means “never switch back,” which parks the load on grid and halves PV harvest, since the MPPT curtails once the bank is full with nowhere to send power. |
switch_to_mains_soc |
Only governs when the BMS is actively talking to the inverter. When it isn’t, the parameter is inert and the box silently falls back to the voltage threshold. |
switch_to_inverter_soc |
Measured inert on this firmware at 60, 90 and 100. There’s no SoC hysteresis pair; the load hands back the instant SoC clears the single threshold. |
So instead of nudging thresholds and hoping, drive the load source directly, from the signal that knows the charge state, using the control that moves the load immediately: supply_priority_mode, the UTI/SBU/SOL enum at 0x1102.
The decision itself is a pure function, which makes it trivial to test every path:
def decide(state, soc_pct, soc_age_s, cfg) -> str:
"""Next state, given the current one and the freshest SoC we have."""
trusted = soc_pct is not None and soc_age_s <= cfg.stale_seconds
if not trusted:
# Cannot see the battery. Never switch back to it; if we were already on
# mains, stay there. On first run with no reading, assume the worst.
return GRID if state in (None, GRID) else state
if soc_pct <= cfg.floor_pct:
return GRID
if soc_pct >= cfg.resume_pct:
return BATTERY
# Between the rails: hold.
return state or BATTERY
Weakest-pack SoC at or below the floor (35%) writes mode 1, UTI: load runs on mains, battery stops draining, PV keeps refilling it. At or above resume (55%) it writes mode 2, SBU, and the load goes back to solar and battery. The config refuses to start if resume ≤ floor, because a guard with no hysteresis will just sit there flapping the relay all evening.
What makes it safe to leave running unattended is that every failure mode resolves toward grid. Grid is the backstop; the battery is the thing I can destroy. Stale SoC, dead BLE, first run with no reading - all route to mains and stay there, and it will never switch back to a battery it can’t currently measure. It writes through the same SafeWriter as everything else, so an automated actor gets no privileged bypass, and it reads back the live mode first, so a landed write goes quiet while a rejected one keeps retrying in the audit log instead of just sitting there doing nothing.
The switch moves the load in about 15 seconds, against the minute-long lag the voltage levers gave me. In service it cycles battery→grid around 35% and back around 55% through each dusk.
There are two easy ways to get the verification wrong, and I found both of them:
Working out which source is carrying the load needs an energy balance, not the charge sign. In daylight the bank charges whether the load is on grid or on battery, because PV surplus spills in either direction, so battery_current < 0 tells you precisely nothing. I burned about three hours on this and confidently reported a “hysteresis works” result that was entirely an artifact of it. What does discriminate, at any hour:
load + parasitic ≈ 475 W # measured at dawn: PV ≈ 0, bank supplies everything
implied_mains = bank_charge_W + 475 - PV_W
# ≈0 ⇒ on battery; >250 ⇒ on grid
The software coulomb counter is not in the routing path, and that was a deliberate demotion. The guard gates on min(pack_socs) from the BMSes and nothing else. My estimator is a model integrating a register that’s missing 3.1A, leaning on a float anchor that might not have fired in days; each JBD pack meanwhile does its own hardware coulomb count against its own shunt. Where those disagree the hardware wins, and it took a bad night to accept that - the estimator once read 33% while both packs sat at ~76%, and dragged the guard into a pointless grid transfer at dusk on a three-quarters-full bank. It’s a fine dashboard number. It has no business steering a contactor.
How it’s deployed
Three systemd units on a Rocky 9 VM with the CH340 and the BT dongle passed through: solar-monitor-daemon owns the serial port and everything derived from it, solar-monitor-ble owns the Bluetooth adapter and the routing guard, and solar-monitor-shim turns MQTT into REST for the dashboard. The BLE unit runs as root because BlueZ’s D-Bus policy denies org.bluez to everyone else. Both pollers are Type=notify with a systemd watchdog, and the daemon carries its own on top for the hung-serial-read case.
Couple of things worth stealing. A udev rule pins the inverter to /dev/solar-inverter and kills USB autosuspend for the CH340, because a suspended adapter looks just like a dead inverter and I only wanted to have that particular afternoon once. And the BLE poller must not inherit the shared mqtt.client_id - two clients with the same id collide on the broker, kick each other in a reconnect storm, and take the daemon’s publishing down along with the poller’s.
Config is one YAML file; the only values you’d have to think about are capacity_ah (must match the real bank or the percentage is meaningless), parasitic_a, the float-anchor pair full_voltage/taper_current_a, and the guard’s floor_pct/resume_pct.
The full register map
Everything below came off the live device. Bus is Modbus-RTU, function 0x03/0x06, 9600 8N1, slave 1, ≤32-register block reads, ≥50ms between requests. Raw 4096 (0x1000) and 0xFFFF are “no value” sentinels.
Live readings - these track reality and cross-check against the LCD:
| Addr | Field | Scale | Unit | How confirmed |
|---|---|---|---|---|
0x0500 |
battery_voltage | 0.1 | V | raw 533 → 53.3V, matches LCD exactly |
0x0501 |
battery_current | 0.1 | A signed | -12.3A while PV→battery arrow lit; negative = charging |
0x0502 |
battery_soc | 1 | % | the only SoC the inverter exposes; where it comes from varies, see limits |
0x0507 |
pv_voltage | 0.1 | V | matches LCD PV reading |
0x0508 |
pv_current | 0.1 | A | live PV input current (manual max 22A) |
0x0509 |
pv_power_reported | 1 | W | = pv_v × pv_i, r=1.000 over 118 distinct values |
0x0510 |
unidentified | ? | W? | live, loosely tracks PV, not proportional. Open |
0x1104 |
ac_output_voltage | 0.1 | V | raw 1200 → 120.0V |
0x212c |
ac_input_voltage | 0.1 | V | raw 1150 → 115.0V; never moves, treat as dead |
0x203c |
mains_state_0x203c | n/a | state | 3588.7 on mains / 191.9 on battery - tracks load source |
0x203d |
mains_state_0x203d | n/a | state | 212.8 on mains / 8.0 on battery - moves with 0x203c |
Settings - every row value-matched against a photograph of the corresponding LCD parameter. Charge-curve voltages use the ×0.4 per-12V-cell scale:
| Addr | Field | LCD | Scale | Notes |
|---|---|---|---|---|
0x1102 |
supply_priority_mode | [01] |
1 | 1=UTI grid-first, 2=SBU battery-first, 3=SOL PV-first |
0x110f |
charging_mode | [06] |
1 | 2=SNU (PV+mains hybrid); OSO/CSO/CUB codes not fully decoded |
0x1003 |
max_charge_current | [07] |
0.1 | A, hardware ceiling 100A |
0x1007 |
boost_voltage | [09] |
0.4 | absorption; raw 146 → 58.4V |
0x1008 |
float_voltage | [11] |
0.4 | should be ≤ boost |
0x1009 |
battery_recharge_v | [37] |
0.4 | recharge recovery point |
0x100a |
battery_uv_recovery_v | [35] |
0.4 | the register that actually ends a grid stint |
0x100c |
over_discharge_v | [12] |
0.4 | over-discharge cutoff |
0x100d |
battery_uv_alarm_v | [14] |
0.4 | under-voltage alarm |
0x1010 |
ac_output_rated_v | [38] |
1 | V |
0x1012 |
ac_output_frequency | [02] |
1 | Hz |
0x1018 |
battery_to_mains_v | [04] |
0.4 | switch load to grid below this |
0x1019 |
mains_to_battery_v | [05] |
0.4 | switch back above this; above float = never |
0x101b |
cutoff_charge_soc | [60] |
1 | % |
0x101c |
discharge_alarm_soc | [58] |
1 | % - alarm only, does not transfer load |
0x101d |
switch_to_mains_soc | [61] |
1 | % - only acts when BMS comms are live |
0x101e |
switch_to_inverter_soc | [62] |
1 | % - measured inert on this firmware |
0x1023 |
rs485_address | [30] |
1 | slave id |
0x1024 |
equalization_interval_d | [20] |
1 | days |
0x1103 |
ac_charge_current | [28] |
0.1 | A - DC-side, not AC draw. See limits |
0x202d |
mppt_temperature | - | 1 | °C, matched LCD 55°C; needs a thermal delta to fully confirm |
Known-dead, published so nobody re-investigates them: 0x203f (looks like load %), 0x204c and 0x2031 (look like load watts), 0x2030 (looks like VA), 0x2127 and 0x2125 (look like PV), 0x2049 and 0x2034 (look like SoC), 0x111f (looks like a second SoC - reads 40% on a near-empty bank), 0x203e (looks like AC-in volts), 0x2121, 0x2123, 0x2037, 0x2044, 0x204f. All frozen across long captures, including across real grid↔battery load transfers.
0x204b is dead, and it’s the one I’d most expected to be alive. An operating-state enum observed at 5, with an SRNE analog, plausibly “on grid / on battery / standby” - the one bit of routing visibility this map otherwise lacks. It sits at 5 across real grid↔battery transfers in both directions, so whatever it is, it isn’t that. The load-source signal is at 0x203c/0x203d instead.
Still open: 0x2116 and 0x2041, candidate temperatures. They stay flat for a legitimate reason - temperatures move slowly and nothing thermal happened while I was polling - so they need a real thermal swing before anyone can say anything about them. And 0x0510, which is unambiguously live (142 distinct values) but whose best correlate is battery power at r=-0.876 with a ±40% ratio spread. A ±40% spread is what made me throw out 0x203c the first time, and I’d rather not repeat that particular mistake, so it stays unnamed until PV and charge power drift far enough apart to tell them apart.
Limits worth knowing before you start
ac_charge_current[28]is DC-side, not AC draw. The manual calls it “Maximum AC charging current.” It’s the DC current into the battery, so 15A DC is roughly 7.5A AC at the wall, and it does not cap total AC draw - the inverter still pulls whatever the load needs on top of it. Worth knowing before you use it as a budget for anything.- The coulomb estimator has one open issue. Its float anchor needs the bank to reach 56.4V with tapered current, and under the SoC-guard regime the bank cycles roughly 35-80% and rarely gets there, so
last_fullcan sit at “never” and the count free-drifts. It’s decoupled from routing and the dashboard flags the low confidence, but the energy ledger leans on it. On the list. - Which lever governs depends on whether BMS comms are live. If the battery is talking to the inverter,
switch_to_mains_socis the real governor and the voltage thresholds do nothing - I heldbattery_to_mains_va full volt above the bank for three minutes and the load never moved. With no BMS link it falls back to voltage, which on LFP means it fires far too late. Check which regime you’re in before tuning anything, because the same setpoint does opposite amounts of nothing in each. - The BLE side is JBD-generic. If your pack answers on service
0xFF00, the decoder above should work as-is - but dump the GATT table and confirm before you trust the badge on the case.
The code that drives all this isn’t public - it’s a personal thing held together with optimism and systemd units, and it assumes my exact bank and my exact power rates. But the map is all up there, dead registers included. If you’ve got the same box, that should be enough to skip most of what I went through.