diff --git a/charger/viessmann.go b/charger/viessmann.go
new file mode 100644
index 000000000..8663fe315
--- /dev/null
+++ b/charger/viessmann.go
@@ -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"
diff --git a/charger/viessmann/api.go b/charger/viessmann/api.go
new file mode 100644
index 000000000..e47ff7489
--- /dev/null
+++ b/charger/viessmann/api.go
@@ -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
+}
diff --git a/charger/viessmann/api_test.go b/charger/viessmann/api_test.go
new file mode 100644
index 000000000..ddba76141
--- /dev/null
+++ b/charger/viessmann/api_test.go
@@ -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)
+}
diff --git a/charger/viessmann/oauth.go b/charger/viessmann/oauth.go
new file mode 100644
index 000000000..e5c804ae9
--- /dev/null
+++ b/charger/viessmann/oauth.go
@@ -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))
+}
diff --git a/charger/viessmann/service.go b/charger/viessmann/service.go
new file mode 100644
index 000000000..60de9b122
--- /dev/null
+++ b/charger/viessmann/service.go
@@ -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")
+}
diff --git a/charger/viessmann/service_test.go b/charger/viessmann/service_test.go
new file mode 100644
index 000000000..82f6b67ed
--- /dev/null
+++ b/charger/viessmann/service_test.go
@@ -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)
+ }
+}
diff --git a/plugin/auth/viessmann.go b/plugin/auth/viessmann.go
deleted file mode 100644
index 0c7a8a98b..000000000
--- a/plugin/auth/viessmann.go
+++ /dev/null
@@ -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)
-}
diff --git a/templates/definition/charger/viessmann.yaml b/templates/definition/charger/viessmann.yaml
index c099583b9..aa2eb257a 100644
--- a/templates/definition/charger/viessmann.yaml
+++ b/templates/definition/charger/viessmann.yaml
@@ -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...
+ Wird nach der Anmeldung automatisch ermittelt.
+
+ Fallback: Installation ID per Kommandozeile ermitteln
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'
```
+
+
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...
+ Determined automatically after login.
+
+ Fallback: determine the installation id on the command line
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'
```
+
+
- name: device_id
required: true
description: