diff --git a/util/request/roundtrip.go b/util/request/roundtrip.go index c8f7272a3..39dd301a4 100644 --- a/util/request/roundtrip.go +++ b/util/request/roundtrip.go @@ -3,6 +3,7 @@ package request import ( "bytes" "io" + "mime" "net/http" "net/http/httputil" "strconv" @@ -100,6 +101,13 @@ func headerContainsToken(h http.Header, key, token string) bool { return false } +// binaryContent reports whether the response carries a binary body whose raw +// bytes are not useful in the trace log, such as a downloaded archive. +func binaryContent(h http.Header) bool { + mediatype, _, _ := mime.ParseMediaType(h.Get("Content-Type")) + return mediatype == "application/octet-stream" +} + // copy of http.drainBody func drainBody(b io.ReadCloser) (r1, r2 io.ReadCloser, err error) { if b == nil || b == http.NoBody { @@ -174,12 +182,14 @@ func (r *roundTripper) RoundTrip(req *http.Request) (*http.Response, error) { resMetric.WithLabelValues(req.URL.Hostname(), strconv.Itoa(resp.StatusCode)).Add(1) if !isWebSocketReq { + // skip binary bodies (e.g. downloaded archives) - their raw bytes are noise in the trace log + logBody := !binaryContent(resp.Header) if LogHeaders { - if body, err := httputil.DumpResponse(resp, true); err == nil { + if body, err := httputil.DumpResponse(resp, logBody); err == nil { bld.WriteString("\n\n") bld.Write(bytes.TrimSpace(body[:min(LogMaxLen, len(body))])) } - } else { + } else if logBody { if save, resp.Body, err = drainBody(resp.Body); err == nil { err = dump(save, bld) } diff --git a/vehicle/vw/eudataact/eudataact_test.go b/vehicle/vw/eudataact/eudataact_test.go index a4328a045..678ab023e 100644 --- a/vehicle/vw/eudataact/eudataact_test.go +++ b/vehicle/vw/eudataact/eudataact_test.go @@ -120,36 +120,12 @@ func TestResolveBrand(t *testing.T) { assert.False(t, ok) } -func TestContentDatasets(t *testing.T) { - list := []dataset{ - {Name: "20260531090000_WVWZZZ.zip"}, - {Name: "20260531080000_WVWZZZ.zip"}, - {Name: "20260531091500_WVWZZZ_no_content_found.zip"}, - } - - content, err := contentDatasets(list) - require.NoError(t, err) - require.Len(t, content, 2, "no-content placeholder dropped") - assert.Equal(t, "20260531080000_WVWZZZ.zip", content[0].Name, "oldest first") - assert.Equal(t, "20260531090000_WVWZZZ.zip", content[1].Name, "newest last") - assert.Equal(t, time.Date(2026, 5, 31, 8, 0, 0, 0, time.UTC), content[0].Timestamp, "timestamp parsed") - - // no-content placeholders are skipped without parsing - empty, err := contentDatasets([]dataset{{Name: "x_no_content_found.zip"}}) - require.NoError(t, err) - assert.Empty(t, empty) - - // a content dataset with an unparseable timestamp is an error - _, err = contentDatasets([]dataset{{Name: "no-timestamp.zip"}}) - require.Error(t, err) -} - func TestPending(t *testing.T) { content := make([]dataset, 0, 11) for i := range 10 { content = append(content, dataset{ Name: fmt.Sprintf("20260531%02d0000_WVWZZZ.zip", i), - Timestamp: time.Date(2026, 5, 31, i, 0, 0, 0, time.UTC), + CreatedOn: time.Date(2026, 5, 31, i, 0, 0, 0, time.UTC), }) // hour i, oldest first } @@ -163,13 +139,13 @@ func TestPending(t *testing.T) { assert.Len(t, pending(content[:3], time.Time{}), 3) // high-water at the newest merged dataset: nothing new to download - after := content[len(content)-1].Timestamp + after := content[len(content)-1].CreatedOn assert.Empty(t, pending(content, after)) // a newer dataset arrives content = append(content, dataset{ Name: "20260531100000_WVWZZZ.zip", - Timestamp: time.Date(2026, 5, 31, 10, 0, 0, 0, time.UTC), + CreatedOn: time.Date(2026, 5, 31, 10, 0, 0, 0, time.UTC), }) got = pending(content, after) require.Len(t, got, 1) @@ -197,32 +173,6 @@ func TestMerge(t *testing.T) { assert.Equal(t, "200", dst[FieldRange].Value, "new field added") } -func TestDatasetTime(t *testing.T) { - ref := time.Date(2026, 5, 31, 8, 0, 0, 0, time.UTC) - - tc := []struct { - d dataset - expected time.Time - err bool - }{ - {dataset{Name: "20260531080000_WVWZZZ_no_content_found.zip"}, ref, false}, // real portal format - {dataset{Name: "20260531080000_WVWZZZ.zip"}, ref, false}, // content file - {dataset{CreatedOn: "2026-05-31T08:00:00Z"}, ref, false}, // createdOn fallback - {dataset{CreatedOn: "2026-05-31T08:00:00Z", Name: "no-stamp.zip"}, ref, false}, // name unparseable, createdOn used - {dataset{Name: "no-timestamp.zip"}, time.Time{}, true}, // nothing parseable - } - - for _, tc := range tc { - got, err := tc.d.time() - if tc.err { - assert.Error(t, err, "dataset %+v", tc.d) - continue - } - require.NoError(t, err, "dataset %+v", tc.d) - assert.Equal(t, tc.expected, got, "dataset %+v", tc.d) - } -} - // TestResetDelay verifies the cache reset is scheduled for when the portal is // expected to deliver the dataset following the one just read. func TestResetDelay(t *testing.T) { diff --git a/vehicle/vw/eudataact/provider.go b/vehicle/vw/eudataact/provider.go index 53269fcd4..77dae58ba 100644 --- a/vehicle/vw/eudataact/provider.go +++ b/vehicle/vw/eudataact/provider.go @@ -64,13 +64,13 @@ func resetDelay(ts, now time.Time) time.Duration { } // lookup returns the first present, non-empty value among the given field names -func lookup(data map[string]point, fields ...string) (string, bool) { +func lookup(data map[string]point, fields ...string) *point { for _, f := range fields { - if v, ok := data[f]; ok && v.Value != "" { - return v.Value, true + if v, ok := data[f]; ok { + return new(v) } } - return "", false + return nil } var _ api.Battery = (*Provider)(nil) @@ -82,8 +82,8 @@ func (v *Provider) Soc() (float64, error) { return 0, err } - if s, ok := lookup(data, FieldSoc, FieldHvSoc); ok { - return strconv.ParseFloat(s, 64) + if p := lookup(data, FieldSoc, FieldHvSoc); p != nil { + return strconv.ParseFloat(p.Value, 64) } return 0, api.ErrNotAvailable @@ -98,14 +98,32 @@ func (v *Provider) Range() (int64, error) { return 0, err } - if s, ok := lookup(data, FieldRange, FieldRangePrimary); ok { - f, err := strconv.ParseFloat(s, 64) + if p := lookup(data, FieldRange, FieldRangePrimary); p != nil { + f, err := strconv.ParseFloat(p.Value, 64) return int64(f), err } return 0, api.ErrNotAvailable } +var _ api.VehicleFinishTimer = (*Provider)(nil) + +// FinishTime implements the api.VehicleFinishTimer interface +func (v *Provider) FinishTime() (time.Time, error) { + data, err := v.statusG() + if err != nil { + return time.Time{}, err + } + + if p := lookup(data, FieldRemainingTime); p != nil && p.Value != "65535" { + if v, err := strconv.ParseInt(p.Value, 0, 64); err == nil { + return p.Timestamp.Add(time.Duration(v) * time.Minute), nil + } + } + + return time.Time{}, api.ErrNotAvailable +} + var _ api.VehicleOdometer = (*Provider)(nil) // Odometer implements the api.VehicleOdometer interface @@ -115,8 +133,8 @@ func (v *Provider) Odometer() (float64, error) { return 0, err } - if s, ok := lookup(data, FieldOdometer); ok { - return strconv.ParseFloat(s, 64) + if p := lookup(data, FieldOdometer); p != nil { + return strconv.ParseFloat(p.Value, 64) } return 0, api.ErrNotAvailable @@ -133,11 +151,11 @@ func (v *Provider) Status() (api.ChargeStatus, error) { return status, err } - if s, ok := lookup(data, FieldPlugState); ok && strings.EqualFold(s, "connected") { + if p := lookup(data, FieldPlugState); p != nil && strings.EqualFold(p.Value, "connected") { status = api.StatusB } - if s, ok := lookup(data, FieldChargingState); ok && strings.EqualFold(s, "charging") { + if p := lookup(data, FieldChargingState); p != nil && strings.EqualFold(p.Value, "charging") { status = api.StatusC } @@ -153,8 +171,8 @@ func (v *Provider) GetLimitSoc() (int64, error) { return 0, err } - if s, ok := lookup(data, FieldTargetSoc); ok { - f, err := strconv.ParseFloat(s, 64) + if p := lookup(data, FieldTargetSoc); p != nil { + f, err := strconv.ParseFloat(p.Value, 64) return int64(f), err } diff --git a/vehicle/vw/eudataact/store.go b/vehicle/vw/eudataact/store.go index 1b4270ee7..d25277ef2 100644 --- a/vehicle/vw/eudataact/store.go +++ b/vehicle/vw/eudataact/store.go @@ -66,12 +66,8 @@ func (s *store) update() (time.Time, error) { var newest time.Time for _, d := range list { - t, err := d.time() - if err != nil { - return time.Time{}, err - } - if t.After(newest) { - newest = t + if d.CreatedOn.After(newest) { + newest = d.CreatedOn } } @@ -94,8 +90,8 @@ func (s *store) update() (time.Time, error) { merge(s.data, data) // advance the high-water mark so this dataset is never downloaded again - if d.Timestamp.After(s.after) { - s.after = d.Timestamp + if d.CreatedOn.After(s.after) { + s.after = d.CreatedOn } if !initial { @@ -146,7 +142,7 @@ func pending(content []dataset, after time.Time) []dataset { res := make([]dataset, 0, len(content)) for _, d := range content { - if d.Timestamp.After(after) { + if d.CreatedOn.After(after) { res = append(res, d) } } diff --git a/vehicle/vw/eudataact/types.go b/vehicle/vw/eudataact/types.go index 0a0c3e806..130d4c685 100644 --- a/vehicle/vw/eudataact/types.go +++ b/vehicle/vw/eudataact/types.go @@ -5,7 +5,6 @@ import ( "bytes" "encoding/json" "errors" - "fmt" "io" "slices" "strings" @@ -78,26 +77,7 @@ func (v Vehicle) Name() string { // createdOn field. type dataset struct { Name string `json:"name"` - CreatedOn string `json:"createdOn"` - Timestamp time.Time `json:"-"` -} - -// nameTime parses the compact timestamp the portal prefixes to a dataset file -// name, e.g. 20260531102941_WAUZZZ..._no_content_found.zip. -func nameTime(name string) (time.Time, error) { - prefix, _, _ := strings.Cut(name, "_") - return time.Parse("20060102150405", prefix) -} - -// time parses the delivery time the dataset carries. The portal embeds it in the -// file name and also delivers it as the createdOn field; the file name is -// preferred and createdOn is the fallback. An error is returned when neither -// carries a parseable timestamp. -func (d dataset) time() (time.Time, error) { - if t, err := nameTime(d.Name); err == nil { - return t, nil - } - return time.Parse(time.RFC3339, d.CreatedOn) + CreatedOn time.Time `json:"createdOn"` } // dataPoint is a single data point as delivered in the dataset JSON document @@ -129,6 +109,7 @@ const ( FieldChargingState = "charging_state" FieldPlugState = "plug_state" FieldTargetSoc = "settings.target_soc" + FieldRemainingTime = "remaining_charging_time" ) // contentDatasets returns the datasets that actually carry content, with their @@ -143,17 +124,11 @@ func contentDatasets(list []dataset) ([]dataset, error) { continue } - t, err := d.time() - if err != nil { - return nil, fmt.Errorf("dataset %q: %w", d.Name, err) - } - d.Timestamp = t - content = append(content, d) } slices.SortStableFunc(content, func(a, b dataset) int { - return a.Timestamp.Compare(b.Timestamp) + return a.CreatedOn.Compare(b.CreatedOn) }) return content, nil