diff --git a/configs/ems-config.yaml b/configs/ems-config.yaml index f53a0ff..704e7b6 100644 --- a/configs/ems-config.yaml +++ b/configs/ems-config.yaml @@ -108,4 +108,5 @@ ems: log_level: "info" state_file: "/run/ems/heartbeat" # written each cycle for state recovery recovery_timeout: "1h" # ignore Shelly state if EMS was down longer - override_timeout: "1h" # how long a manual Shelly change is respected + override_timeout: "1h" # default lockout when EMS detects external Shelly change + override_max_import_w: 800 # cancel override immediately if importing more than this (0 = disabled) diff --git a/internal/config/config.go b/internal/config/config.go index 2757483..0c06578 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -151,12 +151,13 @@ func (f *ForecastConfig) FetchIntervalParsed() time.Duration { // EMSConfig holds operational settings for the EMS daemon. type EMSConfig struct { - PollInterval string `yaml:"poll_interval"` - ListenAddr string `yaml:"listen_addr"` - LogLevel string `yaml:"log_level"` - StateFile string `yaml:"state_file"` - RecoveryTimeout string `yaml:"recovery_timeout"` - OverrideTimeout string `yaml:"override_timeout"` + PollInterval string `yaml:"poll_interval"` + ListenAddr string `yaml:"listen_addr"` + LogLevel string `yaml:"log_level"` + StateFile string `yaml:"state_file"` + RecoveryTimeout string `yaml:"recovery_timeout"` + OverrideTimeout string `yaml:"override_timeout"` + OverrideMaxImportW float64 `yaml:"override_max_import_w"` // cancel override if grid import exceeds this (0 = disabled) } func (e *EMSConfig) PollIntervalParsed() time.Duration { diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 4180675..eab86e0 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -123,6 +123,9 @@ func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC flo // --- SOC emergency brake --- actions = append(actions, e.socEmergencyBrake(soc, now)...) + // --- Override hard stop: cancel override on excessive import --- + actions = append(actions, e.overrideHardStop(gridW)...) + // --- WW window shutdown --- // If WW is active but we're outside the allowed time window, reset immediately. if cs := e.consumers[ConsumerWW]; cs.Active && !wwWindow { @@ -400,7 +403,7 @@ func (e *Engine) shutdownLastConsumer(now time.Time) *Action { } // socEmergencyBrake immediately shuts off consumers whose SOC threshold -// is no longer met, ignoring minimum runtimes. +// is no longer met, ignoring minimum runtimes and manual overrides. func (e *Engine) socEmergencyBrake(soc float64, now time.Time) []Action { var actions []Action allowed := e.allowedConsumers(soc) @@ -416,9 +419,12 @@ func (e *Engine) socEmergencyBrake(soc float64, now time.Time) []Action { e.logger.Warn("SOC emergency brake", "consumer", c, "soc", soc, + "was_override", cs.ManualOverride, ) cs.Active = false + cs.ManualOverride = false // EMS takes back full control after emergency + cs.OverrideUntil = time.Time{} a := Action{ Consumer: c, TurnOn: false, @@ -433,6 +439,69 @@ func (e *Engine) socEmergencyBrake(soc float64, now time.Time) []Action { return actions } +// overrideHardStop cancels active overrides when grid import exceeds the +// configured hard-stop threshold. Called before normal shutdown logic so +// there is no hysteresis delay — protection is immediate. +func (e *Engine) overrideHardStop(gridW float64) []Action { + limit := e.cfg.EMS.OverrideMaxImportW + if limit <= 0 || gridW <= limit { + return nil + } + + var actions []Action + for c, cs := range e.consumers { + if !cs.Active || !cs.ManualOverride { + continue + } + + e.logger.Warn("override hard stop: import exceeds limit", + "consumer", c, + "grid_w", gridW, + "limit_w", limit, + ) + + cs.Active = false + cs.ManualOverride = false + cs.OverrideUntil = time.Time{} + a := Action{ + Consumer: c, + TurnOn: false, + Reason: fmt.Sprintf("override cancelled: import %.0fW > limit %.0fW", gridW, limit), + } + if c == ConsumerWW { + a.TargetTempC = e.cfg.Strategic.WWBaseC + } + actions = append(actions, a) + } + return actions +} + +// ApplyOverride directly sets a consumer's state and override lockout. +// Called from the web UI override handler so the engine state is consistent +// immediately, without waiting for the next SyncHardwareState cycle. +func (e *Engine) ApplyOverride(consumer Consumer, on bool, duration time.Duration) { + cs, ok := e.consumers[consumer] + if !ok { + return + } + now := time.Now() + cs.Active = on + cs.ManualOverride = true + cs.OverrideUntil = now.Add(duration) + cs.LowPowerCycles = 0 + if on { + cs.ActivatedAt = now + } else { + cs.ActivatedAt = time.Time{} + } + e.logger.Info("manual override applied", + "consumer", consumer, + "on", on, + "duration", duration, + "until", cs.OverrideUntil.Format("15:04"), + ) +} + // isHeatingPeriod returns true if the current month is within the heating season. func (e *Engine) isHeatingPeriod(now time.Time) bool { month := int(now.Month()) diff --git a/internal/status/status.go b/internal/status/status.go index 16a26b5..6c67ccd 100644 --- a/internal/status/status.go +++ b/internal/status/status.go @@ -534,6 +534,17 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; } .consumer-detail.active { color: #16a34a; font-weight: 500; } .consumer-detail.override { color: #d97706; font-weight: 500; } +.dur-select { + border: 1px solid #d1d5db; + border-radius: 8px; + padding: 0.3rem 0.4rem; + font-size: 0.75rem; + background: #f9fafb; + color: #374151; + cursor: pointer; + flex-shrink: 0; +} + .override-btn { border: none; border-radius: 8px; @@ -673,13 +684,20 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; } {{formatW .LivePowerW}} {{end}} {{if .CanOverride}} -
+ {{if .Active}} + {{else}} + {{end}}
diff --git a/main.go b/main.go index 3643ec4..32dfdfe 100644 --- a/main.go +++ b/main.go @@ -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) } }