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/import-ceiling-on-box.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Switching the peak import limit on now applies the last limit the box had on, not a value one browser kept from earlier. The Grid dialog shows that value while the limit is off, and `/api/status` serves it as `peak_import_ceiling_last_w`. If the box has never had a limit on, switching on applies nothing until you pick a value and press Save, and Save now looks off while there is nothing to apply.
49 changes: 47 additions & 2 deletions go/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -1251,6 +1251,9 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
"control_feedback": s.controlFeedback(time.Now()),
"slot_delivery_stats": ctrl.SlotDeliveryStats,
}
// The ceiling that switching on applies, so a client shows the box's
// value instead of keeping its own (#1674). Null until one was on.
resp["peak_import_ceiling_last_w"] = s.peakImportCeilingLastW(ctrl.PeakImportCeilingW)
// The operator's cap for storing live PV surplus, 0 when unset. Dispatch
// prefers it to the plan's per-slot cap, so the Plan card needs it to say
// where extra sun goes.
Expand Down Expand Up @@ -1836,14 +1839,56 @@ func (s *Server) handleSetPeakImportCeiling(w http.ResponseWriter, r *http.Reque
return
}
s.deps.CtrlMu.Lock()
s.deps.Ctrl.PeakImportCeilingW = req.PeakImportCeilingW
prevW := s.deps.Ctrl.PeakImportCeilingW
s.deps.CtrlMu.Unlock()
if s.deps.State != nil {
_ = s.deps.State.SaveConfig("peak_import_ceiling_w", strconv.FormatFloat(req.PeakImportCeilingW, 'f', 1, 64))
values := map[string]string{"peak_import_ceiling_w": strconv.FormatFloat(req.PeakImportCeilingW, 'f', 1, 64)}
// Switching off keeps the ceiling that was on, so switching back on
// restores the box's tariff, not a value a browser kept (#1674).
lastW := req.PeakImportCeilingW
if lastW == 0 {
lastW = prevW
}
if lastW > 0 {
values[peakImportCeilingLastKey] = strconv.FormatFloat(lastW, 'f', 1, 64)
}
// Save before applying, so the box never runs a ceiling that a
// restart would undo.
if err := s.deps.State.SaveConfigValues(values); err != nil {
slog.Warn("failed to persist peak_import_ceiling_w", "err", err)
writeJSON(w, 500, map[string]string{"error": "save failed: " + err.Error()})
return
}
}
s.deps.CtrlMu.Lock()
s.deps.Ctrl.PeakImportCeilingW = req.PeakImportCeilingW
s.deps.CtrlMu.Unlock()
writeJSON(w, 200, map[string]any{"status": "ok", "peak_import_ceiling_w": req.PeakImportCeilingW})
}

// peakImportCeilingLastKey holds the last import ceiling that was on.
const peakImportCeilingLastKey = "peak_import_ceiling_last_w"

// peakImportCeilingLastW is the ceiling switching on applies: the current one
// while on, else the last one that was on. Nil when none ever was.
func (s *Server) peakImportCeilingLastW(currentW float64) *float64 {
if currentW > 0 {
return &currentW
}
if s.deps.State == nil {
return nil
}
v, ok := s.deps.State.LoadConfig(peakImportCeilingLastKey)
if !ok {
return nil
}
f, err := strconv.ParseFloat(v, 64)
if err != nil || f <= 0 {
return nil
}
return &f
}

// ---- /api/ev_charging ----

func (s *Server) handleSetEVCharging(w http.ResponseWriter, r *http.Request) {
Expand Down
133 changes: 133 additions & 0 deletions go/internal/api/api_peak_import_ceiling_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package api

import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"testing"

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

// #1674: switching the import ceiling on applied 3,000 W that one browser
// had kept from a test, not the 7,500 W the owner meant. The box keeps the
// last ceiling that was on and serves it, so a client never supplies it.

func newCeilingServer(t *testing.T, st *state.Store, ctrl *control.State) *Server {
t.Helper()
return New(&Deps{State: st, Tel: telemetry.NewStore(), Ctrl: ctrl, CtrlMu: &sync.Mutex{}, CapMu: &sync.RWMutex{}, CfgMu: &sync.RWMutex{}, Cfg: &config.Config{}})
}

func postCeiling(t *testing.T, srv *Server, w string) {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/api/peak_import_ceiling", strings.NewReader(`{"peak_import_ceiling_w":`+w+`}`))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
srv.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("POST %s W: status %d, body %s", w, rr.Code, rr.Body.String())
}
}

