package engine import ( "fmt" "log/slog" "time" "github.com/tb/ems/internal/collector" "github.com/tb/ems/internal/config" ) // Consumer identifies a controllable load. type Consumer int const ( ConsumerSGReady Consumer = iota ConsumerWW // domestic hot water boost via Viessmann API ConsumerWallboxA ConsumerWallboxB ) func (c Consumer) String() string { switch c { case ConsumerSGReady: return "sg_ready" case ConsumerWW: return "ww" case ConsumerWallboxA: return "wallbox_a" case ConsumerWallboxB: return "wallbox_b" default: return "unknown" } } // Action represents a switching decision. type Action struct { Consumer Consumer TurnOn bool Reason string TargetTempC float64 // non-zero for ConsumerWW: the absolute temperature to set } // DeviceStatus holds the hardware-reported state of a consumer device, // as read back from the physical device each cycle. type DeviceStatus struct { On bool PowerW float64 // measured active power; 0 if device has no power meter } // ConsumerState tracks the runtime state of a single consumer. type ConsumerState struct { Active bool ActivatedAt time.Time // when it was last turned on ManualOverride bool OverrideUntil time.Time LowPowerCycles int // consecutive cycles with power below minimum threshold ProactiveCharging bool // true if activated by proactive car-charging logic (bypasses import shutdown) ProbeStartGridW float64 // grid power snapshot at WallboxB activation (for no-car detection) NoCarRetryUntil time.Time // don't retry WallboxB proactive charging until this time } // OverrideInfo is returned to callers that need to display or record override state. type OverrideInfo struct { Active bool Until time.Time } // HysteresisState tracks the timing for hysteresis decisions. type HysteresisState struct { // How long has export been above the on-threshold continuously? ExportSinceAbove map[Consumer]time.Time // How long has import been above the off-threshold continuously? ImportSinceAbove time.Time } // Engine is the EMS decision engine. // It is pure: given a state snapshot and timing info, it returns actions. // No network calls, no side effects — fully testable. type Engine struct { cfg *config.Config consumers map[Consumer]*ConsumerState hyst HysteresisState logger *slog.Logger } // NewEngine creates a new decision engine. func NewEngine(cfg *config.Config, logger *slog.Logger) *Engine { return &Engine{ cfg: cfg, consumers: map[Consumer]*ConsumerState{ ConsumerSGReady: {}, ConsumerWW: {}, ConsumerWallboxA: {}, ConsumerWallboxB: {}, }, hyst: HysteresisState{ ExportSinceAbove: make(map[Consumer]time.Time), }, logger: logger, } } // Decide evaluates the current system state and returns a list of actions. // // - wwBoostC: WW temperature boost in °C from PV forecast (0 = no boost warranted) // - forecastKWh: today's forecast total in kWh (0 if forecasting disabled) // - sunsetTime: estimated time of sunset (used for EOD soft stop); zero = disabled func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC, forecastKWh float64, sunsetTime time.Time) []Action { var actions []Action soc := state.BatterySOC gridW := state.GridPowerW // positive = import, negative = export heatingPeriod := e.isHeatingPeriod(now, state.AmbientTempC) wwWindow := e.isWWWindow(now) allowed := e.allowedConsumers(soc) e.logger.Debug("decision input", "grid_w", gridW, "soc", soc, "pv_w", state.PVProductionW, "forecast_kwh", forecastKWh, "heating_period", heatingPeriod, "ww_window", wwWindow, "ww_boost_c", wwBoostC, ) // --- 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 { e.logger.Info("WW window ended, resetting DHW temperature") cs.Active = false actions = append(actions, Action{ Consumer: ConsumerWW, TurnOn: false, TargetTempC: e.cfg.Strategic.WWBaseC, Reason: "WW time window ended", }) } // --- EOD soft stop: after configured time, stop car charging if remaining PV insufficient --- actions = append(actions, e.checkEODSoftStop(state, now, sunsetTime)...) // --- WallboxB no-car probe: check grid delta after activation --- actions = append(actions, e.checkWallboxBProbe(state, now)...) // --- Shutdown logic (reverse priority order, import hysteresis) --- // Proactive wallboxes are skipped — EOD/probe/idle-cycles handle their stops. if gridW > e.cfg.Thresholds.ImportOffW { if e.hyst.ImportSinceAbove.IsZero() { e.hyst.ImportSinceAbove = now } importDuration := now.Sub(e.hyst.ImportSinceAbove) if importDuration >= e.cfg.Hysteresis.ImportOffDurationParsed() { if a := e.shutdownLastConsumer(now, gridW); a != nil { actions = append(actions, *a) e.hyst.ImportSinceAbove = time.Time{} } } } else { e.hyst.ImportSinceAbove = time.Time{} } // --- Compressor idle: release SG-Ready early if heat pump stopped --- // A startup grace period is applied after activation: the heat pump compressor // takes several minutes to start after receiving the SG-Ready signal, so idle // detection is suppressed until the grace period has elapsed. if cs := e.consumers[ConsumerSGReady]; cs.Active { startupMin := e.cfg.Consumers.SGReadyStartupMin inGrace := startupMin > 0 && !cs.ActivatedAt.IsZero() && now.Sub(cs.ActivatedAt) < time.Duration(startupMin)*time.Minute if inGrace { e.logger.Debug("SG-Ready: startup grace period, skipping idle check", "activated_at", cs.ActivatedAt.Format("15:04"), "grace_min", startupMin, "elapsed_min", int(now.Sub(cs.ActivatedAt).Minutes()), ) } else if state.CompressorPowerW < float64(e.cfg.Consumers.CompressorIdleW) { cs.LowPowerCycles++ e.logger.Debug("SG-Ready: compressor idle cycle", "compressor_w", state.CompressorPowerW, "idle_cycles", cs.LowPowerCycles, ) if cs.LowPowerCycles >= e.cfg.Consumers.IdleCycles { e.logger.Info("SG-Ready released early: compressor idle", "idle_cycles", cs.LowPowerCycles, "compressor_w", state.CompressorPowerW, ) cs.Active = false cs.LowPowerCycles = 0 actions = append(actions, Action{ Consumer: ConsumerSGReady, TurnOn: false, Reason: fmt.Sprintf("compressor idle for %d cycles", e.cfg.Consumers.IdleCycles), }) } } else { cs.LowPowerCycles = 0 } } // --- Car not charging: release wallbox if Shelly PM shows no draw --- for _, wb := range []Consumer{ConsumerWallboxA, ConsumerWallboxB} { cs := e.consumers[wb] if !cs.Active { continue } if cs.LowPowerCycles >= e.cfg.Consumers.IdleCycles { e.logger.Info("wallbox released: car not charging", "consumer", wb, "low_power_cycles", cs.LowPowerCycles, "proactive", cs.ProactiveCharging, ) cs.Active = false cs.ProactiveCharging = false cs.LowPowerCycles = 0 // For WallboxA: set retry timeout so proactive logic doesn't immediately re-activate if wb == ConsumerWallboxA && e.cfg.CarCharging.NoCarRetryMin > 0 { e.consumers[ConsumerWallboxA].NoCarRetryUntil = now.Add( time.Duration(e.cfg.CarCharging.NoCarRetryMin) * time.Minute, ) } actions = append(actions, Action{ Consumer: wb, TurnOn: false, Reason: fmt.Sprintf("car not charging for %d cycles", e.cfg.Consumers.IdleCycles), }) } } // --- Turn-on logic (priority order) --- // P1: Proactive car charging — forecast-driven, no export threshold required. // Mutual exclusion (A vs B) is enforced inside evaluateCarCharging. actions = append(actions, e.evaluateCarCharging(state, forecastKWh, now)...) // P2: WW boost — time window + forecast, no export threshold, no car charging. // Only runs if no wallbox is active (car charging takes priority). if wwWindow && wwBoostC > 0 && !e.consumers[ConsumerWallboxA].Active && !e.consumers[ConsumerWallboxB].Active { actions = append(actions, e.evaluateWWTurnOn(state, wwBoostC, allowed, now)...) } // P3: SG-Ready — reactive, export-threshold based, heating period only. // Only runs if no wallbox is active (car charging takes priority). if heatingPeriod && !e.consumers[ConsumerWallboxA].Active && !e.consumers[ConsumerWallboxB].Active { actions = append(actions, e.evaluateTurnOn( ConsumerSGReady, gridW, e.cfg.Thresholds.SGReadyExportW, allowed, now, )...) } return actions } // evaluateCarCharging implements proactive forecast-driven car charging. // Tries WallboxA first (has PM for car detection), then WallboxB (grid-delta probe). // Does not require export surplus — just sufficient PV production and a good forecast. func (e *Engine) evaluateCarCharging(state collector.SystemState, forecastKWh float64, now time.Time) []Action { cc := e.cfg.CarCharging // Skip if proactive charging is not configured (thresholds must be set) if cc.PVThresholdAW == 0 && cc.PVThresholdBW == 0 { return nil } // Determine activation gate: either forecast is good, or SOC is already high // (battery full → activate regardless of forecast; any PV surplus should charge the car). socHighBypass := float64(cc.SOCHighBypassPct) if socHighBypass == 0 { socHighBypass = 90 // default: bypass forecast check when battery ≥ 90% } forecastOK := forecastKWh > 0 && forecastKWh >= float64(e.cfg.Strategic.ForecastMidKWh) socHigh := state.BatterySOC >= socHighBypass if !forecastOK && !socHigh { e.logger.Debug("proactive car charging: skipped", "forecast_kwh", forecastKWh, "forecast_threshold", e.cfg.Strategic.ForecastMidKWh, "soc", state.BatterySOC, "soc_bypass_pct", socHighBypass, ) return nil } // SOC must be above the minimum for proactive charging if state.BatterySOC < float64(cc.MinSOC) { return nil } csA := e.consumers[ConsumerWallboxA] csB := e.consumers[ConsumerWallboxB] // --- Try WallboxA (has PM, preferred) --- if !csA.Active && !csB.Active { // Respect no-car retry timeout (set after idle-cycles detection) if !csA.NoCarRetryUntil.IsZero() && now.Before(csA.NoCarRetryUntil) { e.logger.Debug("WallboxA proactive: skipping, in no-car retry window", "retry_until", csA.NoCarRetryUntil.Format("15:04"), ) // Fall through to WallboxB below } else if state.PVProductionW >= cc.PVThresholdAW { // Respect manual override if csA.ManualOverride && now.Before(csA.OverrideUntil) { return nil } e.logger.Info("proactive: activating WallboxA", "pv_w", state.PVProductionW, "threshold_w", cc.PVThresholdAW, "soc", state.BatterySOC, "forecast_kwh", forecastKWh, ) csA.Active = true csA.ActivatedAt = now csA.ProactiveCharging = true csA.LowPowerCycles = 0 delete(e.hyst.ExportSinceAbove, ConsumerWallboxA) trigger := fmt.Sprintf("forecast %.1fkWh", forecastKWh) if socHigh && !forecastOK { trigger = fmt.Sprintf("SOC %.0f%% ≥ %.0f%% (bypass)", state.BatterySOC, socHighBypass) } return []Action{{ Consumer: ConsumerWallboxA, TurnOn: true, Reason: fmt.Sprintf("proactive: PV %.0fW ≥ %.0fW, %s", state.PVProductionW, cc.PVThresholdAW, trigger), }} } } // --- Try WallboxB (no PM, uses grid-delta probe) --- // Only if WallboxA is not active and B is not already running. if !csA.Active && !csB.Active { if csB.ManualOverride && now.Before(csB.OverrideUntil) { return nil } if !csB.NoCarRetryUntil.IsZero() && now.Before(csB.NoCarRetryUntil) { e.logger.Debug("WallboxB proactive: skipping, in no-car retry window", "retry_until", csB.NoCarRetryUntil.Format("15:04"), ) return nil } if state.PVProductionW >= cc.PVThresholdBW { e.logger.Info("proactive: activating WallboxB (grid-delta probe)", "pv_w", state.PVProductionW, "threshold_w", cc.PVThresholdBW, "soc", state.BatterySOC, "forecast_kwh", forecastKWh, ) csB.Active = true csB.ActivatedAt = now csB.ProactiveCharging = true csB.ProbeStartGridW = state.GridPowerW csB.LowPowerCycles = 0 delete(e.hyst.ExportSinceAbove, ConsumerWallboxB) trigger := fmt.Sprintf("forecast %.1fkWh", forecastKWh) if socHigh && !forecastOK { trigger = fmt.Sprintf("SOC %.0f%% ≥ %.0f%% (bypass)", state.BatterySOC, socHighBypass) } return []Action{{ Consumer: ConsumerWallboxB, TurnOn: true, Reason: fmt.Sprintf("proactive: PV %.0fW ≥ %.0fW, %s", state.PVProductionW, cc.PVThresholdBW, trigger), }} } } return nil } // checkWallboxBProbe checks whether a car is actually charging on WallboxB after activation. // WallboxB has no PM, so we use grid-delta: if the grid power hasn't shifted by at least // GridDeltaThreshW within the probe window, no car is connected → deactivate and set retry. func (e *Engine) checkWallboxBProbe(state collector.SystemState, now time.Time) []Action { cs := e.consumers[ConsumerWallboxB] if !cs.Active || !cs.ProactiveCharging || cs.ActivatedAt.IsZero() { return nil } cc := e.cfg.CarCharging probeDuration := e.cfg.Hysteresis.MinRuntimeWallboxParsed() / 2 // half the min runtime if now.Sub(cs.ActivatedAt) < probeDuration { return nil // probe window not reached yet } // Already probed if ProbeStartGridW is zero after first probe (reset after detection) if cs.ProbeStartGridW == 0 { return nil // already concluded } gridDelta := cs.ProbeStartGridW - state.GridPowerW // negative = more import = car charging // A 4kW wallbox causes a grid shift of ~4000W (or large export reduction). // Positive gridDelta means we're importing more than at probe start (expected with car charging). // We check: grid increased by at least threshold (car drawing power). if gridDelta >= cc.GridDeltaThreshW || -gridDelta >= cc.GridDeltaThreshW { // Either significantly more import or less export = car detected e.logger.Info("WallboxB probe: car detected via grid delta", "probe_start_w", cs.ProbeStartGridW, "current_w", state.GridPowerW, "delta_w", gridDelta, ) cs.ProbeStartGridW = 0 // mark probe as concluded return nil } // No meaningful grid shift → no car connected e.logger.Info("WallboxB probe: no car detected, deactivating", "probe_start_w", cs.ProbeStartGridW, "current_w", state.GridPowerW, "delta_w", gridDelta, "threshold_w", cc.GridDeltaThreshW, ) cs.Active = false cs.ProactiveCharging = false cs.ProbeStartGridW = 0 if cc.NoCarRetryMin > 0 { cs.NoCarRetryUntil = now.Add(time.Duration(cc.NoCarRetryMin) * time.Minute) } return []Action{{ Consumer: ConsumerWallboxB, TurnOn: false, Reason: fmt.Sprintf("no-car probe: grid delta %.0fW < %.0fW", gridDelta, cc.GridDeltaThreshW), }} } // checkEODSoftStop implements the end-of-day battery protection. // After CarCharging.EODTime, if the remaining estimated PV production is insufficient // to fill the battery to the EOD target by sunset, proactive car charging is stopped. func (e *Engine) checkEODSoftStop(state collector.SystemState, now time.Time, sunsetTime time.Time) []Action { cc := e.cfg.CarCharging if cc.EODTime == "" || cc.EODSOCTarget == 0 || e.cfg.Battery.CapacityKWh == 0 { return nil } eodTime := cc.EODTimeParsed(now) if now.Before(eodTime) { return nil // too early for EOD check } // Determine sunset reference if sunsetTime.IsZero() || sunsetTime.Before(now) { return nil // no valid sunset time, skip } hoursToSunset := sunsetTime.Sub(now).Hours() if hoursToSunset <= 0 { hoursToSunset = 0 } // Estimate remaining PV production (current watt × hours to sunset) remainingPVkWh := (state.PVProductionW / 1000.0) * hoursToSunset // Battery energy needed to reach target SOC socDeficitKWh := (float64(cc.EODSOCTarget)/100.0 - state.BatterySOC/100.0) * e.cfg.Battery.CapacityKWh if socDeficitKWh <= 0 { return nil // already at or above target SOC } // House base load consumption during remaining time houseKWh := (e.cfg.Forecast.BaseLoadW / 1000.0) * hoursToSunset // If remaining PV can't cover the battery deficit plus house load, stop charging if remainingPVkWh >= socDeficitKWh+houseKWh { return nil // enough PV remaining } e.logger.Info("EOD soft stop: remaining PV insufficient to reach target SOC", "remaining_pv_kwh", fmt.Sprintf("%.2f", remainingPVkWh), "soc_deficit_kwh", fmt.Sprintf("%.2f", socDeficitKWh), "house_kwh", fmt.Sprintf("%.2f", houseKWh), "hours_to_sunset", fmt.Sprintf("%.1f", hoursToSunset), "current_soc", state.BatterySOC, "target_soc", cc.EODSOCTarget, ) var stopActions []Action for _, wb := range []Consumer{ConsumerWallboxB, ConsumerWallboxA} { cs := e.consumers[wb] if !cs.Active || !cs.ProactiveCharging { continue } cs.Active = false cs.ProactiveCharging = false stopActions = append(stopActions, Action{ Consumer: wb, TurnOn: false, Reason: fmt.Sprintf("EOD soft stop: %.1fkWh PV remaining < %.1fkWh needed", remainingPVkWh, socDeficitKWh+houseKWh), }) } return stopActions } // min3 returns the minimum of three float64 values. func min3(a, b, c float64) float64 { if b < a { a = b } if c < a { return c } return a } // allowedConsumers returns which consumers are allowed based on SOC. func (e *Engine) allowedConsumers(soc float64) map[Consumer]bool { allowed := make(map[Consumer]bool) if soc >= e.cfg.SOC.AllConsumers { allowed[ConsumerSGReady] = true allowed[ConsumerWW] = true allowed[ConsumerWallboxA] = true allowed[ConsumerWallboxB] = true } else if soc >= e.cfg.SOC.SGReadyOnly { allowed[ConsumerSGReady] = true allowed[ConsumerWW] = true allowed[ConsumerWallboxA] = true } else if soc >= e.cfg.SOC.BlockAll { allowed[ConsumerSGReady] = true allowed[ConsumerWW] = true } // below BlockAll: nothing allowed return allowed } // evaluateTurnOn checks if a consumer should be turned on. func (e *Engine) evaluateTurnOn( consumer Consumer, gridW float64, threshold float64, allowed map[Consumer]bool, now time.Time, ) []Action { cs := e.consumers[consumer] // Already active — nothing to do if cs.Active { return nil } // Manually overridden to OFF — respect until timeout if cs.ManualOverride && now.Before(cs.OverrideUntil) { delete(e.hyst.ExportSinceAbove, consumer) return nil } // Not allowed by SOC if !allowed[consumer] { delete(e.hyst.ExportSinceAbove, consumer) return nil } // Check if export exceeds the threshold // gridW is negative for export, threshold is negative (e.g. -1800) // export > 1800W means gridW < -1800 if gridW > threshold { // Not enough export delete(e.hyst.ExportSinceAbove, consumer) return nil } // Export is above threshold — track how long if _, ok := e.hyst.ExportSinceAbove[consumer]; !ok { e.hyst.ExportSinceAbove[consumer] = now } exportDuration := now.Sub(e.hyst.ExportSinceAbove[consumer]) if exportDuration < e.cfg.Hysteresis.ExportOnDurationParsed() { // Not long enough yet return nil } // All conditions met — turn on e.logger.Info("turning on consumer", "consumer", consumer.String(), "grid_w", gridW, "threshold", threshold, "export_duration", exportDuration, ) cs.Active = true cs.ActivatedAt = now delete(e.hyst.ExportSinceAbove, consumer) return []Action{{ Consumer: consumer, TurnOn: true, Reason: fmt.Sprintf( "export %.0fW > %.0fW for %s", -gridW, -threshold, exportDuration, ), }} } // shutdownLastConsumer turns off the lowest-priority active consumer // that has exceeded its minimum runtime. // gridW is the current grid power (positive = import) used to check per-consumer import tolerance. func (e *Engine) shutdownLastConsumer(now time.Time, gridW float64) *Action { // Reverse priority: WallboxB → WallboxA → WW → SGReady order := []Consumer{ConsumerWallboxB, ConsumerWallboxA, ConsumerWW, ConsumerSGReady} for _, c := range order { cs := e.consumers[c] if !cs.Active { continue } // Manually overridden to ON — don't shut down until override expires if cs.ManualOverride && now.Before(cs.OverrideUntil) { e.logger.Debug("skipping shutdown, consumer is manually overridden", "consumer", c.String(), "override_until", cs.OverrideUntil.Format("15:04"), ) continue } // Proactive wallboxes are not shut down by import hysteresis. // Their stops are handled by EOD soft stop, no-car probe, and idle cycles. if cs.ProactiveCharging && (c == ConsumerWallboxA || c == ConsumerWallboxB) { e.logger.Debug("skipping shutdown, proactive car charging active", "consumer", c.String()) continue } // Per-consumer accepted import tolerance: if the current import is within // the configured tolerance for this wallbox, skip shutdown. var acceptedImportW float64 switch c { case ConsumerWallboxA: acceptedImportW = e.cfg.Consumers.WallboxAAcceptedImportW case ConsumerWallboxB: acceptedImportW = e.cfg.Consumers.WallboxBAcceptedImportW } if acceptedImportW > 0 && gridW <= acceptedImportW { e.logger.Debug("skipping shutdown, import within accepted tolerance", "consumer", c.String(), "grid_w", gridW, "accepted_import_w", acceptedImportW, ) continue } minRuntime := e.minRuntime(c) runtime := now.Sub(cs.ActivatedAt) if runtime < minRuntime { e.logger.Debug("skipping shutdown, min runtime not reached", "consumer", c.String(), "runtime", runtime, "min_runtime", minRuntime, ) continue } e.logger.Info("shutting down consumer", "consumer", c.String(), "runtime", runtime, ) cs.Active = false a := &Action{ Consumer: c, TurnOn: false, Reason: fmt.Sprintf("import detected, runtime %s", runtime), } if c == ConsumerWW { a.TargetTempC = e.cfg.Strategic.WWBaseC } return a } return nil } // socEmergencyBrake immediately shuts off consumers whose SOC threshold // is no longer met, ignoring minimum runtimes and manual overrides. // Proactive car-charging wallboxes use the SOCFloor threshold instead of the // standard SOC gates, allowing charging down to a lower limit during solar hours. func (e *Engine) socEmergencyBrake(soc float64, now time.Time) []Action { var actions []Action allowed := e.allowedConsumers(soc) for c, cs := range e.consumers { if !cs.Active { continue } // Intentional wallbox charging (proactive or trip mode): only emergency-brake at SOCFloor. // Trip mode uses ManualOverride=true, proactive charging uses ProactiveCharging=true — // both deserve soc_floor protection instead of the standard block_all gate. isIntentionalCharging := (cs.ProactiveCharging || cs.ManualOverride) && (c == ConsumerWallboxA || c == ConsumerWallboxB) if isIntentionalCharging { floor := float64(e.cfg.CarCharging.SOCFloor) if floor > 0 && soc >= floor { continue // still above floor, keep charging } } if allowed[c] { continue } e.logger.Warn("SOC emergency brake", "consumer", c.String(), "soc", soc, "was_override", cs.ManualOverride, ) cs.Active = false cs.ManualOverride = false // EMS takes back full control after emergency cs.OverrideUntil = time.Time{} cs.ProactiveCharging = false a := Action{ Consumer: c, TurnOn: false, Reason: fmt.Sprintf("SOC emergency brake: %.0f%%", soc), } if c == ConsumerWW { a.TargetTempC = e.cfg.Strategic.WWBaseC } actions = append(actions, a) } 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.String(), "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.String(), "on", on, "duration", duration, "until", cs.OverrideUntil.Format("15:04"), ) } // RollbackAction reverts the engine's internal state for an action that the actuator // failed to execute. Without this, the engine believes the switch happened, diverges // from hardware, and SyncHardwareState will misinterpret the next read-back as a // manual override and apply a 1-hour lockout. // // After rollback the engine state matches hardware again, so the next cycle's // SyncHardwareState sees no mismatch and the action is simply retried. func (e *Engine) RollbackAction(action Action) { cs, ok := e.consumers[action.Consumer] if !ok { return } e.logger.Warn("rolling back engine state after failed action", "consumer", action.Consumer, "turn_on", action.TurnOn, ) cs.Active = !action.TurnOn if action.TurnOn { // Turn-on failed: undo activation side-effects cs.ActivatedAt = time.Time{} cs.ProactiveCharging = false cs.ProbeStartGridW = 0 cs.LowPowerCycles = 0 } // Turn-off failed: just restore Active=true. ActivatedAt is preserved // (shutdown code doesn't reset it), so min-runtime stays correct. } // isHeatingPeriod returns true if heating is appropriate given the current month // and outdoor temperature. If HeatingMinAmbientC is configured (> 0), ambient // temperatures above that threshold suppress SG-Ready even within the heating months. func (e *Engine) isHeatingPeriod(now time.Time, ambientC float64) bool { month := int(now.Month()) start := e.cfg.Season.HeatingStartMonth end := e.cfg.Season.HeatingEndMonth // Handles wrap-around: e.g. October(10) to April(4) var inMonth bool if start > end { inMonth = month >= start || month <= end } else { inMonth = month >= start && month <= end } if !inMonth { return false } // Temperature override: warm day within heating months → not a heating day threshold := e.cfg.Season.HeatingMinAmbientC if threshold > 0 && ambientC >= threshold { e.logger.Debug("heating period suppressed by ambient temperature", "ambient_c", ambientC, "threshold_c", threshold, ) return false } return true } // minRuntime returns the minimum runtime for a consumer. func (e *Engine) minRuntime(c Consumer) time.Duration { switch c { case ConsumerSGReady: return e.cfg.Hysteresis.MinRuntimeSGReadyParsed() case ConsumerWallboxA, ConsumerWallboxB: return e.cfg.Hysteresis.MinRuntimeWallboxParsed() default: return 0 // WW has no minimum runtime } } // evaluateWWTurnOn checks whether WW boost should be activated. // Prerequisites (time window, forecast > 0, no car charging) are already verified by the caller. // No export threshold is required — the heat pump compressor load is covered by PV. // Dynamic setpoint: current tank top + hysteresis + boost delta, capped at WWMaxSetpointC. func (e *Engine) evaluateWWTurnOn(state collector.SystemState, wwBoostC float64, allowed map[Consumer]bool, now time.Time) []Action { cs := e.consumers[ConsumerWW] if cs.Active { return nil } if cs.ManualOverride && now.Before(cs.OverrideUntil) { return nil } if !allowed[ConsumerWW] { return nil } // Dynamic setpoint: set high enough above current temp to trigger heating immediately. // Viessmann switchOn threshold = setpoint - hysteresis. // Target = current_top + hysteresis + boost_delta, capped at max. hysteresis := e.cfg.Strategic.WWHysteresisC if hysteresis == 0 { hysteresis = 5 // safe default } maxSetpoint := e.cfg.Strategic.WWMaxSetpointC if maxSetpoint == 0 { maxSetpoint = 60 // safe default } targetTemp := state.WWTopTempC + hysteresis + wwBoostC if targetTemp > maxSetpoint { targetTemp = maxSetpoint } e.logger.Info("activating WW boost", "ww_top_c", state.WWTopTempC, "ww_boost_c", wwBoostC, "target_temp_c", targetTemp, ) cs.Active = true cs.ActivatedAt = now return []Action{{ Consumer: ConsumerWW, TurnOn: true, TargetTempC: targetTemp, Reason: fmt.Sprintf("WW boost +%.0f°C → setpoint %.0f°C (tank %.0f°C)", wwBoostC, targetTemp, state.WWTopTempC), }} } // isWWWindow returns true if the current time falls within the configured WW boost window. func (e *Engine) isWWWindow(now time.Time) bool { start, err1 := parseTimeOfDay(e.cfg.Strategic.WWWindowStart, now) end, err2 := parseTimeOfDay(e.cfg.Strategic.WWWindowEnd, now) if err1 != nil || err2 != nil { return false } return now.After(start) && now.Before(end) } // parseTimeOfDay parses "HH:MM" and returns a time.Time on the same day as ref. func parseTimeOfDay(s string, ref time.Time) (time.Time, error) { var h, m int if _, err := fmt.Sscanf(s, "%d:%d", &h, &m); err != nil { return time.Time{}, fmt.Errorf("invalid time-of-day %q: %w", s, err) } return time.Date(ref.Year(), ref.Month(), ref.Day(), h, m, 0, 0, ref.Location()), nil } // ConsumerStates returns a snapshot of all consumer states (for metrics). func (e *Engine) ConsumerStates() map[Consumer]bool { states := make(map[Consumer]bool) for c, cs := range e.consumers { states[c] = cs.Active } return states } // RecoverState injects externally-read consumer states on startup. // Does not set override flags — startup state is treated as the EMS baseline. func (e *Engine) RecoverState(states map[Consumer]DeviceStatus) { for c, status := range states { if cs, ok := e.consumers[c]; ok { cs.Active = status.On cs.ManualOverride = false cs.OverrideUntil = time.Time{} cs.LowPowerCycles = 0 // ActivatedAt left as zero: unknown start time means the consumer // is always considered to have exceeded its minimum runtime. cs.ActivatedAt = time.Time{} } } e.logger.Info("consumer state recovered from Shelly read-back", "sg_ready", states[ConsumerSGReady].On, "wallbox_a", states[ConsumerWallboxA].On, "wallbox_b", states[ConsumerWallboxB].On, ) } // SyncHardwareState compares live hardware states against the engine's internal state. // Discrepancies indicate an external change (manual override via Shelly app etc.). // On mismatch: engine state is updated to match hardware, and the consumer is locked // from EMS control for overrideTimeout. // On match: expired overrides are cleared, resuming normal EMS control. // Power readings (from PM-capable devices) update the low-power cycle counter for // car-not-charging detection. // When monitorOnly is true, hardware mismatches silently update engine state without // logging or applying override lockouts — the EMS cannot act anyway, so treating // every missed switch as a "manual override" would produce spurious log spam. func (e *Engine) SyncHardwareState(states map[Consumer]DeviceStatus, now time.Time, overrideTimeout time.Duration, monitorOnly bool) { for c, status := range states { cs, ok := e.consumers[c] if !ok { continue } if cs.Active != status.On { if monitorOnly { // In monitor-only mode the EMS cannot execute switch actions, so a // mismatch just means a pending action couldn't be carried out. // Silently realign engine state to hardware without locking. cs.Active = status.On if !status.On { cs.ActivatedAt = time.Time{} } else { cs.ActivatedAt = now } } else { // External change detected — log and apply override lockout e.logger.Info("manual override detected — external state change", "consumer", c.String(), "engine_state", cs.Active, "hardware_state", status.On, "override_until", now.Add(overrideTimeout).Format("15:04"), ) cs.Active = status.On cs.ManualOverride = true cs.OverrideUntil = now.Add(overrideTimeout) cs.LowPowerCycles = 0 if !status.On { cs.ActivatedAt = time.Time{} } else { cs.ActivatedAt = now } } } else if cs.ManualOverride && now.After(cs.OverrideUntil) { // Override expired and state matches — resume EMS control cs.ManualOverride = false cs.OverrideUntil = time.Time{} e.logger.Info("manual override expired, resuming EMS control", "consumer", c.String()) } // Track low-power cycles for car-not-charging detection (PM devices only). // Only meaningful when PowerW > 0 (i.e., device has a power meter and is on). if status.PowerW > 0 { if cs.Active && status.PowerW < float64(e.cfg.Consumers.WallboxMinChargeW) { cs.LowPowerCycles++ e.logger.Debug("wallbox low power cycle", "consumer", c.String(), "power_w", status.PowerW, "low_power_cycles", cs.LowPowerCycles, ) } else { cs.LowPowerCycles = 0 } } } } // Overrides returns current override info for all consumers that are overridden. func (e *Engine) Overrides() map[Consumer]OverrideInfo { result := make(map[Consumer]OverrideInfo) for c, cs := range e.consumers { if cs.ManualOverride { result[c] = OverrideInfo{Active: true, Until: cs.OverrideUntil} } } return result }