One Modbus Frame from Freedom

My Solis RAI battery inverter has a perfectly good local control port - Solis just never told anyone. Here's how I found it, moved the inverter onto my own network, and wired it into Octopus Agile automation. Entirely cloud-free.

This project started with an iPhone app that just wouldn’t stay open. An inverter I use for battery-backup on my home networking cabinet, a Solis rai-3k-48es-5g, came with an app called Solis TechView, and on my current phone it would launch, let me poke about for a few seconds, and then instantly die. Which was a shame, because I had a specific reason to want to talk to this inverter: I run SolisAgileManager to charge my batteries when Octopus Agile electricity is cheapest, and it works well on my other inverter, a Solis rhi-3.6k-48es-5g.

This one just wouldn’t play ball - SolisAgileManager drives inverters through the official SolisCloud API, and for reasons Solis never really explained, this unit wouldn’t accept control commands over that API (they said something about needing a firmware update, but it’s too old to accept one - had it been in warranty they’d replace the unit). The cloud could read it all day long, it just wouldn’t take orders. So I had a Pylontech US5000 battery I could watch getting expensive, and no supported way to tell it to behave.

The app was a clue, though, and I started to wonder - if a dead Android app could talk to this inverter, then so could SolisAgileManager.. I just needed to work out how.

Tapping the wire

The app is only a bundle of compiled bytecode, so I pulled it apart with jadx and went looking for anything that smelled like a network connection. It didn’t take long. Buried in a class literally named Const:

HOST     = "10.10.10.1"
TCP_PORT = 8080     // the one it actually uses
LIVE_PORT = 5500    // dead constant

The app joins the inverter’s own Wi-Fi and opens a plain TCP socket to 10.10.10.1:8080.

The wire format was the good bit. Every command the app sent was a raw Modbus RTU frame - unit address, function code, register, a CRC16 on the end - and nothing else. I traced the connection handler expecting a login step, a token, a serial-number handshake, something, and there was nothing there. The socket opens and the very first bytes you’re allowed to send are a valid Modbus read. Every frame is addressed to unit 0x01. There’s no gatekeeper at all.

The security model was that you had to already be on the inverter’s Wi-Fi - that was the whole model!

For a home-automation project that’s about the best news you can get: a documented, unauthenticated, industry-standard protocol. A clean replay, in other words. I confirmed it from my laptop with a twenty-line script - connect, send eight bytes - and the inverter happily told me its entire charge/discharge schedule:

TX → 01 03 A8 85 00 0A F4 44
     unit 01 · fn 03 read · reg 0xA885 · qty 10 · crc F4 44

Mapping the registers

One working read is a foothold; a whole product is a map. I went back through the decompiled screens - the battery page, the power page, the schedule editor - and matched every on-screen number to the register it came from and the maths applied to it. The prize was the time-of-use block at 0xA885: ten consecutive registers holding the entire charge and discharge plan.

Register Field Encoding
0xA885 Charge current amps ×10
0xA886 Discharge current amps ×10
0xA887 / 88 Charge start hour / minute
0xA889 / 8A Charge end hour / minute
0xA88B / 8C Discharge start hour / minute
0xA88D / 8E Discharge end hour / minute

Write those ten registers and you own the battery’s daily plan. Elsewhere in the map: state of charge, battery and grid power, house load, temperature - the lot.

The CRC was the last piece, and it’s nothing exotic - standard Modbus, polynomial 0xA001, appended low byte first. Small enough to reproduce in any language in a handful of lines:

function crc16(bytes) {            // classic Modbus CRC
  let crc = 0xFFFF;
  for (const b of bytes) {
    crc ^= b;
    for (let i = 0; i < 8; i++)
      crc = (crc & 1) ? (crc >> 1) ^ 0xA001 : crc >> 1;
  }
  return [crc & 0xFF, crc >> 8];   // low byte, then high
}

Getting it off the island

There was still a catch. All of this lived on the inverter’s private Wi-Fi at 10.10.10.1 - an island. For any of it to be useful, the inverter needed to be a proper citizen of my home network, reachable by a server that never sleeps.

The Wi-Fi turned out to be a built-in MXCHIP EMW3080 module, and its mDNS record gave the game away: it advertises an _easylink._tcp service and, on port 80, a small web config server behind basic auth. I chased a red herring first - the module also speaks a framed AT-command protocol, but on port 8080 that just falls through to the transparent Modbus bridge, which is why every “AT” I sent came back as inverter telemetry. The real door was the port-80 web page: I entered my home SSID and password, and the module joined the LAN.

