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
5 changes: 5 additions & 0 deletions .changeset/scoped-battery-service-hold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": minor
---

Let an operator place a short manual hold on one home battery. The hold binds to the battery hardware, stops when that battery is not safe to control, clears on restart or stale site data, and keeps all core power, SoC, slew, and fuse limits in force.
41 changes: 40 additions & 1 deletion go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,18 @@ func main() {
}
}
defer reg.ShutdownAll()
batteryIdentity := func(name string) (string, bool) {
return runningDeviceID(reg, name)
}
ctrl.BatteryHoldTargetValid = func(name, deviceID string) bool {
currentID, ok := batteryIdentity(name)
if !ok || currentID != deviceID {
return false
}
health := tel.DriverHealth(name)
reading := tel.Get(name, telemetry.DerBattery)
return health != nil && health.IsOnline() && reading != nil && reading.SoC != nil
Comment on lines +556 to +558

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Cancel holds when the target leaves controllable inventory

If a hot reload marks the target observe_only, classifies it as a loadpoint, or otherwise removes it from driverCapacitiesFrom(..., true), this validator can still return true because the same healthy driver and identity remain live. ComputeDispatch then omits the target from onlineBats while the scoped hold stays active, causing every remaining controllable sibling to be commanded to 0 W until expiry. Revalidate membership in the current controllable-capacity map and clear the hold when it disappears.

Useful? React with 👍 / 👎.

}

