Merge house/lutz — compressor fix, WW boost toggle, button layout
- Compressor: PromQL expression returns actual watts (sensors% × kW × 10), idle threshold raised to 500W; fixes silent early-release of SG-Ready - WW boost toggle: persistent flag file (/etc/ems/ww-boost-off) disables WW boost globally; ⛔/✅ button on WW card, survives reboots - WW reset button: restores base temp + blocks boost until midnight - WW buttons stacked vertically in consumer card - Grafana dashboard: full EMS dashboard pushed to Grafana instance
This commit is contained in:
@@ -10,7 +10,7 @@ prometheus:
|
|||||||
battery_soc: "ess_stateOfCharge_value" # 0-100%
|
battery_soc: "ess_stateOfCharge_value" # 0-100%
|
||||||
pv_production: "photovoltaic_production_current_value" # watts
|
pv_production: "photovoltaic_production_current_value" # watts
|
||||||
battery_power: "ess_power_value" # watts
|
battery_power: "ess_power_value" # watts
|
||||||
compressor_power: "heating_compressors_0_sensors_power_value" # % of rated power (0=idle, 27-92=running); _power_value is rated kW (constant 10)
|
compressor_power: "heating_compressors_0_sensors_power_value * heating_compressors_0_power_value * 10" # actual watts: sensors(%) × rated_kW(10) × 10
|
||||||
ambient_temp: "heating_sensors_temperature_outside_value" # °C
|
ambient_temp: "heating_sensors_temperature_outside_value" # °C
|
||||||
phase_l1_power: "pcc_ac_active_power_phaseOne" # per-phase grid power L1 (W)
|
phase_l1_power: "pcc_ac_active_power_phaseOne" # per-phase grid power L1 (W)
|
||||||
phase_l2_power: "pcc_ac_active_power_phaseTwo" # per-phase grid power L2 (W)
|
phase_l2_power: "pcc_ac_active_power_phaseTwo" # per-phase grid power L2 (W)
|
||||||
@@ -78,7 +78,7 @@ strategic:
|
|||||||
|
|
||||||
# Consumer behavior — idle detection thresholds
|
# Consumer behavior — idle detection thresholds
|
||||||
consumers:
|
consumers:
|
||||||
compressor_idle_w: 20 # compressor idle threshold: sensors_power_value is % of rated power; 0%=idle, ≥27%=running → 20 cleanly separates them
|
compressor_idle_w: 500 # heat pump compressor below this = idle (W); running starts at ~2700W, so 500W cleanly separates idle (0W) from running
|
||||||
wallbox_min_charge_w: 50 # wallbox below this = car not charging (W)
|
wallbox_min_charge_w: 50 # wallbox below this = car not charging (W)
|
||||||
idle_cycles: 3 # consecutive idle cycles before early release (~6 min at 2-min poll)
|
idle_cycles: 3 # consecutive idle cycles before early release (~6 min at 2-min poll)
|
||||||
|
|
||||||
@@ -111,7 +111,8 @@ ems:
|
|||||||
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" # default lockout when EMS detects external Shelly change
|
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)
|
override_max_import_w: 800 # cancel override immediately if importing more than this (0 = disabled)
|
||||||
monitor_only_file: "/etc/ems/monitor-only" # presence of this file = monitor-only mode active
|
monitor_only_file: "/etc/ems/monitor-only" # presence of this file = monitor-only mode active
|
||||||
|
ww_boost_disable_file: "/etc/ems/ww-boost-off" # presence of this file = WW boost disabled
|
||||||
trip_goal_file: "/var/lib/ems/trip-goal.json" # persisted active trip goal
|
trip_goal_file: "/var/lib/ems/trip-goal.json" # persisted active trip goal
|
||||||
session_log_file: "/var/lib/ems/sessions.jsonl" # JSONL log of completed charge sessions
|
session_log_file: "/var/lib/ems/sessions.jsonl" # JSONL log of completed charge sessions
|
||||||
|
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ type EMSConfig struct {
|
|||||||
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)
|
OverrideMaxImportW float64 `yaml:"override_max_import_w"` // cancel override if grid import exceeds this (0 = disabled)
|
||||||
MonitorOnlyFile string `yaml:"monitor_only_file"` // flag file path: presence = monitor-only mode active
|
MonitorOnlyFile string `yaml:"monitor_only_file"` // flag file path: presence = monitor-only mode active
|
||||||
|
WWBoostDisableFile string `yaml:"ww_boost_disable_file"` // flag file path: presence = WW boost disabled
|
||||||
TripGoalFile string `yaml:"trip_goal_file"` // persisted active trip goal
|
TripGoalFile string `yaml:"trip_goal_file"` // persisted active trip goal
|
||||||
SessionLogFile string `yaml:"session_log_file"` // JSONL log of completed charge sessions
|
SessionLogFile string `yaml:"session_log_file"` // JSONL log of completed charge sessions
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,25 +59,27 @@ type Store struct {
|
|||||||
strategic config.StrategicConfig
|
strategic config.StrategicConfig
|
||||||
consumers map[engine.Consumer]*consumerRecord
|
consumers map[engine.Consumer]*consumerRecord
|
||||||
fcResult *forecast.Result
|
fcResult *forecast.Result
|
||||||
monitorMode *monitor.Mode
|
monitorMode *monitor.Mode
|
||||||
tripMgr *trip.Manager
|
wwBoostDisabled *monitor.Mode
|
||||||
carOptions []CarOption
|
tripMgr *trip.Manager
|
||||||
|
carOptions []CarOption
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStore creates a new status store.
|
// NewStore creates a new status store.
|
||||||
func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig, mode *monitor.Mode, tm *trip.Manager, cars []CarOption) *Store {
|
func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig, mode *monitor.Mode, wwBoostDisabled *monitor.Mode, tm *trip.Manager, cars []CarOption) *Store {
|
||||||
consumers := make(map[engine.Consumer]*consumerRecord, len(consumerOrder))
|
consumers := make(map[engine.Consumer]*consumerRecord, len(consumerOrder))
|
||||||
for _, c := range consumerOrder {
|
for _, c := range consumerOrder {
|
||||||
consumers[c] = &consumerRecord{}
|
consumers[c] = &consumerRecord{}
|
||||||
}
|
}
|
||||||
return &Store{
|
return &Store{
|
||||||
dryRun: dryRun,
|
dryRun: dryRun,
|
||||||
wwConfigured: wwConfigured,
|
wwConfigured: wwConfigured,
|
||||||
strategic: strategic,
|
strategic: strategic,
|
||||||
consumers: consumers,
|
consumers: consumers,
|
||||||
monitorMode: mode,
|
monitorMode: mode,
|
||||||
tripMgr: tm,
|
wwBoostDisabled: wwBoostDisabled,
|
||||||
carOptions: cars,
|
tripMgr: tm,
|
||||||
|
carOptions: cars,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,8 +134,10 @@ type consumerView struct {
|
|||||||
Since time.Time
|
Since time.Time
|
||||||
Reason string
|
Reason string
|
||||||
Unconfigured bool
|
Unconfigured bool
|
||||||
CanOverride bool // true for Shelly consumers (hardware read-back available)
|
CanOverride bool // true for Shelly consumers (hardware read-back available)
|
||||||
ManualOverride bool
|
IsWW bool // true for ConsumerWW — shows reset button instead of override
|
||||||
|
WWBoostDisabled bool // copied from pageData for template access inside range
|
||||||
|
ManualOverride bool
|
||||||
OverrideUntil time.Time
|
OverrideUntil time.Time
|
||||||
LivePowerW float64 // from Shelly PM; 0 for non-PM devices
|
LivePowerW float64 // from Shelly PM; 0 for non-PM devices
|
||||||
}
|
}
|
||||||
@@ -178,6 +182,7 @@ type pageData struct {
|
|||||||
ErrMsg string
|
ErrMsg string
|
||||||
DryRun bool
|
DryRun bool
|
||||||
MonitorOnly bool
|
MonitorOnly bool
|
||||||
|
WWBoostDisabled bool
|
||||||
BatterySOC float64
|
BatterySOC float64
|
||||||
GridPowerW float64
|
GridPowerW float64
|
||||||
PVProductionW float64
|
PVProductionW float64
|
||||||
@@ -260,6 +265,7 @@ func (s *Store) Handler() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
monitorOnly := s.monitorMode != nil && s.monitorMode.IsActive()
|
monitorOnly := s.monitorMode != nil && s.monitorMode.IsActive()
|
||||||
|
wwBoostDisabled := s.wwBoostDisabled != nil && s.wwBoostDisabled.IsActive()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
@@ -270,6 +276,7 @@ func (s *Store) Handler() http.HandlerFunc {
|
|||||||
ErrMsg: s.errMsg,
|
ErrMsg: s.errMsg,
|
||||||
DryRun: s.dryRun,
|
DryRun: s.dryRun,
|
||||||
MonitorOnly: monitorOnly,
|
MonitorOnly: monitorOnly,
|
||||||
|
WWBoostDisabled: wwBoostDisabled,
|
||||||
BatterySOC: s.state.BatterySOC,
|
BatterySOC: s.state.BatterySOC,
|
||||||
GridPowerW: s.state.GridPowerW,
|
GridPowerW: s.state.GridPowerW,
|
||||||
PVProductionW: s.state.PVProductionW,
|
PVProductionW: s.state.PVProductionW,
|
||||||
@@ -333,8 +340,10 @@ func (s *Store) Handler() http.HandlerFunc {
|
|||||||
Active: rec.active,
|
Active: rec.active,
|
||||||
Since: rec.since,
|
Since: rec.since,
|
||||||
Reason: rec.reason,
|
Reason: rec.reason,
|
||||||
CanOverride: c != engine.ConsumerWW,
|
CanOverride: c != engine.ConsumerWW,
|
||||||
ManualOverride: rec.manualOverride,
|
IsWW: c == engine.ConsumerWW,
|
||||||
|
WWBoostDisabled: c == engine.ConsumerWW && wwBoostDisabled,
|
||||||
|
ManualOverride: rec.manualOverride,
|
||||||
OverrideUntil: rec.overrideUntil,
|
OverrideUntil: rec.overrideUntil,
|
||||||
LivePowerW: rec.livePowerW,
|
LivePowerW: rec.livePowerW,
|
||||||
}
|
}
|
||||||
@@ -895,6 +904,22 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
|||||||
{{end}}
|
{{end}}
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{if .IsWW}}
|
||||||
|
<div style="display:flex;flex-direction:column;gap:0.3rem;flex-shrink:0">
|
||||||
|
{{if not .Unconfigured}}
|
||||||
|
<form method="post" action="/ww/reset">
|
||||||
|
<button type="submit" class="override-btn turn-off" style="width:100%" title="WW-Solltemperatur auf Basiswert zurücksetzen, Boost für heute sperren">🌡️ Zurücksetzen</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
<form method="post" action="/ww/boost">
|
||||||
|
{{if .WWBoostDisabled}}
|
||||||
|
<button type="submit" class="override-btn turn-on" style="width:100%" title="WW Boost durch PV-Überschuss wieder erlauben">✅ Boost ein</button>
|
||||||
|
{{else}}
|
||||||
|
<button type="submit" class="override-btn turn-off" style="width:100%" title="WW Boost dauerhaft deaktivieren (z.B. im Sommer)">⛔ Boost aus</button>
|
||||||
|
{{end}}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|||||||
76
main.go
76
main.go
@@ -115,6 +115,12 @@ func main() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WW boost disable flag (persistent; presence of file = boost disabled)
|
||||||
|
wwBoostDisabled := monitor.New(cfg.EMS.WWBoostDisableFile)
|
||||||
|
if wwBoostDisabled.IsActive() {
|
||||||
|
logger.Info("WW boost disabled via flag file", "flag_file", cfg.EMS.WWBoostDisableFile)
|
||||||
|
}
|
||||||
|
|
||||||
// Trip mode manager (goal persistence + session learning)
|
// Trip mode manager (goal persistence + session learning)
|
||||||
tripMgr := trip.New(cfg.EMS.TripGoalFile, cfg.EMS.SessionLogFile)
|
tripMgr := trip.New(cfg.EMS.TripGoalFile, cfg.EMS.SessionLogFile)
|
||||||
if g := tripMgr.ActiveGoal(); g != nil {
|
if g := tripMgr.ActiveGoal(); g != nil {
|
||||||
@@ -130,7 +136,7 @@ func main() {
|
|||||||
|
|
||||||
// Status store (shared between HTTP handler and control loop)
|
// Status store (shared between HTTP handler and control loop)
|
||||||
wwConfigured := cfg.Viessmann.InstallationID != ""
|
wwConfigured := cfg.Viessmann.InstallationID != ""
|
||||||
statusStore := status.NewStore(*dryRun, wwConfigured, cfg.Strategic, monitorMode, tripMgr, carOptions)
|
statusStore := status.NewStore(*dryRun, wwConfigured, cfg.Strategic, monitorMode, wwBoostDisabled, tripMgr, carOptions)
|
||||||
|
|
||||||
// Metrics HTTP server
|
// Metrics HTTP server
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
@@ -143,6 +149,8 @@ func main() {
|
|||||||
mux.HandleFunc("/monitor", monitorHandler(monitorMode, logger))
|
mux.HandleFunc("/monitor", monitorHandler(monitorMode, logger))
|
||||||
mux.HandleFunc("/trip", tripSetHandler(tripMgr, cfg, logger))
|
mux.HandleFunc("/trip", tripSetHandler(tripMgr, cfg, logger))
|
||||||
mux.HandleFunc("/trip/cancel", tripCancelHandler(tripMgr, logger))
|
mux.HandleFunc("/trip/cancel", tripCancelHandler(tripMgr, logger))
|
||||||
|
mux.HandleFunc("/ww/reset", wwResetHandler(act, eng, cfg, logger))
|
||||||
|
mux.HandleFunc("/ww/boost", wwBoostToggleHandler(wwBoostDisabled, logger))
|
||||||
mux.HandleFunc("/", statusStore.Handler())
|
mux.HandleFunc("/", statusStore.Handler())
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
@@ -171,12 +179,12 @@ func main() {
|
|||||||
logger.Info("EMS control loop started")
|
logger.Info("EMS control loop started")
|
||||||
|
|
||||||
// Run once immediately
|
// Run once immediately
|
||||||
runCycle(ctx, coll, eng, act, fc, m, statusStore, cfg, cfg.EMS.StateFile, logger, *dryRun, monitorMode, tripMgr)
|
runCycle(ctx, coll, eng, act, fc, m, statusStore, cfg, cfg.EMS.StateFile, logger, *dryRun, monitorMode, wwBoostDisabled, tripMgr)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
runCycle(ctx, coll, eng, act, fc, m, statusStore, cfg, cfg.EMS.StateFile, logger, *dryRun, monitorMode, tripMgr)
|
runCycle(ctx, coll, eng, act, fc, m, statusStore, cfg, cfg.EMS.StateFile, logger, *dryRun, monitorMode, wwBoostDisabled, tripMgr)
|
||||||
|
|
||||||
case sig := <-sigCh:
|
case sig := <-sigCh:
|
||||||
logger.Info("received signal, shutting down", "signal", sig)
|
logger.Info("received signal, shutting down", "signal", sig)
|
||||||
@@ -203,6 +211,7 @@ func runCycle(
|
|||||||
logger *slog.Logger,
|
logger *slog.Logger,
|
||||||
dryRun bool,
|
dryRun bool,
|
||||||
monitorMode *monitor.Mode,
|
monitorMode *monitor.Mode,
|
||||||
|
wwBoostDisabled *monitor.Mode,
|
||||||
tripMgr *trip.Manager,
|
tripMgr *trip.Manager,
|
||||||
) {
|
) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -250,7 +259,10 @@ func runCycle(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Run decision engine
|
// Step 3: Run decision engine
|
||||||
wwBoostC := computeWWBoost(fcResult, cfg)
|
wwBoostC := 0.0
|
||||||
|
if !wwBoostDisabled.IsActive() {
|
||||||
|
wwBoostC = computeWWBoost(fcResult, cfg)
|
||||||
|
}
|
||||||
decisionStart := time.Now()
|
decisionStart := time.Now()
|
||||||
actions := eng.Decide(state, now, wwBoostC)
|
actions := eng.Decide(state, now, wwBoostC)
|
||||||
m.DecisionDuration.Observe(time.Since(decisionStart).Seconds())
|
m.DecisionDuration.Observe(time.Since(decisionStart).Seconds())
|
||||||
@@ -453,6 +465,62 @@ func tripCancelHandler(tm *trip.Manager, logger *slog.Logger) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wwBoostToggleHandler toggles the WW boost disable flag on POST.
|
||||||
|
func wwBoostToggleHandler(disabled *monitor.Mode, logger *slog.Logger) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newState := !disabled.IsActive()
|
||||||
|
if err := disabled.Set(newState); err != nil {
|
||||||
|
logger.Error("WW boost toggle failed", "error", err)
|
||||||
|
} else {
|
||||||
|
if newState {
|
||||||
|
logger.Info("WW boost disabled via web UI")
|
||||||
|
} else {
|
||||||
|
logger.Info("WW boost enabled via web UI")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wwResetHandler resets the WW temperature to the configured base setpoint and
|
||||||
|
// blocks further boosts for the rest of the day via an engine override.
|
||||||
|
func wwResetHandler(act *actuator.Actuator, eng *engine.Engine, cfg *config.Config, logger *slog.Logger) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block re-boost until midnight.
|
||||||
|
now := time.Now()
|
||||||
|
midnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
|
||||||
|
lockDur := time.Until(midnight)
|
||||||
|
|
||||||
|
eng.ApplyOverride(engine.ConsumerWW, false, lockDur)
|
||||||
|
|
||||||
|
// Immediately restore base WW temperature via Viessmann API.
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
action := engine.Action{
|
||||||
|
Consumer: engine.ConsumerWW,
|
||||||
|
TurnOn: false,
|
||||||
|
TargetTempC: cfg.Strategic.WWBaseC,
|
||||||
|
Reason: "manual WW reset via web UI",
|
||||||
|
}
|
||||||
|
if err := act.Execute(ctx, []engine.Action{action}); err != nil {
|
||||||
|
logger.Error("WW reset: actuator failed", "error", err)
|
||||||
|
} else {
|
||||||
|
logger.Info("WW boost reset", "base_c", cfg.Strategic.WWBaseC, "locked_until", midnight.Format("15:04"))
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// monitorHandler toggles monitor-only mode on POST and redirects to the status page.
|
// monitorHandler toggles monitor-only mode on POST and redirects to the status page.
|
||||||
func monitorHandler(mode *monitor.Mode, logger *slog.Logger) http.HandlerFunc {
|
func monitorHandler(mode *monitor.Mode, logger *slog.Logger) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
Reference in New Issue
Block a user