// ceilingStatus returns the ceiling and the last one that was on, as
// /api/status serves them; nil stands for JSON null.
func ceilingStatus(t *testing.T, srv *Server) (any, any) {
t.Helper()
rr := httptest.NewRecorder()
srv.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/status", nil))
var body map[string]any
if rr.Code != http.StatusOK || json.Unmarshal(rr.Body.Bytes(), &body) != nil {
t.Fatalf("status = %d, body: %s", rr.Code, rr.Body.String())
}
last, ok := body["peak_import_ceiling_last_w"]
if !ok {
t.Fatal("status has no peak_import_ceiling_last_w")
}
return body["peak_import_ceiling_w"], last
}

func TestPeakImportCeilingKeepsTheLastOneThatWasOn(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
st, err := state.Open(path)
if err != nil {
t.Fatal(err)
}
srv := newCeilingServer(t, st, &control.State{})

if now, last := ceilingStatus(t, srv); now != 0.0 || last != nil {
t.Fatalf("fresh box: ceiling %v, last %v; want 0 and null", now, last)
}
postCeiling(t, srv, "7500")
if now, last := ceilingStatus(t, srv); now != 7500.0 || last != 7500.0 {
t.Fatalf("on: ceiling %v, last %v; want 7,500 each", now, last)
}
postCeiling(t, srv, "0")
postCeiling(t, srv, "0")
if now, last := ceilingStatus(t, srv); now != 0.0 || last != 7500.0 {
t.Fatalf("off: ceiling %v, last %v; want 0 and 7,500", now, last)
}

// The record outlives a restart, as the ceiling itself does.
if err := st.Close(); err != nil {
t.Fatal(err)
}
st, err = state.Open(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = st.Close() })
srv = newCeilingServer(t, st, &control.State{})
if now, last := ceilingStatus(t, srv); now != 0.0 || last != 7500.0 {
t.Fatalf("after restart: ceiling %v, last %v; want 0 and 7,500", now, last)
}
if v, ok := st.LoadConfig("peak_import_ceiling_w"); !ok || v != "0.0" {
t.Fatalf("stored ceiling %q (%v), want 0.0", v, ok)
}
}

