Skip to the content
PCBKitNew board
2 layers86.9 × 32.8 mmModbusProxyESP32-P4-ETH, the printed circuit board in 3D

ModbusProxyESP32-P4-ETH

ksjoberg/iot-modbus-proxy

ESPHome firmware for a Waveshare ESP32-P4-ETH board acting as a three-port Modbus RTU gateway. The device sits between a Garo GNM3D energy meter, a SolarEdge solar inverter, and a Garo GLB EV charger — translating…

Size
86.9 × 32.8 mm
Copper layers
2
Thickness
1.6 mm
Parts
20
Nets
42
Pads
105
Tracks, vias
119, 2
Pours
1
Routed
Yes
KiCad
KiCad 10.0 (file 20260206)
Created
10 May 2026
Updated
10 May 2026

Overview

ModbusProxyESP32-P4-ETH is an open-source printed circuit board by ksjoberg, from the ksjoberg/iot-modbus-proxy repository on GitHub. It is a 2-layer board of 86.9 × 32.8 mm with 20 parts on 42 nets, routed with 119 tracks and 2 vias.

It is built around the SP3485EN-L/TR. Its parts include 5 capacitors, 5 connectors, 4 ICs and 3 resistors. It belongs with the power boards and the ESP32 and RISC-V boards of this library.

Main components

1

README

README.md

iot-modbus-proxy

ESPHome firmware for a Waveshare ESP32-P4-ETH board acting as a three-port Modbus RTU gateway. The device sits between a Garo GNM3D energy meter, a SolarEdge solar inverter, and a Garo GLB EV charger — translating register maps on the fly so all three devices talk to each other over separate RS-485 buses without any direct wiring between them.


Architecture

text
                        ┌────────────────────────────────────┐
                        │        ESP32-P4-ETH                │
                        │                                    │
  Bus A  ──────────────►│  MASTER (UART1)                    │
  Garo GNM3D meter      │  polls FC04 INPUT registers        │
  address 0x02          │  every 10 s → caches in sensors    │
                        │                                    │
  Bus B  ◄──────────────│  SERVER 1 (UART2)                  │
  SolarEdge inverter    │  responds to FC03 as WattNode 202  │
  expects WattNode      │  reg 1000–1033, 1100–1181 (FP32_R) │
                        │                                    │
  Bus C  ◄──────────────│  SERVER 2 (UART3)                  │
  Garo GLB charger      │  responds as GNM3D clone           │
  expects GNM3D         │  same addr/type/raw values         │
                        └────────────────────────────────────┘

Each bus is electrically isolated (separate MAX485 transceivers). All communication is Modbus RTU at 9600 bps 8N1.

Data flow

  1. Every 10 seconds the master controller on Bus A sends FC04 Read Input Registers to the Garo meter and stores the decoded values in ESPHome sensor entities.
  2. When the SolarEdge on Bus B queries a WattNode register, the matching read_lambda pulls the cached sensor value, applies the WattNode unit convention, and returns it as an IEEE 754 float.
  3. When the Garo charger on Bus C queries a meter register, the matching read_lambda inverse-scales the cached sensor value back to the raw integer the GNM3D would have returned.

Hardware

ComponentDetails
MCU boardWaveshare ESP32-P4-ETH (SKU 32086/32087/32088)
MCUESP32-P4, dual-core RISC-V @ 360 MHz, 32 MB flash
EthernetOnboard IP101 100 Mbps PHY
RS-4853 × MAX485 half-duplex transceiver modules
Power5 V via USB-C or external supply

GPIO pin assignment

Bus / RoleTXRXDE/RE
UART1 — MASTER (Garo meter)GPIO16GPIO14GPIO15
UART2 — SERVER 1 (SolarEdge)GPIO17GPIO19GPIO18
UART3 — SERVER 2 (Garo charger)GPIO22GPIO20GPIO21

