package viessmann import ( "context" "encoding/json" "fmt" "log/slog" "net/http" "net/url" "os" "strings" "sync" "time" "github.com/tb/ems/internal/config" ) const ( tokenEndpoint = "https://iam.viessmann-climatesolutions.com/idp/v3/token" apiBase = "https://api.viessmann-climatesolutions.com/iot/v2" ) // tokenFile mirrors the JSON structure stored on disk. type tokenFile struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` ValidToTimeDate int64 `json:"validToTimeDate"` // Unix milliseconds } // Client manages Viessmann OAuth2 tokens and sends commands to the IoT API. type Client struct { cfg config.ViessmannConfig httpClient *http.Client logger *slog.Logger mu sync.Mutex token tokenFile } // NewClient creates a new Viessmann client and loads the token from disk. func NewClient(cfg config.ViessmannConfig, logger *slog.Logger) (*Client, error) { c := &Client{ cfg: cfg, httpClient: &http.Client{Timeout: 15 * time.Second}, logger: logger, } if err := c.loadToken(); err != nil { return nil, fmt.Errorf("loading token: %w", err) } return c, nil } func (c *Client) loadToken() error { data, err := os.ReadFile(c.cfg.TokenFile) if err != nil { return fmt.Errorf("reading %s: %w", c.cfg.TokenFile, err) } return json.Unmarshal(data, &c.token) } func (c *Client) saveToken() { data, err := json.MarshalIndent(c.token, "", " ") if err != nil { c.logger.Warn("could not marshal token", "error", err) return } if err := os.WriteFile(c.cfg.TokenFile, data, 0600); err != nil { c.logger.Warn("could not save token file", "error", err) } } // ensureToken refreshes the access token if it expires within 5 minutes. // Caller must hold c.mu. func (c *Client) ensureToken(ctx context.Context) error { remaining := time.Until(time.UnixMilli(c.token.ValidToTimeDate)) if remaining > 5*time.Minute { return nil } c.logger.Info("refreshing Viessmann access token", "remaining", remaining.Round(time.Second)) form := url.Values{} form.Set("grant_type", "refresh_token") form.Set("client_id", c.cfg.ClientID) form.Set("refresh_token", c.token.RefreshToken) req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint, strings.NewReader(form.Encode())) if err != nil { return fmt.Errorf("creating token request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("token refresh: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("token refresh returned %d", resp.StatusCode) } var fresh struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` } if err := json.NewDecoder(resp.Body).Decode(&fresh); err != nil { return fmt.Errorf("decoding token response: %w", err) } c.token.AccessToken = fresh.AccessToken if fresh.RefreshToken != "" { c.token.RefreshToken = fresh.RefreshToken } c.token.TokenType = fresh.TokenType c.token.ExpiresIn = fresh.ExpiresIn c.token.ValidToTimeDate = time.Now().Add(time.Duration(fresh.ExpiresIn) * time.Second).UnixMilli() c.saveToken() c.logger.Info("Viessmann token refreshed") return nil } // featureURL builds the IoT API URL for a device feature. func (c *Client) featureURL(feature string) string { return fmt.Sprintf( "%s/features/installations/%s/gateways/%s/devices/%s/features/%s", apiBase, c.cfg.InstallationID, c.cfg.GatewaySerial, c.cfg.DeviceID, feature, ) } // 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() defer c.mu.Unlock() if err := c.ensureToken(ctx); err != nil { return fmt.Errorf("ensuring token: %w", err) } cmdURL := c.featureURL("heating.dhw.temperature.main") + "/commands/setTargetTemperature" body := fmt.Sprintf(`{"temperature":%g}`, tempC) req, err := http.NewRequestWithContext(ctx, http.MethodPost, cmdURL, strings.NewReader(body)) if err != nil { return fmt.Errorf("creating request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+c.token.AccessToken) resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("API call: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { return fmt.Errorf("Viessmann API returned %d", resp.StatusCode) } c.logger.Info("DHW temperature set", "temp_c", tempC) return nil }