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:
@@ -108,4 +108,5 @@ ems:
|
|||||||
log_level: "info"
|
log_level: "info"
|
||||||
state_file: "/run/ems/heartbeat" # written each cycle for state recovery
|
state_file: "/run/ems/heartbeat" # written each cycle for state recovery
|
||||||
recovery_timeout: "1h" # ignore Shelly state if EMS was down longer
|
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)
|
||||||
|
|||||||
@@ -151,12 +151,13 @@ func (f *ForecastConfig) FetchIntervalParsed() time.Duration {
|
|||||||
|
|
||||||
// EMSConfig holds operational settings for the EMS daemon.
|
// EMSConfig holds operational settings for the EMS daemon.
|
||||||
type EMSConfig struct {
|
type EMSConfig struct {
|
||||||
PollInterval string `yaml:"poll_interval"`
|
PollInterval string `yaml:"poll_interval"`
|
||||||
ListenAddr string `yaml:"listen_addr"`
|
ListenAddr string `yaml:"listen_addr"`
|
||||||
LogLevel string `yaml:"log_level"`
|
LogLevel string `yaml:"log_level"`
|
||||||
StateFile string `yaml:"state_file"`
|
StateFile string `yaml:"state_file"`
|
||||||
RecoveryTimeout string `yaml:"recovery_timeout"`
|
RecoveryTimeout string `yaml:"recovery_timeout"`
|
||||||
OverrideTimeout string `yaml:"override_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 {
|
func (e *EMSConfig) PollIntervalParsed() time.Duration {
|
||||||
|
|||||||
@@ -123,6 +123,9 @@ func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC flo
|
|||||||
// --- SOC emergency brake ---
|
// --- SOC emergency brake ---
|
||||||
actions = append(actions, e.socEmergencyBrake(soc, now)...)
|
actions = append(actions, e.socEmergencyBrake(soc, now)...)
|
||||||
|
|
||||||
|
// --- Override hard stop: cancel override on excessive import ---
|
||||||
|
actions = append(actions, e.overrideHardStop(gridW)...)
|
||||||
|
|
||||||
// --- WW window shutdown ---
|
// --- WW window shutdown ---
|
||||||
// If WW is active but we're outside the allowed time window, reset immediately.
|
// If WW is active but we're outside the allowed time window, reset immediately.
|
||||||
if cs := e.consumers[ConsumerWW]; cs.Active && !wwWindow {
|
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
|
// 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 {
|
func (e *Engine) socEmergencyBrake(soc float64, now time.Time) []Action {
|
||||||
var actions []Action
|
var actions []Action
|
||||||
allowed := e.allowedConsumers(soc)
|
allowed := e.allowedConsumers(soc)
|
||||||
@@ -416,9 +419,12 @@ func (e *Engine) socEmergencyBrake(soc float64, now time.Time) []Action {
|
|||||||
e.logger.Warn("SOC emergency brake",
|
e.logger.Warn("SOC emergency brake",
|
||||||
"consumer", c,
|
"consumer", c,
|
||||||
"soc", soc,
|
"soc", soc,
|
||||||
|
"was_override", cs.ManualOverride,
|
||||||
)
|
)
|
||||||
|
|
||||||
cs.Active = false
|
cs.Active = false
|
||||||
|
cs.ManualOverride = false // EMS takes back full control after emergency
|
||||||
|
cs.OverrideUntil = time.Time{}
|
||||||
a := Action{
|
a := Action{
|
||||||
Consumer: c,
|
Consumer: c,
|
||||||
TurnOn: false,
|
TurnOn: false,
|
||||||
@@ -433,6 +439,69 @@ func (e *Engine) socEmergencyBrake(soc float64, now time.Time) []Action {
|
|||||||
return actions
|
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.
|
// isHeatingPeriod returns true if the current month is within the heating season.
|
||||||
func (e *Engine) isHeatingPeriod(now time.Time) bool {
|
func (e *Engine) isHeatingPeriod(now time.Time) bool {
|
||||||
month := int(now.Month())
|
month := int(now.Month())
|
||||||
|
|||||||
@@ -534,6 +534,17 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
|||||||
.consumer-detail.active { color: #16a34a; font-weight: 500; }
|
.consumer-detail.active { color: #16a34a; font-weight: 500; }
|
||||||
.consumer-detail.override { color: #d97706; 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 {
|
.override-btn {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -673,13 +684,20 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
|||||||
<span class="power-badge">{{formatW .LivePowerW}}</span>
|
<span class="power-badge">{{formatW .LivePowerW}}</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .CanOverride}}
|
{{if .CanOverride}}
|
||||||
<form method="post" action="/override">
|
<form method="post" action="/override" style="display:flex;align-items:center;gap:0.4rem;flex-shrink:0">
|
||||||
<input type="hidden" name="consumer" value="{{.ConsumerKey}}">
|
<input type="hidden" name="consumer" value="{{.ConsumerKey}}">
|
||||||
{{if .Active}}
|
{{if .Active}}
|
||||||
<input type="hidden" name="state" value="off">
|
<input type="hidden" name="state" value="off">
|
||||||
|
<input type="hidden" name="duration" value="1h">
|
||||||
<button type="submit" class="override-btn turn-off">Aus</button>
|
<button type="submit" class="override-btn turn-off">Aus</button>
|
||||||
{{else}}
|
{{else}}
|
||||||
<input type="hidden" name="state" value="on">
|
<input type="hidden" name="state" value="on">
|
||||||
|
<select name="duration" class="dur-select">
|
||||||
|
<option value="30m">30 min</option>
|
||||||
|
<option value="1h" selected>1 Std</option>
|
||||||
|
<option value="2h">2 Std</option>
|
||||||
|
<option value="4h">4 Std</option>
|
||||||
|
</select>
|
||||||
<button type="submit" class="override-btn turn-on">Ein</button>
|
<button type="submit" class="override-btn turn-on">Ein</button>
|
||||||
{{end}}
|
{{end}}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
26
main.go
26
main.go
@@ -104,7 +104,7 @@ func main() {
|
|||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
w.Write([]byte("ok"))
|
w.Write([]byte("ok"))
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/override", overrideHandler(act, logger))
|
mux.HandleFunc("/override", overrideHandler(act, eng, logger))
|
||||||
mux.HandleFunc("/", statusStore.Handler())
|
mux.HandleFunc("/", statusStore.Handler())
|
||||||
|
|
||||||
srv := &http.Server{
|
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.
|
// overrideHandler handles manual on/off requests from the status page.
|
||||||
// For Shelly consumers, it switches the hardware directly; the existing
|
// Switches the hardware immediately and informs the engine directly so
|
||||||
// SyncHardwareState mechanism detects the change next cycle and applies
|
// the override lockout is applied without waiting for the next cycle.
|
||||||
// the override lockout automatically.
|
func overrideHandler(act *actuator.Actuator, eng *engine.Engine, logger *slog.Logger) http.HandlerFunc {
|
||||||
func overrideHandler(act *actuator.Actuator, logger *slog.Logger) http.HandlerFunc {
|
|
||||||
consumerByKey := map[string]engine.Consumer{
|
consumerByKey := map[string]engine.Consumer{
|
||||||
"sg_ready": engine.ConsumerSGReady,
|
"sg_ready": engine.ConsumerSGReady,
|
||||||
"wallbox_a": engine.ConsumerWallboxA,
|
"wallbox_a": engine.ConsumerWallboxA,
|
||||||
@@ -287,6 +286,7 @@ func overrideHandler(act *actuator.Actuator, logger *slog.Logger) http.HandlerFu
|
|||||||
|
|
||||||
consumerKey := r.FormValue("consumer")
|
consumerKey := r.FormValue("consumer")
|
||||||
stateVal := r.FormValue("state")
|
stateVal := r.FormValue("state")
|
||||||
|
durationStr := r.FormValue("duration")
|
||||||
|
|
||||||
consumer, ok := consumerByKey[consumerKey]
|
consumer, ok := consumerByKey[consumerKey]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -294,6 +294,11 @@ func overrideHandler(act *actuator.Actuator, logger *slog.Logger) http.HandlerFu
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
duration, err := time.ParseDuration(durationStr)
|
||||||
|
if err != nil || duration <= 0 {
|
||||||
|
duration = time.Hour // safe default
|
||||||
|
}
|
||||||
|
|
||||||
turnOn := stateVal == "on"
|
turnOn := stateVal == "on"
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
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{
|
action := engine.Action{
|
||||||
Consumer: consumer,
|
Consumer: consumer,
|
||||||
TurnOn: turnOn,
|
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 {
|
if err := act.Execute(ctx, []engine.Action{action}); err != nil {
|
||||||
logger.Error("web UI override failed", "consumer", consumerKey, "state", stateVal, "error", err)
|
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
|
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)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user