Vehicle: add drivesomethinggreater (VW group EU Data Act) (#30362)
This commit is contained in:
parent
72f6861c6e
commit
5407715154
7 changed files with 862 additions and 0 deletions
59
templates/definition/vehicle/drivesomethinggreater.yaml
Normal file
59
templates/definition/vehicle/drivesomethinggreater.yaml
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
template: drivesomethinggreater
|
||||
products:
|
||||
- brand: Volkswagen
|
||||
description:
|
||||
generic: EU Data Act
|
||||
- brand: Audi
|
||||
description:
|
||||
generic: EU Data Act
|
||||
- brand: Škoda
|
||||
description:
|
||||
generic: EU Data Act
|
||||
- brand: Seat
|
||||
description:
|
||||
generic: EU Data Act
|
||||
- brand: Cupra
|
||||
description:
|
||||
generic: EU Data Act
|
||||
requirements:
|
||||
description:
|
||||
de: |
|
||||
Nutzt die im Rahmen des EU Data Act bereitgestellten Fahrzeugdaten über das
|
||||
VW-Konzern-Portal (eu-data-act.drivesomethinggreater.com). Die Anmeldung
|
||||
erfolgt mit den Zugangsdaten der jeweiligen Marken-App (We Connect, myAudi,
|
||||
MyŠkoda, SEAT/CUPRA Connect).
|
||||
|
||||
Voraussetzung (einmalig im Browser): im Portal anmelden, die Einwilligung
|
||||
bestätigen, das Fahrzeug verknüpfen und eine fortlaufende Datenanfrage
|
||||
(alle 15 Minuten) aktivieren. Ohne diese Aktivierung liefert das Portal
|
||||
keine Daten. Die Daten werden ca. alle 15 Minuten aktualisiert.
|
||||
|
||||
Implementierung analog zur "EU Data Act" Datenquelle aus
|
||||
https://github.com/TA2k/ioBroker.vw-connect
|
||||
en: |
|
||||
Uses the vehicle data provided under the EU Data Act through the VW group
|
||||
portal (eu-data-act.drivesomethinggreater.com). Log in with the credentials
|
||||
of the respective brand app (We Connect, myAudi, MyŠkoda, SEAT/CUPRA Connect).
|
||||
|
||||
Prerequisite (one-time, in a browser): log in to the portal, confirm the
|
||||
consent screen, link the vehicle and enable a continuous data request
|
||||
(every 15 minutes). Without this activation the portal returns no data.
|
||||
Data is refreshed roughly every 15 minutes.
|
||||
|
||||
Implementation analogous to the "EU Data Act" data source from
|
||||
https://github.com/TA2k/ioBroker.vw-connect
|
||||
params:
|
||||
- preset: vehicle-base
|
||||
- name: brand
|
||||
type: choice
|
||||
choice: ["Volkswagen", "Audi", "Skoda", "Seat", "Cupra"]
|
||||
required: true
|
||||
description:
|
||||
de: Marke
|
||||
en: Brand
|
||||
- preset: vehicle-features
|
||||
render: |
|
||||
type: drivesomethinggreater
|
||||
brand: {{ .brand }}
|
||||
{{ include "vehicle-base" . }}
|
||||
{{ include "vehicle-features" . }}
|
||||
67
vehicle/drivesomethinggreater.go
Normal file
67
vehicle/drivesomethinggreater.go
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
package vehicle
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/evcc-io/evcc/vehicle/vw/eudataact"
|
||||
)
|
||||
|
||||
// https://github.com/TA2k/ioBroker.vw-connect (EU Data Act data source)
|
||||
|
||||
// DriveSomethingGreater is an api.Vehicle implementation for the VW group
|
||||
// "EU Data Act" data portal (eu-data-act.drivesomethinggreater.com)
|
||||
type DriveSomethingGreater struct {
|
||||
*embed
|
||||
*eudataact.Provider
|
||||
}
|
||||
|
||||
func init() {
|
||||
registry.Add("drivesomethinggreater", NewDriveSomethingGreaterFromConfig)
|
||||
}
|
||||
|
||||
// NewDriveSomethingGreaterFromConfig creates a new vehicle
|
||||
func NewDriveSomethingGreaterFromConfig(other map[string]any) (api.Vehicle, error) {
|
||||
cc := struct {
|
||||
embed `mapstructure:",squash"`
|
||||
Brand, User, Password, VIN string
|
||||
Cache time.Duration
|
||||
}{
|
||||
Brand: "Volkswagen",
|
||||
Cache: interval,
|
||||
}
|
||||
|
||||
if err := util.DecodeOther(other, &cc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cc.User == "" || cc.Password == "" {
|
||||
return nil, api.ErrMissingCredentials
|
||||
}
|
||||
|
||||
v := &DriveSomethingGreater{
|
||||
embed: &cc.embed,
|
||||
}
|
||||
|
||||
log := util.NewLogger("dsg").Redact(cc.User, cc.Password, cc.VIN)
|
||||
|
||||
api, err := eudataact.NewAPI(log, cc.Brand, cc.User, cc.Password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
vehicle, err := ensureVehicleEx(
|
||||
cc.VIN, api.Vehicles,
|
||||
func(v eudataact.Vehicle) (string, error) {
|
||||
return v.Vin(), nil
|
||||
},
|
||||
)
|
||||
|
||||
if err == nil {
|
||||
v.fromVehicle(vehicle.Name(), 0)
|
||||
v.Provider = eudataact.NewProvider(api, vehicle.Vin(), cc.Cache)
|
||||
}
|
||||
|
||||
return v, err
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ var acceptable = []string{
|
|||
"unexpected status: 401",
|
||||
"discussions/17501", // Tesla
|
||||
"login failed: code not found", // Polestar
|
||||
"login failed", // drivesomethinggreater (EU Data Act, eager login)
|
||||
"empty instance type- check for missing usage", // Mercedes
|
||||
"connect: connection refused", // MQTT
|
||||
}
|
||||
|
|
|
|||
317
vehicle/vw/eudataact/api.go
Normal file
317
vehicle/vw/eudataact/api.go
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
package eudataact
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/evcc-io/evcc/util/request"
|
||||
"github.com/evcc-io/evcc/vehicle/vag/vwidentity"
|
||||
"github.com/samber/lo"
|
||||
"golang.org/x/net/publicsuffix"
|
||||
)
|
||||
|
||||
// https://github.com/TA2k/ioBroker.vw-connect (lib/euDataAct.js)
|
||||
|
||||
const (
|
||||
// BaseURL is the EU Data Act portal that delivers the mandated vehicle datasets
|
||||
BaseURL = "https://eu-data-act.drivesomethinggreater.com"
|
||||
// RedirectURI is the OIDC redirect target registered for the portal
|
||||
RedirectURI = BaseURL + "/login"
|
||||
// Scope is the OIDC scope requested for the EU Data Act flow
|
||||
Scope = "openid cars profile"
|
||||
)
|
||||
|
||||
var portalHost = strings.TrimPrefix(BaseURL, "https://")
|
||||
|
||||
// API is the EU Data Act portal client. It authenticates through the VW group
|
||||
// identity service and reads vehicle data from the portal's data delivery API.
|
||||
//
|
||||
// Unlike the WeConnect/BFF APIs the portal is not a live telemetry service: it
|
||||
// stores a dataset (a zipped JSON document) roughly every 15 minutes that the
|
||||
// user has to enable once in the browser. Reading vehicle data means downloading
|
||||
// the newest dataset and decoding its flat list of data points.
|
||||
type API struct {
|
||||
*request.Helper
|
||||
brand brand
|
||||
user, password string
|
||||
}
|
||||
|
||||
// NewAPI creates an EU Data Act client and performs the initial login
|
||||
func NewAPI(log *util.Logger, brandName, user, password string) (*API, error) {
|
||||
b, ok := resolveBrand(brandName)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown brand: %s", brandName)
|
||||
}
|
||||
|
||||
v := &API{
|
||||
Helper: request.NewHelper(log),
|
||||
brand: b,
|
||||
user: user,
|
||||
password: password,
|
||||
}
|
||||
|
||||
if err := v.login(); err != nil {
|
||||
return nil, fmt.Errorf("login failed: %w", err)
|
||||
}
|
||||
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// login performs the OIDC authorization-code flow against the VW identity
|
||||
// service. The portal relies on the session cookies that are set while the
|
||||
// browser follows the redirect chain back to RedirectURI, so the cookie jar is
|
||||
// kept on the client for all subsequent data calls.
|
||||
func (v *API) login() error {
|
||||
jar, err := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
v.Client.Jar = jar
|
||||
v.Client.CheckRedirect = func(req *http.Request, _ []*http.Request) error {
|
||||
// follow the https redirect chain to the portal, stop at app schemes
|
||||
if req.URL.Scheme != "https" {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// prime the portal session (best effort)
|
||||
if resp, err := v.Get(BaseURL + "/"); err == nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// start the OIDC authorize flow
|
||||
q := url.Values{
|
||||
"client_id": {v.brand.clientID},
|
||||
"response_type": {"code"},
|
||||
"scope": {Scope},
|
||||
"state": {fmt.Sprintf("de__en__%s", v.brand.state)},
|
||||
"redirect_uri": {RedirectURI},
|
||||
"prompt": {"login"},
|
||||
"nonce": {lo.RandomString(43, lo.LettersCharset)},
|
||||
}
|
||||
|
||||
resp, err := v.Get(vwidentity.Config.AuthURL + "?" + q.Encode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// email/identifier step
|
||||
vars, err := vwidentity.FormValues(bytes.NewReader(body), "form#emailPasswordForm")
|
||||
if err != nil {
|
||||
return fmt.Errorf("identifier form: %w (portal layout may have changed)", err)
|
||||
}
|
||||
|
||||
uri := vwidentity.BaseURL + vars.Action
|
||||
resp, err = v.PostForm(uri, url.Values{
|
||||
"_csrf": {vars.Inputs["_csrf"]},
|
||||
"relayState": {vars.Inputs["relayState"]},
|
||||
"hmac": {vars.Inputs["hmac"]},
|
||||
"email": {v.user},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
params, err := vwidentity.ParseCredentialsPage(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("credentials page: %w", err)
|
||||
}
|
||||
if params.TemplateModel.Error != "" {
|
||||
return errors.New(params.TemplateModel.Error)
|
||||
}
|
||||
|
||||
// password/authenticate step - the client follows the redirect chain back to
|
||||
// the portal which sets the session cookie
|
||||
uri = strings.ReplaceAll(uri, params.TemplateModel.IdentifierUrl, params.TemplateModel.PostAction)
|
||||
resp, err = v.PostForm(uri, url.Values{
|
||||
"_csrf": {params.CsrfToken},
|
||||
"relayState": {params.TemplateModel.RelayState},
|
||||
"hmac": {params.TemplateModel.Hmac},
|
||||
"email": {v.user},
|
||||
"password": {v.password},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
return errors.New(resp.Status)
|
||||
}
|
||||
|
||||
// a successful login lands on the portal; a remaining signin/consent url means
|
||||
// the user has not completed the one-time browser consent and vehicle linking
|
||||
final := resp.Request.URL
|
||||
if strings.Contains(final.Path, "signin-service") || strings.Contains(final.Path, "/consent") || strings.Contains(final.Path, "/error") {
|
||||
return api.UrlError(
|
||||
fmt.Sprintf("login did not complete- open the portal and confirm consent: %s", final),
|
||||
final,
|
||||
)
|
||||
}
|
||||
if final.Host != portalHost {
|
||||
return fmt.Errorf("login did not complete: unexpected landing host %s", final.Host)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// get executes a GET request, re-authenticating once on 401/403, and returns the body
|
||||
func (v *API) get(uri string, headers map[string]string) ([]byte, error) {
|
||||
req, err := request.New(http.MethodGet, uri, nil, headers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := v.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
resp.Body.Close()
|
||||
|
||||
if err := v.login(); err != nil {
|
||||
return nil, fmt.Errorf("login failed: %w", err)
|
||||
}
|
||||
|
||||
if req, err = request.New(http.MethodGet, uri, nil, headers); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp, err = v.Do(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return request.ReadBody(resp)
|
||||
}
|
||||
|
||||
// getJSON executes a GET request and decodes the JSON response
|
||||
func (v *API) getJSON(uri string, res any) error {
|
||||
b, err := v.get(uri, map[string]string{"Accept": request.JSONContent})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(b, res)
|
||||
}
|
||||
|
||||
// Vehicles enumerates the vehicles the user has linked to the portal
|
||||
func (v *API) Vehicles() ([]Vehicle, error) {
|
||||
uri := BaseURL + "/proxy_api/consent/me/vehicles?viewPosition=FRONT_LEFT"
|
||||
|
||||
b, err := v.get(uri, map[string]string{"Accept": request.JSONContent})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// the response is either a bare array or wrapped in {"vehicles": [...]}
|
||||
var arr []Vehicle
|
||||
if err := json.Unmarshal(b, &arr); err == nil && len(arr) > 0 {
|
||||
return arr, nil
|
||||
}
|
||||
|
||||
var wrap struct {
|
||||
Vehicles []Vehicle `json:"vehicles"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &wrap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return wrap.Vehicles, nil
|
||||
}
|
||||
|
||||
// identifier returns the data-request identifier required for the data delivery calls
|
||||
func (v *API) identifier(vin string) (string, error) {
|
||||
uri := fmt.Sprintf("%s/proxy_api/euda-apim/datarequest/vehicles/%s/metadata/partial", BaseURL, vin)
|
||||
|
||||
var res struct {
|
||||
Identifier string `json:"Identifier"`
|
||||
}
|
||||
if err := v.getJSON(uri, &res); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if res.Identifier == "" {
|
||||
return "", errors.New("no data request configured for vehicle")
|
||||
}
|
||||
|
||||
return res.Identifier, nil
|
||||
}
|
||||
|
||||
// datasets lists the available datasets for the given data request
|
||||
func (v *API) datasets(vin, identifier string) ([]dataset, error) {
|
||||
uri := fmt.Sprintf("%s/proxy_api/euda-apim/datadelivery/vehicles/%s/%s/list", BaseURL, vin, identifier)
|
||||
|
||||
b, err := v.get(uri, map[string]string{"Accept": request.JSONContent, "type": "partial"})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var arr []dataset
|
||||
if err := json.Unmarshal(b, &arr); err == nil && len(arr) > 0 {
|
||||
return arr, nil
|
||||
}
|
||||
|
||||
var wrap struct {
|
||||
Files []dataset `json:"files"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &wrap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return wrap.Files, nil
|
||||
}
|
||||
|
||||
// download fetches the dataset zip archive
|
||||
func (v *API) download(vin, identifier, name string) ([]byte, error) {
|
||||
uri := fmt.Sprintf("%s/proxy_api/euda-apim/datadelivery/vehicles/%s/%s/download", BaseURL, vin, identifier)
|
||||
return v.get(uri, map[string]string{"filename": name, "type": "partial"})
|
||||
}
|
||||
|
||||
// Status downloads the newest dataset for the vehicle and decodes its data points
|
||||
// into a map keyed by the dotted field name (e.g. "state_of_charge").
|
||||
func (v *API) Status(vin string) (map[string]string, error) {
|
||||
identifier, err := v.identifier(vin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
list, err := v.datasets(vin, identifier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
name := newestDataset(list)
|
||||
if name == "" {
|
||||
if len(list) > 0 {
|
||||
// the portal only emits "_no_content_found" placeholders while the
|
||||
// vehicle is asleep and has not produced a dataset with content yet
|
||||
return nil, fmt.Errorf("no dataset with content- wake the vehicle via the app: %w", api.ErrNotAvailable)
|
||||
}
|
||||
return nil, api.ErrNotAvailable
|
||||
}
|
||||
|
||||
b, err := v.download(vin, identifier, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return parseDataset(b)
|
||||
}
|
||||
118
vehicle/vw/eudataact/eudataact_test.go
Normal file
118
vehicle/vw/eudataact/eudataact_test.go
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
package eudataact
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// zipJSON builds an in-memory dataset zip containing a single JSON document
|
||||
func zipJSON(t *testing.T, doc datasetFile) []byte {
|
||||
t.Helper()
|
||||
|
||||
raw, err := json.Marshal(doc)
|
||||
require.NoError(t, err)
|
||||
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
w, err := zw.Create("dataset.json")
|
||||
require.NoError(t, err)
|
||||
_, err = w.Write(raw)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, zw.Close())
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestParseDataset(t *testing.T) {
|
||||
doc := datasetFile{
|
||||
VIN: "WVWZZZ123",
|
||||
Data: []dataPoint{
|
||||
{Key: "ffff", DataFieldName: FieldSoc, Value: "73"},
|
||||
{Key: "0001", DataFieldName: FieldSoc, Value: "80"}, // smaller key wins
|
||||
{Key: "aaaa", DataFieldName: FieldOdometer, Value: "12345"},
|
||||
{Key: "bbbb", DataFieldName: FieldRange, Value: "210"},
|
||||
{Key: "cccc", DataFieldName: FieldChargingState, Value: "charging"},
|
||||
{Key: "dddd", DataFieldName: FieldPlugState, Value: "connected"},
|
||||
{Key: "eeee", DataFieldName: FieldTargetSoc, Value: "90"},
|
||||
{Key: "0002", DataFieldName: "", Value: "ignored"}, // empty field name skipped
|
||||
},
|
||||
}
|
||||
|
||||
data, err := parseDataset(zipJSON(t, doc))
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "80", data[FieldSoc], "smallest key must win")
|
||||
assert.Equal(t, "12345", data[FieldOdometer])
|
||||
assert.Equal(t, "210", data[FieldRange])
|
||||
|
||||
p := &Provider{statusG: func() (map[string]string, error) { return data, nil }}
|
||||
|
||||
soc, err := p.Soc()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 80.0, soc)
|
||||
|
||||
rng, err := p.Range()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(210), rng)
|
||||
|
||||
odo, err := p.Odometer()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 12345.0, odo)
|
||||
|
||||
status, err := p.Status()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, api.StatusC, status)
|
||||
|
||||
limit, err := p.GetLimitSoc()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(90), limit)
|
||||
}
|
||||
|
||||
func TestStatusPlugStates(t *testing.T) {
|
||||
tc := []struct {
|
||||
plug, charge string
|
||||
expected api.ChargeStatus
|
||||
}{
|
||||
{"", "", api.StatusA},
|
||||
{"disconnected", "off", api.StatusA},
|
||||
{"connected", "readyForCharging", api.StatusB},
|
||||
{"connected", "charging", api.StatusC},
|
||||
}
|
||||
|
||||
for _, tc := range tc {
|
||||
data := map[string]string{FieldPlugState: tc.plug, FieldChargingState: tc.charge}
|
||||
p := &Provider{statusG: func() (map[string]string, error) { return data, nil }}
|
||||
|
||||
status, err := p.Status()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expected, status, "plug=%q charge=%q", tc.plug, tc.charge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveBrand(t *testing.T) {
|
||||
for _, name := range []string{"audi", "AUDI", "Audi", "aUdI"} {
|
||||
b, ok := resolveBrand(name)
|
||||
require.True(t, ok, "brand %q must resolve", name)
|
||||
assert.Equal(t, brands["Audi"], b)
|
||||
}
|
||||
|
||||
_, ok := resolveBrand("nope")
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestNewestDataset(t *testing.T) {
|
||||
list := []dataset{
|
||||
{Name: "2026-05-31T08-00.zip", CreatedOn: "2026-05-31T08:00:00Z"},
|
||||
{Name: "2026-05-31T09-00.zip", CreatedOn: "2026-05-31T09:00:00Z"},
|
||||
{Name: "2026-05-31T09-15_no_content_found.zip", CreatedOn: "2026-05-31T09:15:00Z"},
|
||||
}
|
||||
|
||||
assert.Equal(t, "2026-05-31T09-00.zip", newestDataset(list), "newest with content, no-content skipped")
|
||||
assert.Empty(t, newestDataset([]dataset{{Name: "x_no_content_found.zip"}}))
|
||||
}
|
||||
122
vehicle/vw/eudataact/provider.go
Normal file
122
vehicle/vw/eudataact/provider.go
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
package eudataact
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
)
|
||||
|
||||
// Provider implements the vehicle api on top of the EU Data Act dataset
|
||||
type Provider struct {
|
||||
statusG func() (map[string]string, error)
|
||||
}
|
||||
|
||||
// NewProvider creates a vehicle api provider
|
||||
func NewProvider(api *API, vin string, cache time.Duration) *Provider {
|
||||
return &Provider{
|
||||
statusG: util.Cached(func() (map[string]string, error) {
|
||||
return api.Status(vin)
|
||||
}, cache),
|
||||
}
|
||||
}
|
||||
|
||||
// lookup returns the first present, non-empty value among the given field names
|
||||
func lookup(data map[string]string, fields ...string) (string, bool) {
|
||||
for _, f := range fields {
|
||||
if v, ok := data[f]; ok && v != "" {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
var _ api.Battery = (*Provider)(nil)
|
||||
|
||||
// Soc implements the api.Battery interface
|
||||
func (v *Provider) Soc() (float64, error) {
|
||||
data, err := v.statusG()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if s, ok := lookup(data, FieldSoc, FieldHvSoc); ok {
|
||||
return strconv.ParseFloat(s, 64)
|
||||
}
|
||||
|
||||
return 0, api.ErrNotAvailable
|
||||
}
|
||||
|
||||
var _ api.VehicleRange = (*Provider)(nil)
|
||||
|
||||
// Range implements the api.VehicleRange interface
|
||||
func (v *Provider) Range() (int64, error) {
|
||||
data, err := v.statusG()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if s, ok := lookup(data, FieldRange, FieldRangePrimary); ok {
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
return int64(f), err
|
||||
}
|
||||
|
||||
return 0, api.ErrNotAvailable
|
||||
}
|
||||
|
||||
var _ api.VehicleOdometer = (*Provider)(nil)
|
||||
|
||||
// Odometer implements the api.VehicleOdometer interface
|
||||
func (v *Provider) Odometer() (float64, error) {
|
||||
data, err := v.statusG()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if s, ok := lookup(data, FieldOdometer); ok {
|
||||
return strconv.ParseFloat(s, 64)
|
||||
}
|
||||
|
||||
return 0, api.ErrNotAvailable
|
||||
}
|
||||
|
||||
var _ api.ChargeState = (*Provider)(nil)
|
||||
|
||||
// Status implements the api.ChargeState interface
|
||||
func (v *Provider) Status() (api.ChargeStatus, error) {
|
||||
status := api.StatusA // disconnected
|
||||
|
||||
data, err := v.statusG()
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
|
||||
if s, ok := lookup(data, FieldPlugState); ok && strings.EqualFold(s, "connected") {
|
||||
status = api.StatusB
|
||||
}
|
||||
|
||||
if s, ok := lookup(data, FieldChargingState); ok && strings.EqualFold(s, "charging") {
|
||||
status = api.StatusC
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
var _ api.SocLimiter = (*Provider)(nil)
|
||||
|
||||
// GetLimitSoc implements the api.SocLimiter interface
|
||||
func (v *Provider) GetLimitSoc() (int64, error) {
|
||||
data, err := v.statusG()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if s, ok := lookup(data, FieldTargetSoc); ok {
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
return int64(f), err
|
||||
}
|
||||
|
||||
return 0, api.ErrNotAvailable
|
||||
}
|
||||
178
vehicle/vw/eudataact/types.go
Normal file
178
vehicle/vw/eudataact/types.go
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
package eudataact
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// brand holds the OIDC client id and state suffix for a VW group brand.
|
||||
// All brands share the same portal and endpoints and differ only in the
|
||||
// identity client id (see lib/euDataAct.js BRAND_CLIENT_IDS).
|
||||
type brand struct {
|
||||
clientID string
|
||||
state string
|
||||
}
|
||||
|
||||
// brands maps the configurable brand name to its identity parameters
|
||||
var brands = map[string]brand{
|
||||
"Volkswagen": {"9b58543e-1c15-4193-91d5-8a14145bebb0@apps_vw-dilab_com", "VOLKSWAGEN_PASSENGER_CARS"},
|
||||
"Audi": {"cc29b87a-5e9a-4362-aecf-5adea6b01bbb@apps_vw-dilab_com", "AUDI"},
|
||||
"Skoda": {"3ea88bf9-1d4e-4a68-b3ad-4098c1f1d246@apps_vw-dilab_com", "SKODA"},
|
||||
"Seat": {"f85e5b69-e3b2-43aa-9c0d-1b7d0e0b576f@apps_vw-dilab_com", "SEAT"},
|
||||
"Cupra": {"f85e5b69-e3b2-43aa-9c0d-1b7d0e0b576f@apps_vw-dilab_com", "CUPRA"},
|
||||
}
|
||||
|
||||
// Brands returns the supported brand names
|
||||
func Brands() []string {
|
||||
return []string{"Volkswagen", "Audi", "Skoda", "Seat", "Cupra"}
|
||||
}
|
||||
|
||||
// resolveBrand looks up a brand by name, case-insensitively
|
||||
func resolveBrand(name string) (brand, bool) {
|
||||
for k, b := range brands {
|
||||
if strings.EqualFold(k, name) {
|
||||
return b, true
|
||||
}
|
||||
}
|
||||
return brand{}, false
|
||||
}
|
||||
|
||||
// Vehicle is a single entry of the portal vehicle list. VIN and name carry
|
||||
// several alternative field names depending on the response variant.
|
||||
type Vehicle struct {
|
||||
VIN string `json:"vin"`
|
||||
VehicleIdentificationNumber string `json:"vehicleIdentificationNumber"`
|
||||
NickName string `json:"nickName"`
|
||||
VehicleNickname string `json:"vehicleNickname"`
|
||||
Nickname string `json:"nickname"`
|
||||
ModelName string `json:"modelName"`
|
||||
}
|
||||
|
||||
// Vin returns the vehicle identification number from whichever field is set
|
||||
func (v Vehicle) Vin() string {
|
||||
if v.VIN != "" {
|
||||
return v.VIN
|
||||
}
|
||||
return v.VehicleIdentificationNumber
|
||||
}
|
||||
|
||||
// Name returns the first non-empty display name
|
||||
func (v Vehicle) Name() string {
|
||||
for _, s := range []string{v.NickName, v.VehicleNickname, v.Nickname, v.ModelName} {
|
||||
if s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// dataset describes a single delivered dataset file
|
||||
type dataset struct {
|
||||
Name string `json:"name"`
|
||||
CreatedOn string `json:"createdOn"`
|
||||
}
|
||||
|
||||
// sortKey returns the value used to find the newest dataset
|
||||
func (d dataset) sortKey() string {
|
||||
if d.CreatedOn != "" {
|
||||
return d.CreatedOn
|
||||
}
|
||||
return d.Name
|
||||
}
|
||||
|
||||
// dataPoint is a single decoded telemetry value of a dataset
|
||||
type dataPoint struct {
|
||||
Key string `json:"key"`
|
||||
DataFieldName string `json:"dataFieldName"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// datasetFile is the JSON document contained in a dataset zip archive
|
||||
type datasetFile struct {
|
||||
VIN string `json:"vin"`
|
||||
Data []dataPoint `json:"Data"`
|
||||
}
|
||||
|
||||
// data field names as delivered in the dataset (see lib/euDataActDictionary.json)
|
||||
const (
|
||||
FieldSoc = "state_of_charge"
|
||||
FieldHvSoc = "hv_soc"
|
||||
FieldRange = "cruising_range_combined"
|
||||
FieldRangePrimary = "cruising_range_primary_engine"
|
||||
FieldOdometer = "mileage"
|
||||
FieldChargingState = "charging_state"
|
||||
FieldPlugState = "charging_plug1_connectionstate"
|
||||
FieldTargetSoc = "settings.target_soc"
|
||||
)
|
||||
|
||||
// newestDataset returns the name of the most recent dataset that actually
|
||||
// carries content. The portal emits "..._no_content_found.zip" placeholders
|
||||
// while the vehicle is asleep, which are skipped.
|
||||
func newestDataset(list []dataset) string {
|
||||
var best dataset
|
||||
for _, d := range list {
|
||||
if strings.HasSuffix(strings.ToLower(d.Name), "_no_content_found.zip") {
|
||||
continue
|
||||
}
|
||||
if best.Name == "" || d.sortKey() > best.sortKey() {
|
||||
best = d
|
||||
}
|
||||
}
|
||||
return best.Name
|
||||
}
|
||||
|
||||
// parseDataset extracts the inner JSON document from the dataset zip archive and
|
||||
// decodes it into a map keyed by the dotted data field name. On duplicate field
|
||||
// names the entry with the smallest key uuid wins, matching the adapter.
|
||||
func parseDataset(b []byte) (map[string]string, error) {
|
||||
zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var file *zip.File
|
||||
for _, f := range zr.File {
|
||||
if strings.HasSuffix(strings.ToLower(f.Name), ".json") {
|
||||
file = f
|
||||
break
|
||||
}
|
||||
}
|
||||
if file == nil {
|
||||
return nil, errors.New("no json document in dataset")
|
||||
}
|
||||
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
raw, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var ds datasetFile
|
||||
if err := json.Unmarshal(raw, &ds); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := make(map[string]string, len(ds.Data))
|
||||
keys := make(map[string]string, len(ds.Data))
|
||||
for _, p := range ds.Data {
|
||||
if p.DataFieldName == "" {
|
||||
continue
|
||||
}
|
||||
if k, ok := keys[p.DataFieldName]; ok && k <= p.Key {
|
||||
continue
|
||||
}
|
||||
res[p.DataFieldName] = p.Value
|
||||
keys[p.DataFieldName] = p.Key
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue