We live off-grid, so electricity is something everyone in the house thinks about. On a clear day, we’ve got enough solar to run the washing machine or heat water without touching the batteries much. On a cloudy day, the same jobs eat a big chunk of what we’ve got stored.

Our inverter, a MUST PV19-6048 EXP, already shows all of this on its front panel. Battery state, load, charging current, the lot. The problem is the data stays there unless someone walks over and looks.

I wanted it in Home Assistant, not as a copy of the same screen on a different device, but as something Home Assistant could actually act on. Tell us when it’s a good time to do laundry. Warn us before the batteries get too low.

That meant getting the inverter talking to something local, and keeping the whole chain off the internet where I could.

A port with no map

The PV1900 has an RJ45 socket on the back carrying RS485 and CAN signals. It looks like an Ethernet port and it categorically isn’t one. Plugging it into a switch is a bad idea.

The pinout is documented in MUST’s manual:

RJ45 pinSignal
1RS485 B
2RS485 A
3Signal ground
4-5CAN high / low
6-8Not assigned

So the physical layer is documented. What isn’t documented, not anywhere I could find, is a Modbus register map for the 4/6 kW PV1900 family. MUST will tell you which pin is which. They won’t tell you which register holds the battery voltage.

That’s the actual wall I hit. Not the wiring, the wiring is trivial. I had a serial port with no public spec for what was on the other end of it.

Someone else had already done the hard part

The register map I ended up using comes from the community Must Inverter Home Assistant integration, which has specifically been tested against the PV19/PV1900 EXP 4 kW and 6 kW models. It’s built entirely from reverse engineering, and it isn’t endorsed by MUST.

Before I wired anything up, I read through its source. Buried in there is a warning I wasn’t expecting: reading certain large blocks of holding registers in one request can shut the inverter and charger down immediately. Getting it running again means disconnecting the grid, battery and PV before restarting the unit.

That warning changed how I approached the whole project. I wasn’t just polling a chatty piece of hardware. I was polling a device that could switch itself off if I got curious with the register ranges.

So I set myself a rule before writing a line of ESPHome config: read only the addresses the community mapping already confirms, nothing else. No scanning to see what responds. No writing. If a register isn’t in that known list, it doesn’t exist as far as my ESP32 is concerned.

Hardware

A generic MAX485 breakout runs on 5V logic, which isn’t a clean match for the ESP32’s 3.3V GPIO. I went with an isolated MAX3485-class transceiver instead, so there’s a proper electrical boundary between the inverter and the rest of the house network.

The ESP32 gets its own power supply. I’m not pulling power off the inverter’s communications socket. Undocumented pins on a device that can apparently shut itself down aren’t something I want to experiment with.

Wiring uses just three of the eight RJ45 pins:

PV1900RS485 transceiver
Pin 1, RS485 BB
Pin 2, RS485 AA
Pin 3, GNDReference ground

I left the CAN pins untouched. On the ESP32 side, the transceiver connects to a UART TX pin, a UART RX pin and a direction-control pin.

Half-duplex is the part that actually bites

RS485 here is half-duplex. The ESP32 and the inverter share the same pair of wires to talk and listen.

Every request follows the same sequence: enable the transceiver’s driver, send the request, disable the driver and let go of the bus, wait for the inverter’s response, then decode it. Get that timing wrong and you end up talking over the inverter’s reply instead of hearing it.

ESPHome handles driver enable and disable through a flow_control_pin on the UART. What it doesn’t do automatically is find the right timing for your device. That’s controlled through settings such as send_wait_time and command_throttle.

Getting those dialled in took a few passes. I don’t have the exact numbers I started with anymore, but those two settings were the ones that mattered. They were the difference between clean responses and the ESP32 trying to talk while the inverter was still replying.

I landed on the same defaults used by the Must Inverter integration. That made sense, since it’s the same device on the other end of the wire.

esphome/pv1900.yaml yaml
uart:
  id: pv1900_uart
  tx_pin: GPIO17
  rx_pin: GPIO16
  flow_control_pin: GPIO4
  baud_rate: 19200
  data_bits: 8
  parity: NONE
  stop_bits: 1

modbus:
  id: pv1900_modbus
  uart_id: pv1900_uart
  send_wait_time: 500ms

modbus_controller:
  - id: pv1900
    address: 0x04
    modbus_id: pv1900_modbus
    command_throttle: 100ms
    update_interval: 15s

Address 4, 19200 baud, 15-second polling. Nothing unusual once it’s stable. It just takes a bit of patience to get there on your own hardware.

The registers I actually trust

I started with a small set rather than pulling every register the community mapping exposes: battery state of charge, PV voltage, battery voltage, solar charging current and power, household load, and battery power.

esphome/pv1900.yaml (sensors) yaml
sensor:
  - platform: modbus_controller
    modbus_controller_id: pv1900
    name: "PV1900 Battery State of Charge"
    register_type: holding
    address: 113
    value_type: U_WORD
    unit_of_measurement: "%"
    device_class: battery
    accuracy_decimals: 0

  - platform: modbus_controller
    modbus_controller_id: pv1900
    name: "PV1900 Solar Charging Power"
    register_type: holding
    address: 15208
    value_type: S_WORD
    unit_of_measurement: "W"
    device_class: power
    accuracy_decimals: 0

  - platform: modbus_controller
    modbus_controller_id: pv1900
    name: "PV1900 Household Load"
    register_type: holding
    address: 25215
    value_type: S_WORD
    unit_of_measurement: "W"
    device_class: power
    accuracy_decimals: 0

One thing worth flagging if you’ve got a PV1900: the community mapping also lists a second PV input, with registers around 16205 for PV2 voltage and current. According to MUST’s own spec sheet, the 4 kW and 6 kW models have a single MPPT. Dual MPPT only starts at the 6.2 kW model.

I’m not adding a second set of PV sensors on a 6 kW unit just because a register exists. Firmware shared across the range can expose addresses that do nothing on the smaller models.

Where it landed

Every reading I’ve pulled matches what’s on the inverter’s own display, which is the only real test that matters. Battery state of charge, load and solar power are all sitting in Home Assistant now, updating every 15 seconds instead of once every five minutes through MUST’s cloud app. None of it leaves the local network to get there.

It’s already doing something with the data too. The first automation is simple: if solar charging is above 1 kW and the battery is above 70 percent before 3pm, Home Assistant tells us it’s a good time to run the washing machine.

There’s nothing clever about the rule itself. What matters is that the inverter now behaves like part of the house instead of a screen you have to walk over and read.

There’s more to build on top of this: low-battery warnings and flagging solar output that’s lower than it should be for the weather. I’ll get into that once there’s more than one automation worth writing about.

For now, the win is smaller and more concrete: the data got out of the box on the wall, and it did it without an internet connection in the middle.