From 8ba86f8fc8860012afdc64a80b06bcb89454aa8b Mon Sep 17 00:00:00 2001 From: Lutz Finsterle Date: Mon, 6 Apr 2026 10:59:57 +0200 Subject: [PATCH] Add --test-viessmann flag for end-to-end API validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds GetDHWTemperature() to the Viessmann client (GET on the DHW feature endpoint) and a --test-viessmann CLI flag that runs three stages without starting the EMS control loop: Stage 1: read current DHW setpoint (confirms auth + endpoint path) Stage 2: write same value back, read-back to confirm round-trip Stage 3: write +1°C, read-back to confirm, restore original The +1°C delta is below the heat pump's 5°C hysteresis so the compressor will not fire, but the change is visible in the Viessmann app for visual confirmation before trusting the WW boost logic in production. Usage: ./ems --config /etc/ems/ems-config.yaml --test-viessmann Co-Authored-By: Claude Sonnet 4.6 --- internal/viessmann/client.go | 46 ++++++++++++++ main.go | 120 +++++++++++++++++++++++++++++++---- 2 files changed, 154 insertions(+), 12 deletions(-) diff --git a/internal/viessmann/client.go b/internal/viessmann/client.go index 34f75ff..6d40274 100644 --- a/internal/viessmann/client.go +++ b/internal/viessmann/client.go @@ -137,6 +137,52 @@ func (c *Client) featureURL(feature string) string { ) } +// dhwFeatureResponse is the minimal structure returned by GET on the DHW temperature feature. +type dhwFeatureResponse struct { + Data struct { + Properties struct { + Value struct { + Value float64 `json:"value"` + } `json:"value"` + } `json:"properties"` + } `json:"data"` +} + +// GetDHWTemperature reads the current domestic hot water target temperature from the Viessmann API. +func (c *Client) GetDHWTemperature(ctx context.Context) (float64, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if err := c.ensureToken(ctx); err != nil { + return 0, fmt.Errorf("ensuring token: %w", err) + } + + featureURL := c.featureURL("heating.dhw.temperature.main") + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, featureURL, nil) + if err != nil { + return 0, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.token.AccessToken) + + resp, err := c.httpClient.Do(req) + if err != nil { + return 0, fmt.Errorf("API call: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("Viessmann API returned %d", resp.StatusCode) + } + + var result dhwFeatureResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return 0, fmt.Errorf("decoding response: %w", err) + } + + return result.Data.Properties.Value.Value, nil +} + // SetDHWTemperature sets the domestic hot water target temperature via the Viessmann API. func (c *Client) SetDHWTemperature(ctx context.Context, tempC float64) error { c.mu.Lock() diff --git a/main.go b/main.go index 32dfdfe..e595527 100644 --- a/main.go +++ b/main.go @@ -27,6 +27,7 @@ import ( 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 @@ -50,6 +51,27 @@ func main() { 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, @@ -62,18 +84,6 @@ func main() { eng := engine.NewEngine(cfg, logger) fc := forecast.NewClient(cfg.Forecast, cfg.Strategic, logger) - // Viessmann client — optional, only if credentials are configured - 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") - } - } - act := actuator.NewActuator(cfg, vc, logger) // Startup: attempt state recovery from Shelly read-back @@ -343,6 +353,92 @@ func computeWWBoost(fc *forecast.Result, cfg *config.Config) float64 { } } +// 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(), 30*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 + 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) + // Try to restore before returning + _ = vc.SetDHWTemperature(ctx, original) + return 1 + } + 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", 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 + } + 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 == "" {