diff --git a/templates/definition/vehicle/drivesomethinggreater.yaml b/templates/definition/vehicle/drivesomethinggreater.yaml new file mode 100644 index 000000000..3c8646e05 --- /dev/null +++ b/templates/definition/vehicle/drivesomethinggreater.yaml @@ -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" . }} diff --git a/vehicle/drivesomethinggreater.go b/vehicle/drivesomethinggreater.go new file mode 100644 index 000000000..6d17bafcb --- /dev/null +++ b/vehicle/drivesomethinggreater.go @@ -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 +} diff --git a/vehicle/template_test.go b/vehicle/template_test.go index 9f49f3627..76dda1ae9 100644 --- a/vehicle/template_test.go +++ b/vehicle/template_test.go @@ -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 } diff --git a/vehicle/vw/eudataact/api.go b/vehicle/vw/eudataact/api.go new file mode 100644 index 000000000..30415adbf --- /dev/null +++ b/vehicle/vw/eudataact/api.go @@ -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) +} diff --git a/vehicle/vw/eudataact/eudataact_test.go b/vehicle/vw/eudataact/eudataact_test.go new file mode 100644 index 000000000..668a4949f --- /dev/null +++ b/vehicle/vw/eudataact/eudataact_test.go @@ -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"}})) +} diff --git a/vehicle/vw/eudataact/provider.go b/vehicle/vw/eudataact/provider.go new file mode 100644 index 000000000..29fae5263 --- /dev/null +++ b/vehicle/vw/eudataact/provider.go @@ -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 +} diff --git a/vehicle/vw/eudataact/types.go b/vehicle/vw/eudataact/types.go new file mode 100644 index 000000000..f542889ee --- /dev/null +++ b/vehicle/vw/eudataact/types.go @@ -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 +}