Add configurable override duration and hard-stop thresholds

Override duration (web UI):
- Dropdown on Ein button: 30min / 1h / 2h / 4h
- Engine notified immediately via ApplyOverride() — no waiting for
  next SyncHardwareState cycle
- Aus button always uses 1h lockout (keeps consumer off for 1h)

Hard-stop thresholds that cancel active overrides:
- SOC emergency brake: now also clears ManualOverride flag so EMS
  resumes full control after the safety shutdown
- override_max_import_w (default 800W): if grid import exceeds this
  while an override is active, override is cancelled immediately
  (no hysteresis delay — protection is instant)

Config: ems.override_max_import_w (0 = disabled)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 10:43:49 +02:00
parent 4530b672d2
commit 7a971c4c41
5 changed files with 117 additions and 16 deletions

26
main.go
View File

@@ -104,7 +104,7 @@ func main() {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/override", overrideHandler(act, logger))
mux.HandleFunc("/override", overrideHandler(act, eng, logger))
mux.HandleFunc("/", statusStore.Handler())
srv := &http.Server{
@@ -269,10 +269,9 @@ func shouldRecover(stateFile string, timeout time.Duration, logger *slog.Logger)
}
// overrideHandler handles manual on/off requests from the status page.
// For Shelly consumers, it switches the hardware directly; the existing
// SyncHardwareState mechanism detects the change next cycle and applies
// the override lockout automatically.
func overrideHandler(act *actuator.Actuator, logger *slog.Logger) http.HandlerFunc {
// Switches the hardware immediately and informs the engine directly so
// the override lockout is applied without waiting for the next cycle.
func overrideHandler(act *actuator.Actuator, eng *engine.Engine, logger *slog.Logger) http.HandlerFunc {
consumerByKey := map[string]engine.Consumer{
"sg_ready": engine.ConsumerSGReady,
"wallbox_a": engine.ConsumerWallboxA,
@@ -287,6 +286,7 @@ func overrideHandler(act *actuator.Actuator, logger *slog.Logger) http.HandlerFu
consumerKey := r.FormValue("consumer")
stateVal := r.FormValue("state")
durationStr := r.FormValue("duration")
consumer, ok := consumerByKey[consumerKey]
if !ok {
@@ -294,6 +294,11 @@ func overrideHandler(act *actuator.Actuator, logger *slog.Logger) http.HandlerFu
return
}
duration, err := time.ParseDuration(durationStr)
if err != nil || duration <= 0 {
duration = time.Hour // safe default
}
turnOn := stateVal == "on"
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
@@ -302,7 +307,7 @@ func overrideHandler(act *actuator.Actuator, logger *slog.Logger) http.HandlerFu
action := engine.Action{
Consumer: consumer,
TurnOn: turnOn,
Reason: "manual override via web UI",
Reason: fmt.Sprintf("manual override via web UI (%s)", duration),
}
if err := act.Execute(ctx, []engine.Action{action}); err != nil {
logger.Error("web UI override failed", "consumer", consumerKey, "state", stateVal, "error", err)
@@ -310,7 +315,14 @@ func overrideHandler(act *actuator.Actuator, logger *slog.Logger) http.HandlerFu
return
}
logger.Info("web UI override executed", "consumer", consumerKey, "state", stateVal)
// Inform engine immediately so override lockout is active before next cycle
eng.ApplyOverride(consumer, turnOn, duration)
logger.Info("web UI override executed",
"consumer", consumerKey,
"state", stateVal,
"duration", duration,
)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
}