The moment of truth was whether the local Modbus survived the move. It did. On my network, on a fixed address, the inverter answered the exact same eight bytes:

TX → 0103A885000AF444
RX ← 01031400C800C8…AA2A

Charge     20.0 A   04:00-16:00
Discharge  20.0 A   16:00-04:00
CRC: OK

Same protocol, new home. Permanent, cloud-free local control, from any machine on the LAN.

Teaching SolisAgileManager to speak Modbus

Now the fun part. SolisAgileManager (SAM) is cloud-only by design - the docs even say there’s no local mode. But it’s well-built, and that turned out to matter more. Every inverter sits behind a small IInverter interface, and a factory picks the implementation from config. That’s a seam. I didn’t need to rewrite SAM, I just needed to add one more implementation.

So I wrote a local-Modbus provider. Its SetCharge maps SAM’s “charge from 12:00 to 12:30 at 20 amps” almost one-to-one onto that 0xA885 block, and its telemetry read fills SAM’s dashboard from the input registers. Two details mattered: it reads the schedule before writing, so it never burns an EEPROM cycle setting a value that’s already correct, and it honours SAM’s built-in simulate flag - dry-run everything, log the exact frame, write nothing - which I left on until I trusted it.

Then came the usual run of deployment papercuts, and to be clear none of them were SAM’s doing - they were all mine: an Arm image I’d built that wouldn’t run on an x86 box, a Dockerfile I’d pinned to the wrong .NET, a build that failed because I’d forgotten a version number. The one genuinely interesting snag was that SAM fixed its inverter provider at boot, so it couldn’t switch inverter type at runtime - which makes complete sense, since nobody had needed to run two different inverters through one instance before. A small gap rather than a fault, and an easy one to close: a two-line change I sent back upstream so the next person with a mixed setup gets it for free.

The final error was the reassuring kind. No route to host - not a code fault, not the inverter, just a Wi-Fi device grabbing a new DHCP lease mid-reconnect. A fixed address on the router put it to bed.

Landing it on the metal

With the battery full and prices about to spike, I clicked discharge. SAM computed the slot, my provider assembled the frame, and the inverter did as it was told. I read the registers straight back to be sure the command had actually landed in hardware rather than just in my head:

Charge     0.0 A   --:--   (cleared)
Discharge  20.0 A  12:00-12:30
CRC: OK

live: battery −1.0 kW discharging   SOC 100→99%

Exactly the slot SAM intended, confirmed on the metal, with about a kilowatt flowing back out of the battery on command. From an inverter the vendor’s own API wouldn’t let me control, to full Octopus Agile automation running on my own hardware - reads, writes and live control, all local.

What I took away

  • The old Android app became the docs. A vendor tool that talks to the hardware contains a complete, working spec of how to talk to the hardware. Decompiling it wasn’t particularly tricky - I set Claude Code off searching the decompiled source for Modbus signatures and got an up-to-date register list back. It’s just reading the manual nobody published.
  • The inverter was never really locked down. Everyone assumed this unit was cloud-only, but the local port was open the whole time and it spoke plain Modbus, which is a fifty-year-old open standard. There was nothing to break into - it just wasn’t written up anywhere, so as far as anyone knew it didn’t exist.
  • A clean interface meant I could add to SAM rather than fork it. Because every inverter sat behind that small IInverter seam, I could bolt local control on alongside everything else rather than keep my own diverging copy, and the handful of fixes I made along the way went back upstream where they’re useful to someone else, instead of rotting in a private fork.
  • Reading the registers back is what proved it worked. After every write I read the block straight back, and I left the dry-run mode on until I trusted it. Watching the schedule actually change in hardware is a very different thing to assuming the command landed just because the script didn’t throw an error.

Watching the battery discharge on command was satisfying, but that’s really just the demo. What I was after was an inverter that does what I ask it to, on my own network, without needing Solis’s cloud to sign off first. That’s what I’ve got now, and it’s been quietly getting on with it ever since.


This was all done on my own inverter and my own network, over what turned out to be an open, standard protocol. Usual disclaimer: no warranties, and if you go poking around your own kit, that’s on you ;)