diff --git a/.changeset/scoped-battery-service-hold.md b/.changeset/scoped-battery-service-hold.md new file mode 100644 index 00000000..b7fc1db3 --- /dev/null +++ b/.changeset/scoped-battery-service-hold.md @@ -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. diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 34ba779d..3209f3d0 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -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, @@ -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, @@ -2321,6 +2334,7 @@ func main() { } if !freshness.Allowed() { + clearBatteryManualHoldForDispatchBlock(ctrl, ctrlMu) continue } @@ -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) { diff --git a/go/cmd/ftw/site_meter_stale_test.go b/go/cmd/ftw/site_meter_stale_test.go index 4b06e973..5f981fd4 100644 --- a/go/cmd/ftw/site_meter_stale_test.go +++ b/go/cmd/ftw/site_meter_stale_test.go @@ -1,9 +1,11 @@ package main import ( + "sync" "testing" "time" + "github.com/srcfl/ftw/go/internal/control" "github.com/srcfl/ftw/go/internal/telemetry" ) @@ -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) { diff --git a/go/internal/api/api.go b/go/internal/api/api.go index b11fa5f4..5aef1bea 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -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 diff --git a/go/internal/api/api_battery_manual.go b/go/internal/api/api_battery_manual.go index a183ccda..07bf888d 100644 --- a/go/internal/api/api_battery_manual.go +++ b/go/internal/api/api_battery_manual.go @@ -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. @@ -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 @@ -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"` } @@ -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, } @@ -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"}) @@ -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() } diff --git a/go/internal/api/api_battery_manual_test.go b/go/internal/api/api_battery_manual_test.go index 6326bf7f..94243421 100644 --- a/go/internal/api/api_battery_manual_test.go +++ b/go/internal/api/api_battery_manual_test.go @@ -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 @@ -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}` @@ -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()) + } + }) + } +} diff --git a/go/internal/control/control_test.go b/go/internal/control/control_test.go index d1f32d94..ff9f8734 100644 --- a/go/internal/control/control_test.go +++ b/go/internal/control/control_test.go @@ -4630,6 +4630,108 @@ func TestBatteryManualHoldBypassesHoldoff(t *testing.T) { } } +func TestBatteryManualHoldScopedPinsTargetAndStopsSiblingBeforeSlew(t *testing.T) { + store := seedStore(5000, []struct { + name string + currentW, soc float64 + }{ + {"bat_a", 3000, 0.5}, + {"bat_b", 2000, 0.5}, + }) + st := NewState(0, 50, "ferroamp") + st.SlewEnabled = true + st.SlewRateW = 500 + st.BatteryHoldTargetValid = func(driver, deviceID string) bool { + return driver == "bat_a" && deviceID == "maker:serial-a" + } + st.SetBatteryManualHold(BatteryManualHold{ + Driver: "bat_a", DeviceID: "maker:serial-a", PowerW: 3000, + ExpiresAt: time.Now().Add(time.Minute), + }) + targets := ComputeDispatch(store, st, caps(map[string]float64{"bat_a": 10000, "bat_b": 10000}), 100000) + byDriver := map[string]float64{} + for _, target := range targets { + byDriver[target.Driver] = target.TargetW + } + if math.Abs(byDriver["bat_a"]-3000) > 1 || math.Abs(byDriver["bat_b"]) > 1 { + t.Fatalf("scoped targets = %+v, want bat_a=3000 bat_b=0", byDriver) + } +} + +func TestBatteryManualHoldScopedClearsWhenTargetIsNoLongerSafe(t *testing.T) { + store := seedStore(0, []struct { + name string + currentW, soc float64 + }{{"bat_a", 0, 0.5}}) + st := NewState(0, 50, "ferroamp") + valid := false + st.BatteryHoldTargetValid = func(string, string) bool { return valid } + st.SetBatteryManualHold(BatteryManualHold{ + Driver: "bat_a", DeviceID: "maker:serial-a", PowerW: 3000, + ExpiresAt: time.Now().Add(time.Minute), + }) + ComputeDispatch(store, st, caps(map[string]float64{"bat_a": 10000}), 100000) + if _, active := st.GetBatteryManualHold(time.Now()); active { + t.Fatal("unsafe target left scoped hold active") + } + valid = true + ComputeDispatch(store, st, caps(map[string]float64{"bat_a": 10000}), 100000) + if _, active := st.GetBatteryManualHold(time.Now()); active { + t.Fatal("cleared scoped hold resumed after target recovered") + } +} + +func TestBatteryManualHoldScopedClearsWhenHardwareIdentityChanges(t *testing.T) { + store := seedStore(0, []struct { + name string + currentW, soc float64 + }{{"bat_a", 0, 0.5}}) + st := NewState(0, 50, "ferroamp") + currentDeviceID := "maker:serial-b" + st.BatteryHoldTargetValid = func(driver, deviceID string) bool { + return driver == "bat_a" && deviceID == currentDeviceID + } + st.SetBatteryManualHold(BatteryManualHold{ + Driver: "bat_a", DeviceID: "maker:serial-a", PowerW: 3000, + ExpiresAt: time.Now().Add(time.Minute), + }) + + ComputeDispatch(store, st, caps(map[string]float64{"bat_a": 10000}), 100000) + + if _, active := st.GetBatteryManualHold(time.Now()); active { + t.Fatal("driver name moved the scoped hold to different hardware") + } +} + +func TestBatteryManualHoldScopedStillUsesCoreClamps(t *testing.T) { + store := seedStore(0, []struct { + name string + currentW, soc float64 + }{{"bat_a", 0, 0.2}}) + st := NewState(0, 50, "ferroamp") + st.SlewEnabled = false + st.DriverLimits = map[string]PowerLimits{"bat_a": {MaxChargeW: 1000, MaxDischargeW: 1000}} + st.BatteryHoldTargetValid = func(string, string) bool { return true } + st.SetBatteryManualHold(BatteryManualHold{ + Driver: "bat_a", DeviceID: "maker:serial-a", PowerW: 3000, + ExpiresAt: time.Now().Add(time.Minute), + }) + targets := ComputeDispatch(store, st, caps(map[string]float64{"bat_a": 10000}), 100000) + if len(targets) != 1 || math.Abs(targets[0].TargetW-1000) > 1 { + t.Fatalf("cap targets = %+v, want bat_a=1000", targets) + } + + st.BatteryBoostReserveSoC = 0.3 + st.SetBatteryManualHold(BatteryManualHold{ + Driver: "bat_a", DeviceID: "maker:serial-a", PowerW: -1000, + ExpiresAt: time.Now().Add(time.Minute), + }) + targets = ComputeDispatch(store, st, caps(map[string]float64{"bat_a": 10000}), 100000) + if len(targets) != 1 || math.Abs(targets[0].TargetW) > 1 { + t.Fatalf("reserve targets = %+v, want bat_a=0", targets) + } +} + // ---- Meter clamp on the legacy PI dispatch arm ---- // // The reactive PI path commits a charge/discharge magnitude based on diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index 7da1879b..ed43978d 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -589,10 +589,10 @@ type State struct { FuseHoldMaxChargeW float64 FuseHoldUntil time.Time - // ManualHold pins the aggregate battery setpoint to a fixed power - // for a bounded duration, bypassing both the active manual mode - // and the MPC. Hot-installed via POST /api/battery/manual_hold; - // auto-expires. Zero ExpiresAt means inactive. + // ManualHold pins the battery pool or one battery to a fixed power + // for a bounded duration, bypassing both the active manual mode and + // the MPC. Hot-installed via POST /api/battery/manual_hold; it is + // never persisted and auto-expires. Zero ExpiresAt means inactive. // // Site sign convention: PowerW > 0 = charge, < 0 = discharge, // 0 = idle. SoC clamps, slew, and the fuse guard still apply on @@ -600,6 +600,10 @@ type State struct { // Mutated under the same outer ctrlMu that protects the rest of // State; no internal mutex. ManualHold BatteryManualHold + // BatteryHoldTargetValid checks that a scoped hold still points at + // the same live hardware. Core wires this to driver health, battery + // telemetry, and the hardware identity reported by the driver. + BatteryHoldTargetValid func(driver, deviceID string) bool // ManualPVHold pins a PV curtail cap for a bounded duration, // overriding whatever the planner's slot directive says about @@ -612,15 +616,17 @@ type State struct { } // BatteryManualHold is the full payload of a battery manual override. -// See State.ManualHold for invariants. +// Driver empty keeps the pool behavior. A scoped hold must also carry +// DeviceID so a reused driver name cannot move it to other hardware. type BatteryManualHold struct { + Driver string + DeviceID string PowerW float64 ExpiresAt time.Time } -// SetBatteryManualHold installs a manual override on the aggregate -// battery setpoint. Caller must hold the outer ctrlMu. A zero -// ExpiresAt clears any active hold (same as ClearBatteryManualHold). +// SetBatteryManualHold installs a manual override. Caller must hold the +// outer ctrlMu. A zero ExpiresAt clears any active hold. func (s *State) SetBatteryManualHold(h BatteryManualHold) { if h.ExpiresAt.IsZero() { s.ManualHold = BatteryManualHold{} @@ -1108,6 +1114,15 @@ func ComputeDispatch( // and the deadband. Falls through to distribute → slew → SoC clamp // → fuse guard so safety bounds still apply. manualHold, manualHoldActive := state.GetBatteryManualHold(time.Now()) + if manualHoldActive && manualHold.Driver != "" { + valid := manualHold.DeviceID != "" && state.BatteryHoldTargetValid != nil && + state.BatteryHoldTargetValid(manualHold.Driver, manualHold.DeviceID) + if !valid { + state.ClearBatteryManualHold() + manualHold = BatteryManualHold{} + manualHoldActive = false + } + } if manualHoldActive { // Reset PI + slot accumulators so reverting to a planner mode // after the hold expires doesn't read stale state — same reset @@ -2046,13 +2061,17 @@ func ComputeDispatch( // ---- Distribute across batteries ---- var raw []DispatchTarget - switch effectiveMode { - case ModeSelfConsumption, ModePeakShaving: - raw = distributeProportional(onlineBats, totalCorrection, groupPV) - case ModePriority: - raw = distributePriority(onlineBats, totalCorrection, state.PriorityOrder) - case ModeWeighted: - raw = distributeWeighted(onlineBats, totalCorrection, state.Weights) + if manualHoldActive && manualHold.Driver != "" { + raw = distributeScopedManualHold(onlineBats, manualHold.Driver, currentTotal+totalCorrection) + } else { + switch effectiveMode { + case ModeSelfConsumption, ModePeakShaving: + raw = distributeProportional(onlineBats, totalCorrection, groupPV) + case ModePriority: + raw = distributePriority(onlineBats, totalCorrection, state.PriorityOrder) + case ModeWeighted: + raw = distributeWeighted(onlineBats, totalCorrection, state.Weights) + } } // ---- Slew rate limit per driver ---- @@ -2087,7 +2106,8 @@ func ComputeDispatch( // A direct move to 0 W is safe here: it only removes battery load / // discharge, and the fuse overflow guard below can still force // discharge if the site actually needs it. - if (plannerSelfExportSurplusGate || + scopedSiblingStanddown := manualHoldActive && manualHold.Driver != "" && raw[i].Driver != manualHold.Driver + if (plannerSelfExportSurplusGate || scopedSiblingStanddown || (manualHoldActive && math.Abs(manualHold.PowerW) < 1)) && math.Abs(raw[i].TargetW) < 1 { raw[i].TargetW = 0 @@ -2784,6 +2804,19 @@ func liveCurtailLimitW(state *State, store *telemetry.Store) (float64, bool) { return liveLoadW + batHeadroomW + evReserveW, true } +func distributeScopedManualHold(bats []batteryInfo, driver string, powerW float64) []DispatchTarget { + out := make([]DispatchTarget, 0, len(bats)) + for _, b := range bats { + targetW := 0.0 + if b.driver == driver { + targetW = powerW + } + clamped, wasClamped := clampWithSoC(targetW, b) + out = append(out, DispatchTarget{Driver: b.driver, TargetW: clamped, Clamped: wasClamped}) + } + return out +} + // distributeProportional splits the total desired battery power across the // available batteries by capacity. Each battery gets its share of the TOTAL // desired site battery power — not its share of the delta. This prevents the diff --git a/web/app.js b/web/app.js index cfded0b6..ea9dcf2c 100644 --- a/web/app.js +++ b/web/app.js @@ -3473,11 +3473,10 @@ evModalDriver = null; }); - // Planet click routing. EV → EV modal scoped to driver. Battery → - // manual-hold modal (no driver scoping; the - // hold applies to the aggregate battery setpoint). Grid → grid - // modal hosting the peak-import ceiling and the (legacy) grid - // target setpoint. + // Planet click routing. EV → EV modal scoped to driver. A battery + // driver opens its own short hold; the merged battery opens pool + // control. Grid → grid modal hosting the peak-import ceiling and + // the legacy grid target setpoint. var gridModal = document.getElementById("grid-modal"); if (energyFlowEl) { energyFlowEl.addEventListener("ftw-planet-click", function (e) { @@ -3488,7 +3487,7 @@ var clicked = drv[d.name || ""] || {}; if (clicked.observe_only) return; var bc = document.getElementById("battery-control"); - if (bc && typeof bc.open === "function") bc.open(); + if (bc && typeof bc.open === "function") bc.open(d.name || d.id || ""); } if (d.role === "pv") { var pc = document.getElementById("pv-control"); diff --git a/web/battery-manual-hold-scope.test.mjs b/web/battery-manual-hold-scope.test.mjs new file mode 100644 index 00000000..4490f7b2 --- /dev/null +++ b/web/battery-manual-hold-scope.test.mjs @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const component = readFileSync(new URL("./components/ftw-battery-control.js", import.meta.url), "utf8"); +const app = readFileSync(new URL("./app.js", import.meta.url), "utf8"); + +test("battery planet opens a scoped manual hold", () => { + assert.match(app, /bc\.open\(d\.name \|\| d\.id \|\| ""\)/); + assert.match(component, /data-scope="pool"/); + assert.match(component, /data-scope="driver"/); + assert.match(component, /body\.driver = this\._formState\.scopeDriver/); +}); + +test("manual hold status names its scope", () => { + assert.match(component, /d\.driver \? " · " \+ d\.driver : " · all batteries"/); + assert.match(component, /data-scope-row/); +}); diff --git a/web/components/ftw-battery-control.js b/web/components/ftw-battery-control.js index fc7f4acf..445468ac 100644 --- a/web/components/ftw-battery-control.js +++ b/web/components/ftw-battery-control.js @@ -1,18 +1,18 @@ // — operator-pinned battery setpoint modal. // // Wraps a with a direction segmented control (charge / -// discharge / idle), power input, duration chips (5 / 15 / 30 min), -// active-hold banner, and Stop / Install buttons. Talks to the -// /api/battery/manual_hold endpoints; safety clamps (SoC, per-driver -// caps, slew, fuse guard) are enforced server-side regardless of what -// is requested here. +// discharge / idle), optional battery scope, power input, duration +// chips (5 / 15 / 30 min), active-hold banner, and Stop / Install +// buttons. Talks to the /api/battery/manual_hold endpoints; core keeps +// every safety limit in force. // // Usage: // // // // const el = document.getElementById("battery-control"); -// el.open(); // opens the modal and starts polling /api/battery/manual_hold +// el.open(); // whole battery pool +// el.open("bat_a"); // selected battery // // Opening from another component (e.g. battery-planet click) is the // only entry point — there's no auto-open behavior. @@ -199,7 +199,7 @@ class FtwBatteryControl extends FtwElement { constructor() { super(); this._refreshTimer = null; - this._formState = { direction: "charge", holdS: 900 }; + this._formState = { direction: "charge", holdS: 900, scopeDriver: "", scopeMode: "pool" }; } connectedCallback() { @@ -213,12 +213,16 @@ class FtwBatteryControl extends FtwElement { } } - open() { + open(driverName) { const modal = this.shadowRoot.querySelector("ftw-modal"); if (!modal) return; this._showError(""); + const driver = (driverName && String(driverName).trim()) || ""; + this._formState.scopeDriver = driver; + this._formState.scopeMode = driver ? "driver" : "pool"; this._selectDirection(this._formState.direction); this._selectDuration(this._formState.holdS); + this._renderScope(); modal.open(); this._refresh(); if (this._refreshTimer) clearInterval(this._refreshTimer); @@ -235,6 +239,14 @@ class FtwBatteryControl extends FtwElement {
+ +
@@ -274,14 +286,18 @@ class FtwBatteryControl extends FtwElement { afterRender() { const root = this.shadowRoot; const modal = root.querySelector("ftw-modal"); - const segBtns = root.querySelectorAll(".seg-btn"); + const directionBtns = root.querySelectorAll("[data-direction]"); + const scopeBtns = root.querySelectorAll("[data-scope]"); const chips = root.querySelectorAll(".chip"); const installBtn = root.querySelector("[data-install]"); const stopBtn = root.querySelector("[data-stop]"); - segBtns.forEach((b) => { + directionBtns.forEach((b) => { b.addEventListener("click", () => this._selectDirection(b.dataset.direction)); }); + scopeBtns.forEach((b) => { + b.addEventListener("click", () => this._selectScope(b.dataset.scope)); + }); chips.forEach((c) => { c.addEventListener("click", () => this._selectDuration(Number(c.dataset.hold))); }); @@ -297,10 +313,31 @@ class FtwBatteryControl extends FtwElement { }); } + _renderScope() { + const root = this.shadowRoot; + const row = root.querySelector("[data-scope-row]"); + if (!row) return; + const hasDriver = !!this._formState.scopeDriver; + row.classList.toggle("hidden", !hasDriver); + const label = root.querySelector("[data-scope-driver-label]"); + if (label && hasDriver) label.textContent = this._formState.scopeDriver; + this._selectScope(this._formState.scopeMode); + } + + _selectScope(mode) { + if (mode === "driver" && !this._formState.scopeDriver) mode = "pool"; + this._formState.scopeMode = mode; + this.shadowRoot.querySelectorAll("[data-scope]").forEach((button) => { + const on = button.dataset.scope === mode; + button.classList.toggle("active", on); + button.setAttribute("aria-checked", on ? "true" : "false"); + }); + } + _selectDirection(dir) { this._formState.direction = dir; const root = this.shadowRoot; - root.querySelectorAll(".seg-btn").forEach((b) => { + root.querySelectorAll("[data-direction]").forEach((b) => { const on = b.dataset.direction === dir; b.classList.toggle("active", on); b.setAttribute("aria-checked", on ? "true" : "false"); @@ -341,10 +378,11 @@ class FtwBatteryControl extends FtwElement { return; } const dir = d.direction || "idle"; + const scope = d.driver ? " · " + d.driver : " · all batteries"; const headline = - dir === "charge" ? "Charging at " + (d.power_w || 0) + " W" : - dir === "discharge" ? "Discharging at " + (d.power_w || 0) + " W" : - "Holding idle"; + dir === "charge" ? "Charging at " + (d.power_w || 0) + " W" + scope : + dir === "discharge" ? "Discharging at " + (d.power_w || 0) + " W" + scope : + "Holding idle" + scope; let remaining = ""; if (d.expires_at_ms) { const ms = d.expires_at_ms - Date.now(); @@ -384,11 +422,15 @@ class FtwBatteryControl extends FtwElement { return; } } + const body = { direction: dir, power_w: powerW, hold_s: holdS }; + if (this._formState.scopeMode === "driver" && this._formState.scopeDriver) { + body.driver = this._formState.scopeDriver; + } installBtn.disabled = true; apiFetch("/api/battery/manual_hold", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ direction: dir, power_w: powerW, hold_s: holdS }), + body: JSON.stringify(body), }) .then((r) => { if (!r.ok) {