// A ceiling switched on before this release was stored without a last
// value. Switching it off still keeps it.
func TestPeakImportCeilingOffKeepsACeilingSetBeforeTheRecord(t *testing.T) {
st, err := state.Open(filepath.Join(t.TempDir(), "state.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = st.Close() })
srv := newCeilingServer(t, st, &control.State{PeakImportCeilingW: 6000})

postCeiling(t, srv, "0")
if now, last := ceilingStatus(t, srv); now != 0.0 || last != 6000.0 {
t.Fatalf("off: ceiling %v, last %v; want 0 and 6,000", now, last)
}
}

// A ceiling the box cannot save is not applied: a restart would bring the
// old one back, and the caller has to learn that the change did not hold.
func TestPeakImportCeilingNotAppliedWhenTheSaveFails(t *testing.T) {
st, err := state.Open(filepath.Join(t.TempDir(), "state.db"))
if err != nil {
t.Fatal(err)
}
ctrl := &control.State{PeakImportCeilingW: 6000}
srv := newCeilingServer(t, st, ctrl)
if err := st.Close(); err != nil {
t.Fatal(err)
}

req := httptest.NewRequest(http.MethodPost, "/api/peak_import_ceiling", strings.NewReader(`{"peak_import_ceiling_w":0}`))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
srv.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500 (body=%s)", rr.Code, rr.Body.String())
}
if ctrl.PeakImportCeilingW != 6000 {
t.Fatalf("ceiling = %.0f W after a failed save, want 6,000", ctrl.PeakImportCeilingW)
}
}
5 changes: 5 additions & 0 deletions web/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -1295,6 +1295,11 @@ body.ftw-app .slider-group-compact input[type="range"]:disabled {
body.ftw-app .slider-group-compact:has(input[type="range"]:disabled) .slider-current {
color: var(--fg-muted);
}
/* Save lights up only when there is a value to apply. */
body.ftw-app .slider-group-compact .btn-send:disabled {
cursor: not-allowed;
opacity: 0.45;
}
body.ftw-app .slider-current {
font-family: var(--mono);
font-size: 12px;
Expand Down
52 changes: 24 additions & 28 deletions web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -290,19 +290,10 @@
// visual cue that there's work to commit. Cleared on successful POST.
let peakLimitDirty = false;
let gridTargetDirty = false;
// Last non-zero peak ceiling, remembered so unchecking + rechecking
// the enable toggle restores the previous value instead of resetting
// to the default. Keyed by localStorage so it survives reloads.
function readLastPeakLimitW() {
try {
const v = localStorage.getItem("ftw-peak-import-ceiling-w");
const n = v == null ? null : Number(v);
return Number.isFinite(n) && n > 0 ? n : 5000;
} catch (e) { return 5000; }
}
function writeLastPeakLimitW(w) {
try { localStorage.setItem("ftw-peak-import-ceiling-w", String(w)); } catch (e) {}
}
// The last peak ceiling that was on, as the box serves it: what switching
// the toggle back on applies. A copy kept in the browser once applied an
// old test value (#1674), so only the box keeps it.
let peakLimitLastW = null;
const fuseUse = $("fuse-use");
const fuseFill = $("fuse-fill");
const fusePhases = $("fuse-phases");
Expand Down Expand Up @@ -871,20 +862,21 @@
const peakSrcW = data.peak_import_ceiling_w != null
? data.peak_import_ceiling_w
: data.peak_limit_w;
peakLimitLastW = data.peak_import_ceiling_last_w > 0 ? data.peak_import_ceiling_last_w : null;
if (peakLimitSlider && !peakLimitDirty && peakSrcW != null) {
const enabled = peakSrcW > 0;
if (peakLimitEnableToggle) {
peakLimitEnableToggle.checked = enabled;
if (peakLimitEnableLabel) peakLimitEnableLabel.textContent = enabled ? "On" : "Off";
}
peakLimitSlider.disabled = !enabled;
const display = enabled ? peakSrcW : readLastPeakLimitW();
// Off, the slider shows what switching on applies.
const display = enabled ? peakSrcW : peakLimitLastW || Number(peakLimitSlider.defaultValue);
peakLimitSlider.value = display;
// Don't decorate the value with " (off)" — the toggle is the
// single source of truth for enabled/disabled, doubling that
// signal is the kind of redundancy the shared design system warns about.
peakLimitValue.textContent = formatW(display);
if (enabled) writeLastPeakLimitW(peakSrcW);
if (peakLimitSend) peakLimitSend.disabled = true; // pristine
}
// Energy today
Expand Down Expand Up @@ -2665,9 +2657,9 @@
}

// Peak slider: same dirty pattern, plus an enable/disable checkbox.
// Off → POST 0 (backend reads 0 = disabled). On → POST slider value.
// The slider is disabled when the toggle is off so the operator can't
// accidentally drag a dead control.
// Off → POST 0 (backend reads 0 = disabled). On → POST the box's last
// ceiling. The slider is disabled when the toggle is off so the operator
// can't accidentally drag a dead control.
if (peakLimitSlider) {
peakLimitSlider.addEventListener("input", function () {
const w = Number(peakLimitSlider.value);
Expand All @@ -2681,15 +2673,20 @@
const enabled = peakLimitEnableToggle.checked;
if (peakLimitEnableLabel) peakLimitEnableLabel.textContent = enabled ? "On" : "Off";
if (peakLimitSlider) peakLimitSlider.disabled = !enabled;
// Show the value the toggle is about to enable (last known)
// without committing — the operator still has to press Save
// unless the toggle is being switched OFF, which is a destructive
// change worth one extra click confirmation. Wait — the spec
// calls for the toggle itself to be the on/off control, so flip
// straight through: post immediately on toggle change.
const w = enabled ? Number(peakLimitSlider.value) || readLastPeakLimitW() : 0;
if (enabled && peakLimitSlider) peakLimitSlider.value = w;
if (peakLimitValue) peakLimitValue.textContent = formatW(w);
if (enabled && !peakLimitLastW) {
// The box has never had a ceiling on, so there is none to restore:
// the owner picks one and Save applies it.
peakLimitDirty = true;
if (peakLimitSend) peakLimitSend.disabled = false;
return;
}
// On applies the ceiling the box last had on, shown as it applies;
// off applies 0. The toggle is the switch, so neither waits for Save.
const w = enabled ? peakLimitLastW : 0;
if (enabled) {
if (peakLimitSlider) peakLimitSlider.value = w;
if (peakLimitValue) peakLimitValue.textContent = formatW(w);
}
peakLimitEnableToggle.disabled = true;
setPeakImportCeiling(w)
.then(function () { peakLimitDirty = false; if (peakLimitSend) peakLimitSend.disabled = true; })
Expand All @@ -2700,7 +2697,6 @@
if (peakLimitSend) {
peakLimitSend.addEventListener("click", function () {
const w = Number(peakLimitSlider.value);
writeLastPeakLimitW(w);
peakLimitSend.disabled = true;
setPeakImportCeiling(w)
.then(function () { peakLimitDirty = false; })
Expand Down
Loading
Loading