Viessmann: discover installation id and gateway serial (#32308)

This commit is contained in:
Matthias 2026-07-31 09:55:29 +02:00 • committed by GitHub
parent f70a33e466
commit 3c75a65651
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 305 additions and 54 deletions

5
charger/viessmann.go Normal file
View file

@ -0,0 +1,5 @@
package charger
// Viessmann heat pumps are configured by template using the http plugin.
// The device package provides the OAuth token source and the config service.
import _ "github.com/evcc-io/evcc/charger/viessmann"

50
charger/viessmann/api.go Normal file
View file

@ -0,0 +1,50 @@
package viessmann
import (
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"golang.org/x/oauth2"
)
// Gateway is a Viessmann communication module.
type Gateway struct {
Serial string `json:"serial"`
}
// Installation is a Viessmann installation including its gateways.
type Installation struct {
ID int `json:"id"`
Gateways []Gateway `json:"gateways"`
}
// API is the Viessmann IoT API client.
type API struct {
*request.Helper
uri string
}
// NewAPI creates a new api client
func NewAPI(log *util.Logger, uri string, ts oauth2.TokenSource) *API {
v := &API{
Helper: request.NewHelper(log),
uri: uri,
}
v.Client.Transport = &oauth2.Transport{
Source: ts,
Base: v.Client.Transport,
}
return v
}
// Installations returns the account's installations including their gateways.
func (v *API) Installations() ([]Installation, error) {
var res struct {
Data []Installation `json:"data"`
}
err := v.GetJSON(v.uri+"/equipment/installations?includeGateways=true", &res)
return res.Data, err
}

View file

@ -0,0 +1,51 @@
package viessmann
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/evcc-io/evcc/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
)
func testAPI(t *testing.T, handler http.HandlerFunc) *API {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "token"})
return NewAPI(util.NewLogger("viessmann"), srv.URL, ts)
}
func TestInstallations(t *testing.T) {
api := testAPI(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/equipment/installations", r.URL.Path)
require.Equal(t, "true", r.URL.Query().Get("includeGateways"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[
{"id":3242119,"gateways":[{"serial":"7472258009383262"},{"serial":"7472258009383263"}]},
{"id":1000001,"gateways":[{"serial":"9999999999999999"}]}
]}`))
})
res, err := api.Installations()
require.NoError(t, err)
require.Len(t, res, 2)
assert.Equal(t, 3242119, res[0].ID)
assert.Len(t, res[0].Gateways, 2)
assert.Equal(t, "7472258009383262", res[0].Gateways[0].Serial)
}
func TestInstallationsError(t *testing.T) {
api := testAPI(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)
})
_, err := api.Installations()
require.Error(t, err)
}

View file

@ -0,0 +1,55 @@
package viessmann
import (
"context"
"github.com/evcc-io/evcc/plugin/auth"
"github.com/evcc-io/evcc/util"
"golang.org/x/oauth2"
)
const (
// OAuthURI is the Viessmann IAM base URL.
OAuthURI = "https://iam.viessmann-climatesolutions.com/idp/v3"
// ApiURI is the Viessmann IoT API base URL.
ApiURI = "https://api.viessmann-climatesolutions.com/iot/v2"
)
func init() {
auth.Register("viessmann", func(other map[string]any) (oauth2.TokenSource, error) {
var cc struct {
ClientID string
RedirectURI string
Gateway string `mapstructure:"gateway_serial"`
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
log := util.NewLogger("viessmann").Redact(cc.ClientID)
ctx := util.WithLogger(context.Background(), log)
return NewOAuth(ctx, cc.ClientID, cc.RedirectURI, cc.Gateway)
})
}
// OAuthConfig returns the Viessmann IoT API OAuth2 config.
func OAuthConfig(clientID, redirectURI string) *oauth2.Config {
return &oauth2.Config{
ClientID: clientID,
RedirectURL: redirectURI,
Endpoint: oauth2.Endpoint{
AuthURL: OAuthURI + "/authorize",
TokenURL: OAuthURI + "/token",
AuthStyle: oauth2.AuthStyleInHeader,
},
Scopes: []string{"IoT User", "offline_access"},
}
}
// NewOAuth creates the Viessmann IoT API token source using the authorization
// code flow. The user authorizes interactively via the evcc UI.
func NewOAuth(ctx context.Context, clientID, redirectURI, device string) (oauth2.TokenSource, error) {
return auth.NewOAuth(ctx, "Viessmann", device, OAuthConfig(clientID, redirectURI))
}

View file

@ -0,0 +1,94 @@
package viessmann
import (
"context"
"encoding/json"
"errors"
"net/http"
"slices"
"strconv"
"github.com/evcc-io/evcc/server/service"
"github.com/evcc-io/evcc/util"
)
var serviceMux = http.NewServeMux()
func init() {
serviceMux.HandleFunc("GET /equipment", getEquipment)
service.Register("viessmann", serviceMux)
}
// errUnauthorized indicates that the user has not (yet) authorized. The config
// UI polls the service while the form is being filled, hence this is expected.
var errUnauthorized = errors.New("unauthorized")
// equipment reuses the OAuth instance created for the same client, so results
// appear once the user has authorized via the UI.
func equipment(req *http.Request) ([]Installation, error) {
q := req.URL.Query()
clientID, redirectURI := q.Get("clientid"), q.Get("redirecturi")
if clientID == "" {
return nil, errUnauthorized
}
log := util.NewLogger("viessmann").Redact(clientID)
ctx := util.WithLogger(context.Background(), log)
ts, err := NewOAuth(ctx, clientID, redirectURI, q.Get("gateway_serial"))
if err != nil {
return nil, err
}
// no values until the user has authorized
if _, err := ts.Token(); err != nil {
return nil, errUnauthorized
}
return NewAPI(log, ApiURI, ts).Installations()
}
// values extracts the installation ids or, with gateways set, the deduplicated
// gateway serials of the given installations.
func values(installations []Installation, gateways bool) []string {
res := []string{}
for _, inst := range installations {
if !gateways {
res = append(res, strconv.Itoa(inst.ID))
continue
}
for _, gw := range inst.Gateways {
if !slices.Contains(res, gw.Serial) {
res = append(res, gw.Serial)
}
}
}
slices.Sort(res)
return res
}
// getEquipment lists the account's installation ids or, with detail=gateways,
// their gateway serials, driving the respective selection in the template.
func getEquipment(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
res := []string{}
defer func() { _ = json.NewEncoder(w).Encode(res) }()
installations, err := equipment(req)
if err != nil {
// unexpected errors are worth logging, missing authorization is not
if !errors.Is(err, errUnauthorized) {
util.NewLogger("viessmann").ERROR.Println(err)
}
return
}
res = values(installations, req.URL.Query().Get("detail") == "gateways")
}

View file

@ -0,0 +1,36 @@
package viessmann
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestValues(t *testing.T) {
installations := []Installation{
{ID: 3242119, Gateways: []Gateway{{Serial: "7472258009383263"}, {Serial: "7472258009383262"}}},
{ID: 1000001, Gateways: []Gateway{{Serial: "7472258009383262"}}},
}
assert.Equal(t, []string{"1000001", "3242119"}, values(installations, false))
assert.Equal(t, []string{"7472258009383262", "7472258009383263"}, values(installations, true))
// empty list, not null
assert.Equal(t, []string{}, values(nil, false))
assert.Equal(t, []string{}, values(nil, true))
}
// The service handler must return an empty JSON list when the user has not
// authorized yet, as the config UI polls it while the form is being filled.
func TestServiceHandlerUnauthorized(t *testing.T) {
for _, query := range []string{"", "&detail=gateways"} {
req := httptest.NewRequest(http.MethodGet, "/equipment?clientid=&redirecturi="+query, nil)
w := httptest.NewRecorder()
serviceMux.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, query)
assert.JSONEq(t, "[]", w.Body.String(), query)
}
}

View file

@ -1,50 +0,0 @@
package auth
import (
"context"
"dario.cat/mergo"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"golang.org/x/oauth2"
)
const OAuthURI = "https://iam.viessmann-climatesolutions.com/idp/v3"
var oauthConfig = oauth2.Config{
Endpoint: oauth2.Endpoint{
AuthURL: OAuthURI + "/authorize",
TokenURL: OAuthURI + "/token",
AuthStyle: oauth2.AuthStyleInHeader,
},
Scopes: []string{"IoT User", "offline_access"},
}
func init() {
registry.AddCtx("viessmann", NewViessmannFromConfig)
}
func NewViessmannFromConfig(ctx context.Context, other map[string]any) (oauth2.TokenSource, error) {
var cc struct {
ClientID string
RedirectURI string
Gateway string `mapstructure:"gateway_serial"`
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
log := util.NewLogger("viessmann").Redact(cc.ClientID)
ctx = context.WithValue(ctx, oauth2.HTTPClient, request.NewClient(log))
oc := oauth2.Config{
ClientID: cc.ClientID,
RedirectURL: cc.RedirectURI,
}
if err := mergo.Merge(&oc, oauthConfig); err != nil {
return nil, err
}
return NewOAuth(ctx, "Viessmann", cc.Gateway, &oc)
}

View file

@ -28,20 +28,24 @@ params:
service: auth/redirecturi
- name: gateway_serial
required: true
service: viessmann/equipment?detail=gateways&clientid={clientid}&redirecturi={redirecturi}
description:
de: Gateway Serial
en: Gateway Serial
help:
de: Seriennummer des VitoConnect modul (VitoCare App -> Einstellungen -> Kommunikationsmodul -> Seriennummer)
en: VitoConnect serial number (VitoCare App -> Settings -> Communication module -> Serial number)
de: Wird nach der Anmeldung automatisch ermittelt. Alternativ manuell eintragen (VitoCare App -> Einstellungen -> Kommunikationsmodul -> Seriennummer)
en: Determined automatically after login. Alternatively enter manually (VitoCare App -> Settings -> Communication module -> Serial number)
- name: installation_id
required: true
service: viessmann/equipment?clientid={clientid}&redirecturi={redirecturi}
description:
de: Installation ID
en: Installation ID
help:
de: |
Leider kann man die Installation ID nicht einfach in der Viessmann App einsehen - stattdessen müssen wir die folgenden Kommandos in der Kommandozeile ausführen. Es ist uns bewusst, dass das nicht für jeden Benutzer einfach umsetzbar ist, aber bisher haben wir leider keinen besseren Ablauf...<br/>
Wird nach der Anmeldung automatisch ermittelt.
<details><summary>Fallback: Installation ID per Kommandozeile ermitteln</summary>
Vorraussetzungen: curl, jq, und die folgenden Umgebungsvariblen:
@ -82,8 +86,12 @@ params:
https://api.viessmann-climatesolutions.com/iot/v2/equipment/installations?includeGateways=true \
| jq '.data[].id'
```
</details>
en: |
Unfortunately you cannot simply lookup this number in the Viessmann app - instead you need to use the following commands on the command line... we're aware this is not for every user, but currently we don't have a better workflow...<br/>
Determined automatically after login.
<details><summary>Fallback: determine the installation id on the command line</summary>
Prerequisites: curl, jq, and the following parameters:
@ -124,6 +132,8 @@ params:
https://api.viessmann-climatesolutions.com/iot/v2/equipment/installations?includeGateways=true \
| jq '.data[].id'
```
</details>
- name: device_id
required: true
description: