Skip to content

Commit 41ef19d

Browse files
committed
feat: add TCP keep-alive and connect delay for upstream connection
- Enable TCP keep-alive (30s) for connection health monitoring - Add MODBUS_CONNECT_DELAY for post-connection settling time (default: 0) - Use custom dialer with net.Dialer for keep-alive support
1 parent 1ef180a commit 41ef19d

File tree

6 files changed

+44
-4
lines changed

6 files changed

+44
-4
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ All configuration is via environment variables:
3535
| `MODBUS_READONLY` | Read-only mode: `false`, `true`, `deny` | `true` |
3636
| `MODBUS_TIMEOUT` | Upstream connection timeout | `10s` |
3737
| `MODBUS_REQUEST_DELAY` | Delay after each upstream request | `0` (disabled) |
38+
| `MODBUS_CONNECT_DELAY` | Silent period after connecting to upstream | `0` (disabled) |
3839
| `MODBUS_SHUTDOWN_TIMEOUT` | Graceful shutdown timeout | `30s` |
3940
| `LOG_LEVEL` | Log level: `INFO`, `DEBUG` | `INFO` |
4041

@@ -76,6 +77,7 @@ services:
7677
MODBUS_READONLY: "true"
7778
MODBUS_TIMEOUT: "10s"
7879
MODBUS_REQUEST_DELAY: "0"
80+
MODBUS_CONNECT_DELAY: "0"
7981
MODBUS_SHUTDOWN_TIMEOUT: "30s"
8082
LOG_LEVEL: "INFO"
8183
restart: unless-stopped

SPEC.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ Many Modbus devices (inverters, meters, battery systems) have limited polling ca
4343
- Support clients requesting different slave IDs through the proxy
4444
- Auto-reconnect on connection failure (unlimited retries, no backoff)
4545
- Request pacing: configurable delay between upstream requests to prevent overwhelming slow devices
46+
- TCP keep-alive enabled (30s interval) for connection health monitoring
47+
- Connect delay: optional silent period after establishing connection for device settling
4648

4749
### 3. In-Memory Cache
4850

@@ -102,6 +104,7 @@ Three modes:
102104
| `MODBUS_READONLY` | Read-only mode | `true` | `false`, `true`, `deny` |
103105
| `MODBUS_TIMEOUT` | Upstream connection timeout | `10s` | `5s`, `30s` |
104106
| `MODBUS_REQUEST_DELAY` | Delay after each upstream request | `0` (disabled) | `100ms`, `500ms` |
107+
| `MODBUS_CONNECT_DELAY` | Silent period after connecting to upstream | `0` (disabled) | `500ms`, `2s` |
105108
| `MODBUS_SHUTDOWN_TIMEOUT` | Graceful shutdown timeout | `30s` | `10s`, `60s` |
106109
| `LOG_LEVEL` | Log level | `INFO` | `INFO`, `DEBUG` |
107110