Reserved pins (do not use):

  • Ethernet IP101: GPIO28, 29, 30, 31, 34, 35, 49, 50, 51, 52
  • I2C ES8311: GPIO7 (SDA), GPIO8 (SCL)
  • I2S audio: GPIO9–13
  • Speaker enable: GPIO53
  • UART0 logger: USB-C console port

MAX485 wiring (identical for all three channels)

text
ESP32-P4              MAX485               RS-485 bus
──────────────        ─────────────        ──────────
GPIO TX    ──────►  DI  (pin 4)
GPIO RX    ◄──────  RO  (pin 1)
flow_ctrl  ──────►  DE  (pin 3)  ──┐
                     RE  (pin 2) ──┘  (tie together)
                     A   (pin 6)  ──────────── A
                     B   (pin 7)  ──────────── B
                     VCC          ────────────  3.3 V
                     GND          ────────────  GND

The flow_control_pin on each modbus hub is asserted HIGH before transmitting and released LOW after the last byte. No manual GPIO control is needed.

For cable runs longer than 5 m fit a 120 Ω termination resistor between the A and B conductors at each physical end of the segment.


Modbus register maps

Bus A — Garo GNM3D-LP-RS485 (master, FC04 input registers)

All registers are INPUT registers (FC04). 32-bit values use big-word-order (high word at lower address; S_DWORD_R in ESPHome). 16-bit values are signed (S_WORD).

AddressTypeScaleESPHome sensor IDUnit
0x0000S_DWORD_R×0.1garo_p1n_voltageV
0x0002S_DWORD_R×0.1garo_p2n_voltageV
0x0004S_DWORD_R×0.1garo_p3n_voltageV
0x0006S_DWORD_R×0.1garo_p12_voltageV
0x0008S_DWORD_R×0.1garo_p23_voltageV
0x000AS_DWORD_R×0.1garo_p31_voltageV
0x000CS_DWORD_R×0.001garo_p1_currentA
0x000ES_DWORD_R×0.001garo_p2_currentA
0x0010S_DWORD_R×0.001garo_p3_currentA
0x0012S_DWORD_R×0.1garo_p1_power_activeW
0x0014S_DWORD_R×0.1garo_p2_power_activeW
0x0016S_DWORD_R×0.1garo_p3_power_activeW
0x0018S_DWORD_R×0.0001garo_p1_energy_apparentkVAh
0x001AS_DWORD_R×0.0001garo_p2_energy_apparentkVAh
0x001CS_DWORD_R×0.0001garo_p3_energy_apparentkVAh
0x001ES_DWORD_R×0.0001garo_p1_energy_reactivekVArh
0x0020S_DWORD_R×0.0001garo_p2_energy_reactivekVArh
0x0022S_DWORD_R×0.0001garo_p3_energy_reactivekVArh
0x0024S_DWORD_R×0.1garo_voltage_lnV
0x0026S_DWORD_R×0.1garo_voltage_llV
0x0028S_DWORD_R×0.0001garo_power_activekW
0x002AS_DWORD_R×0.0001garo_power_apparentkVA
0x002CS_DWORD_R×0.0001garo_power_reactivekVAr
0x002ES_WORD×0.001garo_p1_power_factor—
0x002FS_WORD×0.001garo_p2_power_factor—
0x0030S_WORD×0.001garo_p3_power_factor—
0x0031S_WORD×0.001garo_power_factor—
0x0033S_WORD×0.1garo_frequencyHz
0x0034S_DWORD_R×0.1garo_import_energy_activekWh
0x0036S_DWORD_R×0.1garo_import_energy_reactivekVArh
0x0038S_DWORD_R×0.0001garo_demand_power_activekW
0x003AS_DWORD_R×0.0001garo_max_demand_powerkW
0x0040S_DWORD_R×0.1garo_p1_import_energy_activekWh
0x0042S_DWORD_R×0.1garo_p2_import_energy_activekWh
0x0044S_DWORD_R×0.1garo_p3_import_energy_activekWh
0x004ES_DWORD_R×0.1garo_export_energy_activekWh
0x0050S_DWORD_R×0.1garo_export_energy_reactivekVArh

