Add --test-viessmann flag for end-to-end API validation

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 10:59:57 +02:00
parent 590ba1e675
commit 8ba86f8fc8
2 changed files with 154 additions and 12 deletions

View File

@@ -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()

120
main.go
View File

@@ -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 == "" {