diff --git a/assets/js/components/Config/Markdown.vue b/assets/js/components/Config/Markdown.vue index ffe7da61d..424778e62 100644 --- a/assets/js/components/Config/Markdown.vue +++ b/assets/js/components/Config/Markdown.vue @@ -1,6 +1,6 @@ + diff --git a/plugin/auth/viessmann.go b/plugin/auth/viessmann.go new file mode 100644 index 000000000..116da2d8e --- /dev/null +++ b/plugin/auth/viessmann.go @@ -0,0 +1,115 @@ +package auth + +import ( + "context" + "fmt" + "net/http" + "net/http/cookiejar" + "net/url" + + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/evcc-io/evcc/util/transport" + "github.com/samber/lo" + "golang.org/x/oauth2" +) + +const ( + OAuthURI = "https://iam.viessmann.com/idp/v3" + RedirectURI = "http://localhost:4200/" + // ^ the value of RedirectURI doesn't matter, but it must be the same between requests +) + +func OAuth2Config(clientID string) *oauth2.Config { + return &oauth2.Config{ + ClientID: clientID, + Endpoint: oauth2.Endpoint{ + AuthURL: OAuthURI + "/authorize", + TokenURL: OAuthURI + "/token", + AuthStyle: oauth2.AuthStyleInHeader, + }, + RedirectURL: RedirectURI, + Scopes: []string{"IoT User", "offline_access"}, + } +} + +func init() { + registry.AddCtx("viessmann", NewViessmannFromConfig) +} + +type Viessmann struct { + client *http.Client + ts oauth2.TokenSource +} + +func NewViessmannFromConfig(ctx context.Context, other map[string]any) (Authorizer, error) { + var cc struct { + ClientID string + User, Password string + } + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + v := &Viessmann{ + client: request.NewClient(util.NewLogger("viessmann")), + } + + oc := OAuth2Config(cc.ClientID) + + ctx = context.WithValue(context.Background(), oauth2.HTTPClient, v.client) + token, err := v.login(ctx, oc, cc.User, cc.Password) + if err != nil { + return nil, err + } + + v.ts = oc.TokenSource(ctx, token) + + return v, nil +} + +func (v *Viessmann) Transport(base http.RoundTripper) (http.RoundTripper, error) { + return &oauth2.Transport{ + Base: base, + Source: v.ts, + }, nil +} + +func (v *Viessmann) login(ctx context.Context, oc *oauth2.Config, user, password string) (*oauth2.Token, error) { + cv := oauth2.GenerateVerifier() + + state := lo.RandomString(16, lo.AlphanumericCharset) + uri := oc.AuthCodeURL(state, oauth2.S256ChallengeOption(cv)) + + v.client.Jar, _ = cookiejar.New(nil) + v.client.CheckRedirect = request.DontFollow + defer func() { + v.client.Jar = nil + v.client.CheckRedirect = nil + }() + + req, _ := request.New(http.MethodGet, uri, nil, map[string]string{ + "Authorization": transport.BasicAuthHeader(user, password), + }) + + resp, err := v.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusFound { + return nil, fmt.Errorf("unexpected status %d", resp.StatusCode) + } + + loc, err := url.Parse(resp.Header.Get("Location")) + if err != nil { + return nil, err + } + code := loc.Query().Get("code") + + ctx, cancel := context.WithTimeout(ctx, request.Timeout) + defer cancel() + + return oc.Exchange(ctx, code, oauth2.VerifierOption(cv)) +} diff --git a/templates/definition/charger/viessmann.yaml b/templates/definition/charger/viessmann.yaml new file mode 100644 index 000000000..63bec3526 --- /dev/null +++ b/templates/definition/charger/viessmann.yaml @@ -0,0 +1,148 @@ +template: viessmann +products: + - brand: Viessmann + description: + generic: Heatpump (SG Ready) +group: heating +requirements: + # evcc: ["sponsorship"] + evcc: ["skiptest"] + description: + de: | + Einmalige Warmwasserbereitung auf eine konfigurierbare Solltemperatur. Das Gerät entscheidet eigenständig, ob die Wärmepumpe oder die elektrische Zusatzheizung (falls vorhanden) genutzt wird. + en: | + One-time hot water preparation to a configurable target temperature. The device automatically decides whether to use the heat pump or the auxiliary electric heater (if available). +params: + - name: user + required: true + help: + de: Registrieren auf [app.developer.viessmann.com](https://app.developer.viessmann.com) + en: Register at [app.developer.viessmann.com](https://app.developer.viessmann.com) + - name: password + required: true + help: + de: Für den konfigurierten Viessmann Account. + en: For the configured Viessmann account. + - name: clientid + required: true + description: + de: Client ID + en: Client ID + help: + de: Konfigurieren in [app.developer.viessmann.com](https://app.developer.viessmann.com) + en: Configure at [app.developer.viessmann.com](https://app.developer.viessmann.com) + - name: gateway_serial + required: true + 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) + - name: installation_id + required: true + description: + de: Installation ID + en: Installation ID + help: + de: siehe https://docs.evcc.io/docs/devices/heating#viessmann + 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...
+ + Prerequisites: curl, jq, and the following parameters: + + ``` + VIESSMANN_USER= + VIESSMANN_PASS= + VIESSMANN_CLIENT_ID= + ``` + + Then execute the following (n.b. it's best to paste the entire block as-is, since the intermediate 'CODE' is only valid for 20 seconds): + + ``` + VIESSMANN_REDIRECT_URI="http://localhost:4200/" + VIESSMANN_CODE_CHALLENGE="5M5nhkBfkWZCGfLZYcTL-l7esjPUN7PpZ4rq8k4cmys" + VIESSMANN_CODE_VERIFIER="6PygdmeK8JKPuuftlkc6q4ceyvjhMM_a_cJrPbcmcLc-SPjx2ZXTYr-SOofPUBydQ3McNYRy7Hibc2L2WtVLJFpOQ~Qbgic455ArKjUz9_UiTLnO6q8A3e.I_fIF8hAo" + ``` + - name: device_id + required: true + description: + de: Device ID + en: Device ID + help: + de: normalerweise `0` + en: typically `0` + default: 0 + - name: target_temperature + description: + de: Zieltemperatur für Einmal-Warmwasser-Zubereitung (°C) + en: Target temperature for one-time charge (°C) + help: + de: max. 60°C + en: max. 60°C + required: true + default: 45 + type: int +render: | + type: sgready + getmode: + source: http + uri: https://api.viessmann.com/iot/v2/features/installations/{{.installation_id}}/gateways/{{.gateway_serial}}/devices/{{.device_id}}/features/heating.dhw.oneTimeCharge + cache: 2s # to prevent making two identical requests straight after each other for "getmode" + auth: + source: viessmann + user: {{ .user }} + password: {{ .password }} + clientid: {{ .clientid }} + jq: '.data.properties.active.value | if . == false then 1 elif . == true then 2 else . end' + # false -> oneTimeCharge is disabled -> normal mode -> 1 + # true -> oneTimeCharge is enabled -> boost mode -> 2 + setmode: + source: watchdog + timeout: 60m # re-write at timeout/2 + reset: 1 # reset watchdog on normal + set: + source: switch + switch: + - case: 1 # normal + set: + source: http + uri: https://api.viessmann.com/iot/v2/features/installations/{{.installation_id}}/gateways/{{.gateway_serial}}/devices/{{.device_id}}/features/heating.dhw.oneTimeCharge/commands/deactivate + method: POST + headers: + - content-type: application/json + auth: + source: viessmann + user: {{ .user }} + password: {{ .password }} + clientid: {{ .clientid }} + body: > + { } + - case: 2 # boost + set: + source: sequence + set: + - source: http + uri: https://api.viessmann.com/iot/v2/features/installations/{{.installation_id}}/gateways/{{.gateway_serial}}/devices/{{.device_id}}/features/heating.dhw.temperature.temp2/commands/setTargetTemperature + method: POST + headers: + - content-type: application/json + auth: + source: viessmann + user: {{ .user }} + password: {{ .password }} + clientid: {{ .clientid }} + body: > + {"temperature": {{.target_temperature}}} + - source: http + uri: https://api.viessmann.com/iot/v2/features/installations/{{.installation_id}}/gateways/{{.gateway_serial}}/devices/{{.device_id}}/features/heating.dhw.oneTimeCharge/commands/activate + method: POST + headers: + - content-type: application/json + auth: + source: viessmann + user: {{ .user }} + password: {{ .password }} + clientid: {{ .clientid }} + body: > + { }