Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ A lightweight Modbus TCP proxy with in-memory caching. Designed to reduce load o
- **Caching**: In-memory cache with configurable TTL
- **Request coalescing**: Identical concurrent requests share a single upstream fetch
- **Read-only mode**: Optionally block or ignore write requests
- **Vendor function codes**: Forward non-standard PDUs such as Huawei `0x41` without caching or retrying them
- **Auto-reconnect**: Automatic upstream reconnection on failure
- **Stale data fallback**: Optionally serve stale cache on upstream errors
- **Request diagnostics**: Structured lifecycle timing, retry, exception, and health state
Expand Down Expand Up @@ -93,6 +94,11 @@ corresponding success has occurred.
- `true`: Silently ignore write requests, return success response
- `deny`: Reject write requests with Modbus illegal function exception

Read-only mode applies only to the standard write function codes (`0x05`,
`0x06`, `0x0F`, `0x10`). Other function codes, including vendor codes such as
Huawei `0x41`, are forwarded as opaque PDUs. Those requests are not cached and
are not retried.

## Docker Compose Examples

### Basic Setup
Expand Down
11 changes: 11 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ Many Modbus devices (inverters, meters, battery systems) have limited polling ca
- `0x06` Write Single Register
- `0x0F` Write Multiple Coils
- `0x10` Write Multiple Registers
- Forward any other function code as an opaque PDU. This covers vendor codes
such as Huawei SUN2000 `0x41` (installer login and file transfer). Opaque
requests are not cached and are not retried.

### 2. Upstream Connection
- Connect to downstream Modbus device via TCP/IP only
Expand Down Expand Up @@ -125,6 +128,10 @@ Three modes:
- `true` (default): Silently ignore write requests, return success
- `deny`: Reject write requests with Modbus exception (illegal function)

Read-only mode applies only to the four standard write function codes. Vendor
and other non-standard function codes are always forwarded, because mbproxy
cannot invent a valid response for an unknown PDU.

### 5. Graceful Shutdown
- Handle SIGTERM/SIGINT signals
- Complete in-flight requests before shutdown (with configurable timeout, default: 30s)
Expand Down Expand Up @@ -275,6 +282,10 @@ The cache also exposes `Coalesce(ctx, rangeKey, fetch)` for request coalescing.
- Check readonly mode
- If allowed: increment the write generation and invalidate every cached register/coil in the written address range before forwarding upstream
- Return response
5. **For other function codes**:
- Forward the raw PDU upstream
- Do not cache, coalesce, or retry
- Do not invent a local success or exception response unless the PDU itself is missing or malformed

## Logging

Expand Down
52 changes: 50 additions & 2 deletions internal/modbus/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type clientSession interface {
Close() error
SetSlave(byte)
BeginRequest(context.Context, time.Duration) func() error
SendRaw(context.Context, []byte) ([]byte, error)
}

type sessionFactory func() (clientSession, requestClient)
Expand Down Expand Up @@ -184,6 +185,47 @@ func (c *tcpSession) SetSlave(slaveID byte) {
c.handler.SetSlave(slaveID)
}

func (c *tcpSession) SendRaw(ctx context.Context, pdu []byte) ([]byte, error) {
if len(pdu) < 1 {
return nil, fmt.Errorf("empty pdu")
}

request := &gridmodbus.ProtocolDataUnit{
FunctionCode: pdu[0],
Data: append([]byte(nil), pdu[1:]...),
}
aduRequest, err := c.handler.Encode(request)
if err != nil {
return nil, err
}
aduResponse, err := c.handler.Send(ctx, aduRequest)
if err != nil {
return nil, err
}
if err := c.handler.Verify(aduRequest, aduResponse); err != nil {
return nil, err
}
response, err := c.handler.Decode(aduResponse)
if err != nil {
return nil, err
}
if response.FunctionCode != request.FunctionCode {
exceptionCode := byte(0)
if len(response.Data) > 0 {
exceptionCode = response.Data[0]
}
return nil, &gridmodbus.Error{
FunctionCode: response.FunctionCode,
ExceptionCode: exceptionCode,
}
}

out := make([]byte, 1+len(response.Data))
out[0] = response.FunctionCode
copy(out[1:], response.Data)
return out, nil
}

func (c *tcpSession) BeginRequest(ctx context.Context, attemptTimeout time.Duration) func() error {
c.handler.Timeout = attemptTimeout

Expand Down Expand Up @@ -821,7 +863,13 @@ func ValidateRequest(req *Request) error {
return newValidationError(ExcIllegalValue, "write data has %d bytes, expected %d", len(req.Data), expected)
}
default:
return newValidationError(ExcIllegalFunction, "unsupported function code: 0x%02X", req.FunctionCode)
if len(req.PDU) < 1 {
return newValidationError(ExcIllegalFunction, "missing pdu for function code: 0x%02X", req.FunctionCode)
}
if req.PDU[0] != req.FunctionCode {
return newValidationError(ExcIllegalFunction, "pdu function code 0x%02X does not match request 0x%02X", req.PDU[0], req.FunctionCode)
}
return nil
}
if uint32(req.Address)+uint32(req.Quantity) > 65536 {
return newValidationError(ExcIllegalAddress, "address range exceeds 0xFFFF")
Expand Down Expand Up @@ -882,7 +930,7 @@ func (c *Client) executeRequest(ctx context.Context, req *Request) ([]byte, erro
}
return c.buildWriteResponse(req.FunctionCode, req.Address, results), nil
default:
return nil, fmt.Errorf("unsupported function code: 0x%02X", req.FunctionCode)
return c.session.SendRaw(ctx, req.PDU)
}
}

Expand Down
Loading