Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/modbus-proxy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": minor
---

Optional Modbus TCP proxy. Drivers share one socket per host:port, and other LAN integrations can talk to that same session through FTW. Off by default; writes stay blocked unless you opt in.
16 changes: 16 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ drivers:
host: 192.168.1.10
port: 502
unit_id: 1
# proxy_listen: ":1502" # only needed when modbus_proxy is on and this is not the only Modbus device

# Pixii PowerShaper. When troubleshooting mode is enabled in Settings,
# Pixii also exposes calibration/control status and setpoint readback metrics.
Expand Down Expand Up @@ -143,6 +144,21 @@ homeassistant:
password: homeems
publish_interval_s: 5

# Modbus TCP proxy. FTW already holds the inverter's single socket; other
# integrations (Home Assistant's native Modbus / Sungrow / SolarEdge
# integrations, Node-RED, …) share it by talking to this box instead of the
# device. Off by default: the listener has no Modbus authentication, and
# writes would bypass FTW's control loop.
# modbus_proxy:
# enabled: true
# listen: ":1502" # used when the site has one unique Modbus TCP endpoint
# allow_write: false # keep off unless you trust every host on the LAN
#
# Two inverters (different host:port) each need their own listen address:
# capabilities.modbus.proxy_listen: ":1502" / ":1503"
# Docker host-networking already publishes 1502; other compose files need
# the port mapped.

