diff --git a/.changeset/validate-phases-sitemeter.md b/.changeset/validate-phases-sitemeter.md new file mode 100644 index 00000000..7297f286 --- /dev/null +++ b/.changeset/validate-phases-sitemeter.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Config validation now rejects `fuse.phases` above 3. Dispatch only reads three phase currents while the aggregate power limit used every configured phase, so a larger value overstated the usable fuse budget. It also rejects more than one driver with `is_site_meter: true`; the first match previously won without warning. Existing invalid configs must be corrected before the next restart: set `fuse.phases` to 1, 2 or 3 and keep exactly one site meter. diff --git a/go/internal/config/config.go b/go/internal/config/config.go index f07a38f8..2287c056 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -1698,6 +1698,12 @@ func (c *Config) Validate() error { if len(c.Drivers) > 0 && siteMeters == 0 { return errors.New("at least one driver must be is_site_meter: true") } + // SiteMeterDriver() returns the first match, so a second is_site_meter + // entry was silently ignored — the operator thinks meter B is the site + // boundary while dispatch trusts meter A. Make the ambiguity an error. + if siteMeters > 1 { + return fmt.Errorf("exactly one driver may set is_site_meter: true (found %d)", siteMeters) + } if c.Site.ControlIntervalS < 0 { return errors.New("site.control_interval_s must be >= 0") @@ -1726,6 +1732,14 @@ func (c *Config) Validate() error { if c.Fuse.Phases <= 0 { return errors.New("fuse.phases must be > 0") } + // The phase-current safety model ([3]float64 arrays, meter_l1..l3_a + // metrics) covers 1..3 conductors. A larger value used to be silently + // truncated to 3 by the dispatch freshness gate while MaxPowerW still + // counted every configured phase. That overstated the usable aggregate + // fuse budget. Reject it instead. + if c.Fuse.Phases > 3 { + return errors.New("fuse.phases must be 1, 2 or 3") + } if c.Fuse.Voltage <= 0 { return errors.New("fuse.voltage must be > 0") } diff --git a/go/internal/config/validate_site_test.go b/go/internal/config/validate_site_test.go new file mode 100644 index 00000000..259d4f67 --- /dev/null +++ b/go/internal/config/validate_site_test.go @@ -0,0 +1,75 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func loadSiteValidationConfig(t *testing.T, yaml string) error { + t.Helper() + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte(yaml), 0o600); err != nil { + t.Fatal(err) + } + _, err := Load(path) + return err +} + +func TestLoadRejectsMoreThanThreeFusePhases(t *testing.T) { + err := loadSiteValidationConfig(t, strings.Replace(minimalYAML, "max_amps: 16", "max_amps: 16\n phases: 4", 1)) + if err == nil || err.Error() != "fuse.phases must be 1, 2 or 3" { + t.Errorf("Load error = %v, want fuse phase validation error", err) + } +} + +func TestValidateAcceptsOneToThreePhases(t *testing.T) { + for phases := 1; phases <= 3; phases++ { + c := &Config{ + Site: Site{SmoothingAlpha: 0.3}, + Fuse: Fuse{MaxAmps: 16, Phases: phases, Voltage: 230}, + } + if err := c.Validate(); err != nil { + t.Errorf("phases=%d: unexpected error: %v", phases, err) + } + } +} + +func meterDriver(name string, siteMeter bool) Driver { + return Driver{ + Name: name, + Lua: "drivers/test.lua", + IsSiteMeter: siteMeter, + Capabilities: Capabilities{ + Modbus: &ModbusConfig{Host: "192.168.1.10", Port: 502}, + }, + } +} + +func TestLoadRejectsDuplicateSiteMeters(t *testing.T) { + yaml := strings.Replace(minimalYAML, "api:\n", ` + - name: second-meter + lua: drivers/second-meter.lua + is_site_meter: true + capabilities: + mqtt: + host: 192.168.1.154 +api: +`, 1) + err := loadSiteValidationConfig(t, yaml) + if err == nil || err.Error() != "exactly one driver may set is_site_meter: true (found 2)" { + t.Errorf("Load error = %v, want duplicate site meter validation error", err) + } +} + +func TestValidateAcceptsSingleSiteMeter(t *testing.T) { + c := &Config{ + Site: Site{SmoothingAlpha: 0.3}, + Fuse: Fuse{MaxAmps: 16, Phases: 3, Voltage: 230}, + Drivers: []Driver{meterDriver("a", true), meterDriver("b", false)}, + } + if err := c.Validate(); err != nil { + t.Errorf("unexpected error: %v", err) + } +}