Vehicle: add drivesomethinggreater (VW group EU Data Act) (#30362)

This commit is contained in:
andig 2026-05-31 10:46:52 +02:00 • committed by GitHub
parent 72f6861c6e
commit 5407715154
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 862 additions and 0 deletions

View 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" . }}

View 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
}

View file

@ -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
View 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)
}

View 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"}}))
}

View 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
}

View 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
}