Byte order note: The GNM3D stores INT32 values in big-big endian (both byte and word order are big-endian). This differs from the more common big-little convention. S_DWORD_R in ESPHome reads the high word from the lower register address, which matches the GNM3D native layout.


Bus B — WattNode 202 emulation for SolarEdge (server 1, FC03 holding registers)

The SolarEdge inverter expects a Continental Control Systems WattNode Modbus meter. All registers are 32-bit IEEE 754 floats stored in big word order (high word at lower address; FP32_R in ESPHome), matching the WattNode Modbus specification.

Registers not available from the GNM3D return 0.0.

Block 1000 — Power, voltage, frequency
RegisterWattNode fieldSource sensorUnit
1000Total active energygaro_import_energy_activekWh
1002Imported active energygaro_import_energy_activekWh
1004Total active energy (non-reset)garo_import_energy_activekWh
1006Imported active energy (non-reset)garo_import_energy_activekWh
1008Total active powergaro_power_activekW
1010L1 active powergaro_p1_power_activeW
1012L2 active powergaro_p2_power_activeW
1014L3 active powergaro_p3_power_activeW
1016L-N voltage (average)garo_voltage_lnV
1018L1-N voltagegaro_p1n_voltageV
1020L2-N voltagegaro_p2n_voltageV
1022L3-N voltagegaro_p3n_voltageV
1024L-L voltage (average)garo_voltage_llV
1026L1-L2 voltagegaro_p12_voltageV
1028L2-L3 voltagegaro_p23_voltageV
1030L3-L1 voltagegaro_p31_voltageV
1032Frequencygaro_frequencyHz
Block 1100 — Per-phase energy, power factor, reactive/apparent power, currents
RegisterWattNode fieldSource sensorUnit
1100L1 active energygaro_p1_import_energy_activekWh
1102L2 active energygaro_p2_import_energy_activekWh
1104L3 active energygaro_p3_import_energy_activekWh
1106L1 imported active energygaro_p1_import_energy_activekWh
1108L2 imported active energygaro_p2_import_energy_activekWh
1110L3 imported active energygaro_p3_import_energy_activekWh
1112Total exported active energygaro_export_energy_activekWh
1114Total exported active energy (non-reset)garo_export_energy_activekWh
1116–1120Per-phase exported energy0.0 (not in GNM3D)kWh
1122Total reactive energygaro_import_energy_reactivekVArh
1124L1 reactive energygaro_p1_energy_reactivekVArh
1126L2 reactive energygaro_p2_energy_reactivekVArh
1128L3 reactive energygaro_p3_energy_reactivekVArh
1130Total apparent energy0.0 (not in GNM3D)kVAh
1132L1 apparent energygaro_p1_energy_apparentkVAh
1134L2 apparent energygaro_p2_energy_apparentkVAh
1136L3 apparent energygaro_p3_energy_apparentkVAh
1138Power factor (total)garo_power_factor—
1140L1 power factorgaro_p1_power_factor—
1142L2 power factorgaro_p2_power_factor—
1144L3 power factorgaro_p3_power_factor—
1146Total reactive powergaro_power_reactivekVAr
1148–1152Per-phase reactive power0.0 (not in GNM3D)kVAr
1154Total apparent powergaro_power_apparentkVA
1156–1160Per-phase apparent power0.0 (not in GNM3D)kVA
1162L1 currentgaro_p1_currentA
1164L2 currentgaro_p2_currentA
1166L3 currentgaro_p3_currentA
1168Demand active powergaro_demand_power_activekW
1170Minimum demand power0.0 (not in GNM3D)kW
1172Maximum demand powergaro_max_demand_powerkW
1174–1180Apparent/per-phase demand0.0 (not in GNM3D)—

