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:
@@ -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
120
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 == "" {
|
||||
|
||||
Reference in New Issue
Block a user