

ModbusProxyESP32-P4-ETH
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
1README
README.mdiot-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
┌────────────────────────────────────┐
│ 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
- 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.
- When the SolarEdge on Bus B queries a WattNode register, the matching
read_lambdapulls the cached sensor value, applies the WattNode unit convention, and returns it as an IEEE 754 float. - When the Garo charger on Bus C queries a meter register, the matching
read_lambdainverse-scales the cached sensor value back to the raw integer the GNM3D would have returned.
Hardware
| Component | Details |
|---|---|
| MCU board | Waveshare ESP32-P4-ETH (SKU 32086/32087/32088) |
| MCU | ESP32-P4, dual-core RISC-V @ 360 MHz, 32 MB flash |
| Ethernet | Onboard IP101 100 Mbps PHY |
| RS-485 | 3 × MAX485 half-duplex transceiver modules |
| Power | 5 V via USB-C or external supply |
GPIO pin assignment
| Bus / Role | TX | RX | DE/RE |
|---|---|---|---|
| UART1 — MASTER (Garo meter) | GPIO16 | GPIO14 | GPIO15 |
| UART2 — SERVER 1 (SolarEdge) | GPIO17 | GPIO19 | GPIO18 |
| UART3 — SERVER 2 (Garo charger) | GPIO22 | GPIO20 | GPIO21 |
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)
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 ──────────── GNDThe 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).
| Address | Type | Scale | ESPHome sensor ID | Unit |
|---|---|---|---|---|
| 0x0000 | S_DWORD_R | ×0.1 | garo_p1n_voltage | V |
| 0x0002 | S_DWORD_R | ×0.1 | garo_p2n_voltage | V |
| 0x0004 | S_DWORD_R | ×0.1 | garo_p3n_voltage | V |
| 0x0006 | S_DWORD_R | ×0.1 | garo_p12_voltage | V |
| 0x0008 | S_DWORD_R | ×0.1 | garo_p23_voltage | V |
| 0x000A | S_DWORD_R | ×0.1 | garo_p31_voltage | V |
| 0x000C | S_DWORD_R | ×0.001 | garo_p1_current | A |
| 0x000E | S_DWORD_R | ×0.001 | garo_p2_current | A |
| 0x0010 | S_DWORD_R | ×0.001 | garo_p3_current | A |
| 0x0012 | S_DWORD_R | ×0.1 | garo_p1_power_active | W |
| 0x0014 | S_DWORD_R | ×0.1 | garo_p2_power_active | W |
| 0x0016 | S_DWORD_R | ×0.1 | garo_p3_power_active | W |
| 0x0018 | S_DWORD_R | ×0.0001 | garo_p1_energy_apparent | kVAh |
| 0x001A | S_DWORD_R | ×0.0001 | garo_p2_energy_apparent | kVAh |
| 0x001C | S_DWORD_R | ×0.0001 | garo_p3_energy_apparent | kVAh |
| 0x001E | S_DWORD_R | ×0.0001 | garo_p1_energy_reactive | kVArh |
| 0x0020 | S_DWORD_R | ×0.0001 | garo_p2_energy_reactive | kVArh |
| 0x0022 | S_DWORD_R | ×0.0001 | garo_p3_energy_reactive | kVArh |
| 0x0024 | S_DWORD_R | ×0.1 | garo_voltage_ln | V |
| 0x0026 | S_DWORD_R | ×0.1 | garo_voltage_ll | V |
| 0x0028 | S_DWORD_R | ×0.0001 | garo_power_active | kW |
| 0x002A | S_DWORD_R | ×0.0001 | garo_power_apparent | kVA |
| 0x002C | S_DWORD_R | ×0.0001 | garo_power_reactive | kVAr |
| 0x002E | S_WORD | ×0.001 | garo_p1_power_factor | — |
| 0x002F | S_WORD | ×0.001 | garo_p2_power_factor | — |
| 0x0030 | S_WORD | ×0.001 | garo_p3_power_factor | — |
| 0x0031 | S_WORD | ×0.001 | garo_power_factor | — |
| 0x0033 | S_WORD | ×0.1 | garo_frequency | Hz |
| 0x0034 | S_DWORD_R | ×0.1 | garo_import_energy_active | kWh |
| 0x0036 | S_DWORD_R | ×0.1 | garo_import_energy_reactive | kVArh |
| 0x0038 | S_DWORD_R | ×0.0001 | garo_demand_power_active | kW |
| 0x003A | S_DWORD_R | ×0.0001 | garo_max_demand_power | kW |
| 0x0040 | S_DWORD_R | ×0.1 | garo_p1_import_energy_active | kWh |
| 0x0042 | S_DWORD_R | ×0.1 | garo_p2_import_energy_active | kWh |
| 0x0044 | S_DWORD_R | ×0.1 | garo_p3_import_energy_active | kWh |
| 0x004E | S_DWORD_R | ×0.1 | garo_export_energy_active | kWh |
| 0x0050 | S_DWORD_R | ×0.1 | garo_export_energy_reactive | kVArh |
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
| Register | WattNode field | Source sensor | Unit |
|---|---|---|---|
| 1000 | Total active energy | garo_import_energy_active | kWh |
| 1002 | Imported active energy | garo_import_energy_active | kWh |
| 1004 | Total active energy (non-reset) | garo_import_energy_active | kWh |
| 1006 | Imported active energy (non-reset) | garo_import_energy_active | kWh |
| 1008 | Total active power | garo_power_active | kW |
| 1010 | L1 active power | garo_p1_power_active | W |
| 1012 | L2 active power | garo_p2_power_active | W |
| 1014 | L3 active power | garo_p3_power_active | W |
| 1016 | L-N voltage (average) | garo_voltage_ln | V |
| 1018 | L1-N voltage | garo_p1n_voltage | V |
| 1020 | L2-N voltage | garo_p2n_voltage | V |
| 1022 | L3-N voltage | garo_p3n_voltage | V |
| 1024 | L-L voltage (average) | garo_voltage_ll | V |
| 1026 | L1-L2 voltage | garo_p12_voltage | V |
| 1028 | L2-L3 voltage | garo_p23_voltage | V |
| 1030 | L3-L1 voltage | garo_p31_voltage | V |
| 1032 | Frequency | garo_frequency | Hz |
Block 1100 — Per-phase energy, power factor, reactive/apparent power, currents
| Register | WattNode field | Source sensor | Unit |
|---|---|---|---|
| 1100 | L1 active energy | garo_p1_import_energy_active | kWh |
| 1102 | L2 active energy | garo_p2_import_energy_active | kWh |
| 1104 | L3 active energy | garo_p3_import_energy_active | kWh |
| 1106 | L1 imported active energy | garo_p1_import_energy_active | kWh |
| 1108 | L2 imported active energy | garo_p2_import_energy_active | kWh |
| 1110 | L3 imported active energy | garo_p3_import_energy_active | kWh |
| 1112 | Total exported active energy | garo_export_energy_active | kWh |
| 1114 | Total exported active energy (non-reset) | garo_export_energy_active | kWh |
| 1116–1120 | Per-phase exported energy | 0.0 (not in GNM3D) | kWh |
| 1122 | Total reactive energy | garo_import_energy_reactive | kVArh |
| 1124 | L1 reactive energy | garo_p1_energy_reactive | kVArh |
| 1126 | L2 reactive energy | garo_p2_energy_reactive | kVArh |
| 1128 | L3 reactive energy | garo_p3_energy_reactive | kVArh |
| 1130 | Total apparent energy | 0.0 (not in GNM3D) | kVAh |
| 1132 | L1 apparent energy | garo_p1_energy_apparent | kVAh |
| 1134 | L2 apparent energy | garo_p2_energy_apparent | kVAh |
| 1136 | L3 apparent energy | garo_p3_energy_apparent | kVAh |
| 1138 | Power factor (total) | garo_power_factor | — |
| 1140 | L1 power factor | garo_p1_power_factor | — |
| 1142 | L2 power factor | garo_p2_power_factor | — |
| 1144 | L3 power factor | garo_p3_power_factor | — |
| 1146 | Total reactive power | garo_power_reactive | kVAr |
| 1148–1152 | Per-phase reactive power | 0.0 (not in GNM3D) | kVAr |
| 1154 | Total apparent power | garo_power_apparent | kVA |
| 1156–1160 | Per-phase apparent power | 0.0 (not in GNM3D) | kVA |
| 1162 | L1 current | garo_p1_current | A |
| 1164 | L2 current | garo_p2_current | A |
| 1166 | L3 current | garo_p3_current | A |
| 1168 | Demand active power | garo_demand_power_active | kW |
| 1170 | Minimum demand power | 0.0 (not in GNM3D) | kW |
| 1172 | Maximum demand power | garo_max_demand_power | kW |
| 1174–1180 | Apparent/per-phase demand | 0.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.
| Register | Raw value formula | Sensor |
|---|---|---|
| 0x0000 | state × 10 | garo_p1n_voltage (V → tenths) |
| 0x000C | state × 1000 | garo_p1_current (A → mA) |
| 0x0012 | state × 10 | garo_p1_power_active (W → tenths) |
| 0x0028 | state × 10000 | garo_power_active (kW → 0.1 mW units) |
| 0x0034 | state × 10 | garo_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:
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:
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).
# 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.yamlThe 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| Qty | Value | Footprint | References |
|---|---|---|---|
| 3 | 0.1uF | Capacitor_SMD:C_0603_1608Metric_Pad1.08x0.95mm_HandSolder | C1, C2, C3 |
| 3 | 120 | Resistor_SMD:R_0603_1608Metric_Pad0.98x0.95mm_HandSolder | R1, R2, R3 |
| 3 | 2350514-3 | SamacSys_Parts:SHDRRA3W90P0X350_1X3_1110X920X691P | J1, J2, J3 |
| 3 | Jumper_2_Small_Bridged | Jumper:SolderJumper-2_P1.3mm_Bridged_RoundedPad1.0x1.5mm | JP1, JP2, JP3 |
| 3 | SP3485EN-L/TR | Package_SO:SOIC-8_3.9x4.9mm_P1.27mm | U2, U3, U4 |
| 2 | 10uF | Capacitor_SMD:C_1210_3225Metric_Pad1.33x2.70mm_HandSolder | C4, C5 |
| 2 | Conn_01x04_Pin | Connector_PinSocket_2.54mm:PinSocket_1x04_P2.54mm_Vertical | J4, J5 |
| 1 | ~ | ESP32-P4-ETH:ESP32-P4-ETH | U1 |
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 mm

- 4 layers85 × 113 mm

- 4 layers267 × 75 mm

Fracture
SharKingStudios/Fracture
An infinitely expandable, velocity sensing split midi keyboard.
208 parts10MIT - 2 layers62 × 208.5 mm

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 mm

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 mm

ELS project
jonasnic/Dreimaskin-ELS
this will contain everything in the Electronic lead screw project
83 parts1 - 2 layers55 × 73.5 mm

Pcb dezentral
petermaerki/puenterswis_heizung_2023_git
Puenterswis Heizungsersatz, Steuerung Anlage mit Oekofen Kaskade Pellematic Maxi PESK
98 parts1 - 2 layers100 × 100 mm

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