Skip to content
Draft
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.
38 changes: 37 additions & 1 deletion go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,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
}

// ---- Identity bootstrap ----
// Drivers report make/serial inside driver_init via host.set_make / set_sn,
Expand Down Expand Up @@ -1870,7 +1882,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,
DriverDir: resolveDriverDir(),
UserDriverDir: *userDriversDirFlag,
DriverMQTTFactory: reg.MQTTFactory,
Expand Down Expand Up @@ -2321,6 +2334,7 @@ func main() {
}

if !freshness.Allowed() {
clearBatteryManualHoldForDispatchBlock(ctrl, ctrlMu)
continue
}

Expand Down Expand Up @@ -2764,6 +2778,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, reg *drivers.Registry, 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
18 changes: 11 additions & 7 deletions go/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,17 @@ type Deps struct {
Tel *telemetry.Store
// LogRing is the in-memory log buffer wired in main.go. Nil makes
// /api/drivers/{name}/logs and /api/support/dump return 503.
LogRing *telemetry.LogRing
Ctrl *control.State
CtrlMu *sync.Mutex
State *state.Store
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)
LogRing *telemetry.LogRing
Ctrl *control.State
CtrlMu *sync.Mutex
State *state.Store
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)

// 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
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