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
7 changes: 7 additions & 0 deletions .changeset/modbus-write-owner.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"ftw": patch
---

Keep one Modbus writer per device. The proxy defaults to read only. When external writes are enabled, FTW keeps reading but stops all device writes until the owner changes the setting and restarts Core. Restrict proxy requests to configured unit IDs, give driver traffic priority, and close idle clients on shutdown.

Rename the Home Assistant settings tab to Integrations. Home Assistant MQTT and the Modbus proxy each keep their own section.
7 changes: 6 additions & 1 deletion config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,12 @@ homeassistant:
# 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
# allow_write: false # read-only proxy; FTW owns control
# # true gives LAN clients write ownership; FTW only reads these endpoints.
# # All configured unit IDs on each endpoint share this choice. Unknown unit
# # IDs (including broadcast 0) are blocked. The external controller owns
# # device safety. A disconnect does not return control to FTW; change the
# # setting and restart. Ownership stays in force if the listener fails.
#
# Two inverters (different host:port) each need their own listen address:
# capabilities.modbus.proxy_listen: ":1502" / ":1503"
Expand Down
2 changes: 1 addition & 1 deletion go/cmd/ftw/control_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ func newControlStateFromConfig(cfg *config.Config) *control.State {
ctrl.InverterGroups = inverterGroupsFrom(cfg.Drivers)
ctrl.SupportsPVCurtail = supportsPVCurtailFrom(cfg.Drivers)
ctrl.SolarFeedDrivers = solarFeedDriversFrom(cfg.Drivers)
ctrl.DriverLimits = driverLimitsFrom(cfg.Drivers, cfg.Batteries)
ctrl.DriverLimits = driverLimitsFrom(cfg.ModbusControlDrivers(), cfg.Batteries)
Comment on lines 25 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Exclude external owners from PV curtail capability

When an externally owned Modbus inverter also has supports_pv_curtail, only DriverLimits uses the ownership-filtered drivers; SupportsPVCurtail still advertises that inverter as controllable, and the hot-reload path repeats the same pattern. During an export-ceiling breach, computePVExportGuard therefore counts the proposed cap as a real reduction and can report the residual as cleared, while the registry rejects the command as observe-only and nothing reaches the device. Exclude externally owned drivers when building the PV-curtail map so the guard reports the export as uncontrollable rather than hiding the unmet clamp.

AGENTS.md reference: AGENTS.md:L22-L23

Useful? React with 👍 / 👎.

// Per-phase fuse params for the per-phase clamp inside applyFuseGuard
// + forceFuseDischarge. Reads l1_a/l2_a/l3_a from the meter driver
// when SiteFuseAmps > 0; otherwise the per-phase clamp is disabled.
Expand Down
14 changes: 9 additions & 5 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -647,7 +647,8 @@ func main() {
// ---- Driver capacities (site, for control + fuse guard) ----
// Loadpoint drivers are filtered out — their battery_capacity_wh
// is vehicle capacity, not site-battery capacity.
capacities := driverCapacitiesFrom(cfg.Drivers, cfg.Loadpoints, driverCatalog, true)
cfg.PinModbusProxyOwnership()
capacities := driverCapacitiesFrom(cfg.ModbusControlDrivers(), cfg.Loadpoints, driverCatalog, true)
telemetryCapacities := driverCapacitiesFrom(cfg.Drivers, cfg.Loadpoints, driverCatalog, false)
observeOnly := config.ObserveOnlyDriverSet(cfg)
warnIfEVHasBatteryCapacity(cfg.Drivers, cfg.Loadpoints, driverCatalog)
Expand Down Expand Up @@ -699,6 +700,7 @@ func main() {
return mqttcli.DialWithOptions(c.Host, c.Port, c.Username, c.Password, "ftw-"+name, c.AllowUnverifiedLocal)
}
modbusEngine := modbuscli.NewEngine()
modbusEngine.SetWriteOwners(cfg.ModbusProxyWriteOwners)
reg.ModbusFactory = func(name string, c *config.ModbusConfig) (drivers.ModbusCap, error) {
return modbusEngine.Open(c.Host, c.Port, c.UnitID, c.AllowUnverifiedLocal)
}
Expand All @@ -716,6 +718,7 @@ func main() {
Host: b.Host,
Port: b.Port,
AllowUnverifiedLocal: b.AllowUnverifiedLocal,
UnitIDs: b.UnitIDs,
})
}
proxy, err := modbusEngine.Listen(mbBinds, cfg.ModbusProxy.AllowWrite)
Expand All @@ -735,7 +738,7 @@ func main() {
// WithBatterySoCBounds routes each battery's soc_min/soc_max into the
// matching driver's config (discharge_floor_soc/charge_ceil_soc) so the
// operator's SoC window reaches the driver, not just the planner.
for _, d := range config.WithBatterySoCBounds(cfg.Drivers, cfg.Batteries) {
for _, d := range config.WithBatterySoCBounds(cfg.ModbusControlDrivers(), cfg.Batteries) {
if d.Disabled {
slog.Info("driver skipped (disabled)", "name", d.Name)
continue
Expand Down Expand Up @@ -992,13 +995,14 @@ func main() {
// ---- Apply saved configuration ----
// Settings commit to SQLite before this callback applies them.
applyConfigChange := func(newCfg, oldCfg *config.Config) {
newCfg.ModbusProxyWriteOwners = oldCfg.ModbusProxyWriteOwners
forecastConfigMu.Lock()
defer forecastConfigMu.Unlock()
// Driver paths are already resolved by config.Load; no extra
// work needed here. Re-apply the battery SoC-window → driver
// config mapping so a hot-edited soc_max reaches the driver too.
reg.Reload(ctx,
config.WithBatterySoCBounds(newCfg.Drivers, newCfg.Batteries),
config.WithBatterySoCBounds(newCfg.ModbusControlDrivers(), newCfg.Batteries),
newCfg.Site.TroubleshootingMode)
// Refresh capacities — mutate the existing map in place so
// Deps.Capacities (a map header captured at init) sees the
Expand All @@ -1022,7 +1026,7 @@ func main() {
} else {
driverCatalog = reloadCatalog
}
for k, v := range driverCapacitiesFrom(newCfg.Drivers, newCfg.Loadpoints, reloadCatalog, true) {
for k, v := range driverCapacitiesFrom(newCfg.ModbusControlDrivers(), newCfg.Loadpoints, reloadCatalog, true) {
capacities[k] = v
}
for k, v := range driverCapacitiesFrom(newCfg.Drivers, newCfg.Loadpoints, reloadCatalog, false) {
Expand All @@ -1040,7 +1044,7 @@ func main() {
ctrl.InverterGroups = inverterGroupsFrom(newCfg.Drivers)
ctrl.SupportsPVCurtail = supportsPVCurtailFrom(newCfg.Drivers)
ctrl.SolarFeedDrivers = solarFeedDriversFrom(newCfg.Drivers)
ctrl.DriverLimits = driverLimitsFrom(newCfg.Drivers, newCfg.Batteries)
ctrl.DriverLimits = driverLimitsFrom(newCfg.ModbusControlDrivers(), newCfg.Batteries)
// Fuse params + safety margin: previously startup-only.
// Hot-reload them so operators can tune the per-phase margin
// from the UI without restarting (e.g. raising it after the
Expand Down
28 changes: 28 additions & 0 deletions go/cmd/ftw/modbus_owner_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package main

import (
"testing"

"github.com/srcfl/ftw/go/internal/config"
)

func TestExternalModbusOwnerKeepsTelemetryButLeavesPlannerAndDispatch(t *testing.T) {
cfg := &config.Config{
ModbusProxy: &config.ModbusProxy{Enabled: true, AllowWrite: true},
Drivers: []config.Driver{{Name: "sungrow", Lua: "drivers/sungrow.lua", BatteryCapacityWh: 10000,
Capabilities: config.Capabilities{Modbus: &config.ModbusConfig{Host: "127.0.0.1", Port: 502, UnitID: 1}}}},
}
cfg.PinModbusProxyOwnership()
if len(driverCapacitiesFrom(cfg.ModbusControlDrivers(), nil, evCatalog(), true)) != 0 {
t.Fatal("external battery remains in planner pool")
}
if got := driverCapacitiesFrom(cfg.Drivers, nil, evCatalog(), false); got["sungrow"] != 10000 {
t.Fatalf("telemetry capacity lost: %v", got)
}
if len(driverLimitsFrom(cfg.ModbusControlDrivers(), cfg.Batteries)) != 0 {
t.Fatal("external battery remains in dispatch limits")
}
if !config.ObserveOnlyDriverSet(cfg)["sungrow"] {
t.Fatal("watchdog does not see read-only owner")
}
}
1 change: 1 addition & 0 deletions go/internal/api/api_drivers_debug.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ func (s *Server) configuredDriver(name string) (config.Driver, bool) {
}
for _, d := range s.deps.Cfg.Drivers {
if d.Name == name {
d.ObserveOnly = d.ObserveOnly || s.deps.Cfg.ModbusProxyOwnsWrites(d)
return d, true
}
}
Expand Down
29 changes: 29 additions & 0 deletions go/internal/api/api_modbus_owner_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package api

import (
"net/http"
"testing"

"github.com/srcfl/ftw/go/internal/config"
)

func TestDriverControlReportsExternalModbusOwnership(t *testing.T) {
srv, _ := controlServerWithLuaConfig(t, controlProbeLua, config.Driver{
Name: "heat", ObserveOnly: true,
Capabilities: config.Capabilities{Modbus: &config.ModbusConfig{Host: "127.0.0.1", Port: 502, UnitID: 1}},
})
// The setting belongs to the endpoint; it is not a saved observe_only
// flag on one driver that another driver could omit.
srv.deps.Cfg.Drivers[0].ObserveOnly = false
srv.deps.Cfg.ModbusProxy = &config.ModbusProxy{Enabled: true, AllowWrite: true}
srv.deps.Cfg.PinModbusProxyOwnership()
rec := post(t, srv, "/api/drivers/heat/control", `{"control":"set_offset","value":2,"duration_s":600}`)
if rec.Code != http.StatusForbidden {
t.Fatalf("external owner POST = %d: %s", rec.Code, rec.Body.String())
}
srv.deps.Cfg.ModbusProxy.AllowWrite = false
rec = post(t, srv, "/api/drivers/heat/control", `{"control":"set_offset","value":2,"duration_s":600}`)
if rec.Code != http.StatusForbidden {
t.Fatalf("save returned ownership before restart: %d", rec.Code)
}
}
4 changes: 4 additions & 0 deletions go/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ type Config struct {
ConfigDatabase string `yaml:"config_database,omitempty" json:"-"`
Revision int64 `yaml:"-" json:"-"`
LANPasswordHash string `yaml:"-" json:"-"`
// Startup write ownership stays in force until restart, including after
// saving a different proxy mode or a failed listener start.
ModbusProxyWriteOwners map[string]bool `yaml:"-" json:"-"`
}

// OCPP configures the built-in OCPP 1.6J and 2.0.1 Central System. Chargers connect to
Expand Down Expand Up @@ -1352,6 +1355,7 @@ func (c Config) MaskSecrets() Config {
// wherever the incoming value is empty (the UI sends "" for masked fields).
// Call this before saving a config received from the API.
func (incoming *Config) PreserveMaskedSecrets(existing *Config) {
incoming.ModbusProxyWriteOwners = existing.ModbusProxyWriteOwners
incoming.ConfigDatabase = existing.ConfigDatabase
incoming.Revision = existing.Revision
incoming.LANPasswordHash = existing.LANPasswordHash
Expand Down
85 changes: 85 additions & 0 deletions go/internal/config/modbus_ownership_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package config

import (
"reflect"
"testing"
)

func proxyOwnershipConfig() *Config {
return &Config{
ModbusProxy: &ModbusProxy{Enabled: true, AllowWrite: true},
Drivers: []Driver{
{Name: "battery", BatteryCapacityWh: 10000, Capabilities: Capabilities{Modbus: &ModbusConfig{Host: "10.0.0.1", UnitID: 1}}},
{Name: "meter", Capabilities: Capabilities{Modbus: &ModbusConfig{Host: "10.0.0.1", Port: 502, UnitID: 2}}},
{Name: "mqtt", Capabilities: Capabilities{MQTT: &MQTTConfig{Host: "10.0.0.2"}}},
},
}
}

func TestModbusProxyOwnershipSurvivesSavedModeChanges(t *testing.T) {
cfg := proxyOwnershipConfig()
cfg.PinModbusProxyOwnership()
cfg.ModbusProxy.AllowWrite = false
if got := ObserveOnlyDriverSet(cfg); !got["battery"] || !got["meter"] || got["mqtt"] {
t.Fatalf("active ownership changed before restart: %v", got)
}
runtime := cfg.ModbusControlDrivers()
if !runtime[0].ObserveOnly || !runtime[1].ObserveOnly || runtime[2].ObserveOnly {
t.Fatalf("runtime drivers: %+v", runtime)
}
if cfg.Drivers[0].ObserveOnly || cfg.Drivers[1].ObserveOnly {
t.Fatal("derived flags changed saved settings")
}
incoming := proxyOwnershipConfig()
incoming.ModbusProxy.Enabled = false
incoming.PreserveMaskedSecrets(cfg)
if !incoming.ModbusProxyOwnsWrites(incoming.Drivers[0]) {
t.Fatal("settings save lost active ownership")
}
incoming.PinModbusProxyOwnership()
if len(ObserveOnlyDriverSet(incoming)) != 0 {
t.Fatal("restart did not return ownership to FTW")
}
}

func TestModbusProxyReadOnlyDoesNotTakeControlOnSave(t *testing.T) {
cfg := proxyOwnershipConfig()
cfg.ModbusProxy.AllowWrite = false
cfg.PinModbusProxyOwnership()
cfg.ModbusProxy.AllowWrite = true
if len(ObserveOnlyDriverSet(cfg)) != 0 {
t.Fatal("write ownership changed before restart")
}
cfg.PinModbusProxyOwnership()
if !cfg.ModbusProxyOwnsWrites(cfg.Drivers[0]) {
t.Fatal("restart did not apply external ownership")
}
}

func TestModbusProxyAllowlistAndRestartFollowEffectiveEndpoints(t *testing.T) {
cfg := proxyOwnershipConfig()
binds, err := cfg.ModbusProxyBinds()
if err != nil {
t.Fatal(err)
}
if len(binds) != 1 || !reflect.DeepEqual(binds[0].UnitIDs, []uint8{1, 2}) {
t.Fatalf("binds: %+v", binds)
}
other := proxyOwnershipConfig()
other.Drivers[0], other.Drivers[1] = other.Drivers[1], other.Drivers[0]
if got := RestartRequiredFor(cfg, other); len(got) != 0 {
t.Fatalf("driver order changed effective listener: %v", got)
}
other.Drivers[0].Capabilities.Modbus.UnitID = 3
if got := RestartRequiredFor(cfg, other); len(got) == 0 {
t.Fatal("unit allowlist change needs restart")
}
}

func TestUnusedModbusProxySettingsDoNotRequireRestart(t *testing.T) {
old := &Config{}
newCfg := &Config{ModbusProxy: &ModbusProxy{Listen: ":1502"}}
if got := RestartRequiredFor(old, newCfg); len(got) != 0 {
t.Fatalf("disabled defaults require restart: %v", got)
}
}
Loading
Loading