@@ -195,6 +198,7 @@ level=DEBUG msg="cache hit" slave_id=1 func=0x03 addr=0 qty=10
195198
level=DEBUG msg="cache miss" slave_id=1 func=0x03 addr=0 qty=10
196199
level=DEBUG msg="upstream request completed" slave_id=1 func=0x03 addr=0 qty=10 duration=15ms
197200
level=DEBUG msg="applying request delay" delay=100ms
201+
level=DEBUG msg="applying connect delay" delay=500ms
198202
level=WARN msg="upstream error, serving stale" slave_id=1 error="timeout"
199203
level=INFO msg="shutting down"
200204
```

internal/config/config.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ type Config struct {
2828
ReadOnly ReadOnlyMode
2929
Timeout time.Duration
3030
RequestDelay time.Duration
31+
ConnectDelay time.Duration
3132
ShutdownTimeout time.Duration
3233
LogLevel string
3334
}
@@ -43,6 +44,7 @@ func Load() (*Config, error) {
4344
ReadOnly: ReadOnlyOn,
4445
Timeout: 10 * time.Second,
4546
RequestDelay: 0,
47+
ConnectDelay: 0,
4648
ShutdownTimeout: 30 * time.Second,
4749
LogLevel: getEnv("LOG_LEVEL", "INFO"),
4850
}
@@ -106,6 +108,15 @@ func Load() (*Config, error) {
106108
cfg.RequestDelay = d
107109
}
108110

111+
// Parse connect delay
112+
if s := os.Getenv("MODBUS_CONNECT_DELAY"); s != "" {
113+
d, err := time.ParseDuration(s)
114+
if err != nil {
115+
return nil, fmt.Errorf("invalid MODBUS_CONNECT_DELAY: %w", err)
116+
}
117+
cfg.ConnectDelay = d
118+
}
119+
109120
// Parse shutdown timeout
110121
if s := os.Getenv("MODBUS_SHUTDOWN_TIMEOUT"); s != "" {
111122
d, err := time.ParseDuration(s)

internal/config/config_test.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ func TestLoad_Defaults(t *testing.T) {
5050
if cfg.RequestDelay != 0 {
5151
t.Errorf("expected 0 request delay, got %v", cfg.RequestDelay)
5252
}
53+
if cfg.ConnectDelay != 0 {
54+
t.Errorf("expected 0 connect delay, got %v", cfg.ConnectDelay)
55+
}
5356
if cfg.ShutdownTimeout != 30*time.Second {
5457
t.Errorf("expected 30s shutdown timeout, got %v", cfg.ShutdownTimeout)
5558
}
@@ -76,6 +79,7 @@ func TestLoad_CustomValues(t *testing.T) {
7679
os.Setenv("MODBUS_READONLY", "false")
7780
os.Setenv("MODBUS_TIMEOUT", "5s")
7881
os.Setenv("MODBUS_REQUEST_DELAY", "100ms")
82+
os.Setenv("MODBUS_CONNECT_DELAY", "200ms")
7983
os.Setenv("MODBUS_SHUTDOWN_TIMEOUT", "60s")
8084
os.Setenv("LOG_LEVEL", "DEBUG")
8185

@@ -88,6 +92,7 @@ func TestLoad_CustomValues(t *testing.T) {
8892
os.Unsetenv("MODBUS_READONLY")
8993
os.Unsetenv("MODBUS_TIMEOUT")
9094
os.Unsetenv("MODBUS_REQUEST_DELAY")
95+
os.Unsetenv("MODBUS_CONNECT_DELAY")
9196
os.Unsetenv("MODBUS_SHUTDOWN_TIMEOUT")
9297
os.Unsetenv("LOG_LEVEL")
9398
}()
@@ -118,6 +123,9 @@ func TestLoad_CustomValues(t *testing.T) {
118123
if cfg.RequestDelay != 100*time.Millisecond {
119124
t.Errorf("expected 100ms request delay, got %v", cfg.RequestDelay)
120125
}
126+
if cfg.ConnectDelay != 200*time.Millisecond {
127+
t.Errorf("expected 200ms connect delay, got %v", cfg.ConnectDelay)
128+
}
121129
if cfg.ShutdownTimeout != 60*time.Second {
122130
t.Errorf("expected 60s shutdown timeout, got %v", cfg.ShutdownTimeout)
123131
}
@@ -174,7 +182,7 @@ func TestLoad_InvalidDuration(t *testing.T) {
174182
os.Setenv("MODBUS_UPSTREAM", "localhost:502")
175183
defer os.Unsetenv("MODBUS_UPSTREAM")
176184

177-
tests := []string{"MODBUS_CACHE_TTL", "MODBUS_TIMEOUT", "MODBUS_REQUEST_DELAY", "MODBUS_SHUTDOWN_TIMEOUT"}
185+
tests := []string{"MODBUS_CACHE_TTL", "MODBUS_TIMEOUT", "MODBUS_REQUEST_DELAY", "MODBUS_CONNECT_DELAY", "MODBUS_SHUTDOWN_TIMEOUT"}
178186
for _, envVar := range tests {
179187
os.Setenv(envVar, "invalid")
180188
_, err := Load()

internal/modbus/client.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/binary"
66
"fmt"
77
"log/slog"
8+
"net"
89
"sync"
910
"time"
1011

@@ -16,6 +17,7 @@ type Client struct {
1617
address string
1718
timeout time.Duration
1819
requestDelay time.Duration
20+
connectDelay time.Duration
1921
logger *slog.Logger
2022

2123
mu sync.Mutex
@@ -24,11 +26,12 @@ type Client struct {
2426
}
2527

2628
// NewClient creates a new Modbus TCP client.
27-
func NewClient(address string, timeout, requestDelay time.Duration, logger *slog.Logger) *Client {
29+
func NewClient(address string, timeout, requestDelay, connectDelay time.Duration, logger *slog.Logger) *Client {
2830
return &Client{
2931
address: address,
3032
timeout: timeout,
3133
requestDelay: requestDelay,
34+
connectDelay: connectDelay,
3235
logger: logger,
3336
}
3437
}
@@ -46,16 +49,28 @@ func (c *Client) connectLocked(ctx context.Context) error {
4649
c.conn.Close()
4750
}
4851

49-
handler := modbus.NewTCPClientHandler(c.address)
52+
// Custom dialer with TCP keep-alive for connection health monitoring
53+
dialer := &net.Dialer{
54+
Timeout: c.timeout,
55+
KeepAlive: 30 * time.Second,
56+
}
57+
58+
handler := modbus.NewTCPClientHandler(c.address, modbus.WithDialer(dialer.DialContext))
5059
handler.Timeout = c.timeout
5160
handler.IdleTimeout = c.timeout
61+
handler.ConnectDelay = c.connectDelay
5262

5363
if err := handler.Connect(ctx); err != nil {
5464
return fmt.Errorf("connect to %s: %w", c.address, err)
5565
}
5666

5767
c.conn = handler
5868
c.client = modbus.NewClient(handler)
69+
70+
if c.connectDelay > 0 {
71+
c.logger.Debug("applying connect delay", "delay", c.connectDelay)
72+
}
73+
5974
c.logger.Info("connected to upstream", "address", c.address)
6075
return nil
6176
}

internal/proxy/proxy.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ func New(cfg *config.Config, logger *slog.Logger) (*Proxy, error) {
2727
p := &Proxy{
2828
cfg: cfg,
2929
logger: logger,
30-
client: modbus.NewClient(cfg.Upstream, cfg.Timeout, cfg.RequestDelay, logger),
30+
client: modbus.NewClient(cfg.Upstream, cfg.Timeout, cfg.RequestDelay, cfg.ConnectDelay, logger),
3131
cache: cache.New(cfg.CacheTTL),
3232
}
3333

0 commit comments

Comments
 (0)