Registers 1600–1740 (WattNode configuration and identity blocks) are silently absorbed by server_courtesy_response which returns 0 for any address not explicitly registered.

Unit note: The WattNode register map is inconsistent in power units between the GNM3D source data — total active power (register 1008) is in kW, while per-phase active powers (registers 1010–1014) are in W. This matches the behaviour of the Python reference proxy which writes GNM3D values directly without conversion.


Bus C — Garo GNM3D transparent passthrough (server 2)

The Garo GLB charger is natively designed to communicate with the GNM3D companion meter and queries INPUT register addresses directly. This server mirrors the GNM3D register map by inverse-scaling the cached sensor values back to the raw integers the real meter would return.

RegisterRaw value formulaSensor
0x0000state × 10garo_p1n_voltage (V → tenths)
0x000Cstate × 1000garo_p1_current (A → mA)
0x0012state × 10garo_p1_power_active (W → tenths)
0x0028state × 10000garo_power_active (kW → 0.1 mW units)
0x0034state × 10garo_import_energy_active (kWh → 0.1 kWh)
……(full table in iot-modbus-proxy.yaml)

All 32-bit registers use S_DWORD_R (big word order) and all 16-bit registers use S_WORD (signed), matching the GNM3D native format.

Address gaps in the GNM3D register map (e.g. 0x003B–0x003F) are handled by server_courtesy_response which returns 0.


Configuration

Substitutions

All pin and address configuration is in the substitutions: block at the top of iot-modbus-proxy.yaml:

yaml
substitutions:
  mb_master_tx:  GPIO16   # Bus A TX → MAX485 DI
  mb_master_rx:  GPIO14   # Bus A RX ← MAX485 RO
  mb_master_de:  GPIO15   # Bus A DE/RE

  mb_slave1_tx:  GPIO17   # Bus B TX
  mb_slave1_rx:  GPIO19   # Bus B RX
  mb_slave1_de:  GPIO18   # Bus B DE/RE

  mb_slave2_tx:  GPIO22   # Bus C TX
  mb_slave2_rx:  GPIO20   # Bus C RX
  mb_slave2_de:  GPIO21   # Bus C DE/RE

  slave1_addr:   "2"      # SolarEdge expects WattNode at address 2
  slave2_addr:   "2"      # Garo charger expects meter at address 2

  master_poll_interval: "5s"

Secrets

Create a secrets.yaml file alongside the main YAML:

yaml
modbus_proxy_api_encryption_key: "your_32_byte_base64_key_here"
modbus_proxy_ota_password:       "your_ota_password_here"

Build and flash

Requires ESPHome >= 2025.1.0 (ESP32-P4 support).

bash
# Compile (no device needed — checks YAML validity)
esphome compile iot-modbus-proxy.yaml

# Compile and upload via USB
esphome run iot-modbus-proxy.yaml

# OTA update (device must be on the network)
esphome run iot-modbus-proxy.yaml --device <IP_ADDRESS>

# View serial logs
esphome logs iot-modbus-proxy.yaml

The board uses USB-C for both power and serial flashing. UART0 is reserved for the ESPHome logger; do not reassign it.


Known limitations and caveats

Floating-point precision for large energy values: The Bus C passthrough inverse-scales sensor floats back to raw integers. IEEE 754 single precision can represent integers exactly up to 2²⁴ = 16,777,216. For accumulated energy this means values above ~1,677,721 kWh (~1.7 GWh) may lose the last digit of precision. In practice this is not a concern for residential or small commercial installations.

Missing per-phase fields: The GNM3D does not expose per-phase reactive/apparent instantaneous power or per-phase export energy. Those WattNode registers return 0.0. The SolarEdge typically does not require these for basic energy yield monitoring.

Polling latency: Sensor values are refreshed every 5 seconds. The SolarEdge and Garo charger will read values that are up to 5 seconds old. Reduce master_poll_interval if more frequent updates are needed, keeping in mind the increased RS-485 bus load.