// ---- Identity bootstrap ----
// Drivers report make/serial inside driver_init via host.set_make / set_sn,
Expand Down Expand Up @@ -2076,7 +2088,8 @@ func main() {
Tel: tel, LogRing: logRing, Ctrl: ctrl, CtrlMu: ctrlMu,
State: st,
CapMu: capMu, Capacities: capacities, TelemetryCapacities: telemetryCapacities,
CfgMu: cfgMu, Cfg: cfg, ConfigPath: *configPath,
BatteryIdentity: batteryIdentity,
CfgMu: cfgMu, Cfg: cfg, ConfigPath: *configPath,
ConfigApplier: applyConfigChange,
DriverDir: resolveDriverDir(),
UserDriverDir: *userDriversDirFlag,
Expand Down Expand Up @@ -2527,6 +2540,10 @@ func main() {
}

if !freshness.Allowed() {
// Clear before persisting: persistTelemetryTick snapshots
// ctrl, so the stored tick has to show the hold already
// released rather than one the blocked tick never executed.
clearBatteryManualHoldForDispatchBlock(ctrl, ctrlMu)
sampleCount, err := persistTelemetryTick(st, tel, ctrl, nowMs, watchdogTimeout)
if err != nil {
slog.Warn("tick persistence failed", "samples", sampleCount, "err", err)
Expand Down Expand Up @@ -2964,6 +2981,28 @@ func registerAllDevices(st *state.Store, reg *drivers.Registry) {
}
}

func runningDeviceID(reg *drivers.Registry, name string) (string, bool) {
if reg == nil || name == "" {
return "", false
}
env := reg.Env(name)
if env == nil {
return "", false
}
makeName, serial, mac, endpoint := env.FullIdentity()
id := state.ResolveDeviceID(makeName, serial, mac, endpoint)
return id, id != ""
}

func clearBatteryManualHoldForDispatchBlock(ctrl *control.State, ctrlMu *sync.Mutex) {
if ctrl == nil || ctrlMu == nil {
return
}
ctrlMu.Lock()
ctrl.ClearBatteryManualHold()
ctrlMu.Unlock()
}

const driverDefaultTimeout = 2 * time.Second

func sendDriverDefault(ctx context.Context, srv *api.Server, name, reason string, observeOnly map[string]bool) {
Expand Down
21 changes: 21 additions & 0 deletions go/cmd/ftw/site_meter_stale_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package main

import (
"sync"
"testing"
"time"

"github.com/srcfl/ftw/go/internal/control"
"github.com/srcfl/ftw/go/internal/telemetry"
)

Expand Down Expand Up @@ -100,6 +102,25 @@ func TestStaleSiteDefaultTrackerIncludesOwnerOnceAndRearmsOnRecovery(t *testing.
assertStringsEqual(t, pending, names)
}

func TestDispatchBlockClearsBatteryManualHold(t *testing.T) {
ctrl := control.NewState(0, 50, "meter")
ctrl.SetBatteryManualHold(control.BatteryManualHold{
Driver: "bat_a",
DeviceID: "maker:serial-a",
PowerW: -2000,
ExpiresAt: time.Now().Add(time.Minute),
})
if _, active := ctrl.GetBatteryManualHold(time.Now()); !active {
t.Fatal("precondition: battery manual hold is not active")
}

clearBatteryManualHoldForDispatchBlock(ctrl, &sync.Mutex{})

if _, active := ctrl.GetBatteryManualHold(time.Now()); active {
t.Fatal("stale site dispatch block left battery manual hold active")
}
}

func assertStringsEqual(t *testing.T, got, want []string) {
t.Helper()
if len(got) != len(want) {
Expand Down
24 changes: 14 additions & 10 deletions go/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,16 +79,20 @@ type Deps struct {
CapMu *sync.RWMutex
Capacities map[string]float64 // driver → battery_capacity_wh (controllable pool)
TelemetryCapacities map[string]float64 // all site batteries incl. observe_only (SoC weighting)
CfgMu *sync.RWMutex
Cfg *config.Config
ConfigPath string
DriverDir string // where to scan for Lua drivers (default: <config-dir>/drivers)
UserDriverDir string // persistent user-drivers overlay; searched before DriverDir
Models map[string]*battery.Model
ModelsMu *sync.Mutex
SelfTune *selftune.Coordinator
DtS float64 // control interval seconds (for model τ / age displays)
SaveConfig func(path string, c *config.Config) error // injection for testability

// BatteryIdentity resolves the live driver to its current hardware.
BatteryIdentity func(driver string) (deviceID string, ok bool)

CfgMu *sync.RWMutex
Cfg *config.Config
ConfigPath string
DriverDir string // where to scan for Lua drivers (default: <config-dir>/drivers)
UserDriverDir string // persistent user-drivers overlay; searched before DriverDir
Models map[string]*battery.Model
ModelsMu *sync.Mutex
SelfTune *selftune.Coordinator
DtS float64 // control interval seconds (for model τ / age displays)
SaveConfig func(path string, c *config.Config) error // injection for testability
// ConfigApplier is main.go's config-applied callback — the same
// closure the configreload watcher runs (registry reload with SoC
// bounds, capacities, inverter groups, fuse and mpc/loadmodel
Expand Down
50 changes: 45 additions & 5 deletions go/internal/api/api_battery_manual.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ import (
"time"

"github.com/srcfl/ftw/go/internal/control"
"github.com/srcfl/ftw/go/internal/telemetry"
)

// Battery manual-hold endpoint. Pins the aggregate battery setpoint to
// a fixed power (charge / discharge / idle) for a bounded duration,
// bypassing both the active control mode and the MPC. SoC clamps,
// per-driver capability caps, slew, and the site fuse guard still
// apply on the resulting target — operators cannot override safety.
// Battery manual-hold endpoint. Pins the pool or one named battery to
// a fixed power for a bounded duration. A scoped request binds the
// runtime driver name to its current hardware identity. Core SoC,
// power, slew, reserve, and fuse limits still apply.
//
// Sibling of api_loadpoint_manual.go and registered alongside it in
// api.go's routes() table.
Expand All @@ -25,6 +25,7 @@ type batteryManualHoldRequest struct {
Direction string `json:"direction"`
PowerW float64 `json:"power_w"`
HoldS int `json:"hold_s"`
Driver string `json:"driver,omitempty"`
}

// batteryManualHoldResponse mirrors the active hold so the operator
Expand All @@ -33,6 +34,7 @@ type batteryManualHoldResponse struct {
Active bool `json:"active"`
Direction string `json:"direction,omitempty"`
PowerW float64 `json:"power_w,omitempty"`
Driver string `json:"driver,omitempty"`
ExpiresAtMs int64 `json:"expires_at_ms,omitempty"`
}

Expand Down Expand Up @@ -70,9 +72,20 @@ func (s *Server) handleBatteryManualHold(w http.ResponseWriter, r *http.Request)
writeJSON(w, 400, map[string]string{"error": err.Error()})
return
}
deviceID := ""
if req.Driver != "" {
var status int
deviceID, status, err = s.resolveBatteryManualHoldTarget(req.Driver)
if err != nil {
writeJSON(w, status, map[string]string{"error": err.Error()})
return
}
}

expires := time.Now().Add(time.Duration(req.HoldS) * time.Second)
hold := control.BatteryManualHold{
Driver: req.Driver,
DeviceID: deviceID,
PowerW: signed,
ExpiresAt: expires,
}
Expand All @@ -85,10 +98,36 @@ func (s *Server) handleBatteryManualHold(w http.ResponseWriter, r *http.Request)
"power_w", req.PowerW,
"signed_w", signed,
"hold_s", req.HoldS,
"driver", req.Driver,
)
writeJSON(w, 200, batteryManualHoldResponseFrom(hold, true))
}

func (s *Server) resolveBatteryManualHoldTarget(driver string) (string, int, error) {
if s.deps.CapMu == nil || s.deps.Capacities == nil {
return "", http.StatusServiceUnavailable, apiError("battery control inventory not available")
}
s.deps.CapMu.RLock()
_, known := s.deps.Capacities[driver]
s.deps.CapMu.RUnlock()
if !known {
return "", http.StatusBadRequest, apiError("unknown controllable battery driver")
}
if s.deps.Tel == nil || s.deps.BatteryIdentity == nil {
return "", http.StatusServiceUnavailable, apiError("battery control state not available")
}
health := s.deps.Tel.DriverHealth(driver)
reading := s.deps.Tel.Get(driver, telemetry.DerBattery)
if health == nil || !health.IsOnline() || reading == nil || reading.SoC == nil {
return "", http.StatusConflict, apiError("battery driver is not ready for a scoped hold")
}
deviceID, ok := s.deps.BatteryIdentity(driver)
if !ok || deviceID == "" {
return "", http.StatusConflict, apiError("battery hardware identity is not available")
}
return deviceID, http.StatusOK, nil
}

func (s *Server) handleBatteryManualHoldClear(w http.ResponseWriter, r *http.Request) {
if s.deps.Ctrl == nil || s.deps.CtrlMu == nil {
writeJSON(w, 503, map[string]string{"error": "control state not available"})
Expand Down Expand Up @@ -150,6 +189,7 @@ func batteryManualHoldResponseFrom(h control.BatteryManualHold, active bool) bat
resp.Direction = "idle"
resp.PowerW = 0
}
resp.Driver = h.Driver
if !h.ExpiresAt.IsZero() {
resp.ExpiresAtMs = h.ExpiresAt.UnixMilli()
}
Expand Down
71 changes: 71 additions & 0 deletions go/internal/api/api_battery_manual_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"time"

"github.com/srcfl/ftw/go/internal/control"
"github.com/srcfl/ftw/go/internal/telemetry"
)

// Battery manual-hold endpoint tests. Validation, route wiring, and
Expand All @@ -23,6 +24,22 @@ func newBatteryHoldServer(t *testing.T) (*Server, *control.State, *sync.Mutex) {
return srv, st, mu
}

func newScopedBatteryHoldServer(t *testing.T) (*Server, *control.State) {
t.Helper()
srv, st, _ := newBatteryHoldServer(t)
soc := 0.5
tel := telemetry.NewStore()
tel.Update("bat_a", telemetry.DerBattery, 0, &soc, nil)
tel.DriverHealthMut("bat_a").RecordSuccess()
srv.deps.Tel = tel
srv.deps.CapMu = &sync.RWMutex{}
srv.deps.Capacities = map[string]float64{"bat_a": 10000}
srv.deps.BatteryIdentity = func(name string) (string, bool) {
return "maker:serial-a", name == "bat_a"
}
return srv, st
}

func TestBatteryHoldUnavailableWithoutCtrl(t *testing.T) {
srv := New(&Deps{}) // no Ctrl/CtrlMu
body := `{"direction":"charge","power_w":3000,"hold_s":60}`
Expand Down Expand Up @@ -173,3 +190,57 @@ func TestBatteryHoldGetReturnsInactiveWhenNoHold(t *testing.T) {
t.Errorf("GET on empty state should report inactive, got %+v", got)
}
}

func TestBatteryHoldInstallsHardwareBoundDriverScope(t *testing.T) {
srv, st := newScopedBatteryHoldServer(t)
body := `{"direction":"charge","power_w":3000,"hold_s":60,"driver":"bat_a"}`
req := httptest.NewRequest(http.MethodPost, "/api/battery/manual_hold", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
srv.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("POST status=%d body=%s", rr.Code, rr.Body.String())
}
hold, active := st.GetBatteryManualHold(time.Now())
if !active || hold.Driver != "bat_a" || hold.DeviceID != "maker:serial-a" {
t.Fatalf("scoped hold = %+v, active=%v", hold, active)
}
var got batteryManualHoldResponse
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.Driver != hold.Driver {
t.Fatalf("response = %+v, hold = %+v", got, hold)
}
}

func TestBatteryHoldDriverScopeFailsClosed(t *testing.T) {
tests := []struct {
name string
mutate func(*Server)
want int
}{
{"unknown driver", func(*Server) {}, http.StatusBadRequest},
{"offline driver", func(s *Server) { s.deps.Capacities["ghost"] = 10000 }, http.StatusConflict},
{"missing identity", func(s *Server) {
s.deps.Capacities["ghost"] = 10000
soc := 0.5
s.deps.Tel.Update("ghost", telemetry.DerBattery, 0, &soc, nil)
s.deps.Tel.DriverHealthMut("ghost").RecordSuccess()
}, http.StatusConflict},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
srv, _ := newScopedBatteryHoldServer(t)
tc.mutate(srv)
body := `{"direction":"charge","power_w":3000,"hold_s":60,"driver":"ghost"}`
req := httptest.NewRequest(http.MethodPost, "/api/battery/manual_hold", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
srv.Handler().ServeHTTP(rr, req)
if rr.Code != tc.want {
t.Fatalf("status=%d, want %d body=%s", rr.Code, tc.want, rr.Body.String())
}
})
}
}
Loading