A lightweight, standalone Modbus TCP proxy server that caches register values in memory. Designed to reduce load on downstream Modbus devices by serving cached responses to multiple clients (e.g., Home Assistant, EVCC, other energy management systems).
Many Modbus devices (inverters, meters, battery systems) have limited polling capacity or slow response times. When multiple consumers need the same data, each polling independently can overload the device or cause timeouts. A caching proxy allows frequent polling from multiple clients while minimizing upstream device load.
┌─────────────────┐ ┌──────────────────────────┐ ┌─────────────────┐
│ Modbus Client │────▶│ Modbus Proxy Server │────▶│ Modbus Device │
│ (HA, EVCC...) │◀────│ (with in-memory cache) │◀────│ (Inverter...) │
└─────────────────┘ └──────────────────────────┘ └─────────────────┘
▲ │
│ ┌────┴────┐
│ │ Cache │
│ │ (Memory)│
└────────────────────┴─────────┘
- Listen on configurable TCP port
- Support multiple concurrent client connections
- Handle standard Modbus function codes:
0x01Read Coils0x02Read Discrete Inputs0x03Read Holding Registers0x04Read Input Registers0x05Write Single Coil0x06Write Single Register0x0FWrite Multiple Coils0x10Write Multiple Registers
- Connect to downstream Modbus device via TCP/IP only
- Support multiple slave IDs through single connection
- Support clients requesting different slave IDs through the proxy
- Auto-reconnect on connection failure (unlimited retries, no backoff)
- Request pacing: configurable delay between upstream requests to prevent overwhelming slow devices
- TCP keep-alive enabled (30s interval) for connection health monitoring
- Connect delay: optional silent period after establishing connection for device settling
{slave_id}:{function_code}:{start_address}:{quantity}
type CacheEntry struct {
Data []byte
Timestamp time.Time
TTL time.Duration
}- Read Operations: Check cache first, return if valid (not expired)
- Write Operations: Always forward to device, invalidate exact matching cache entries (same slave_id, function_code, start_address, quantity)
- TTL: Configurable (default: 10 seconds)
- Cleanup: Time-based expiration (entries removed when TTL expires)
- Staleness: Option to serve stale data on upstream failure (default: off)
- Identical in-flight requests are coalesced (same slave_id, function, address, quantity)
- Second request arriving while first is pending will wait for and share the first's response
- Prevents thundering herd on cache miss
- Configurable delay after each successful upstream request
- Protects slow Modbus devices that cannot handle rapid-fire requests
- Delay is context-aware: cancelled if the request context is cancelled
- Only applied after successful requests (not during error recovery/reconnection)
- Logged at DEBUG level when applied
Three modes:
false: Full read/write passthroughtrue(default): Silently ignore write requests, return successdeny: Reject write requests with Modbus exception (illegal function)
- Handle SIGTERM/SIGINT signals
- Complete in-flight requests before shutdown (with configurable timeout, default: 30s)
- Close upstream connection cleanly
| Variable | Description | Default | Example |
|---|---|---|---|
MODBUS_LISTEN |
TCP address and port to listen on | :5502 |
:5502, 0.0.0.0:502 |
MODBUS_UPSTREAM |
Upstream Modbus device address | (required) | 192.168.1.100:502 |
MODBUS_SLAVE_ID |
Default slave ID for upstream | 1 |
1 |
MODBUS_CACHE_TTL |
Cache time-to-live | 10s |
10s, 1m, 500ms |
MODBUS_CACHE_SERVE_STALE |
Serve stale data on upstream error | false |
true, false |
MODBUS_READONLY |
Read-only mode | true |
false, true, deny |
MODBUS_TIMEOUT |
Upstream connection timeout | 10s |
5s, 30s |
MODBUS_REQUEST_DELAY |
Delay after each upstream request | 0 (disabled) |
100ms, 500ms |
MODBUS_CONNECT_DELAY |
Silent period after connecting to upstream | 0 (disabled) |
500ms, 2s |
MODBUS_SHUTDOWN_TIMEOUT |
Graceful shutdown timeout | 30s |
10s, 60s |
LOG_LEVEL |
Log level | INFO |
INFO, DEBUG |
The container health check runs mbproxy -health, which performs an internal upstream connectivity check without binding a separate local TCP port.
github.com/grid-x/modbus- Modbus TCP client (upstream communication)
The Modbus TCP server is implemented from scratch (~300-400 lines) rather than using an external library. This provides:
- Better fit for proxy use case (libraries like
mbserverare designed to emulate devices, not proxies) - Clean handler-based interface as shown below
- No dependency risk from unmaintained libraries
- Full control over connection handling and request routing
type Handler interface {
HandleCoils(req *CoilsRequest) ([]bool, error)
HandleDiscreteInputs(req *DiscreteInputsRequest) ([]bool, error)
HandleHoldingRegisters(req *HoldingRegistersRequest) ([]uint16, error)
HandleInputRegisters(req *InputRegistersRequest) ([]uint16, error)
}
type CachingHandler struct {
log Logger
readOnly ReadOnlyMode
conn Connection
cache *Cache
}type Cache struct {
mu sync.RWMutex
entries map[string]*CacheEntry
ttl time.Duration // default: 10 * time.Second
}
func (c *Cache) Get(key string) ([]byte, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
entry, ok := c.entries[key]
if !ok || time.Since(entry.Timestamp) > entry.TTL {
return nil, false
}
return entry.Data, true
}
func (c *Cache) Set(key string, data []byte, ttl time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.entries[key] = &CacheEntry{
Data: data,
Timestamp: time.Now(),
TTL: ttl,
}
}- Client sends Modbus TCP request
- Parse request: extract slave ID, function code, address, quantity
- For reads:
- Build cache key
- Check cache → if hit & valid, return cached data
- On miss: forward to upstream device
- Store response in cache
- Return response to client
- For writes:
- Check readonly mode
- If allowed: forward to upstream, optionally invalidate cache
- Return response
- INFO (default): Startup, shutdown, connection events
- DEBUG: Cache hits/misses, upstream requests, timing
level=INFO msg="starting proxy" listen=:5502 upstream=192.168.1.100:502
level=DEBUG msg="cache hit" slave_id=1 func=0x03 addr=0 qty=10
level=DEBUG msg="cache miss" slave_id=1 func=0x03 addr=0 qty=10
level=DEBUG msg="upstream request completed" slave_id=1 func=0x03 addr=0 qty=10 duration=15ms
level=DEBUG msg="applying request delay" delay=100ms
level=DEBUG msg="applying connect delay" delay=500ms
level=WARN msg="upstream error, serving stale" slave_id=1 error="timeout"
level=INFO msg="shutting down"
# Minimal (required: MODBUS_UPSTREAM)
MODBUS_UPSTREAM=192.168.1.100:502 modbus-proxy
# Custom listen port and TTL
MODBUS_LISTEN=:502 MODBUS_CACHE_TTL=5s MODBUS_UPSTREAM=192.168.1.100:502 modbus-proxy
# Enable writes passthrough
MODBUS_READONLY=false MODBUS_UPSTREAM=192.168.1.100:502 modbus-proxy
# Debug logging
LOG_LEVEL=DEBUG MODBUS_UPSTREAM=192.168.1.100:502 modbus-proxyAfter implementation, generate the following based on the actual code:
- README.md: User-facing documentation with Docker Compose examples showing how to run the container
- Dockerfile: Multi-stage build targeting scratch base image for minimal size (~10MB)
- .dockerignore: Exclude unnecessary files from build context
- docker-publish.yml: Build and publish to GHCR on tags and main branch, multi-arch (amd64/arm64)
- test.yml: Run tests and linting on PRs