Files
EMS/main.go
Lutz Finsterle 56710b4328 Fix Viessmann API hostname and harden test propagation waits
- Update apiBase: api.viessmann.com → api.viessmann-climatesolutions.com
  (Viessmann renamed their IoT API host; old domain is NXDOMAIN)
- Extend --test-viessmann context to 120s
- Add 30s propagation wait before read-back in both Stage 3 and Restore:
  cloud accepts writes immediately but gateway polls on its own schedule,
  so GET returns the last confirmed device value with ~30s lag

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 11:05:33 +02:00

460 lines
14 KiB
Go

package main
import (
"context"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/tb/ems/internal/actuator"
"github.com/tb/ems/internal/collector"
"github.com/tb/ems/internal/config"
"github.com/tb/ems/internal/engine"
"github.com/tb/ems/internal/forecast"
"github.com/tb/ems/internal/metrics"
"github.com/tb/ems/internal/status"
"github.com/tb/ems/internal/viessmann"
)
func main() {
configPath := flag.String("config", "configs/ems-config.yaml", "Path to config file")
dryRun := flag.Bool("dry-run", false, "Run without executing actions (log only)")
testViessmann := flag.Bool("test-viessmann", false, "Test Viessmann API: read setpoint, round-trip write, +1°C delta, then restore")
flag.Parse()
// Load configuration
cfg, err := config.Load(*configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
os.Exit(1)
}
// Setup structured logging
logLevel := slog.LevelInfo
switch cfg.EMS.LogLevel {
case "debug":
logLevel = slog.LevelDebug
case "warn":
logLevel = slog.LevelWarn
case "error":
logLevel = slog.LevelError
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: logLevel,
}))
// Viessmann client — needed for both normal operation and --test-viessmann
var vc *viessmann.Client
if cfg.Viessmann.InstallationID != "" && cfg.Viessmann.ClientID != "" {
var err error
vc, err = viessmann.NewClient(cfg.Viessmann, logger)
if err != nil {
logger.Warn("Viessmann client init failed, WW boost disabled", "error", err)
} else {
logger.Info("Viessmann client initialized")
}
}
// --test-viessmann: run the API test and exit — do not start the control loop
if *testViessmann {
if vc == nil {
fmt.Fprintln(os.Stderr, "ERROR: Viessmann credentials not configured or token failed to load")
os.Exit(1)
}
os.Exit(runViessmannTest(vc))
}
logger.Info("starting EMS",
"config", *configPath,
"dry_run", *dryRun,
"poll_interval", cfg.EMS.PollInterval,
"listen_addr", cfg.EMS.ListenAddr,
)
// Initialize components
coll := collector.NewCollector(cfg, logger)
eng := engine.NewEngine(cfg, logger)
fc := forecast.NewClient(cfg.Forecast, cfg.Strategic, logger)
act := actuator.NewActuator(cfg, vc, logger)
// Startup: attempt state recovery from Shelly read-back
recoverCtx, recoverCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer recoverCancel()
if shouldRecover(cfg.EMS.StateFile, cfg.EMS.RecoveryTimeoutParsed(), logger) {
if states, err := act.ReadAllStates(recoverCtx); err != nil {
logger.Warn("state recovery failed, starting with all consumers off", "error", err)
} else {
eng.RecoverState(states)
}
} else {
logger.Info("state recovery skipped (downtime exceeded threshold or first run)")
}
// Prometheus metrics
reg := prometheus.NewRegistry()
m := metrics.NewMetrics(reg)
// Status store (shared between HTTP handler and control loop)
wwConfigured := cfg.Viessmann.InstallationID != ""
statusStore := status.NewStore(*dryRun, wwConfigured, cfg.Strategic)
// Metrics HTTP server
mux := http.NewServeMux()
mux.Handle("/metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{}))
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/override", overrideHandler(act, eng, logger))
mux.HandleFunc("/", statusStore.Handler())
srv := &http.Server{
Addr: cfg.EMS.ListenAddr,
Handler: mux,
}
go func() {
logger.Info("metrics server listening", "addr", cfg.EMS.ListenAddr)
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
logger.Error("metrics server error", "error", err)
}
}()
// Graceful shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
// Main control loop
ticker := time.NewTicker(cfg.EMS.PollIntervalParsed())
defer ticker.Stop()
logger.Info("EMS control loop started")
// Run once immediately
runCycle(ctx, coll, eng, act, fc, m, statusStore, cfg, cfg.EMS.StateFile, logger, *dryRun)
for {
select {
case <-ticker.C:
runCycle(ctx, coll, eng, act, fc, m, statusStore, cfg, cfg.EMS.StateFile, logger, *dryRun)
case sig := <-sigCh:
logger.Info("received signal, shutting down", "signal", sig)
cancel()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
srv.Shutdown(shutdownCtx)
return
}
}
}
func runCycle(
ctx context.Context,
coll *collector.Collector,
eng *engine.Engine,
act *actuator.Actuator,
fc *forecast.Client,
m *metrics.Metrics,
store *status.Store,
cfg *config.Config,
stateFile string,
logger *slog.Logger,
dryRun bool,
) {
now := time.Now()
// Step 1: Collect current state from Prometheus
state, err := coll.Collect(ctx)
if err != nil {
logger.Error("collection failed", "error", err)
store.Update(collector.SystemState{}, nil, nil, nil, err)
return
}
// Write heartbeat so next startup can assess downtime
writeHeartbeat(stateFile, logger)
// Step 1b: Read Shelly states and sync to engine (detects manual overrides).
// Partial results are fine — unreachable devices are logged inside ReadAllStates.
if shellyStates, err := act.ReadAllStates(ctx); err != nil {
logger.Warn("all Shelly devices unreachable, skipping override detection", "error", err)
} else {
eng.SyncHardwareState(shellyStates, now, cfg.EMS.OverrideTimeoutParsed())
store.SyncDeviceStates(shellyStates)
}
// Update metrics with current state
m.GridPowerW.Set(state.GridPowerW)
m.BatterySOC.Set(state.BatterySOC)
m.PVProductionW.Set(state.PVProductionW)
// Track energy flow (rough estimation based on 2-min intervals)
intervalHours := 2.0 / 60.0
if state.GridPowerW > 0 {
m.GridImportKWh.Add(state.GridPowerW / 1000.0 * intervalHours)
} else {
m.GridExportKWh.Add(-state.GridPowerW / 1000.0 * intervalHours)
}
// Step 2: Fetch forecast (cached; only hits the API once per day)
var fcResult *forecast.Result
if r, err := fc.Today(ctx); err == nil && r.TotalKWh > 0 {
fcResult = &r
}
// Step 3: Run decision engine
wwBoostC := computeWWBoost(fcResult, cfg)
decisionStart := time.Now()
actions := eng.Decide(state, now, wwBoostC)
m.DecisionDuration.Observe(time.Since(decisionStart).Seconds())
// Update consumer state metrics
m.UpdateConsumerStates(eng.ConsumerStates())
// Update status page
store.Update(state, actions, eng.Overrides(), fcResult, nil)
store.SyncConsumerStates(eng.ConsumerStates())
if len(actions) == 0 {
logger.Debug("no actions this cycle",
"grid_w", state.GridPowerW,
"soc", state.BatterySOC,
)
return
}
// Step 4: Execute actions
m.RecordActions(actions)
if dryRun {
for _, a := range actions {
logger.Info("[DRY RUN] would execute",
"consumer", a.Consumer,
"turn_on", a.TurnOn,
"reason", a.Reason,
)
}
return
}
if err := act.Execute(ctx, actions); err != nil {
logger.Error("execution failed", "error", err)
}
}
// shouldRecover checks the heartbeat file to decide whether to read back
// Shelly states on startup. Returns false if the file is missing (first run)
// or older than the recovery timeout.
func shouldRecover(stateFile string, timeout time.Duration, logger *slog.Logger) bool {
if stateFile == "" {
return false
}
info, err := os.Stat(stateFile)
if err != nil {
return false // first run or file was cleaned up
}
age := time.Since(info.ModTime())
if age > timeout {
logger.Info("heartbeat too old, skipping state recovery",
"age", age.Round(time.Second),
"timeout", timeout,
)
return false
}
return true
}
// overrideHandler handles manual on/off requests from the status page.
// 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,
"wallbox_b": engine.ConsumerWallboxB,
}
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
consumerKey := r.FormValue("consumer")
stateVal := r.FormValue("state")
durationStr := r.FormValue("duration")
consumer, ok := consumerByKey[consumerKey]
if !ok {
http.Error(w, "unknown consumer", http.StatusBadRequest)
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)
defer cancel()
action := engine.Action{
Consumer: consumer,
TurnOn: turnOn,
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)
http.Error(w, "switch failed — check logs", http.StatusInternalServerError)
return
}
// 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)
}
}
// computeWWBoost derives the WW temperature boost in °C from the daily PV forecast.
// Returns 0 if the forecast is unavailable or below the mid threshold.
func computeWWBoost(fc *forecast.Result, cfg *config.Config) float64 {
if fc == nil || fc.TotalKWh == 0 {
return 0
}
switch {
case fc.TotalKWh >= cfg.Strategic.ForecastHighKWh:
return cfg.Strategic.WWBoostHighC
case fc.TotalKWh >= cfg.Strategic.ForecastMidKWh:
return cfg.Strategic.WWBoostMidC
default:
return 0
}
}
// runViessmannTest exercises the Viessmann API read/write path without starting
// the EMS control loop. It performs three stages:
// 1. Read current DHW setpoint (confirms auth + endpoint)
// 2. Write the same value back (confirms write path with zero net change)
// 3. Write setpoint +1°C, read back to confirm, then restore original
//
// Returns 0 on success, 1 on any failure.
func runViessmannTest(vc *viessmann.Client) int {
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
pass := func(format string, args ...any) { fmt.Printf(" PASS "+format+"\n", args...) }
fail := func(format string, args ...any) { fmt.Fprintf(os.Stderr, " FAIL "+format+"\n", args...) }
step := func(format string, args ...any) { fmt.Printf("\n[test] "+format+"\n", args...) }
fmt.Println("=== Viessmann API test ===")
// Stage 1: read current setpoint
step("Stage 1 — read current DHW setpoint")
original, err := vc.GetDHWTemperature(ctx)
if err != nil {
fail("GetDHWTemperature: %v", err)
return 1
}
pass("current setpoint = %.1f°C", original)
// Stage 2: write same value back (round-trip, no observable effect)
step("Stage 2 — write same value back (%.1f°C → %.1f°C, no net change)", original, original)
if err := vc.SetDHWTemperature(ctx, original); err != nil {
fail("SetDHWTemperature(%.1f): %v", original, err)
return 1
}
readback, err := vc.GetDHWTemperature(ctx)
if err != nil {
fail("read-back after round-trip: %v", err)
return 1
}
if readback != original {
fail("round-trip mismatch: wrote %.1f, read back %.1f", original, readback)
return 1
}
pass("round-trip confirmed (%.1f°C)", readback)
// Stage 3: +1°C delta — heat pump won't notice (5°C hysteresis), but API change is visible.
// The Viessmann cloud queues the write to the gateway; the GET endpoint returns the last
// *confirmed* device value, so we wait up to 30s for the change to propagate.
delta := original + 1
step("Stage 3 — +1°C delta test (%.1f°C → %.1f°C)", original, delta)
if err := vc.SetDHWTemperature(ctx, delta); err != nil {
fail("SetDHWTemperature(%.1f): %v", delta, err)
_ = vc.SetDHWTemperature(ctx, original)
return 1
}
fmt.Println(" ...waiting 30s for cloud→gateway propagation...")
time.Sleep(30 * time.Second)
readback, err = vc.GetDHWTemperature(ctx)
if err != nil {
fail("read-back after +1°C: %v", err)
_ = vc.SetDHWTemperature(ctx, original)
return 1
}
if readback != delta {
fail("+1°C mismatch: wrote %.1f, read back %.1f after 30s — gateway may be offline or propagation takes longer", delta, readback)
_ = vc.SetDHWTemperature(ctx, original)
return 1
}
pass("+1°C confirmed (%.1f°C) — check Viessmann app now if you want visual confirmation", readback)
// Restore original
step("Restore — writing original setpoint back (%.1f°C)", original)
if err := vc.SetDHWTemperature(ctx, original); err != nil {
fail("restore SetDHWTemperature(%.1f): %v", original, err)
return 1
}
fmt.Println(" ...waiting 30s for cloud→gateway propagation...")
time.Sleep(30 * time.Second)
readback, err = vc.GetDHWTemperature(ctx)
if err != nil {
fail("read-back after restore: %v", err)
return 1
}
if readback != original {
fail("restore mismatch: wrote %.1f, read back %.1f", original, readback)
return 1
}
pass("restored to %.1f°C", readback)
fmt.Println("\n=== ALL STAGES PASSED — Viessmann API read/write confirmed ===")
return 0
}
// writeHeartbeat updates the heartbeat file timestamp each cycle.
func writeHeartbeat(stateFile string, logger *slog.Logger) {
if stateFile == "" {
return
}
if err := os.MkdirAll(filepath.Dir(stateFile), 0755); err != nil {
logger.Warn("could not create heartbeat directory", "error", err)
return
}
if err := os.WriteFile(stateFile, []byte(time.Now().Format(time.RFC3339)), 0644); err != nil {
logger.Warn("could not write heartbeat file", "error", err)
}
}