Parts

20
QtyValueFootprintReferences
30.1uFCapacitor_SMD:C_0603_1608Metric_Pad1.08x0.95mm_HandSolderC1, C2, C3
3120Resistor_SMD:R_0603_1608Metric_Pad0.98x0.95mm_HandSolderR1, R2, R3
32350514-3SamacSys_Parts:SHDRRA3W90P0X350_1X3_1110X920X691PJ1, J2, J3
3Jumper_2_Small_BridgedJumper:SolderJumper-2_P1.3mm_Bridged_RoundedPad1.0x1.5mmJP1, JP2, JP3
3SP3485EN-L/TRPackage_SO:SOIC-8_3.9x4.9mm_P1.27mmU2, U3, U4
210uFCapacitor_SMD:C_1210_3225Metric_Pad1.33x2.70mm_HandSolderC4, C5
2Conn_01x04_PinConnector_PinSocket_2.54mm:PinSocket_1x04_P2.54mm_VerticalJ4, J5
1~ESP32-P4-ETH:ESP32-P4-ETHU1

Questions

How big is the ModbusProxyESP32-P4-ETH PCB?

It measures 86.9 × 32.8 mm and has 2 copper layers, 1.6 mm thick.

What parts does the ModbusProxyESP32-P4-ETH use?

20 parts, built around the SP3485EN-L/TR. The full list of its parts is on this page, with links to each part's symbol and footprint where PCBKit's library has them.

Where can I download the ModbusProxyESP32-P4-ETH KiCad files?

From the ksjoberg/iot-modbus-proxy repository on GitHub, at the commit this page was made from: the board is pcb/ModbusProxyESP32-P4-ETH.kicad_pcb and its schematic pcb/ModbusProxyESP32-P4-ETH.kicad_sch. PCBKit opens the board in the browser, with nothing to install.

Can I use the ModbusProxyESP32-P4-ETH design in my own project?

Its repository states no licence, so its author keeps all rights: ask ksjoberg before reusing the design.

Similar boards

  • 4 layers85 × 56 mmAnthC, the printed circuit board in 3D

    AnthC

    Anthilla/AnthC

    Anthilla AnthC Board

    211 parts40
  • 4 layers267 × 75 mmFracture, the printed circuit board in 3D

    Fracture

    SharKingStudios/Fracture

    An infinitely expandable, velocity sensing split midi keyboard.

    208 parts10MIT
  • 2 layers62 × 208.5 mmCombined, the printed circuit board in 3D

    Combined

    wr/airwriter

    Print to vintage Apple printers from modern devices over AirPrint, or from a vintage Mac over AppleTalk.

    154 parts7
  • 2 layers100 × 75 mmDisplay, the printed circuit board in 3D

    Display

    DLaxV-Community-Projects-Hub/Open-Shot-Clock

    Build your own DIY Shot Clock

    209 parts5CERN-OHL-P-2.0
  • 4 layers95.6 × 86.1 mmELS project, the printed circuit board in 3D

    ELS project

    jonasnic/Dreimaskin-ELS

    this will contain everything in the Electronic lead screw project

    83 parts1
  • 2 layers55 × 73.5 mmPcb dezentral, the printed circuit board in 3D

    Pcb dezentral

    petermaerki/puenterswis_heizung_2023_git

    Puenterswis Heizungsersatz, Steuerung Anlage mit Oekofen Kaskade Pellematic Maxi PESK

    98 parts1
  • 2 layers100 × 100 mmPcb moduloDidaticoSCADA, the printed circuit board in 3D

    Pcb moduloDidaticoSCADA

    wvianna/pcb-moduloDidatico-SCADA-IoT

    Projeto de uma PCB para utilização de protocolos: Modbus RTU, Modbus TCP, MQTT, OPC UA pub/sub. Finalidade didática. O firmware também está implementado.

    98 parts1