# Calendar-based planner constraints (#498). FTW hosts its OWN in-process,
# pure-Go CalDAV server (emersion/go-webdav, MIT — no sidecar, works in a
# single container incl. a Home Assistant add-on; objects persist in state.db)
Expand Down
27 changes: 26 additions & 1 deletion go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -626,8 +626,33 @@ func main() {
reg.MQTTFactory = func(name string, c *config.MQTTConfig) (drivers.MQTTCap, error) {
return mqttcli.DialWithOptions(c.Host, c.Port, c.Username, c.Password, "ftw-"+name, c.AllowUnverifiedLocal)
}
modbusEngine := modbuscli.NewEngine()
reg.ModbusFactory = func(name string, c *config.ModbusConfig) (drivers.ModbusCap, error) {
return modbuscli.DialWithOptions(c.Host, c.Port, c.UnitID, c.AllowUnverifiedLocal)
return modbusEngine.Open(c.Host, c.Port, c.UnitID, c.AllowUnverifiedLocal)
}
if cfg.ModbusProxy.On() {
binds, err := cfg.ModbusProxyBinds()
if err != nil {
slog.Error("modbus proxy not started", "err", err)
} else if len(binds) == 0 {
slog.Warn("modbus proxy enabled but no Modbus TCP drivers to expose")
} else {
mbBinds := make([]modbuscli.Bind, 0, len(binds))
for _, b := range binds {
mbBinds = append(mbBinds, modbuscli.Bind{
Listen: b.Listen,
Host: b.Host,
Port: b.Port,
AllowUnverifiedLocal: b.AllowUnverifiedLocal,
})
}
proxy, err := modbusEngine.Listen(mbBinds, cfg.ModbusProxy.AllowWrite)
if err != nil {
slog.Error("modbus proxy listen failed", "err", err)
} else {
defer proxy.Close()
}
}
}
reg.SerialFactory = func(name string, c *config.SerialConfig) (drivers.SerialCap, error) {
return drivers.OpenSerial(c)
Expand Down
12 changes: 12 additions & 0 deletions go/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type Config struct {
Drivers []Driver `yaml:"drivers" json:"drivers"`
API API `yaml:"api" json:"api"`
HomeAssistant *HomeAssistant `yaml:"homeassistant,omitempty" json:"homeassistant,omitempty"`
ModbusProxy *ModbusProxy `yaml:"modbus_proxy,omitempty" json:"modbus_proxy,omitempty"`
State *StateConf `yaml:"state,omitempty" json:"state,omitempty"`
Price *Price `yaml:"price,omitempty" json:"price,omitempty"`
Weather *Weather `yaml:"weather,omitempty" json:"weather,omitempty"`
Expand Down Expand Up @@ -924,6 +925,11 @@ type ModbusConfig struct {
Host string `yaml:"host" json:"host"`
Port int `yaml:"port,omitempty" json:"port,omitempty"` // default 502
UnitID int `yaml:"unit_id,omitempty" json:"unit_id,omitempty"` // default 1
// ProxyListen is the local Modbus TCP address FTW binds for this
// backend when modbus_proxy is enabled. Required when the site has
// more than one unique host:port; a single-endpoint site uses
// modbus_proxy.listen.
ProxyListen string `yaml:"proxy_listen,omitempty" json:"proxy_listen,omitempty"`
// AllowUnverifiedLocal is copied from capabilities.allow_unverified_local
// by the core before this config reaches the transport factory. It is
// runtime-only and never comes from this nested YAML block.
Expand Down Expand Up @@ -1607,6 +1613,9 @@ func applyDefaults(c *Config) {
c.HomeAssistant.PublishIntervalS = 5
}
}
if c.ModbusProxy != nil && strings.TrimSpace(c.ModbusProxy.Listen) == "" {
c.ModbusProxy.Listen = DefaultModbusProxyListen
}
// Backfill for configs that predate notifications: — lands a
// populated-but-disabled stub so upgrading an existing install
// lights up the Notifications tab with the defaults instead of an
Expand Down Expand Up @@ -1701,6 +1710,9 @@ func (c *Config) Validate() error {
if err := c.CalDAV.Validate(); err != nil {
return err
}
if err := c.validateModbusProxy(); err != nil {
return err
}
if err := c.FleetPing.Validate(); err != nil {
return err
}
Expand Down
205 changes: 205 additions & 0 deletions go/internal/config/modbus_proxy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
package config

import (
"fmt"
"net"
"reflect"
"strconv"
"strings"
)

// DefaultModbusProxyListen is the bind used when the site has exactly one
// Modbus TCP endpoint and the driver does not set proxy_listen.
const DefaultModbusProxyListen = ":1502"

// ModbusProxy exposes driver Modbus TCP sessions on the LAN so other
// integrations can share the socket FTW already holds. Off by default:
// Modbus TCP has no authentication, and writes bypass the control loop.
type ModbusProxy struct {
Enabled bool `yaml:"enabled" json:"enabled"`
Listen string `yaml:"listen,omitempty" json:"listen,omitempty"`
AllowWrite bool `yaml:"allow_write,omitempty" json:"allow_write,omitempty"`
}

// ModbusProxyBind is one listener attached to a driver Modbus TCP endpoint.
type ModbusProxyBind struct {
Listen string
Host string
Port int
AllowUnverifiedLocal bool
}

// On reports whether the proxy should bind. Nil-safe.
func (p *ModbusProxy) On() bool {
return p != nil && p.Enabled
}

// ListenAddr is the default bind for a single-endpoint site.
func (p *ModbusProxy) ListenAddr() string {
if p != nil && strings.TrimSpace(p.Listen) != "" {
return strings.TrimSpace(p.Listen)
}
return DefaultModbusProxyListen
}

func (c *Config) validateModbusProxy() error {
if c == nil || !c.ModbusProxy.On() {
return nil
}
if _, err := NormalizeListenAddr(c.ModbusProxy.ListenAddr()); err != nil {
return fmt.Errorf("modbus_proxy.listen: %w", err)
}
if _, err := c.ModbusProxyBinds(); err != nil {
return err
}
return nil
}

// ModbusProxyBinds is the listen/backend map the runtime engine should serve.
func (c *Config) ModbusProxyBinds() ([]ModbusProxyBind, error) {
if c == nil || !c.ModbusProxy.On() {
return nil, nil
}
type ep struct {
host, listen string
port int
allowUnverified bool
drivers []string
}
byKey := map[string]*ep{}
order := []string{}
for _, d := range c.Drivers {
if d.Disabled {
continue
}
mb := d.EffectiveModbus()
if mb == nil || strings.TrimSpace(mb.Host) == "" {
continue
}
port := mb.Port
if port == 0 {
port = 502
}
key := net.JoinHostPort(mb.Host, strconv.Itoa(port))
e := byKey[key]
if e == nil {
e = &ep{host: mb.Host, port: port, allowUnverified: d.Capabilities.AllowUnverifiedLocal}
byKey[key] = e
order = append(order, key)
}
if d.Capabilities.AllowUnverifiedLocal {
e.allowUnverified = true
}
e.drivers = append(e.drivers, d.Name)
pl := strings.TrimSpace(mb.ProxyListen)
if pl == "" {
continue
}
norm, err := NormalizeListenAddr(pl)
if err != nil {
return nil, fmt.Errorf("driver %q: proxy_listen: %w", d.Name, err)
}
if e.listen != "" && e.listen != norm {
return nil, fmt.Errorf("modbus_proxy: endpoint %s has conflicting proxy_listen (%s vs %s)", key, e.listen, norm)
}
e.listen = norm
}

if len(order) == 0 {
return nil, nil
}
if len(order) > 1 {
for _, key := range order {
if byKey[key].listen == "" {
return nil, fmt.Errorf("modbus_proxy: multiple Modbus endpoints; set capabilities.modbus.proxy_listen on each (missing for %s, drivers %s)", key, strings.Join(byKey[key].drivers, ", "))
}
}
}
defListen, err := NormalizeListenAddr(c.ModbusProxy.ListenAddr())
if err != nil {
return nil, fmt.Errorf("modbus_proxy.listen: %w", err)
}

used := map[string]string{}
out := make([]ModbusProxyBind, 0, len(order))
for _, key := range order {
e := byKey[key]
listen := e.listen
if listen == "" {
listen = defListen
}
if other := used[listen]; other != "" {
return nil, fmt.Errorf("modbus_proxy: listen %s used by both %s and %s", listen, other, key)
}
used[listen] = key
out = append(out, ModbusProxyBind{
Listen: listen,
Host: e.host,
Port: e.port,
AllowUnverifiedLocal: e.allowUnverified,
})
}
return out, nil
}

// NormalizeListenAddr accepts ":1502", "1502", "0.0.0.0:1502".
func NormalizeListenAddr(s string) (string, error) {
s = strings.TrimSpace(s)
if s == "" {
s = DefaultModbusProxyListen
}
if !strings.Contains(s, ":") {
s = ":" + s
}
host, port, err := net.SplitHostPort(s)
if err != nil {
return "", fmt.Errorf("invalid listen address %q", s)
}
p, err := strconv.Atoi(port)
if err != nil || p < 1 || p > 65535 {
return "", fmt.Errorf("invalid listen port in %q", s)
}
return net.JoinHostPort(host, port), nil
}

func modbusProxyRestartReasons(oldCfg, newCfg *Config) []string {
var reasons []string
if !reflect.DeepEqual(oldCfg.ModbusProxy, newCfg.ModbusProxy) {
reasons = append(reasons, "modbus_proxy — TCP listener binds at startup")
}
if oldCfg.ModbusProxy.On() || newCfg.ModbusProxy.On() {
if !reflect.DeepEqual(modbusProxySignature(oldCfg), modbusProxySignature(newCfg)) {
reasons = append(reasons, "modbus_proxy endpoints — driver Modbus host/port/listen feeds the proxy at startup")
}
}
return reasons
}

type proxySig struct {
Host, Listen string
Port int
AllowUnverified bool
}

func modbusProxySignature(c *Config) []proxySig {
if c == nil {
return nil
}
var out []proxySig
for _, d := range c.Drivers {
if d.Disabled {
continue
}
mb := d.EffectiveModbus()
if mb == nil {
continue
}
out = append(out, proxySig{
Host: mb.Host,
Port: mb.Port,
Listen: mb.ProxyListen,
AllowUnverified: d.Capabilities.AllowUnverifiedLocal,
})
}
return out
}
Loading