From 17c5ebcc72e713836a41fed7959157ce34fbf2ab Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Fri, 22 May 2026 00:14:08 +0200 Subject: [PATCH 0001/1007] Translations update from Hosted Weblate (#30026) --- i18n/cs.json | 41 +++++++++++++++++++++++++++++++++++++---- i18n/et.json | 9 ++++++++- i18n/lt.json | 4 ++++ i18n/sv.json | 9 ++++++++- 4 files changed, 57 insertions(+), 6 deletions(-) diff --git a/i18n/cs.json b/i18n/cs.json index 623cd6747..e88da1be4 100644 --- a/i18n/cs.json +++ b/i18n/cs.json @@ -827,6 +827,7 @@ "allMonths": "Všechny měsíce", "allTimes": "Všechny časy", "cancel": "Zrušit", + "charge": "Nabíjet", "days": "Dny", "edit": "Upravit", "hours": "Hodiny", @@ -1156,17 +1157,24 @@ "update": "Nastavit", "weekdays": "Dny" }, + "continuousStatus": { + "charging": "Rychlé dobití aktivní.", + "connected": "Standardní režim.", + "waitForVehicle": "Rychlé dobití vyžádáno…" + }, "energyflow": { "battery": "Baterie domu", "batteryCharge": "Nabíjení baterie", "batteryDischarge": "Vybíjení baterie", - "batteryForecastEmpty": "prázdný {time}", - "batteryForecastFull": "plný {time}", + "batteryForecastEmpty": "Prázdné od {time}", + "batteryForecastFull": "Plný od {time}", + "batteryForecastNextHigh": "Příští špička", + "batteryForecastNextLow": "Příští nízká", "batteryGridChargeActive": "nabíjení domácí baterie ze sítě je aktivní", "batteryGridChargeLimit": "nabíjení ze sítě, když", "batteryHold": "Baterie (udržování stavu nabití)", "batteryTooltip": "{energy} z {total} ({soc})", - "forecast": "Předpověď ", + "forecast": "Předpověď", "forecastTooltip": "předpověď: zbývající solární výroba dnes", "gridImport": "Odběr ze sítě", "homePower": "Spotřeba domu", @@ -1182,12 +1190,36 @@ "charging": "Ohřívání…", "connected": "Pohotovostní režim.", "vehicleLimit": "Limit topení", - "waitForVehicle": "Připraven. Čekám na odpověď topidla…" + "waitForVehicle": "Připraven k vytápění…" }, "hemsWarning": { "description": "Zpomalené nabíjení zamezující přesáhnutí {limit}.", "title": "Externí limit:" }, + "history": { + "direction": { + "battery": { + "energy": "nabito", + "returnEnergy": "vybito" + }, + "grid": { + "energy": "nakoupeno z distribuce", + "returnEnergy": "prodáno do distribuce" + } + }, + "downloadCsv": "Stáhnout CSV", + "empty": "Pro zvolený úsek nejsou k dispozici historická data.", + "group": { + "battery": "Baterie", + "forecast": "Předpověď", + "grid": "Distribuční síť", + "loadpoint": "Nabíjení a vytápění", + "meter": "Spotřeba", + "pv": "Výroba" + }, + "otherConsumers": "Ostatní", + "title": "Historie" + }, "loadpoint": { "avgPrice": "⌀ Cena", "charged": "Nabito", @@ -1459,6 +1491,7 @@ "odometer": "Kilometry", "overview": "Přehled", "period": { + "day": "Den", "month": "Měsíc", "total": "Celkem", "year": "Rok" diff --git a/i18n/et.json b/i18n/et.json index e476b7600..0f96e8f0d 100644 --- a/i18n/et.json +++ b/i18n/et.json @@ -4,7 +4,14 @@ "authCodeHelp": "Kopeeri see kood ja kasuta teda järgmises sammus. Kehtivus {duration}.", "authorizationFailed": "Autoriseerimine ei õnnestunud", "authorizationRequired": "Autoriseerimine on vajalik", - "authorizationSuccessful": "Autoriseerimine õnnestus" + "authorizationSuccessful": "Autoriseerimine õnnestus", + "buttonConnect": "Ühenda teenusepakkujaga {provider}", + "buttonDisconnect": "Katkesta ühendus", + "confirmLogout": "Kas oled kindel, et soovid katkestada {title} ühenduse?", + "connect": "ühenda", + "disconnect": "katkesta ühendus", + "loggedOut": "Väljalogimine õnnestus", + "logoutFailed": "Väljalogimine ei õnnestunud" }, "batterySettings": { "batteryLevel": "Akutase", diff --git a/i18n/lt.json b/i18n/lt.json index 54e131784..da1aa4dc5 100644 --- a/i18n/lt.json +++ b/i18n/lt.json @@ -46,6 +46,10 @@ "noBattery": "Nėra sukonfigūruotų kaupiklių.", "usageTab": "Kaupiklio naudojimas" }, + "colors": { + "auto": "Auto", + "hex": "Hex spalva" + }, "config": { "aux": { "description": "Įrenginys, kuris koreguoja savo suvartojimą pagal esamą energijos perteklių (pvz., išmanieji vandens šildytuvai). evcc tikisi, kad šis įrenginys prireikus sumažins energijos suvartojimą.", diff --git a/i18n/sv.json b/i18n/sv.json index 058f8c400..06df34a1a 100644 --- a/i18n/sv.json +++ b/i18n/sv.json @@ -46,6 +46,10 @@ "noBattery": "Inga batterier konfigurerade.", "usageTab": "Batterianvändning" }, + "colors": { + "auto": "Bil", + "hex": "Hex-färg" + }, "config": { "aux": { "description": "Enhet som anpassar sin förbrukning baserat på tillgängligt överskott (ex. smarta varmvattenberedare). Evcc förväntar sig att denna enhet reducerar sin förbrukning om det är nödvändigt.", @@ -1171,6 +1175,8 @@ "batteryDischarge": "Batteri laddas ur", "batteryForecastEmpty": "tom {time}", "batteryForecastFull": "full {time}", + "batteryForecastNextHigh": "Nästa höjdpunkt", + "batteryForecastNextLow": "Nästa lågpunkt", "batteryGridChargeActive": "Nätladdning: aktiv", "batteryGridChargeLimit": "Nätladdning: när", "batteryHold": "Batteri (låst)", @@ -1218,7 +1224,8 @@ "meter": "Konsumtion", "pv": "Produktion" }, - "otherConsumers": "Andra" + "otherConsumers": "Andra", + "title": "Historik" }, "loadpoint": { "avgPrice": "⌀ pris", From 827149d4a391ed86c6cafe124eb3d294bd88a045 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 11:26:47 +0200 Subject: [PATCH 0002/1007] chore(deps-dev): bump js-cookie from 3.0.5 to 3.0.7 (#30080) --- package-lock.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index fec569b23..3d9c13826 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8104,13 +8104,13 @@ } }, "node_modules/js-cookie": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", - "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.7.tgz", + "integrity": "sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==", "dev": true, "license": "MIT", "engines": { - "node": ">=14" + "node": ">=20" } }, "node_modules/js-sdsl": { From b41dc59531a4d5a99431c1fe5caf0f76508a2b8c Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 22 May 2026 13:39:04 +0200 Subject: [PATCH 0003/1007] cli: add metrics command (#30099) --- cmd/metrics.go | 113 ++++++++++++++ cmd/metrics_battery.go | 118 ++++++++++++++ cmd/metrics_battery_test.go | 64 ++++++++ cmd/metrics_data.go | 260 +++++++++++++++++++++++++++++++ cmd/metrics_data_test.go | 131 ++++++++++++++++ cmd/metrics_entities.go | 45 ++++++ cmd/metrics_forecast.go | 82 ++++++++++ cmd/metrics_forecast_test.go | 51 ++++++ core/metrics/db_entities.go | 55 +++++++ core/metrics/db_entities_test.go | 47 ++++++ core/metrics/db_history.go | 9 +- 11 files changed, 971 insertions(+), 4 deletions(-) create mode 100644 cmd/metrics.go create mode 100644 cmd/metrics_battery.go create mode 100644 cmd/metrics_battery_test.go create mode 100644 cmd/metrics_data.go create mode 100644 cmd/metrics_data_test.go create mode 100644 cmd/metrics_entities.go create mode 100644 cmd/metrics_forecast.go create mode 100644 cmd/metrics_forecast_test.go create mode 100644 core/metrics/db_entities.go create mode 100644 core/metrics/db_entities_test.go diff --git a/cmd/metrics.go b/cmd/metrics.go new file mode 100644 index 000000000..4b6172aeb --- /dev/null +++ b/cmd/metrics.go @@ -0,0 +1,113 @@ +package cmd + +import ( + "slices" + "strconv" + "strings" + "time" + + "github.com/evcc-io/evcc/core/metrics" + "github.com/evcc-io/evcc/util/config" + "github.com/evcc-io/evcc/util/templates" + "github.com/spf13/cobra" +) + +// metricsCmd represents the metrics command +var metricsCmd = &cobra.Command{ + Use: "metrics", + Short: "Inspect stored energy metrics", +} + +func init() { + rootCmd.AddCommand(metricsCmd) +} + +// setupMetrics loads the config file and opens the database. Both metrics +// subcommands need the metric tables and the device configuration. +func setupMetrics(cmd *cobra.Command) { + if err := loadConfigFile(&conf, !cmd.Flag(flagIgnoreDatabase).Changed); err != nil { + log.FATAL.Fatal(err) + } + + if err := configureDatabase(conf.Database); err != nil { + log.FATAL.Fatal(err) + } +} + +// metricsGroupRank returns the canonical sort position of a metric group. +func metricsGroupRank(group string) int { + if i := slices.Index(metrics.GroupOrder, group); i >= 0 { + return i + } + return len(metrics.GroupOrder) +} + +// metricsSortCanonical sorts entities by canonical group order, then name. +func metricsSortCanonical(entities []metrics.EntityInfo) { + slices.SortFunc(entities, func(a, b metrics.EntityInfo) int { + if d := metricsGroupRank(a.Group) - metricsGroupRank(b.Group); d != 0 { + return d + } + return strings.Compare(a.Name, b.Name) + }) +} + +// metricsFormatDate formats a date for display, empty for the zero time. +func metricsFormatDate(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Local().Format("2006-01-02") +} + +// metricsEntityTitle resolves human-readable titles for metric entities. Titles +// exist only for configured loadpoints and meters; virtual entities (home, +// forecast) have none. The returned function maps an entity to its title, or an +// empty string when no title is configured. +func metricsEntityTitle() func(group, name string) string { + // loadpoints are addressed as lp-, numbered yaml-first then database, + // mirroring configureLoadpoints + loadpoints := make(map[string]string) + idx := 0 + addLoadpoint := func(n config.Named) { + idx++ + if t, ok := n.Property("title").(string); ok && t != "" { + loadpoints["lp-"+strconv.Itoa(idx)] = t + } + } + for _, lp := range conf.Loadpoints { + addLoadpoint(lp) + } + if devices, err := config.ConfigurationsByClass(templates.Loadpoint); err == nil { + for _, dev := range devices { + addLoadpoint(dev.Named()) + } + } + + // meter entities are addressed by their device ref; the title comes from the + // device configuration + meters := make(map[string]string) + for _, m := range conf.Meters { + if t, ok := m.Property("title").(string); ok && t != "" { + meters[m.Name] = t + } + } + if devices, err := config.ConfigurationsByClass(templates.Meter); err == nil { + for _, dev := range devices { + if dev.Title != "" { + meters[config.NameForID(dev.ID)] = dev.Title + } + } + } + + return func(group, name string) string { + switch group { + case metrics.Loadpoint: + return loadpoints[name] + case metrics.Grid, metrics.PV, metrics.Battery, metrics.Meter: + return meters[name] + default: + return "" + } + } +} diff --git a/cmd/metrics_battery.go b/cmd/metrics_battery.go new file mode 100644 index 000000000..7f136ba66 --- /dev/null +++ b/cmd/metrics_battery.go @@ -0,0 +1,118 @@ +package cmd + +import ( + "fmt" + "io" + "os" + "text/tabwriter" + + "github.com/evcc-io/evcc/core/metrics" + "github.com/spf13/cobra" +) + +// metricsBatteryCmd represents the metrics battery command +var metricsBatteryCmd = &cobra.Command{ + Use: "battery [name ...]", + Short: "Compare battery charge and discharge energy", + Long: `Compare charge and discharge energy per battery. + +Without arguments all batteries are compared for the current day. Batteries can +be selected by name or title.`, + Run: runMetricsBattery, +} + +func init() { + metricsCmd.AddCommand(metricsBatteryCmd) + metricsBatteryCmd.Flags().String("range", "", "Quick timeframe: day, month or year") + metricsBatteryCmd.Flags().String("from", "", "Start date as YYYY-MM-DD (default today)") + metricsBatteryCmd.Flags().String("to", "", "End date as YYYY-MM-DD, inclusive (default today)") + metricsBatteryCmd.MarkFlagsMutuallyExclusive("range", "from") + metricsBatteryCmd.MarkFlagsMutuallyExclusive("range", "to") +} + +func runMetricsBattery(cmd *cobra.Command, args []string) { + setupMetrics(cmd) + + from, to, err := metricsTimeframe(cmd.Flag("range").Value.String(), cmd.Flag("from").Value.String(), cmd.Flag("to").Value.String()) + if err != nil { + log.FATAL.Fatal(err) + } + + entities, err := metrics.ListEntities() + if err != nil { + log.FATAL.Fatal(err) + } + + // limit selectable entities to the battery group + batteries := make([]metrics.EntityInfo, 0, len(entities)) + for _, e := range entities { + if e.Group == metrics.Battery { + batteries = append(batteries, e) + } + } + if len(batteries) == 0 { + log.FATAL.Fatal("no battery entities found") + } + + title := metricsEntityTitle() + + selected, err := metricsSelectEntities(batteries, args, "", title) + if err != nil { + log.FATAL.Fatal(err) + } + + series, err := metrics.QueryEnergy(from, to, "month", false) + if err != nil { + log.FATAL.Fatal(err) + } + + metricsWriteBatteryTable(os.Stdout, selected, metricsBatteryTotals(series), title) + fmt.Fprintln(os.Stderr, "\nvalues in kWh") +} + +// batteryTotals holds the accumulated charge and discharge energy of a battery. +// For a battery entity charge is stored as import energy, discharge as export +// energy (see core/site.go updateBatteryMeters). +type batteryTotals struct { + charge float64 + discharge float64 +} + +// metricsBatteryTotals sums charge and discharge energy per battery entity. +func metricsBatteryTotals(series []metrics.Series) map[string]batteryTotals { + res := make(map[string]batteryTotals) + for _, s := range series { + if s.Group != metrics.Battery { + continue + } + t := res[s.Name] + for _, slot := range s.Data { + t.charge += slot.Energy + t.discharge += slot.ReturnEnergy + } + res[s.Name] = t + } + return res +} + +// metricsWriteBatteryTable renders one row per battery comparing charge and +// discharge energy. Efficiency is the discharge/charge ratio, left blank when +// no energy was charged. +func metricsWriteBatteryTable(w io.Writer, selected []metrics.EntityInfo, totals map[string]batteryTotals, title func(group, name string) string) { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "name\ttitle\tcharge\tdischarge\tefficiency") + + for _, e := range selected { + t := totals[e.Name] + + efficiency := "" + if t.charge > 0 { + efficiency = fmt.Sprintf("%.1f%%", t.discharge/t.charge*100) + } + + fmt.Fprintf(tw, "%s\t%s\t%.3f\t%.3f\t%s\n", + e.Name, title(e.Group, e.Name), t.charge, t.discharge, efficiency) + } + + tw.Flush() +} diff --git a/cmd/metrics_battery_test.go b/cmd/metrics_battery_test.go new file mode 100644 index 000000000..bbc61d730 --- /dev/null +++ b/cmd/metrics_battery_test.go @@ -0,0 +1,64 @@ +package cmd + +import ( + "bytes" + "strings" + "testing" + + "github.com/evcc-io/evcc/core/metrics" + "github.com/stretchr/testify/require" +) + +func TestMetricsBatteryTotals(t *testing.T) { + series := []metrics.Series{ + {Group: metrics.Battery, Name: "bat", Data: []metrics.Slot{ + {Energy: 1.0, ReturnEnergy: 0.4}, + {Energy: 2.0, ReturnEnergy: 1.6}, + }}, + // non-battery series must be ignored + {Group: metrics.Grid, Name: "grid", Data: []metrics.Slot{ + {Energy: 5.0, ReturnEnergy: 3.0}, + }}, + } + + totals := metricsBatteryTotals(series) + require.Len(t, totals, 1) + require.InDelta(t, 3.0, totals["bat"].charge, 0.001) + require.InDelta(t, 2.0, totals["bat"].discharge, 0.001) +} + +func TestMetricsWriteBatteryTable(t *testing.T) { + selected := []metrics.EntityInfo{ + {Group: metrics.Battery, Name: "bat1"}, + {Group: metrics.Battery, Name: "bat2"}, + } + totals := map[string]batteryTotals{ + "bat1": {charge: 10.0, discharge: 9.0}, + // bat2 deliberately absent: no data in the timeframe + } + title := func(group, name string) string { + if name == "bat1" { + return "Home" + } + return "" + } + + var buf bytes.Buffer + metricsWriteBatteryTable(&buf, selected, totals, title) + + lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + require.Len(t, lines, 3) // header + 2 rows + + require.Contains(t, lines[0], "efficiency") + + // bat1: title resolved, efficiency = discharge/charge + require.Contains(t, lines[1], "Home") + require.Contains(t, lines[1], "10.000") + require.Contains(t, lines[1], "9.000") + require.Contains(t, lines[1], "90.0%") + + // bat2: no data -> zero totals, blank efficiency + require.Contains(t, lines[2], "bat2") + require.Contains(t, lines[2], "0.000") + require.NotContains(t, lines[2], "%") +} diff --git a/cmd/metrics_data.go b/cmd/metrics_data.go new file mode 100644 index 000000000..270c705e8 --- /dev/null +++ b/cmd/metrics_data.go @@ -0,0 +1,260 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "slices" + "strings" + "text/tabwriter" + "time" + + "github.com/evcc-io/evcc/core/metrics" + "github.com/spf13/cobra" +) + +// metricsDataCmd represents the metrics data command +var metricsDataCmd = &cobra.Command{ + Use: "data [entity ...]", + Short: "Export energy data as a table", + Long: `Export aggregated energy data as a table. + +Without arguments all entities are exported for the current day. Entities can be +selected by name or title; run the entities subcommand to list them.`, + Run: runMetricsData, +} + +func init() { + metricsCmd.AddCommand(metricsDataCmd) + metricsDataCmd.Flags().String("range", "", "Quick timeframe: day, month or year") + metricsDataCmd.Flags().String("from", "", "Start date as YYYY-MM-DD (default today)") + metricsDataCmd.Flags().String("to", "", "End date as YYYY-MM-DD, inclusive (default today)") + metricsDataCmd.Flags().String("aggregate", "hour", "Aggregation interval: 15m, hour, day or month") + metricsDataCmd.Flags().String("group", "", "Limit output to an entity group") + metricsDataCmd.Flags().Bool("csv", false, "Output CSV instead of a table") + metricsDataCmd.MarkFlagsMutuallyExclusive("range", "from") + metricsDataCmd.MarkFlagsMutuallyExclusive("range", "to") +} + +func runMetricsData(cmd *cobra.Command, args []string) { + setupMetrics(cmd) + + group := cmd.Flag("group").Value.String() + if group != "" && len(args) > 0 { + log.FATAL.Fatal("--group and entity arguments are mutually exclusive") + } + + from, to, err := metricsTimeframe(cmd.Flag("range").Value.String(), cmd.Flag("from").Value.String(), cmd.Flag("to").Value.String()) + if err != nil { + log.FATAL.Fatal(err) + } + + aggregate := cmd.Flag("aggregate").Value.String() + + entities, err := metrics.ListEntities() + if err != nil { + log.FATAL.Fatal(err) + } + + title := metricsEntityTitle() + + selected, err := metricsSelectEntities(entities, args, group, title) + if err != nil { + log.FATAL.Fatal(err) + } + + series, err := metrics.QueryEnergy(from, to, aggregate, false) + if err != nil { + log.FATAL.Fatal(err) + } + + byEntity := make(map[string]metrics.Series, len(series)) + for _, s := range series { + byEntity[s.Group+"/"+s.Name] = s + } + + if asCSV, _ := cmd.Flags().GetBool("csv"); asCSV { + var out metrics.SeriesCSV + for _, e := range selected { + if s, ok := byEntity[e.Group+"/"+e.Name]; ok { + out = append(out, s) + } + } + if err := out.WriteCsv(context.Background(), os.Stdout); err != nil { + log.FATAL.Fatal(err) + } + return + } + + metricsWriteTable(os.Stdout, selected, byEntity, title, aggregate) + fmt.Fprintln(os.Stderr, "\nvalues in kWh") +} + +// metricsTimeframe resolves the from/to query bounds. A non-empty range string +// (today, month, year) takes precedence and is mutually exclusive with the +// from/to date flags. The to date is inclusive; an empty timeframe defaults to +// the current day. +func metricsTimeframe(rangeStr, fromStr, toStr string) (time.Time, time.Time, error) { + const layout = "2006-01-02" + + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local) + + if rangeStr != "" { + switch strings.ToLower(rangeStr) { + case "day": + return today, today.AddDate(0, 0, 1), nil + case "month": + from := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.Local) + return from, from.AddDate(0, 1, 0), nil + case "year": + from := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.Local) + return from, from.AddDate(1, 0, 0), nil + default: + return time.Time{}, time.Time{}, fmt.Errorf("invalid --range %q (day, month or year)", rangeStr) + } + } + + from := today + if fromStr != "" { + t, err := time.ParseInLocation(layout, fromStr, time.Local) + if err != nil { + return time.Time{}, time.Time{}, fmt.Errorf("invalid --from date: %w", err) + } + from = t + } + + to := today.AddDate(0, 0, 1) + if toStr != "" { + t, err := time.ParseInLocation(layout, toStr, time.Local) + if err != nil { + return time.Time{}, time.Time{}, fmt.Errorf("invalid --to date: %w", err) + } + to = t.AddDate(0, 0, 1) // inclusive end day + } + + if to.Before(from) { + return time.Time{}, time.Time{}, errors.New("--to must not be before --from") + } + + return from, to, nil +} + +// metricsSelectEntities resolves the entities to export. Without selectors all +// entities (optionally limited to a group) are returned in canonical order; +// explicit selectors match by name or title and preserve the requested order. +func metricsSelectEntities(entities []metrics.EntityInfo, args []string, group string, title func(group, name string) string) ([]metrics.EntityInfo, error) { + if len(args) == 0 { + res := make([]metrics.EntityInfo, 0, len(entities)) + for _, e := range entities { + if group == "" || e.Group == group { + res = append(res, e) + } + } + if group != "" && len(res) == 0 { + return nil, fmt.Errorf("no entities in group %q", group) + } + metricsSortCanonical(res) + return res, nil + } + + var res []metrics.EntityInfo + for _, arg := range args { + var matched []metrics.EntityInfo + for _, e := range entities { + if e.Name == arg || title(e.Group, e.Name) == arg { + matched = append(matched, e) + } + } + if len(matched) == 0 { + return nil, fmt.Errorf("unknown entity %q", arg) + } + res = append(res, matched...) + } + return res, nil +} + +// metricsTimeLayout returns the time column format for the given aggregation. +func metricsTimeLayout(aggregate string) string { + switch aggregate { + case "day": + return "2006-01-02" + case "month": + return "2006-01" + default: // 15m, hour + return "2006-01-02 15:04" + } +} + +// metricsWriteTable renders the wide energy table: one row per time slot, one +// column per entity, plus a second column for the export energy of +// bidirectional entities (grid, battery). +func metricsWriteTable(w io.Writer, selected []metrics.EntityInfo, byEntity map[string]metrics.Series, title func(group, name string) string, aggregate string) { + layout := metricsTimeLayout(aggregate) + + type colSpec struct { + entity metrics.EntityInfo + energyCol int + returnCol int // -1 unless the entity is bidirectional + } + + header := []string{"time"} + var specs []colSpec + + for _, e := range selected { + label := title(e.Group, e.Name) + if label == "" { + label = e.Name + } + + spec := colSpec{entity: e, energyCol: len(header) - 1, returnCol: -1} + header = append(header, label) + + if e.Group == metrics.Grid || e.Group == metrics.Battery { + spec.returnCol = len(header) - 1 + header = append(header, label+"↑") + } + + specs = append(specs, spec) + } + + ncols := len(header) - 1 + + rowByKey := make(map[string][]string) + var rowKeys []string + + cells := func(key string) []string { + if c, ok := rowByKey[key]; ok { + return c + } + c := make([]string, ncols) + rowByKey[key] = c + rowKeys = append(rowKeys, key) + return c + } + + for _, spec := range specs { + s, ok := byEntity[spec.entity.Group+"/"+spec.entity.Name] + if !ok { + continue + } + for _, slot := range s.Data { + row := cells(slot.Start.Local().Format(layout)) + row[spec.energyCol] = fmt.Sprintf("%.3f", slot.Energy) + if spec.returnCol >= 0 { + row[spec.returnCol] = fmt.Sprintf("%.3f", slot.ReturnEnergy) + } + } + } + + slices.Sort(rowKeys) + + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, strings.Join(header, "\t")) + for _, key := range rowKeys { + fmt.Fprintln(tw, key+"\t"+strings.Join(rowByKey[key], "\t")) + } + tw.Flush() +} diff --git a/cmd/metrics_data_test.go b/cmd/metrics_data_test.go new file mode 100644 index 000000000..8df630378 --- /dev/null +++ b/cmd/metrics_data_test.go @@ -0,0 +1,131 @@ +package cmd + +import ( + "bytes" + "strings" + "testing" + "time" + + "github.com/evcc-io/evcc/core/metrics" + "github.com/stretchr/testify/require" +) + +func TestMetricsTimeframe(t *testing.T) { + // explicit range: to is inclusive, so it extends to the end of the named day + from, to, err := metricsTimeframe("", "2026-05-01", "2026-05-03") + require.NoError(t, err) + require.Equal(t, time.Date(2026, 5, 1, 0, 0, 0, 0, time.Local), from) + require.Equal(t, time.Date(2026, 5, 4, 0, 0, 0, 0, time.Local), to) + + // to before from + _, _, err = metricsTimeframe("", "2026-05-05", "2026-05-01") + require.Error(t, err) + + // invalid date + _, _, err = metricsTimeframe("", "notadate", "") + require.Error(t, err) +} + +func TestMetricsTimeframeRange(t *testing.T) { + now := time.Now() + + // day + from, to, err := metricsTimeframe("day", "", "") + require.NoError(t, err) + require.Equal(t, time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local), from) + require.Equal(t, from.AddDate(0, 0, 1), to) + + // month, case-insensitive + from, to, err = metricsTimeframe("Month", "", "") + require.NoError(t, err) + require.Equal(t, time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.Local), from) + require.Equal(t, from.AddDate(0, 1, 0), to) + + // year + from, to, err = metricsTimeframe("year", "", "") + require.NoError(t, err) + require.Equal(t, time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.Local), from) + require.Equal(t, from.AddDate(1, 0, 0), to) + + // invalid range value + _, _, err = metricsTimeframe("decade", "", "") + require.Error(t, err) +} + +func TestMetricsSelectEntities(t *testing.T) { + entities := []metrics.EntityInfo{ + {Group: metrics.Grid, Name: "grid"}, + {Group: metrics.PV, Name: "pv1"}, + {Group: metrics.Loadpoint, Name: "lp-1"}, + } + title := func(group, name string) string { + if group == metrics.Loadpoint && name == "lp-1" { + return "Carport" + } + return "" + } + + // explicit selectors match by name or title and preserve argument order + res, err := metricsSelectEntities(entities, []string{"Carport", "grid"}, "", title) + require.NoError(t, err) + require.Equal(t, []string{"lp-1", "grid"}, []string{res[0].Name, res[1].Name}) + + // unknown selector errors + _, err = metricsSelectEntities(entities, []string{"bogus"}, "", title) + require.Error(t, err) + + // no selectors: all entities in canonical group order (pv before grid) + res, err = metricsSelectEntities(entities, nil, "", title) + require.NoError(t, err) + require.Equal(t, metrics.PV, res[0].Group) + + // empty group errors + _, err = metricsSelectEntities(entities, nil, metrics.Battery, title) + require.Error(t, err) +} + +func TestMetricsWriteTable(t *testing.T) { + h0 := time.Date(2026, 5, 22, 0, 0, 0, 0, time.Local) + h1 := h0.Add(time.Hour) + + selected := []metrics.EntityInfo{ + {Group: metrics.Loadpoint, Name: "lp-1"}, + {Group: metrics.Grid, Name: "grid"}, + } + byEntity := map[string]metrics.Series{ + metrics.Loadpoint + "/lp-1": {Group: metrics.Loadpoint, Name: "lp-1", Data: []metrics.Slot{ + {Start: h0, Energy: 1.84}, + }}, + metrics.Grid + "/grid": {Group: metrics.Grid, Name: "grid", Data: []metrics.Slot{ + {Start: h0, Energy: 0.412}, + {Start: h1, Energy: 0.38, ReturnEnergy: 0.05}, + }}, + } + title := func(group, name string) string { + if group == metrics.Loadpoint { + return "Carport" + } + return "" + } + + var buf bytes.Buffer + metricsWriteTable(&buf, selected, byEntity, title, "hour") + + lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + require.Len(t, lines, 3) // header + 2 rows + + // header: title falls back to name; bidirectional grid gets a return column + require.Contains(t, lines[0], "Carport") + require.Contains(t, lines[0], "grid↑") + + // row 1: both lp-1 and grid have data + require.Contains(t, lines[1], "2026-05-22 00:00") + require.Contains(t, lines[1], "1.840") + require.Contains(t, lines[1], "0.412") + + // row 2: lp-1 has no slot -> blank cell, not 0.000; grid return energy shown + require.Contains(t, lines[2], "2026-05-22 01:00") + require.Contains(t, lines[2], "0.380") + require.Contains(t, lines[2], "0.050") + require.NotContains(t, lines[2], "0.000") +} diff --git a/cmd/metrics_entities.go b/cmd/metrics_entities.go new file mode 100644 index 000000000..13f06b350 --- /dev/null +++ b/cmd/metrics_entities.go @@ -0,0 +1,45 @@ +package cmd + +import ( + "fmt" + "os" + "text/tabwriter" + + "github.com/evcc-io/evcc/core/metrics" + "github.com/spf13/cobra" +) + +// metricsEntitiesCmd represents the metrics entities command +var metricsEntitiesCmd = &cobra.Command{ + Use: "entities", + Short: "List metric entities", + Args: cobra.NoArgs, + Run: runMetricsEntities, +} + +func init() { + metricsCmd.AddCommand(metricsEntitiesCmd) +} + +func runMetricsEntities(cmd *cobra.Command, args []string) { + setupMetrics(cmd) + + entities, err := metrics.ListEntities() + if err != nil { + log.FATAL.Fatal(err) + } + + metricsSortCanonical(entities) + title := metricsEntityTitle() + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "group\tname\ttitle\tslots\tfirst\tlast") + + for _, e := range entities { + fmt.Fprintf(tw, "%s\t%s\t%s\t%d\t%s\t%s\n", + e.Group, e.Name, title(e.Group, e.Name), e.Slots, + metricsFormatDate(e.First), metricsFormatDate(e.Last)) + } + + tw.Flush() +} diff --git a/cmd/metrics_forecast.go b/cmd/metrics_forecast.go new file mode 100644 index 000000000..ad683c92d --- /dev/null +++ b/cmd/metrics_forecast.go @@ -0,0 +1,82 @@ +package cmd + +import ( + "fmt" + "io" + "os" + "text/tabwriter" + + "github.com/evcc-io/evcc/core/metrics" + "github.com/spf13/cobra" +) + +// metricsForecastCmd represents the metrics forecast command +var metricsForecastCmd = &cobra.Command{ + Use: "forecast", + Short: "Compare solar forecast against actual PV production", + Args: cobra.NoArgs, + Run: runMetricsForecast, +} + +func init() { + metricsCmd.AddCommand(metricsForecastCmd) + metricsForecastCmd.Flags().String("range", "", "Quick timeframe: day, month or year") + metricsForecastCmd.Flags().String("from", "", "Start date as YYYY-MM-DD (default today)") + metricsForecastCmd.Flags().String("to", "", "End date as YYYY-MM-DD, inclusive (default today)") + metricsForecastCmd.MarkFlagsMutuallyExclusive("range", "from") + metricsForecastCmd.MarkFlagsMutuallyExclusive("range", "to") +} + +func runMetricsForecast(cmd *cobra.Command, args []string) { + setupMetrics(cmd) + + from, to, err := metricsTimeframe(cmd.Flag("range").Value.String(), cmd.Flag("from").Value.String(), cmd.Flag("to").Value.String()) + if err != nil { + log.FATAL.Fatal(err) + } + + series, err := metrics.QueryEnergy(from, to, "month", false) + if err != nil { + log.FATAL.Fatal(err) + } + + forecast, actual := metricsForecastTotals(series) + + metricsWriteForecastTable(os.Stdout, forecast, actual) + fmt.Fprintln(os.Stderr, "\nvalues in kWh") +} + +// metricsForecastTotals sums forecasted solar energy and actual PV production +// over the given series. +func metricsForecastTotals(series []metrics.Series) (forecast, actual float64) { + for _, s := range series { + var sum float64 + for _, slot := range s.Data { + sum += slot.Energy + } + + switch s.Group { + case metrics.Forecast: + forecast += sum + case metrics.PV: + actual += sum + } + } + return forecast, actual +} + +// metricsWriteForecastTable renders a one-row comparison of forecasted versus +// actual solar energy. Accuracy is the actual/forecast ratio, left blank when +// nothing was forecast. +func metricsWriteForecastTable(w io.Writer, forecast, actual float64) { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "forecast\tactual\taccuracy") + + accuracy := "" + if forecast > 0 { + accuracy = fmt.Sprintf("%.1f%%", actual/forecast*100) + } + fmt.Fprintf(tw, "%.3f\t%.3f\t%s\n", forecast, actual, accuracy) + + tw.Flush() +} diff --git a/cmd/metrics_forecast_test.go b/cmd/metrics_forecast_test.go new file mode 100644 index 000000000..da0e69284 --- /dev/null +++ b/cmd/metrics_forecast_test.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "bytes" + "strings" + "testing" + + "github.com/evcc-io/evcc/core/metrics" + "github.com/stretchr/testify/require" +) + +func TestMetricsForecastTotals(t *testing.T) { + series := []metrics.Series{ + {Group: metrics.Forecast, Name: "forecast", Data: []metrics.Slot{ + {Energy: 10.0}, {Energy: 5.0}, + }}, + // actual PV production is summed across all pv entities + {Group: metrics.PV, Name: "pv1", Data: []metrics.Slot{ + {Energy: 4.0}, {Energy: 3.0}, + }}, + {Group: metrics.PV, Name: "pv2", Data: []metrics.Slot{ + {Energy: 2.0}, + }}, + // other groups are ignored + {Group: metrics.Grid, Name: "grid", Data: []metrics.Slot{ + {Energy: 99.0}, + }}, + } + + forecast, actual := metricsForecastTotals(series) + require.InDelta(t, 15.0, forecast, 0.001) + require.InDelta(t, 9.0, actual, 0.001) +} + +func TestMetricsWriteForecastTable(t *testing.T) { + var buf bytes.Buffer + metricsWriteForecastTable(&buf, 100.0, 90.0) + + lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + require.Len(t, lines, 2) // header + 1 row + require.Contains(t, lines[0], "accuracy") + require.Contains(t, lines[1], "100.000") + require.Contains(t, lines[1], "90.000") + require.Contains(t, lines[1], "90.0%") + + // zero forecast -> blank accuracy + buf.Reset() + metricsWriteForecastTable(&buf, 0, 0) + lines = strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + require.NotContains(t, lines[1], "%") +} diff --git a/core/metrics/db_entities.go b/core/metrics/db_entities.go new file mode 100644 index 000000000..34ad12614 --- /dev/null +++ b/core/metrics/db_entities.go @@ -0,0 +1,55 @@ +package metrics + +import ( + "database/sql" + "time" + + "github.com/evcc-io/evcc/server/db" +) + +// EntityInfo describes a metric entity and the extent of its stored data. +type EntityInfo struct { + Group string + Name string + Slots int // number of persisted 15min slots + First time.Time // start of the earliest slot, zero if no data + Last time.Time // start of the latest slot, zero if no data +} + +// ListEntities returns all metric entities together with their slot count and +// data range. Entities without any persisted slots are included with a zero +// slot count and zero First/Last timestamps. +func ListEntities() ([]EntityInfo, error) { + type row struct { + Group string + Name string + Slots int + First sql.NullInt64 + Last sql.NullInt64 + } + + var rows []row + if err := db.Instance.Table("entities e"). + Select(`e."group" AS "group", e.name AS name, + COUNT(m.ts) AS slots, + MIN(m.ts) AS first, MAX(m.ts) AS last`). + Joins("LEFT JOIN meters m ON m.meter = e.id"). + Group("e.id"). + Scan(&rows).Error; err != nil { + return nil, err + } + + res := make([]EntityInfo, 0, len(rows)) + for _, r := range rows { + e := EntityInfo{Group: r.Group, Name: r.Name, Slots: r.Slots} + if r.First.Valid { + e.First = time.Unix(r.First.Int64, 0) + } + if r.Last.Valid { + e.Last = time.Unix(r.Last.Int64, 0) + } + res = append(res, e) + } + + return res, nil +} diff --git a/core/metrics/db_entities_test.go b/core/metrics/db_entities_test.go new file mode 100644 index 000000000..660ad64ce --- /dev/null +++ b/core/metrics/db_entities_test.go @@ -0,0 +1,47 @@ +package metrics + +import ( + "testing" + "time" + + "github.com/evcc-io/evcc/server/db" + "github.com/stretchr/testify/require" +) + +func TestListEntities(t *testing.T) { + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + require.NoError(t, SetupSchema()) + + // SetupSchema reserves the home entity (id 1) without data + grid := entity{Id: 2, Name: "grid", Group: Grid} + require.NoError(t, db.Instance.Create(&grid).Error) + pv := entity{Id: 3, Name: "pv1", Group: PV} + require.NoError(t, db.Instance.Create(&pv).Error) + + base := time.Date(2026, 4, 15, 16, 0, 0, 0, time.Now().Location()) + require.NoError(t, persist(grid, base, 1, 0)) + require.NoError(t, persist(grid, base.Add(time.Hour), 2, 0)) + + entities, err := ListEntities() + require.NoError(t, err) + require.Len(t, entities, 3) // home + grid + pv1 + + byName := make(map[string]EntityInfo, len(entities)) + for _, e := range entities { + byName[e.Name] = e + } + + // entity with data: slot count and range + require.Equal(t, Grid, byName["grid"].Group) + require.Equal(t, 2, byName["grid"].Slots) + require.True(t, base.Equal(byName["grid"].First)) + require.True(t, base.Add(time.Hour).Equal(byName["grid"].Last)) + + // entity without data: zero slots, zero timestamps + require.Equal(t, 0, byName["pv1"].Slots) + require.True(t, byName["pv1"].First.IsZero()) + require.True(t, byName["pv1"].Last.IsZero()) + + // home entity exists but has no data + require.Equal(t, 0, byName["home"].Slots) +} diff --git a/core/metrics/db_history.go b/core/metrics/db_history.go index de15951ae..7f31df1e2 100644 --- a/core/metrics/db_history.go +++ b/core/metrics/db_history.go @@ -37,8 +37,9 @@ type Series struct { // SeriesCSV wraps a slice of Series for CSV export. type SeriesCSV []Series -// csvGroupOrder mirrors the frontend GROUP_ORDER plus home/forecast. -var csvGroupOrder = []string{PV, Battery, Grid, Loadpoint, Meter, Home, Forecast} +// GroupOrder is the canonical display order of metric groups, mirroring the +// frontend GROUP_ORDER plus home/forecast. +var GroupOrder = []string{PV, Battery, Grid, Loadpoint, Meter, Home, Forecast} var aggregateFormats = map[string]string{ "15m": "%Y-%m-%d %H:%M", @@ -147,8 +148,8 @@ func (s SeriesCSV) WriteCsv(ctx context.Context, w io.Writer) error { byGroup[g] = append(byGroup[g], &s[i]) } - rank := make(map[string]int, len(csvGroupOrder)) - for i, g := range csvGroupOrder { + rank := make(map[string]int, len(GroupOrder)) + for i, g := range GroupOrder { rank[g] = i } groups := make([]string, 0, len(byGroup)) From d72cd4e6537525a7092fc432dd2f5740b5335a2f Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 22 May 2026 15:09:08 +0200 Subject: [PATCH 0004/1007] =?UTF-8?q?Revert=20"OCPP:=20fix=20flaky=20test?= =?UTF-8?q?=20deadlock=20between=20trigger=20handler=20and=20WS=20?= =?UTF-8?q?=E2=80=A6=20(#30110)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- charger/ocpp_test_handler.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/charger/ocpp_test_handler.go b/charger/ocpp_test_handler.go index f3031c8b6..1069dedb0 100644 --- a/charger/ocpp_test_handler.go +++ b/charger/ocpp_test_handler.go @@ -14,10 +14,7 @@ type ChargePointHandler struct { // core func (handler *ChargePointHandler) OnChangeAvailability(request *core.ChangeAvailabilityRequest) (confirmation *core.ChangeAvailabilityConfirmation, err error) { - // dispatch asynchronously: the trigger handler issues synchronous CP→CS - // requests whose responses are read by this same goroutine, so a blocking - // send would deadlock the WebSocket read loop - go func() { handler.triggerC <- core.ChangeAvailabilityFeatureName }() + defer func() { handler.triggerC <- core.ChangeAvailabilityFeatureName }() return core.NewChangeAvailabilityConfirmation(core.AvailabilityStatusAccepted), nil } @@ -64,8 +61,7 @@ func (handler *ChargePointHandler) OnUnlockConnector(request *core.UnlockConnect } func (handler *ChargePointHandler) OnTriggerMessage(request *remotetrigger.TriggerMessageRequest) (confirmation *remotetrigger.TriggerMessageConfirmation, err error) { - // see OnChangeAvailability for why this is async - go func() { handler.triggerC <- request.RequestedMessage }() + defer func() { handler.triggerC <- request.RequestedMessage }() return remotetrigger.NewTriggerMessageConfirmation(remotetrigger.TriggerMessageStatusAccepted), nil } From 8eb719a94e0063133e477e58b6e74bcca197bc45 Mon Sep 17 00:00:00 2001 From: Roman Hohenberg <58924431+roman0803@users.noreply.github.com> Date: Fri, 22 May 2026 15:29:40 +0200 Subject: [PATCH 0005/1007] Cupra: add missing OLA API request headers (#30105) --- vehicle/seat/cupra/api.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/vehicle/seat/cupra/api.go b/vehicle/seat/cupra/api.go index e3cfb70d7..be467509b 100644 --- a/vehicle/seat/cupra/api.go +++ b/vehicle/seat/cupra/api.go @@ -17,6 +17,9 @@ const ( ActionCharge = "charging" ActionChargeStart = "start" ActionChargeStop = "stop" + + // appVersion is the Cupra app version mirrored in request headers + appVersion = "2.15.0" ) // API is an api.Vehicle implementation for Seat Cupra cars @@ -35,8 +38,14 @@ func NewAPI(log *util.Logger, ts oauth2.TokenSource) *API { Source: ts, Base: v.Client.Transport, }, + // These headers mirror the official Cupra app and are required to avoid + // 403 Forbidden from the OLA backend (verified against pycupra v0.2.30). Decorator: transport.DecorateHeaders(map[string]string{ - "app-market": "android", + "app-market": "android", + "app-brand": "cupra", + "app-version": appVersion, + "User-Agent": "OLACupra/" + appVersion + " (Android 12; sdk_gphone64_x86_64; Google) Mobile", + "origin": "app", }), } From 6400adb53256227bfe864dfc1fbb7ac28d1291bd Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 22 May 2026 19:14:13 +0200 Subject: [PATCH 0006/1007] chore: remove stray debug println in struct publisher (#30117) --- server/mqtt.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/server/mqtt.go b/server/mqtt.go index 26eb0b5d5..c3f94298f 100644 --- a/server/mqtt.go +++ b/server/mqtt.go @@ -136,8 +136,6 @@ func (m *MQTT) publishComplex(topic string, retained bool, payload any) { topic := topic if !mqttTagAttribute("squash", f) { topic = fmt.Sprintf("%s/%s", topic, strings.ToLower(f.Name[:1])+f.Name[1:]) - } else { - println(1) } if val.Field(i).IsZero() && jsonOmitEmpty(f) { From 04931033de5ca99c18c9b39fbfa2efb235af8fdb Mon Sep 17 00:00:00 2001 From: FrankvdAa Date: Fri, 22 May 2026 21:33:35 +0200 Subject: [PATCH 0007/1007] Enphase: rename IQ Envoy to IQ Gateway (#30122) --- templates/definition/meter/enphase.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/definition/meter/enphase.yaml b/templates/definition/meter/enphase.yaml index 1e12992df..821b7a351 100644 --- a/templates/definition/meter/enphase.yaml +++ b/templates/definition/meter/enphase.yaml @@ -2,7 +2,7 @@ template: enphase products: - brand: Enphase description: - generic: IQ Envoy + generic: IQ Gateway requirements: params: - name: usage From 6ff8ffe20bb66f4108e20600209041fa13e878a1 Mon Sep 17 00:00:00 2001 From: FrankvdAa Date: Fri, 22 May 2026 21:34:09 +0200 Subject: [PATCH 0008/1007] Atmoce: fix battery energy (#30120) --- templates/definition/meter/atmoce.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/definition/meter/atmoce.yaml b/templates/definition/meter/atmoce.yaml index f16f3706d..173b96321 100644 --- a/templates/definition/meter/atmoce.yaml +++ b/templates/definition/meter/atmoce.yaml @@ -124,7 +124,7 @@ render: | source: modbus {{- include "modbus" . | indent 2 }} register: - address: 60166 # Cumulative Energy Storage Charging Capacity (kWh * 100) + address: 60172 # Cumulative Energy Storage Discharging Capacity (kWh * 100) type: holding decode: uint64 scale: 0.01 From e63196e4bae8d92a047ee3bd7f9fcdc879cfb752 Mon Sep 17 00:00:00 2001 From: CiNcH83 Date: Fri, 22 May 2026 21:41:02 +0200 Subject: [PATCH 0009/1007] IAMMETER: split templates, support all devices (#29505) --- templates/definition/meter/iammeter-1p.yaml | 31 +++++++ templates/definition/meter/iammeter-2p.yaml | 39 +++++++++ templates/definition/meter/iammeter-3p.yaml | 93 +++++++++++++++++++++ templates/definition/meter/iammeter.yaml | 1 + 4 files changed, 164 insertions(+) create mode 100644 templates/definition/meter/iammeter-1p.yaml create mode 100644 templates/definition/meter/iammeter-2p.yaml create mode 100644 templates/definition/meter/iammeter-3p.yaml diff --git a/templates/definition/meter/iammeter-1p.yaml b/templates/definition/meter/iammeter-1p.yaml new file mode 100644 index 000000000..5407c2e92 --- /dev/null +++ b/templates/definition/meter/iammeter-1p.yaml @@ -0,0 +1,31 @@ +template: iammeter-1p +products: + - brand: IAMMETER + description: + generic: WEM3080 +params: + - name: usage + choice: ["grid", "pv", "charge"] + - name: host + - name: cache + advanced: true + default: 2s +render: | + type: custom + power: + source: http + uri: http://{{ .host }}/api/monitorjson + jq: .Data[2] # active power, unit: W + cache: {{ .cache }} + {{- if eq .usage "pv" }} + scale: -1 + {{- end }} + energy: + source: http + uri: http://{{ .host }}/api/monitorjson + {{- if eq .usage "pv" }} + jq: .Data[4] # reverse active energy, unit: kWh + {{- else }} + jq: .Data[3] # forward active energy, unit: kWh + {{- end }} + cache: {{ .cache }} diff --git a/templates/definition/meter/iammeter-2p.yaml b/templates/definition/meter/iammeter-2p.yaml new file mode 100644 index 000000000..e977fdc72 --- /dev/null +++ b/templates/definition/meter/iammeter-2p.yaml @@ -0,0 +1,39 @@ +template: iammeter-2p +products: + - brand: IAMMETER + description: + generic: WEM2067 +params: + - name: usage + choice: ["grid", "pv", "charge"] + - name: host + - name: channel + type: int + default: 1 + required: true + description: + de: Messkanal (1-2) + en: Meter channel (1-2) + - name: cache + advanced: true + default: 2s +render: | + type: custom + {{- $idx := sub .channel 1 }} + power: + source: http + uri: http://{{ .host }}/api/monitorjson + jq: .Datas[{{ $idx }}][2] # active power, unit: W + cache: {{ .cache }} + {{- if eq .usage "pv" }} + scale: -1 + {{- end }} + energy: + source: http + uri: http://{{ .host }}/api/monitorjson + {{- if eq .usage "pv" }} + jq: .Datas[{{ $idx }}][4] # reverse active energy, unit: kWh + {{- else }} + jq: .Datas[{{ $idx }}][3] # forward active energy, unit: kWh + {{- end }} + cache: {{ .cache }} diff --git a/templates/definition/meter/iammeter-3p.yaml b/templates/definition/meter/iammeter-3p.yaml new file mode 100644 index 000000000..46f735bf1 --- /dev/null +++ b/templates/definition/meter/iammeter-3p.yaml @@ -0,0 +1,93 @@ +template: iammeter-3p +products: + - brand: IAMMETER + description: + generic: WEM3080T + - brand: IAMMETER + description: + generic: WEM3046T + - brand: IAMMETER + description: + generic: WEM3050T +requirements: + description: + de: | + Die Aktivierung des Net Metering Mode (NEM) (=phasensaldierende Zählung) ist erforderlich. + Siehe https://www.iammeter.com/newsshow/net-energy-metering + en: | + Net Metering Mode (NEM) must be enabled. + See https://www.iammeter.com/newsshow/net-energy-metering +params: + - name: usage + choice: ["grid", "pv", "charge"] + - name: host + - name: cache + advanced: true + default: 2s +render: | + type: custom + power: + source: http + uri: http://{{ .host }}/api/monitorjson + jq: .Datas[3][2] # active power, unit: W + cache: {{ .cache }} + {{- if eq .usage "pv" }} + scale: -1 + {{- end }} + energy: + source: http + uri: http://{{ .host }}/api/monitorjson + {{- if eq .usage "pv" }} + jq: .Datas[3][4] # reverse active energy, unit: kWh + {{- else }} + jq: .Datas[3][3] # forward active energy, unit: kWh + {{- end }} + cache: {{ .cache }} + powers: + - source: http + uri: http://{{ .host }}/api/monitorjson + jq: .Datas[0][2] # L1 power, unit: W + cache: {{ .cache }} + {{- if eq .usage "pv" }} + scale: -1 + {{- end }} + - source: http + uri: http://{{ .host }}/api/monitorjson + jq: .Datas[1][2] # L2 power, unit: W + cache: {{ .cache }} + {{- if eq .usage "pv" }} + scale: -1 + {{- end }} + - source: http + uri: http://{{ .host }}/api/monitorjson + jq: .Datas[2][2] # L3 power, unit: W + cache: {{ .cache }} + {{- if eq .usage "pv" }} + scale: -1 + {{- end }} + currents: + - source: http + uri: http://{{ .host }}/api/monitorjson + jq: .Datas[0][1] # L1 current, unit: A + cache: {{ .cache }} + - source: http + uri: http://{{ .host }}/api/monitorjson + jq: .Datas[1][1] # L2 current, unit: A + cache: {{ .cache }} + - source: http + uri: http://{{ .host }}/api/monitorjson + jq: .Datas[2][1] # L3 current, unit: A + cache: {{ .cache }} + voltages: + - source: http + uri: http://{{ .host }}/api/monitorjson + jq: .Datas[0][0] # L1 voltage, unit: V + cache: {{ .cache }} + - source: http + uri: http://{{ .host }}/api/monitorjson + jq: .Datas[1][0] # L2 voltage, unit: V + cache: {{ .cache }} + - source: http + uri: http://{{ .host }}/api/monitorjson + jq: .Datas[2][0] # L3 voltage, unit: V + cache: {{ .cache }} diff --git a/templates/definition/meter/iammeter.yaml b/templates/definition/meter/iammeter.yaml index 407cfd709..b182fece4 100644 --- a/templates/definition/meter/iammeter.yaml +++ b/templates/definition/meter/iammeter.yaml @@ -1,4 +1,5 @@ template: iammeter +deprecated: true products: - brand: IAMMETER description: From 14b422da856c9b41489b5d9db35911a66ad9ef1e Mon Sep 17 00:00:00 2001 From: premultiply <4681172+premultiply@users.noreply.github.com> Date: Fri, 22 May 2026 21:43:03 +0200 Subject: [PATCH 0010/1007] Templates: render template-specific modbus defaults in instance mode (#29852) --- util/templates/template_modbus.go | 10 ++-- util/templates/template_modbus_test.go | 73 ++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 5 deletions(-) create mode 100644 util/templates/template_modbus_test.go diff --git a/util/templates/template_modbus.go b/util/templates/template_modbus.go index 5d4c81aa8..72b523486 100644 --- a/util/templates/template_modbus.go +++ b/util/templates/template_modbus.go @@ -93,13 +93,13 @@ func (t *Template) ModbusValues(renderMode int, values map[string]any) { } if defaultValue != "" { - // for modbus params the default value is carried - // using the parameter default, not the value - // TODO figure out why that's necessary + // apply the template-specific default to both the render values + // (so RenderModeInstance YAML reflects it) and the param definition + // (so the Config UI surfaces it as default). The earlier guard above + // ensures user-supplied values are not overwritten. + values[p.Name] = defaultValue if renderMode == RenderModeInstance { t.SetParamDefault(p.Name, defaultValue) - } else { - values[p.Name] = defaultValue } } } diff --git a/util/templates/template_modbus_test.go b/util/templates/template_modbus_test.go new file mode 100644 index 000000000..3c446f4a2 --- /dev/null +++ b/util/templates/template_modbus_test.go @@ -0,0 +1,73 @@ +package templates + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var renderModeNames = map[int]string{ + RenderModeInstance: "instance", + RenderModeDocs: "docs", + RenderModeUnitTest: "unittest", +} + +// TestModbusTemplateDefaultID verifies that a template-specific modbus id +// (e.g. Wallbe/Phoenix controllers using id 255) is rendered into the resulting +// instance config when the user did not supply an explicit id. See #29804. +func TestModbusTemplateDefaultID(t *testing.T) { + for _, mode := range []int{RenderModeInstance, RenderModeDocs, RenderModeUnitTest} { + t.Run(renderModeNames[mode], func(t *testing.T) { + tmpl, err := ByName(Charger, "phoenix-ev-eth") + require.NoError(t, err) + + _, values, err := tmpl.RenderResult(mode, map[string]any{ + "host": "192.168.0.8", + "port": 502, + }) + require.NoError(t, err) + assert.Equal(t, "255", values["id"], "template-specific modbus id must be applied") + }) + } +} + +// TestModbusTemplateUserIDOverridesTemplate ensures a user-supplied id wins +// over the template default in all render modes. +func TestModbusTemplateUserIDOverridesTemplate(t *testing.T) { + for _, mode := range []int{RenderModeInstance, RenderModeDocs, RenderModeUnitTest} { + t.Run(renderModeNames[mode], func(t *testing.T) { + tmpl, err := ByName(Charger, "phoenix-ev-eth") + require.NoError(t, err) + + _, values, err := tmpl.RenderResult(mode, map[string]any{ + "host": "192.168.0.8", + "port": 502, + "id": 42, + }) + require.NoError(t, err) + assert.Equal(t, "42", values["id"], "user-supplied modbus id must not be overwritten") + }) + } +} + +// TestWallbeTemplateCoveredByPhoenix verifies the BC migration: a config that +// still references the removed `wallbe` templates is transparently routed to +// the phoenix-ev-eth template via the `covers:` directive, while still +// producing the Wallbe controller's modbus slave id 255. +func TestWallbeTemplateCoveredByPhoenix(t *testing.T) { + for _, name := range []string{"wallbe", "wallbe-meter", "wallbe-pre2019", "wallbe-pre2019-meter"} { + t.Run(name, func(t *testing.T) { + tmpl, err := ByName(Charger, name) + require.NoError(t, err) + assert.Equal(t, "phoenix-ev-eth", tmpl.Template) + + _, values, err := tmpl.RenderResult(RenderModeInstance, map[string]any{ + "host": "192.168.0.8", + "port": 502, + }) + require.NoError(t, err) + assert.Equal(t, "255", values["id"]) + }) + } +} From ead776949d80dbbd3d3b4dfb032420ab3948a38d Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 22 May 2026 23:33:37 +0200 Subject: [PATCH 0011/1007] Add GoodWe Wallbox (Gen2) charger (#29822) --- charger/goodwe.go | 287 ++++++++++++++++++ .../definition/charger/goodwe-wallbox.yaml | 26 ++ 2 files changed, 313 insertions(+) create mode 100644 charger/goodwe.go create mode 100644 templates/definition/charger/goodwe-wallbox.yaml diff --git a/charger/goodwe.go b/charger/goodwe.go new file mode 100644 index 000000000..475a07df8 --- /dev/null +++ b/charger/goodwe.go @@ -0,0 +1,287 @@ +package charger + +// LICENSE + +// Copyright (c) evcc.io (andig, naltatis, premultiply) + +// This module is NOT covered by the MIT license. All rights reserved. + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import ( + "bytes" + "context" + "encoding/binary" + "fmt" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/api/implement" + "github.com/evcc-io/evcc/core/loadpoint" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/modbus" +) + +// GoodWe AC EV Charger Gen2 (e.g. GW11K-HCA) — Modbus TCP. +// Protocol reference: "AC EV Charger 2nd Gen Modbus Protocol v1.0.15" +// derived from the Home Assistant integration on the gen2-modbus branch: +// https://github.com/pedrodivisez/goodwe-wallbox-sems-home-assistant/tree/gen2-modbus +// Default device ID is 247 (0xF7); TCP Modbus must be enabled in SolarGo. + +type GoodWe struct { + implement.Caps + lp loadpoint.API + conn *modbus.Connection + phases int + maxPower int +} + +const ( + goodweRegVoltages = 10009 // U16 ×0.1 V, 3 regs (L1/L2/L3) + goodweRegCurrents = 10012 // U16 ×0.1 A, 3 regs (L1/L2/L3) + goodweRegActualPower = 10015 // U16 ×0.1 kW + goodweRegStatus = 10017 // U16 enum (see status mapping) + goodweRegPhaseSwEnabled = 10023 // U16 (0=Off, 1=On) + goodweRegMaxPower = 10029 // U16 ×0.1 kW, raw range [14,220] + goodweRegChargeMode = 10032 // U16 (0=fast, 1=PV, 2=PV+battery) + goodweRegRfid = 10050 // 7 regs ASCII (14 bytes) + goodweRegPowerSpec = 10058 // U16 (0=7kW, 1=11kW, 2=22kW) + goodweRegPhaseSpec = 10059 // U16 (0=1p, 1=3p) + goodweRegChargeCommand = 10060 // U16 (1=stop, 2=start) + goodweRegTotalEnergy = 10065 // U32 ×0.1 kWh, 2 regs + + goodweChargeStop = 1 + goodweChargeStart = 2 + + goodweChargeModeFast = 0 +) + +func init() { + registry.AddCtx("goodwe-wallbox", NewGoodWeFromConfig) +} + +// NewGoodWeFromConfig creates a GoodWe wallbox charger from generic config. +func NewGoodWeFromConfig(ctx context.Context, other map[string]any) (api.Charger, error) { + cc := struct { + modbus.TcpSettings `mapstructure:",squash"` + }{ + TcpSettings: modbus.TcpSettings{ID: 247}, + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + return NewGoodWe(ctx, cc.URI, cc.ID) +} + +// NewGoodWe creates a GoodWe wallbox charger. +func NewGoodWe(ctx context.Context, uri string, slaveID uint8) (api.Charger, error) { + uri = util.DefaultPort(uri, 502) + + conn, err := modbus.NewConnection(ctx, uri, "", "", 0, modbus.Tcp, slaveID) + if err != nil { + return nil, err + } + + log := util.NewLogger("goodwe") + conn.Logger(log.TRACE) + + // defaults if hardware capability registers can't be read: + // assume 3-phase wallbox without phase switching at typical 11 kW class. + wb := &GoodWe{ + Caps: implement.New(), + conn: conn, + phases: 3, + maxPower: 11000, + } + + // read hardware power spec (0=7kW, 1=11kW, 2=22kW); fall back to default on error/unknown + if b, err := wb.conn.ReadHoldingRegisters(goodweRegPowerSpec, 1); err == nil { + switch v := binary.BigEndian.Uint16(b); v { + case 0: + wb.phases = 1 + wb.maxPower = 7000 + case 1: + wb.maxPower = 11000 + case 2: + wb.maxPower = 22000 + default: + log.WARN.Printf("unknown power spec %d, defaulting to %d W", v, wb.maxPower) + } + } else { + log.WARN.Printf("read power capability failed, defaulting to %d W: %v", wb.maxPower, err) + } + + // read hardware phase spec (0=1-phase, 1=3-phase); fall back to 3-phase on error + if b, err := wb.conn.ReadHoldingRegisters(goodweRegPhaseSpec, 1); err == nil { + if binary.BigEndian.Uint16(b) == 0 { + wb.phases = 1 + } + } else { + log.WARN.Printf("read hw phase count failed, defaulting to 3-phase: %v", err) + } + + // only 3-phase hardware can do 1p/3p switching; conditionally register phase switching + // based on hardware capability. On read error, fall back to fixed-phase operation. + if wb.phases == 3 { + if b, err := wb.conn.ReadHoldingRegisters(goodweRegPhaseSwEnabled, 1); err == nil { + if binary.BigEndian.Uint16(b) == 1 { + implement.Has(wb, implement.PhaseSwitcher(wb.phases1p3p)) + } + } else { + log.WARN.Printf("read phase switch config failed, disabling dynamic phase switching: %v", err) + } + } + + // force "fast" charging mode so evcc fully controls power setpoint + if _, err := wb.conn.WriteSingleRegister(goodweRegChargeMode, goodweChargeModeFast); err != nil { + return nil, fmt.Errorf("set charge mode: %w", err) + } + + return wb, nil +} + +// calcPower converts a per-phase current target into the wallbox's 0.1 kW power setpoint. +// The protocol exposes only a total power register; how the wallbox converts that back into +// a per-phase current limit is unspecified — it may assume a fixed nominal voltage (e.g. 230 V) +// or use its own live voltage measurement. 230 V is used here as a best-effort approximation; +// actual delivered current may deviate accordingly until verified on hardware. +func (wb *GoodWe) calcPower(current float64, phases int) uint16 { + return uint16(min(max(230*float64(phases)*current, 1400), float64(wb.maxPower)) / 100) // 0.1 kW +} + +// Status implements api.ChargeState +func (wb *GoodWe) Status() (api.ChargeStatus, error) { + b, err := wb.conn.ReadHoldingRegisters(goodweRegStatus, 1) + if err != nil { + return api.StatusNone, err + } + + switch s := binary.BigEndian.Uint16(b); s { + case 0: // idle, no plug + return api.StatusA, nil + case 1, 2, 4, 6, 10: // plugged, handshaking, completed, scheduled, interrupted + return api.StatusB, nil + case 3: // charging + return api.StatusC, nil + default: + return api.StatusNone, fmt.Errorf("invalid status: %d", s) + } +} + +// Enabled implements api.Charger +func (wb *GoodWe) Enabled() (bool, error) { + b, err := wb.conn.ReadHoldingRegisters(goodweRegChargeCommand, 1) + if err != nil { + return false, err + } + return binary.BigEndian.Uint16(b) == goodweChargeStart, nil +} + +// Enable implements api.Charger +func (wb *GoodWe) Enable(enable bool) error { + v := uint16(goodweChargeStop) + if enable { + v = goodweChargeStart + } + _, err := wb.conn.WriteSingleRegister(goodweRegChargeCommand, v) + return err +} + +// MaxCurrent implements api.Charger +func (wb *GoodWe) MaxCurrent(current int64) error { + return wb.MaxCurrentMillis(float64(current)) +} + +var _ api.ChargerEx = (*GoodWe)(nil) + +// MaxCurrentMillis implements api.ChargerEx +func (wb *GoodWe) MaxCurrentMillis(current float64) error { + if current < 6 { + return fmt.Errorf("invalid current %.1f", current) + } + + _, err := wb.conn.WriteSingleRegister(goodweRegMaxPower, wb.calcPower(current, wb.phases)) + return err +} + +var _ api.Meter = (*GoodWe)(nil) + +// CurrentPower implements api.Meter +func (wb *GoodWe) CurrentPower() (float64, error) { + b, err := wb.conn.ReadHoldingRegisters(goodweRegActualPower, 1) + if err != nil { + return 0, err + } + return float64(binary.BigEndian.Uint16(b)) * 100, nil +} + +var _ api.MeterEnergy = (*GoodWe)(nil) + +// TotalEnergy implements api.MeterEnergy +func (wb *GoodWe) TotalEnergy() (float64, error) { + b, err := wb.conn.ReadHoldingRegisters(goodweRegTotalEnergy, 2) + if err != nil { + return 0, err + } + return float64(binary.BigEndian.Uint32(b)) / 10, nil +} + +func (wb *GoodWe) phaseValues(reg uint16, divider float64) (float64, float64, float64, error) { + b, err := wb.conn.ReadHoldingRegisters(reg, 3) + if err != nil { + return 0, 0, 0, err + } + + var v [3]float64 + for i := range v { + v[i] = float64(binary.BigEndian.Uint16(b[2*i:])) / divider + } + return v[0], v[1], v[2], nil +} + +var _ api.PhaseCurrents = (*GoodWe)(nil) + +// Currents implements api.PhaseCurrents +func (wb *GoodWe) Currents() (float64, float64, float64, error) { + return wb.phaseValues(goodweRegCurrents, 10) +} + +var _ api.PhaseVoltages = (*GoodWe)(nil) + +// Voltages implements api.PhaseVoltages +func (wb *GoodWe) Voltages() (float64, float64, float64, error) { + return wb.phaseValues(goodweRegVoltages, 10) +} + +func (wb *GoodWe) phases1p3p(phases int) error { + wb.phases = phases + return nil +} + +var _ api.Identifier = (*GoodWe)(nil) + +// Identify implements api.Identifier (RFID UID, 14-byte NUL-padded ASCII). +func (wb *GoodWe) Identify() (string, error) { + b, err := wb.conn.ReadHoldingRegisters(goodweRegRfid, 7) + if err != nil { + return "", err + } + return bytesAsString(bytes.Trim(b, "\x00")), nil +} + +var _ loadpoint.Controller = (*GoodWe)(nil) + +// LoadpointControl implements loadpoint.Controller +func (wb *GoodWe) LoadpointControl(lp loadpoint.API) { + wb.lp = lp +} diff --git a/templates/definition/charger/goodwe-wallbox.yaml b/templates/definition/charger/goodwe-wallbox.yaml new file mode 100644 index 000000000..49cf7e4c8 --- /dev/null +++ b/templates/definition/charger/goodwe-wallbox.yaml @@ -0,0 +1,26 @@ +template: goodwe-wallbox +products: + - brand: GoodWe + description: + generic: HCA Wallbox (Gen2) +capabilities: ["mA", "rfid", "1p3p", "meter"] +requirements: + description: + de: | + Modbus TCP muss in der SolarGo App im Menü „Kommunikation → Wallbox" aktiviert werden. + Standard-Geräte-ID ist 247 (0xF7). + Die Wallbox erlaubt nur 2 gleichzeitige Modbus-TCP-Sitzungen (1× SEMS-Cloud, 1× Client). Bei aktiver Cloud-Verbindung konkurriert evcc um den zweiten Slot. + Hinweis: Die Implementierung wurde anhand der Home Assistant Integration (gen2-modbus Branch) erstellt und ist nicht herstellerseitig verifiziert. + en: | + Enable Modbus TCP in the SolarGo app via "Communication → Wallbox". + Default device ID is 247 (0xF7). + The wallbox only accepts 2 concurrent Modbus TCP sessions (1× SEMS cloud, 1× client). If the cloud is active, evcc competes for the second slot. + Note: Implementation is derived from the Home Assistant integration (gen2-modbus branch) and is not vendor-verified. +params: + - name: modbus + choice: ["tcpip"] + id: 247 + port: 502 +render: | + type: goodwe-wallbox + {{- include "modbus" . }} From 04b7e076a748c677a443d836635fbb987b15db51 Mon Sep 17 00:00:00 2001 From: premultiply <4681172+premultiply@users.noreply.github.com> Date: Fri, 22 May 2026 23:38:29 +0200 Subject: [PATCH 0012/1007] chore: fix missing requirement --- charger/goodwe.go | 5 +++++ templates/definition/charger/goodwe-wallbox.yaml | 1 + 2 files changed, 6 insertions(+) diff --git a/charger/goodwe.go b/charger/goodwe.go index 475a07df8..aa2217c9e 100644 --- a/charger/goodwe.go +++ b/charger/goodwe.go @@ -28,6 +28,7 @@ import ( "github.com/evcc-io/evcc/core/loadpoint" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/modbus" + "github.com/evcc-io/evcc/util/sponsor" ) // GoodWe AC EV Charger Gen2 (e.g. GW11K-HCA) — Modbus TCP. @@ -92,6 +93,10 @@ func NewGoodWe(ctx context.Context, uri string, slaveID uint8) (api.Charger, err return nil, err } + if !sponsor.IsAuthorized() { + return nil, api.ErrSponsorRequired + } + log := util.NewLogger("goodwe") conn.Logger(log.TRACE) diff --git a/templates/definition/charger/goodwe-wallbox.yaml b/templates/definition/charger/goodwe-wallbox.yaml index 49cf7e4c8..e9ca504b5 100644 --- a/templates/definition/charger/goodwe-wallbox.yaml +++ b/templates/definition/charger/goodwe-wallbox.yaml @@ -5,6 +5,7 @@ products: generic: HCA Wallbox (Gen2) capabilities: ["mA", "rfid", "1p3p", "meter"] requirements: + evcc: ["sponsorship"] description: de: | Modbus TCP muss in der SolarGo App im Menü „Kommunikation → Wallbox" aktiviert werden. From 88d0f9c40112b723d14e0ff687559260afba4f0d Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 23 May 2026 10:01:37 +0200 Subject: [PATCH 0013/1007] MQTT: cleanup deletes at QoS 0, synchronously (#30139) --- plugin/mqtt/client.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plugin/mqtt/client.go b/plugin/mqtt/client.go index 4afc5adba..73da06bae 100644 --- a/plugin/mqtt/client.go +++ b/plugin/mqtt/client.go @@ -154,7 +154,11 @@ func (m *Client) Cleanup(topic string, retained bool) error { } m.log.TRACE.Printf("delete: %s", msg.Topic()) - m.Publish(msg.Topic(), true, "") + // Delete synchronously at QoS 0 so the cleanup burst self-throttles + // to one in-flight message at a time and avoids overwhelming the + // broker (see #30086). + token := m.client.Publish(msg.Topic(), 0, true, "") + token.WaitTimeout(request.Timeout) // reset timeout timer.Reset(time.Second) From 2839f873ed9ca4ff29c25acea87ab8de2933ef16 Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 23 May 2026 10:21:45 +0200 Subject: [PATCH 0014/1007] MQTT: serialize resubscribe after reconnect (#30138) --- plugin/mqtt/client.go | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/plugin/mqtt/client.go b/plugin/mqtt/client.go index 73da06bae..123e5f39a 100644 --- a/plugin/mqtt/client.go +++ b/plugin/mqtt/client.go @@ -135,12 +135,24 @@ func (m *Client) ConnectionHandler(client paho.Client) { m.log.DEBUG.Printf("%s connected", m.broker) m.mux.Lock() - defer m.mux.Unlock() - + topics := make([]string, 0, len(m.listener)) for topic := range m.listener { - m.log.DEBUG.Printf("%s subscribe %s", m.broker, topic) - go m.listen(topic) + topics = append(topics, topic) } + m.mux.Unlock() + + // Resubscribe sequentially to avoid bursting the broker right after reconnect. + go func() { + for _, topic := range topics { + m.log.DEBUG.Printf("%s subscribe %s", m.broker, topic) + token := m.listen(topic) + if !token.WaitTimeout(request.Timeout) { + m.log.ERROR.Printf("subscribe %s: timeout", topic) + } else if err := token.Error(); err != nil { + m.log.ERROR.Printf("subscribe %s: %v", topic, err) + } + } + }() } // Cleanup recursively removes a topic From 55dd26b3c00b3577dbac7592bf41ba0030896731 Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 23 May 2026 10:26:52 +0200 Subject: [PATCH 0015/1007] MQTT: cancel in-flight publishes on disconnect (#30142) --- plugin/mqtt/client.go | 62 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 54 insertions(+), 8 deletions(-) diff --git a/plugin/mqtt/client.go b/plugin/mqtt/client.go index 123e5f39a..c27180b9f 100644 --- a/plugin/mqtt/client.go +++ b/plugin/mqtt/client.go @@ -48,6 +48,10 @@ type Client struct { Qos byte listener map[string][]func(string) inflight *semaphore.Weighted + + connMu sync.Mutex + connCtx context.Context + connCancel context.CancelFunc } type Option func(*paho.ClientOptions) @@ -125,14 +129,45 @@ func NewClient(log *util.Logger, broker, user, password, clientID string, qos by return mc, nil } +// connContext returns a context that is cancelled on disconnect and replaced +// on every reconnect. Pending publishes use it to bail out if the connection +// that scheduled them has gone away. +func (m *Client) connContext() context.Context { + m.connMu.Lock() + defer m.connMu.Unlock() + if m.connCtx == nil { + return context.Background() + } + return m.connCtx +} + +func (m *Client) renewConnContext() { + m.connMu.Lock() + defer m.connMu.Unlock() + if m.connCancel != nil { + m.connCancel() + } + m.connCtx, m.connCancel = context.WithCancel(context.Background()) +} + +func (m *Client) cancelConnContext() { + m.connMu.Lock() + defer m.connMu.Unlock() + if m.connCancel != nil { + m.connCancel() + } +} + // ConnectionLostHandler logs cause of connection loss as warning func (m *Client) ConnectionLostHandler(client paho.Client, reason error) { m.log.ERROR.Printf("%s connection lost: %v", m.broker, reason.Error()) + m.cancelConnContext() } // ConnectionHandler restores listeners func (m *Client) ConnectionHandler(client paho.Client) { m.log.DEBUG.Printf("%s connected", m.broker) + m.renewConnContext() m.mux.Lock() topics := make([]string, 0, len(m.listener)) @@ -190,24 +225,35 @@ func (m *Client) Cleanup(topic string, retained bool) error { // Publish asynchronously publishes payload using client qos func (m *Client) Publish(topic string, retained bool, payload any) { + connCtx := m.connContext() go func() { - ctx, cancel := context.WithTimeout(context.Background(), request.Timeout) + ctx, cancel := context.WithTimeout(connCtx, request.Timeout) defer cancel() if err := m.inflight.Acquire(ctx, 1); err != nil { - m.log.ERROR.Printf("send %s: %v", topic, err) + if connCtx.Err() == nil { + m.log.ERROR.Printf("send %s: %v", topic, err) + } return } defer m.inflight.Release(1) + // Bail out if the connection that scheduled this publish has been lost. + if connCtx.Err() != nil { + return + } + m.log.TRACE.Printf("send %s: '%v'", topic, payload) token := m.client.Publish(topic, m.Qos, retained, payload) - err := api.ErrTimeout - if token.WaitTimeout(request.Timeout) { - err = token.Error() - } - if err != nil { - m.log.ERROR.Printf("send: %s: %v", topic, err) + select { + case <-connCtx.Done(): + return + case <-token.Done(): + if err := token.Error(); err != nil { + m.log.ERROR.Printf("send: %s: %v", topic, err) + } + case <-time.After(request.Timeout): + m.log.ERROR.Printf("send: %s: timeout", topic) } }() } From 18cadfa7a106031699b76aa853d6f1fe6823fc8a Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 23 May 2026 10:28:45 +0200 Subject: [PATCH 0016/1007] Cupra: keep plugged "NotReadyForCharging" as connected status (#30140) Co-authored-by: Claude Opus 4.7 (1M context) --- vehicle/seat/cupra/provider.go | 6 +++- vehicle/seat/cupra/provider_test.go | 53 +++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 vehicle/seat/cupra/provider_test.go diff --git a/vehicle/seat/cupra/provider.go b/vehicle/seat/cupra/provider.go index a604a6909..9fa3f82b2 100644 --- a/vehicle/seat/cupra/provider.go +++ b/vehicle/seat/cupra/provider.go @@ -66,13 +66,17 @@ func (v *Provider) Status() (api.ChargeStatus, error) { return api.StatusNone, err } + // BatteryCardStatus is the authoritative disconnect signal — the Cupra API + // only emits "notConnected" when the cable is unplugged. When the car is + // plugged the field is omitted and Status alone is unreliable: it reports + // "NotReadyForCharging" both for an unplugged car. // https://github.com/evcc-io/evcc/issues/30045 if strings.ToLower(res.Services.Charging.BatteryCardStatus) == "notconnected" { return api.StatusA, nil } switch strings.ToLower(res.Services.Charging.Status) { - case "connected", "readyforcharging", "error": + case "connected", "readyforcharging", "notreadyforcharging", "error": return api.StatusB, nil case "charging": return api.StatusC, nil diff --git a/vehicle/seat/cupra/provider_test.go b/vehicle/seat/cupra/provider_test.go new file mode 100644 index 000000000..119743c12 --- /dev/null +++ b/vehicle/seat/cupra/provider_test.go @@ -0,0 +1,53 @@ +package cupra + +import ( + "testing" + + "github.com/evcc-io/evcc/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestProviderStatus covers the Cupra v5 charging-status payload variants +// captured in issues #30045 and #30118. +func TestProviderStatus(t *testing.T) { + cases := []struct { + name string + statusField string + batteryCardStatus string + want api.ChargeStatus + }{ + // cable not plugged in: API emits batteryCardStatus=notConnected + {"unplugged", "NotReadyForCharging", "notConnected", api.StatusA}, + // charging: API omits batteryCardStatus + {"charging", "Charging", "", api.StatusC}, + // plugged but not charging (e.g. chargeMode=off): API omits + // batteryCardStatus, Status reports NotReadyForCharging — must NOT be + // misread as disconnected (#30118) + {"plugged_paused", "NotReadyForCharging", "", api.StatusB}, + // plugged, ready to charge but not yet started + {"ready", "ReadyForCharging", "", api.StatusB}, + // already-connected pre-v5 payload + {"connected", "Connected", "", api.StatusB}, + // fault / error while plugged + {"error", "error", "", api.StatusB}, + // unknown future status with no card info defaults to disconnected + {"unknown", "SomethingNew", "", api.StatusA}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var s Status + s.Services.Charging.Status = tc.statusField + s.Services.Charging.BatteryCardStatus = tc.batteryCardStatus + + v := &Provider{ + statusG: func() (Status, error) { return s, nil }, + } + + got, err := v.Status() + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} From 3d59da473b5f3a7d64922cee804603717a6c0f58 Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 23 May 2026 10:35:05 +0200 Subject: [PATCH 0017/1007] MQTT: lower parallelInflightLimit from 128 to 32 (#30141) --- plugin/mqtt/client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/mqtt/client.go b/plugin/mqtt/client.go index c27180b9f..c6fccc7ef 100644 --- a/plugin/mqtt/client.go +++ b/plugin/mqtt/client.go @@ -20,7 +20,7 @@ import ( // Instance is the paho Mqtt client singleton var Instance *Client -const parallelInflightLimit int64 = 128 +const parallelInflightLimit int64 = 32 // ClientID created unique mqtt client id func ClientID() string { From 8dc0edd8d9d21830c53db92824621a5f4283060c Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 23 May 2026 11:42:29 +0200 Subject: [PATCH 0018/1007] HEMS: redesign dim and curtail api as tristate (#30116) --- api/api.go | 4 ++-- api/mock.go | 8 ++++---- core/circuit/circuit.go | 24 ++++++++++++------------ core/helper.go | 8 ++++---- core/loadpoint.go | 22 +++++++++++----------- core/site_battery.go | 2 +- core/site_circuit_test.go | 23 +++++++++++++++-------- core/site_circuits.go | 30 ++++++++++++++++++------------ 8 files changed, 67 insertions(+), 54 deletions(-) diff --git a/api/api.go b/api/api.go index d92a7e65b..f2be451dc 100644 --- a/api/api.go +++ b/api/api.go @@ -280,11 +280,11 @@ type Circuit interface { // EnWG §14a - reduce demand/consumption Dim(bool) - Dimmed() bool + Dimmed() *bool // EEG §9 - reduce feed-in to the grid Curtail(bool) - Curtailed() bool + Curtailed() *bool } // Redactor is an interface to redact sensitive data diff --git a/api/mock.go b/api/mock.go index 4591c1e38..937dd667f 100644 --- a/api/mock.go +++ b/api/mock.go @@ -869,10 +869,10 @@ func (mr *MockCircuitMockRecorder) Curtail(arg0 any) *gomock.Call { } // Curtailed mocks base method. -func (m *MockCircuit) Curtailed() bool { +func (m *MockCircuit) Curtailed() *bool { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Curtailed") - ret0, _ := ret[0].(bool) + ret0, _ := ret[0].(*bool) return ret0 } @@ -895,10 +895,10 @@ func (mr *MockCircuitMockRecorder) Dim(arg0 any) *gomock.Call { } // Dimmed mocks base method. -func (m *MockCircuit) Dimmed() bool { +func (m *MockCircuit) Dimmed() *bool { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Dimmed") - ret0, _ := ret[0].(bool) + ret0, _ := ret[0].(*bool) return ret0 } diff --git a/core/circuit/circuit.go b/core/circuit/circuit.go index d31670bdf..6a018477e 100644 --- a/core/circuit/circuit.go +++ b/core/circuit/circuit.go @@ -36,8 +36,8 @@ type Circuit struct { current float64 power float64 - dimmed bool - curtailed bool + dimmed *bool + curtailed *bool currentUpdated time.Time powerUpdated time.Time @@ -394,19 +394,19 @@ func (c *Circuit) ValidateCurrent(old, new float64) float64 { func (c *Circuit) Dim(dim bool) { c.mu.Lock() defer c.mu.Unlock() - c.dimmed = dim + c.dimmed = &dim } -func (c *Circuit) Dimmed() bool { +func (c *Circuit) Dimmed() *bool { c.mu.RLock() defer c.mu.RUnlock() - if c.dimmed { - return true + if c.dimmed != nil { + return c.dimmed } if c.parent == nil { - return false + return nil } return c.parent.Dimmed() @@ -415,19 +415,19 @@ func (c *Circuit) Dimmed() bool { func (c *Circuit) Curtail(curtail bool) { c.mu.Lock() defer c.mu.Unlock() - c.curtailed = curtail + c.curtailed = &curtail } -func (c *Circuit) Curtailed() bool { +func (c *Circuit) Curtailed() *bool { c.mu.RLock() defer c.mu.RUnlock() - if c.curtailed { - return true + if c.curtailed != nil { + return c.curtailed } if c.parent == nil { - return false + return nil } return c.parent.Curtailed() diff --git a/core/helper.go b/core/helper.go index 37b006b4f..2db51326e 100644 --- a/core/helper.go +++ b/core/helper.go @@ -82,18 +82,18 @@ func circuitMaxPower(circuit api.Circuit) float64 { } // circuitDimmed returns a circuits dim status -func circuitDimmed(circuit api.Circuit) bool { +func circuitDimmed(circuit api.Circuit) *bool { if circuit == nil { - return false + return nil } return circuit.Dimmed() } // circuitCurtailed returns a circuit's curtail status -func circuitCurtailed(circuit api.Circuit) bool { +func circuitCurtailed(circuit api.Circuit) *bool { if circuit == nil { - return false + return nil } return circuit.Curtailed() diff --git a/core/loadpoint.go b/core/loadpoint.go index 17de6ba7e..fba363314 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -1963,20 +1963,20 @@ func (lp *Loadpoint) Update(sitePower, batteryBoostPower float64, consumption, f return } - dim := lp.circuit != nil && lp.circuit.Dimmed() + if dim := circuitDimmed(lp.circuit); dim != nil { + if *dim != dimmed { + if err := dimmer.Dim(*dim); err != nil { + lp.log.ERROR.Printf("dim: %v", err) + return + } - if dim != dimmed { - if err := dimmer.Dim(dim); err != nil { - lp.log.ERROR.Printf("dim: %v", err) - return + lp.publish(keys.Dimmed, *dim) + lp.log.INFO.Printf("§14a dim: %t", *dim) } - lp.publish(keys.Dimmed, dim) - lp.log.INFO.Printf("§14a dim: %t", dim) - } - - if dim { - return + if *dim { + return + } } } diff --git a/core/site_battery.go b/core/site_battery.go index b22d4d7ce..a052bc74c 100644 --- a/core/site_battery.go +++ b/core/site_battery.go @@ -57,7 +57,7 @@ func (site *Site) updateBatteryMode(batteryGridChargeActive bool, rate api.Rate) // put battery into hold mode when charging is active and circuit dimmed fromToCharge := batteryMode == api.BatteryCharge || batteryMode == api.BatteryUnknown && site.batteryMode == api.BatteryCharge - if fromToCharge && circuitDimmed(site.circuit) { + if dimmed := circuitDimmed(site.circuit); fromToCharge && dimmed != nil && *dimmed { site.log.DEBUG.Println("battery mode: circuit dimmed") batteryMode = api.BatteryHold } diff --git a/core/site_circuit_test.go b/core/site_circuit_test.go index 49481139f..d36256417 100644 --- a/core/site_circuit_test.go +++ b/core/site_circuit_test.go @@ -29,18 +29,25 @@ func TestDimming(t *testing.T) { } for _, tc := range []struct { - has, want bool + has bool + want *bool }{ - {has: false, want: false}, - {has: false, want: true}, - {has: true, want: false}, - {has: true, want: true}, + // nil: circuit has no opinion - the device must not be touched, so a + // limit configured outside evcc is preserved (issue #30068) + {has: false, want: nil}, + {has: true, want: nil}, + {has: false, want: new(false)}, + {has: false, want: new(true)}, + {has: true, want: new(false)}, + {has: true, want: new(true)}, } { t.Logf("%+v", tc) - dimmer.EXPECT().Dimmed().Return(tc.has, nil) - if tc.has != tc.want { - dimmer.EXPECT().Dim(tc.want).Return(nil) + if tc.want != nil { + dimmer.EXPECT().Dimmed().Return(tc.has, nil) + if tc.has != *tc.want { + dimmer.EXPECT().Dim(*tc.want).Return(nil) + } } require.NoError(t, s.dimMeters(tc.want)) diff --git a/core/site_circuits.go b/core/site_circuits.go index 4d9372ff0..f1dc2c800 100644 --- a/core/site_circuits.go +++ b/core/site_circuits.go @@ -19,8 +19,8 @@ type circuitStruct struct { Current *float64 `json:"current,omitempty"` MaxPower float64 `json:"maxPower,omitempty"` MaxCurrent float64 `json:"maxCurrent,omitempty"` - Dimmed bool `json:"dimmed"` - Curtailed bool `json:"curtailed"` + Dimmed *bool `json:"dimmed,omitempty"` + Curtailed *bool `json:"curtailed,omitempty"` } // publishCircuits returns a list of circuit titles @@ -58,9 +58,12 @@ func (site *Site) publishCircuits() { site.publish(keys.Circuits, res) } -func (site *Site) dimMeters(dim bool) error { - var errs error +func (site *Site) dimMeters(dim *bool) error { + if dim == nil { + return nil + } + var errs error for _, dev := range slices.Concat(site.auxMeters, site.extMeters) { m, ok := api.Cap[api.Dimmer](dev.Instance()) if !ok { @@ -68,7 +71,7 @@ func (site *Site) dimMeters(dim bool) error { } if dimmed, err := m.Dimmed(); err == nil { - if dim == dimmed { + if *dim == dimmed { continue } } else { @@ -78,8 +81,8 @@ func (site *Site) dimMeters(dim bool) error { continue } - if err := m.Dim(dim); err == nil { - site.log.DEBUG.Printf("%s dim: %t", dev.Config().Name, dim) + if err := m.Dim(*dim); err == nil { + site.log.DEBUG.Printf("%s dim: %t", dev.Config().Name, *dim) } else if !errors.Is(err, api.ErrNotAvailable) { errs = errors.Join(errs, fmt.Errorf("%s dim: %w", dev.Config().Name, err)) } @@ -88,9 +91,12 @@ func (site *Site) dimMeters(dim bool) error { return errs } -func (site *Site) curtailPV(curtail bool) error { - var errs error +func (site *Site) curtailPV(curtail *bool) error { + if curtail == nil { + return nil + } + var errs error for _, dev := range site.pvMeters { m, ok := api.Cap[api.Curtailer](dev.Instance()) if !ok { @@ -98,7 +104,7 @@ func (site *Site) curtailPV(curtail bool) error { } if curtailed, err := m.Curtailed(); err == nil { - if curtail == curtailed { + if *curtail == curtailed { continue } } else { @@ -108,8 +114,8 @@ func (site *Site) curtailPV(curtail bool) error { continue } - if err := m.Curtail(curtail); err == nil { - site.log.DEBUG.Printf("%s curtail: %t", dev.Config().Name, curtail) + if err := m.Curtail(*curtail); err == nil { + site.log.DEBUG.Printf("%s curtail: %t", dev.Config().Name, *curtail) } else if !errors.Is(err, api.ErrNotAvailable) { errs = errors.Join(errs, fmt.Errorf("%s curtail: %w", dev.Config().Name, err)) } From 0847c2a046e3f187b201495718ad558acbe33ef9 Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 23 May 2026 12:37:17 +0200 Subject: [PATCH 0019/1007] Polestar: add gRPC vehicle implementation (#30119) --- templates/definition/vehicle/polestar.yaml | 2 +- vehicle/polestar/api.go | 27 ++ vehicle/polestar/grpc.go | 147 ++++++ vehicle/polestar/pb/polestar.pb.go | 505 +++++++++++++++++++++ vehicle/polestar/pb/polestar.proto | 68 +++ vehicle/polestar/pb/polestar_grpc.pb.go | 136 ++++++ vehicle/polestar/provider_grpc.go | 106 +++++ vehicle/polestar/provider_grpc_test.go | 73 +++ vehicle/polestargrpc.go | 75 +++ 9 files changed, 1138 insertions(+), 1 deletion(-) create mode 100644 vehicle/polestar/grpc.go create mode 100644 vehicle/polestar/pb/polestar.pb.go create mode 100644 vehicle/polestar/pb/polestar.proto create mode 100644 vehicle/polestar/pb/polestar_grpc.pb.go create mode 100644 vehicle/polestar/provider_grpc.go create mode 100644 vehicle/polestar/provider_grpc_test.go create mode 100644 vehicle/polestargrpc.go diff --git a/templates/definition/vehicle/polestar.yaml b/templates/definition/vehicle/polestar.yaml index 33e4fdba4..502a1a019 100644 --- a/templates/definition/vehicle/polestar.yaml +++ b/templates/definition/vehicle/polestar.yaml @@ -8,5 +8,5 @@ params: - name: vin example: LPSVS... render: | - type: polestar + type: polestar-grpc {{ include "vehicle-base" . }} diff --git a/vehicle/polestar/api.go b/vehicle/polestar/api.go index aa8d1c91d..d460f29cb 100644 --- a/vehicle/polestar/api.go +++ b/vehicle/polestar/api.go @@ -3,6 +3,7 @@ package polestar import ( "context" + "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/request" "github.com/hasura/go-graphql-client" @@ -82,3 +83,29 @@ func (v *API) CarTelemetry(ctx context.Context, vin string) (CarTelemetryData, e return filteredData, err } + +// Odometer returns the odometer reading in km for the given VIN. +// The gRPC battery service does not expose odometer data, so it is queried +// via GraphQL. Only the odometer field is requested, avoiding the removed +// battery chargingStatus field that breaks the full telematics query. +func (v *API) Odometer(ctx context.Context, vin string) (float64, error) { + var res struct { + CarTelematicsV2 struct { + Odometer []OdometerData + } `graphql:"carTelematicsV2(vins: $vins)"` + } + + if err := v.client.Query(ctx, &res, map[string]any{ + "vins": []string{vin}, + }, graphql.OperationName("CarTelematicsV2")); err != nil { + return 0, err + } + + for _, o := range res.CarTelematicsV2.Odometer { + if o.VIN == vin { + return float64(o.OdometerMeters) / 1e3, nil + } + } + + return 0, api.ErrNotAvailable +} diff --git a/vehicle/polestar/grpc.go b/vehicle/polestar/grpc.go new file mode 100644 index 000000000..70de34f8d --- /dev/null +++ b/vehicle/polestar/grpc.go @@ -0,0 +1,147 @@ +package polestar + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net" + "net/http" + "strconv" + + "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/polestar/pb" + "github.com/google/uuid" + "golang.org/x/oauth2" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +// Polestar gRPC battery API. +// +// Polestar removed the chargingStatus field from the GraphQL API. The gRPC +// battery service still exposes the charger connection and charging status. +// Protocol reconstructed by the pypolestar project, see +// https://github.com/pypolestar/pypolestar. +const ( + // c3DiscoveryURI resolves the dynamic gRPC host of the battery service + c3DiscoveryURI = "https://cnepmob.volvocars.com" + c3DiscoveryAccept = "application/volvo.cloud.cnepmob.v1+json" + + // defaultGrpcPort is used when discovery does not return a port + defaultGrpcPort = 443 +) + +// GrpcAPI is the Polestar gRPC battery API client +type GrpcAPI struct { + client pb.BatteryServiceClient +} + +// NewGrpcAPI creates a Polestar gRPC battery API client. The token source is +// shared with the GraphQL API so both reuse the same OAuth2 session. +func NewGrpcAPI(log *util.Logger, ts oauth2.TokenSource) (*GrpcAPI, error) { + host, err := discoverC3Host(log) + if err != nil { + return nil, fmt.Errorf("c3 discovery: %w", err) + } + + conn, err := grpc.NewClient(host, + grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})), + grpc.WithPerRPCCredentials(tokenCredentials{ts}), + ) + if err != nil { + return nil, err + } + + return &GrpcAPI{ + client: pb.NewBatteryServiceClient(conn), + }, nil +} + +// tokenCredentials adapts an oauth2.TokenSource to grpc.PerRPCCredentials, +// adding the bearer token as gRPC authorization metadata on every call. +type tokenCredentials struct { + oauth2.TokenSource +} + +func (c tokenCredentials) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) { + token, err := c.Token() + if err != nil { + return nil, err + } + return map[string]string{ + "authorization": token.Type() + " " + token.AccessToken, + }, nil +} + +func (tokenCredentials) RequireTransportSecurity() bool { + return true +} + +// discoverC3Host resolves the C3 gRPC host:port via the cnepmob discovery endpoint +func discoverC3Host(log *util.Logger) (string, error) { + req, err := request.New(http.MethodGet, c3DiscoveryURI, nil, map[string]string{ + "Accept": c3DiscoveryAccept, + }) + if err != nil { + return "", err + } + + var res struct { + C3 struct { + GrpcHost string `json:"grpcHost"` + GrpcPort int `json:"grpcPort"` + } `json:"c3"` + } + if err := request.NewHelper(log).DoJSON(req, &res); err != nil { + return "", err + } + + if res.C3.GrpcHost == "" { + return "", errors.New("missing grpc host") + } + + port := res.C3.GrpcPort + if port == 0 { + port = defaultGrpcPort + } + + return net.JoinHostPort(res.C3.GrpcHost, strconv.Itoa(port)), nil +} + +// Battery returns the latest battery state for the given VIN +func (v *GrpcAPI) Battery(ctx context.Context, vin string) (*pb.Battery, error) { + ctx = metadata.AppendToOutgoingContext(ctx, "vin", vin) + + res, err := v.client.GetLatestBattery(ctx, &pb.GetBatteryRequest{ + Id: uuid.NewString(), + Vin: vin, + }) + if err != nil { + return nil, retryable(err) + } + + battery := res.GetBattery() + if battery == nil { + return nil, api.ErrNotAvailable + } + + return battery, nil +} + +// retryable marks transient gRPC errors as api.ErrMustRetry so util.Cached +// retries them on the next cycle instead of caching the failure and delaying +// recovery via exponential back-off. +func retryable(err error) error { + switch status.Code(err) { + case codes.Unavailable, codes.DeadlineExceeded: + return fmt.Errorf("%w: %w", api.ErrMustRetry, err) + default: + return err + } +} diff --git a/vehicle/polestar/pb/polestar.pb.go b/vehicle/polestar/pb/polestar.pb.go new file mode 100644 index 000000000..fdb530d2f --- /dev/null +++ b/vehicle/polestar/pb/polestar.pb.go @@ -0,0 +1,505 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v7.34.1 +// source: polestar.proto + +// Polestar gRPC API (PCCS vehicle battery service). +// +// Polestar removed the chargingStatus field from the GraphQL carTelematicsV2 +// API. The gRPC battery service still exposes the charger connection and +// charging status. Protocol reconstructed by the pypolestar project from the +// Polestar Android app, see https://github.com/pypolestar/pypolestar. +// +// The proto package name is significant: it forms the gRPC method path +// (/services.vehiclestates.battery.BatteryService/GetLatestBattery) expected +// by the server. +// +// Regenerate with: +// protoc --go_out=. --go_opt=paths=source_relative \ +// --go-grpc_out=. --go-grpc_opt=paths=source_relative polestar.proto + +package pb + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type ChargerConnectionStatus int32 + +const ( + ChargerConnectionStatus_CHARGER_CONNECTION_STATUS_UNSPECIFIED ChargerConnectionStatus = 0 + ChargerConnectionStatus_CHARGER_CONNECTION_STATUS_CONNECTED ChargerConnectionStatus = 1 + ChargerConnectionStatus_CHARGER_CONNECTION_STATUS_DISCONNECTED ChargerConnectionStatus = 2 + ChargerConnectionStatus_CHARGER_CONNECTION_STATUS_FAULT ChargerConnectionStatus = 3 +) + +// Enum value maps for ChargerConnectionStatus. +var ( + ChargerConnectionStatus_name = map[int32]string{ + 0: "CHARGER_CONNECTION_STATUS_UNSPECIFIED", + 1: "CHARGER_CONNECTION_STATUS_CONNECTED", + 2: "CHARGER_CONNECTION_STATUS_DISCONNECTED", + 3: "CHARGER_CONNECTION_STATUS_FAULT", + } + ChargerConnectionStatus_value = map[string]int32{ + "CHARGER_CONNECTION_STATUS_UNSPECIFIED": 0, + "CHARGER_CONNECTION_STATUS_CONNECTED": 1, + "CHARGER_CONNECTION_STATUS_DISCONNECTED": 2, + "CHARGER_CONNECTION_STATUS_FAULT": 3, + } +) + +func (x ChargerConnectionStatus) Enum() *ChargerConnectionStatus { + p := new(ChargerConnectionStatus) + *p = x + return p +} + +func (x ChargerConnectionStatus) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ChargerConnectionStatus) Descriptor() protoreflect.EnumDescriptor { + return file_polestar_proto_enumTypes[0].Descriptor() +} + +func (ChargerConnectionStatus) Type() protoreflect.EnumType { + return &file_polestar_proto_enumTypes[0] +} + +func (x ChargerConnectionStatus) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ChargerConnectionStatus.Descriptor instead. +func (ChargerConnectionStatus) EnumDescriptor() ([]byte, []int) { + return file_polestar_proto_rawDescGZIP(), []int{0} +} + +type ChargingStatus int32 + +const ( + ChargingStatus_CHARGING_STATUS_UNSPECIFIED ChargingStatus = 0 + ChargingStatus_CHARGING_STATUS_CHARGING ChargingStatus = 1 + ChargingStatus_CHARGING_STATUS_IDLE ChargingStatus = 2 + ChargingStatus_CHARGING_STATUS_DONE ChargingStatus = 3 + ChargingStatus_CHARGING_STATUS_FAULT ChargingStatus = 4 + ChargingStatus_CHARGING_STATUS_SCHEDULED ChargingStatus = 5 + ChargingStatus_CHARGING_STATUS_DISCHARGING ChargingStatus = 6 + ChargingStatus_CHARGING_STATUS_ERROR ChargingStatus = 7 + ChargingStatus_CHARGING_STATUS_SMART_CHARGING ChargingStatus = 8 +) + +// Enum value maps for ChargingStatus. +var ( + ChargingStatus_name = map[int32]string{ + 0: "CHARGING_STATUS_UNSPECIFIED", + 1: "CHARGING_STATUS_CHARGING", + 2: "CHARGING_STATUS_IDLE", + 3: "CHARGING_STATUS_DONE", + 4: "CHARGING_STATUS_FAULT", + 5: "CHARGING_STATUS_SCHEDULED", + 6: "CHARGING_STATUS_DISCHARGING", + 7: "CHARGING_STATUS_ERROR", + 8: "CHARGING_STATUS_SMART_CHARGING", + } + ChargingStatus_value = map[string]int32{ + "CHARGING_STATUS_UNSPECIFIED": 0, + "CHARGING_STATUS_CHARGING": 1, + "CHARGING_STATUS_IDLE": 2, + "CHARGING_STATUS_DONE": 3, + "CHARGING_STATUS_FAULT": 4, + "CHARGING_STATUS_SCHEDULED": 5, + "CHARGING_STATUS_DISCHARGING": 6, + "CHARGING_STATUS_ERROR": 7, + "CHARGING_STATUS_SMART_CHARGING": 8, + } +) + +func (x ChargingStatus) Enum() *ChargingStatus { + p := new(ChargingStatus) + *p = x + return p +} + +func (x ChargingStatus) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ChargingStatus) Descriptor() protoreflect.EnumDescriptor { + return file_polestar_proto_enumTypes[1].Descriptor() +} + +func (ChargingStatus) Type() protoreflect.EnumType { + return &file_polestar_proto_enumTypes[1] +} + +func (x ChargingStatus) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ChargingStatus.Descriptor instead. +func (ChargingStatus) EnumDescriptor() ([]byte, []int) { + return file_polestar_proto_rawDescGZIP(), []int{1} +} + +type Timestamp struct { + state protoimpl.MessageState `protogen:"open.v1"` + Seconds int64 `protobuf:"varint,1,opt,name=seconds,proto3" json:"seconds,omitempty"` + Nanos int32 `protobuf:"varint,2,opt,name=nanos,proto3" json:"nanos,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Timestamp) Reset() { + *x = Timestamp{} + mi := &file_polestar_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Timestamp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Timestamp) ProtoMessage() {} + +func (x *Timestamp) ProtoReflect() protoreflect.Message { + mi := &file_polestar_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Timestamp.ProtoReflect.Descriptor instead. +func (*Timestamp) Descriptor() ([]byte, []int) { + return file_polestar_proto_rawDescGZIP(), []int{0} +} + +func (x *Timestamp) GetSeconds() int64 { + if x != nil { + return x.Seconds + } + return 0 +} + +func (x *Timestamp) GetNanos() int32 { + if x != nil { + return x.Nanos + } + return 0 +} + +type Battery struct { + state protoimpl.MessageState `protogen:"open.v1"` + Timestamp *Timestamp `protobuf:"bytes,1,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + BatteryChargeLevelPercentage float64 `protobuf:"fixed64,2,opt,name=battery_charge_level_percentage,json=batteryChargeLevelPercentage,proto3" json:"battery_charge_level_percentage,omitempty"` + EstimatedDistanceToEmptyKm int32 `protobuf:"varint,4,opt,name=estimated_distance_to_empty_km,json=estimatedDistanceToEmptyKm,proto3" json:"estimated_distance_to_empty_km,omitempty"` + EstimatedChargingTimeToFullMinutes int32 `protobuf:"varint,5,opt,name=estimated_charging_time_to_full_minutes,json=estimatedChargingTimeToFullMinutes,proto3" json:"estimated_charging_time_to_full_minutes,omitempty"` + ChargerConnectionStatus ChargerConnectionStatus `protobuf:"varint,6,opt,name=charger_connection_status,json=chargerConnectionStatus,proto3,enum=services.vehiclestates.battery.ChargerConnectionStatus" json:"charger_connection_status,omitempty"` + ChargingStatus ChargingStatus `protobuf:"varint,7,opt,name=charging_status,json=chargingStatus,proto3,enum=services.vehiclestates.battery.ChargingStatus" json:"charging_status,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Battery) Reset() { + *x = Battery{} + mi := &file_polestar_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Battery) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Battery) ProtoMessage() {} + +func (x *Battery) ProtoReflect() protoreflect.Message { + mi := &file_polestar_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Battery.ProtoReflect.Descriptor instead. +func (*Battery) Descriptor() ([]byte, []int) { + return file_polestar_proto_rawDescGZIP(), []int{1} +} + +func (x *Battery) GetTimestamp() *Timestamp { + if x != nil { + return x.Timestamp + } + return nil +} + +func (x *Battery) GetBatteryChargeLevelPercentage() float64 { + if x != nil { + return x.BatteryChargeLevelPercentage + } + return 0 +} + +func (x *Battery) GetEstimatedDistanceToEmptyKm() int32 { + if x != nil { + return x.EstimatedDistanceToEmptyKm + } + return 0 +} + +func (x *Battery) GetEstimatedChargingTimeToFullMinutes() int32 { + if x != nil { + return x.EstimatedChargingTimeToFullMinutes + } + return 0 +} + +func (x *Battery) GetChargerConnectionStatus() ChargerConnectionStatus { + if x != nil { + return x.ChargerConnectionStatus + } + return ChargerConnectionStatus_CHARGER_CONNECTION_STATUS_UNSPECIFIED +} + +func (x *Battery) GetChargingStatus() ChargingStatus { + if x != nil { + return x.ChargingStatus + } + return ChargingStatus_CHARGING_STATUS_UNSPECIFIED +} + +type GetBatteryRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Vin string `protobuf:"bytes,2,opt,name=vin,proto3" json:"vin,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetBatteryRequest) Reset() { + *x = GetBatteryRequest{} + mi := &file_polestar_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetBatteryRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetBatteryRequest) ProtoMessage() {} + +func (x *GetBatteryRequest) ProtoReflect() protoreflect.Message { + mi := &file_polestar_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetBatteryRequest.ProtoReflect.Descriptor instead. +func (*GetBatteryRequest) Descriptor() ([]byte, []int) { + return file_polestar_proto_rawDescGZIP(), []int{2} +} + +func (x *GetBatteryRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *GetBatteryRequest) GetVin() string { + if x != nil { + return x.Vin + } + return "" +} + +type GetBatteryResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Vin string `protobuf:"bytes,2,opt,name=vin,proto3" json:"vin,omitempty"` + Battery *Battery `protobuf:"bytes,3,opt,name=battery,proto3" json:"battery,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetBatteryResponse) Reset() { + *x = GetBatteryResponse{} + mi := &file_polestar_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetBatteryResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetBatteryResponse) ProtoMessage() {} + +func (x *GetBatteryResponse) ProtoReflect() protoreflect.Message { + mi := &file_polestar_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetBatteryResponse.ProtoReflect.Descriptor instead. +func (*GetBatteryResponse) Descriptor() ([]byte, []int) { + return file_polestar_proto_rawDescGZIP(), []int{3} +} + +func (x *GetBatteryResponse) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *GetBatteryResponse) GetVin() string { + if x != nil { + return x.Vin + } + return "" +} + +func (x *GetBatteryResponse) GetBattery() *Battery { + if x != nil { + return x.Battery + } + return nil +} + +var File_polestar_proto protoreflect.FileDescriptor + +const file_polestar_proto_rawDesc = "" + + "\n" + + "\x0epolestar.proto\x12\x1eservices.vehiclestates.battery\";\n" + + "\tTimestamp\x12\x18\n" + + "\aseconds\x18\x01 \x01(\x03R\aseconds\x12\x14\n" + + "\x05nanos\x18\x02 \x01(\x05R\x05nanos\"\x80\x04\n" + + "\aBattery\x12G\n" + + "\ttimestamp\x18\x01 \x01(\v2).services.vehiclestates.battery.TimestampR\ttimestamp\x12E\n" + + "\x1fbattery_charge_level_percentage\x18\x02 \x01(\x01R\x1cbatteryChargeLevelPercentage\x12B\n" + + "\x1eestimated_distance_to_empty_km\x18\x04 \x01(\x05R\x1aestimatedDistanceToEmptyKm\x12S\n" + + "'estimated_charging_time_to_full_minutes\x18\x05 \x01(\x05R\"estimatedChargingTimeToFullMinutes\x12s\n" + + "\x19charger_connection_status\x18\x06 \x01(\x0e27.services.vehiclestates.battery.ChargerConnectionStatusR\x17chargerConnectionStatus\x12W\n" + + "\x0fcharging_status\x18\a \x01(\x0e2..services.vehiclestates.battery.ChargingStatusR\x0echargingStatus\"5\n" + + "\x11GetBatteryRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x10\n" + + "\x03vin\x18\x02 \x01(\tR\x03vin\"y\n" + + "\x12GetBatteryResponse\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x10\n" + + "\x03vin\x18\x02 \x01(\tR\x03vin\x12A\n" + + "\abattery\x18\x03 \x01(\v2'.services.vehiclestates.battery.BatteryR\abattery*\xbe\x01\n" + + "\x17ChargerConnectionStatus\x12)\n" + + "%CHARGER_CONNECTION_STATUS_UNSPECIFIED\x10\x00\x12'\n" + + "#CHARGER_CONNECTION_STATUS_CONNECTED\x10\x01\x12*\n" + + "&CHARGER_CONNECTION_STATUS_DISCONNECTED\x10\x02\x12#\n" + + "\x1fCHARGER_CONNECTION_STATUS_FAULT\x10\x03*\x9d\x02\n" + + "\x0eChargingStatus\x12\x1f\n" + + "\x1bCHARGING_STATUS_UNSPECIFIED\x10\x00\x12\x1c\n" + + "\x18CHARGING_STATUS_CHARGING\x10\x01\x12\x18\n" + + "\x14CHARGING_STATUS_IDLE\x10\x02\x12\x18\n" + + "\x14CHARGING_STATUS_DONE\x10\x03\x12\x19\n" + + "\x15CHARGING_STATUS_FAULT\x10\x04\x12\x1d\n" + + "\x19CHARGING_STATUS_SCHEDULED\x10\x05\x12\x1f\n" + + "\x1bCHARGING_STATUS_DISCHARGING\x10\x06\x12\x19\n" + + "\x15CHARGING_STATUS_ERROR\x10\a\x12\"\n" + + "\x1eCHARGING_STATUS_SMART_CHARGING\x10\b2\x8b\x01\n" + + "\x0eBatteryService\x12y\n" + + "\x10GetLatestBattery\x121.services.vehiclestates.battery.GetBatteryRequest\x1a2.services.vehiclestates.battery.GetBatteryResponseB-Z+github.com/evcc-io/evcc/vehicle/polestar/pbb\x06proto3" + +var ( + file_polestar_proto_rawDescOnce sync.Once + file_polestar_proto_rawDescData []byte +) + +func file_polestar_proto_rawDescGZIP() []byte { + file_polestar_proto_rawDescOnce.Do(func() { + file_polestar_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_polestar_proto_rawDesc), len(file_polestar_proto_rawDesc))) + }) + return file_polestar_proto_rawDescData +} + +var file_polestar_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_polestar_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_polestar_proto_goTypes = []any{ + (ChargerConnectionStatus)(0), // 0: services.vehiclestates.battery.ChargerConnectionStatus + (ChargingStatus)(0), // 1: services.vehiclestates.battery.ChargingStatus + (*Timestamp)(nil), // 2: services.vehiclestates.battery.Timestamp + (*Battery)(nil), // 3: services.vehiclestates.battery.Battery + (*GetBatteryRequest)(nil), // 4: services.vehiclestates.battery.GetBatteryRequest + (*GetBatteryResponse)(nil), // 5: services.vehiclestates.battery.GetBatteryResponse +} +var file_polestar_proto_depIdxs = []int32{ + 2, // 0: services.vehiclestates.battery.Battery.timestamp:type_name -> services.vehiclestates.battery.Timestamp + 0, // 1: services.vehiclestates.battery.Battery.charger_connection_status:type_name -> services.vehiclestates.battery.ChargerConnectionStatus + 1, // 2: services.vehiclestates.battery.Battery.charging_status:type_name -> services.vehiclestates.battery.ChargingStatus + 3, // 3: services.vehiclestates.battery.GetBatteryResponse.battery:type_name -> services.vehiclestates.battery.Battery + 4, // 4: services.vehiclestates.battery.BatteryService.GetLatestBattery:input_type -> services.vehiclestates.battery.GetBatteryRequest + 5, // 5: services.vehiclestates.battery.BatteryService.GetLatestBattery:output_type -> services.vehiclestates.battery.GetBatteryResponse + 5, // [5:6] is the sub-list for method output_type + 4, // [4:5] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name +} + +func init() { file_polestar_proto_init() } +func file_polestar_proto_init() { + if File_polestar_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_polestar_proto_rawDesc), len(file_polestar_proto_rawDesc)), + NumEnums: 2, + NumMessages: 4, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_polestar_proto_goTypes, + DependencyIndexes: file_polestar_proto_depIdxs, + EnumInfos: file_polestar_proto_enumTypes, + MessageInfos: file_polestar_proto_msgTypes, + }.Build() + File_polestar_proto = out.File + file_polestar_proto_goTypes = nil + file_polestar_proto_depIdxs = nil +} diff --git a/vehicle/polestar/pb/polestar.proto b/vehicle/polestar/pb/polestar.proto new file mode 100644 index 000000000..fd4ea5a29 --- /dev/null +++ b/vehicle/polestar/pb/polestar.proto @@ -0,0 +1,68 @@ +syntax = "proto3"; + +// Polestar gRPC API (PCCS vehicle battery service). +// +// Polestar removed the chargingStatus field from the GraphQL carTelematicsV2 +// API. The gRPC battery service still exposes the charger connection and +// charging status. Protocol reconstructed by the pypolestar project from the +// Polestar Android app, see https://github.com/pypolestar/pypolestar. +// +// The proto package name is significant: it forms the gRPC method path +// (/services.vehiclestates.battery.BatteryService/GetLatestBattery) expected +// by the server. +// +// Regenerate with: +// protoc --go_out=. --go_opt=paths=source_relative \ +// --go-grpc_out=. --go-grpc_opt=paths=source_relative polestar.proto + +package services.vehiclestates.battery; + +option go_package = "github.com/evcc-io/evcc/vehicle/polestar/pb"; + +enum ChargerConnectionStatus { + CHARGER_CONNECTION_STATUS_UNSPECIFIED = 0; + CHARGER_CONNECTION_STATUS_CONNECTED = 1; + CHARGER_CONNECTION_STATUS_DISCONNECTED = 2; + CHARGER_CONNECTION_STATUS_FAULT = 3; +} + +enum ChargingStatus { + CHARGING_STATUS_UNSPECIFIED = 0; + CHARGING_STATUS_CHARGING = 1; + CHARGING_STATUS_IDLE = 2; + CHARGING_STATUS_DONE = 3; + CHARGING_STATUS_FAULT = 4; + CHARGING_STATUS_SCHEDULED = 5; + CHARGING_STATUS_DISCHARGING = 6; + CHARGING_STATUS_ERROR = 7; + CHARGING_STATUS_SMART_CHARGING = 8; +} + +message Timestamp { + int64 seconds = 1; + int32 nanos = 2; +} + +message Battery { + Timestamp timestamp = 1; + double battery_charge_level_percentage = 2; + int32 estimated_distance_to_empty_km = 4; + int32 estimated_charging_time_to_full_minutes = 5; + ChargerConnectionStatus charger_connection_status = 6; + ChargingStatus charging_status = 7; +} + +message GetBatteryRequest { + string id = 1; + string vin = 2; +} + +message GetBatteryResponse { + string id = 1; + string vin = 2; + Battery battery = 3; +} + +service BatteryService { + rpc GetLatestBattery(GetBatteryRequest) returns (GetBatteryResponse); +} diff --git a/vehicle/polestar/pb/polestar_grpc.pb.go b/vehicle/polestar/pb/polestar_grpc.pb.go new file mode 100644 index 000000000..650ba6a6a --- /dev/null +++ b/vehicle/polestar/pb/polestar_grpc.pb.go @@ -0,0 +1,136 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.0 +// - protoc v7.34.1 +// source: polestar.proto + +// Polestar gRPC API (PCCS vehicle battery service). +// +// Polestar removed the chargingStatus field from the GraphQL carTelematicsV2 +// API. The gRPC battery service still exposes the charger connection and +// charging status. Protocol reconstructed by the pypolestar project from the +// Polestar Android app, see https://github.com/pypolestar/pypolestar. +// +// The proto package name is significant: it forms the gRPC method path +// (/services.vehiclestates.battery.BatteryService/GetLatestBattery) expected +// by the server. +// +// Regenerate with: +// protoc --go_out=. --go_opt=paths=source_relative \ +// --go-grpc_out=. --go-grpc_opt=paths=source_relative polestar.proto + +package pb + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + BatteryService_GetLatestBattery_FullMethodName = "/services.vehiclestates.battery.BatteryService/GetLatestBattery" +) + +// BatteryServiceClient is the client API for BatteryService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type BatteryServiceClient interface { + GetLatestBattery(ctx context.Context, in *GetBatteryRequest, opts ...grpc.CallOption) (*GetBatteryResponse, error) +} + +type batteryServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewBatteryServiceClient(cc grpc.ClientConnInterface) BatteryServiceClient { + return &batteryServiceClient{cc} +} + +func (c *batteryServiceClient) GetLatestBattery(ctx context.Context, in *GetBatteryRequest, opts ...grpc.CallOption) (*GetBatteryResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetBatteryResponse) + err := c.cc.Invoke(ctx, BatteryService_GetLatestBattery_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// BatteryServiceServer is the server API for BatteryService service. +// All implementations must embed UnimplementedBatteryServiceServer +// for forward compatibility. +type BatteryServiceServer interface { + GetLatestBattery(context.Context, *GetBatteryRequest) (*GetBatteryResponse, error) + mustEmbedUnimplementedBatteryServiceServer() +} + +// UnimplementedBatteryServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedBatteryServiceServer struct{} + +func (UnimplementedBatteryServiceServer) GetLatestBattery(context.Context, *GetBatteryRequest) (*GetBatteryResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetLatestBattery not implemented") +} +func (UnimplementedBatteryServiceServer) mustEmbedUnimplementedBatteryServiceServer() {} +func (UnimplementedBatteryServiceServer) testEmbeddedByValue() {} + +// UnsafeBatteryServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to BatteryServiceServer will +// result in compilation errors. +type UnsafeBatteryServiceServer interface { + mustEmbedUnimplementedBatteryServiceServer() +} + +func RegisterBatteryServiceServer(s grpc.ServiceRegistrar, srv BatteryServiceServer) { + // If the following call panics, it indicates UnimplementedBatteryServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&BatteryService_ServiceDesc, srv) +} + +func _BatteryService_GetLatestBattery_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetBatteryRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(BatteryServiceServer).GetLatestBattery(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: BatteryService_GetLatestBattery_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(BatteryServiceServer).GetLatestBattery(ctx, req.(*GetBatteryRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// BatteryService_ServiceDesc is the grpc.ServiceDesc for BatteryService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var BatteryService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "services.vehiclestates.battery.BatteryService", + HandlerType: (*BatteryServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "GetLatestBattery", + Handler: _BatteryService_GetLatestBattery_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "polestar.proto", +} diff --git a/vehicle/polestar/provider_grpc.go b/vehicle/polestar/provider_grpc.go new file mode 100644 index 000000000..fdce16447 --- /dev/null +++ b/vehicle/polestar/provider_grpc.go @@ -0,0 +1,106 @@ +package polestar + +import ( + "context" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/vehicle/polestar/pb" +) + +// GrpcProvider implements the vehicle API using the Polestar gRPC battery +// service for charge state and the GraphQL API for odometer. +type GrpcProvider struct { + batteryG func() (*pb.Battery, error) + odometerG func() (float64, error) +} + +// NewGrpcProvider creates a Polestar gRPC vehicle data provider +func NewGrpcProvider(grpc *GrpcAPI, api *API, vin string, timeout, cache time.Duration) *GrpcProvider { + return &GrpcProvider{ + batteryG: util.Cached(func() (*pb.Battery, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + return grpc.Battery(ctx, vin) + }, cache), + odometerG: util.Cached(func() (float64, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + return api.Odometer(ctx, vin) + }, cache), + } +} + +var _ api.Battery = (*GrpcProvider)(nil) + +// Soc implements the api.Battery interface +func (v *GrpcProvider) Soc() (float64, error) { + res, err := v.batteryG() + if err != nil { + return 0, err + } + return res.GetBatteryChargeLevelPercentage(), nil +} + +var _ api.ChargeState = (*GrpcProvider)(nil) + +// Status implements the api.ChargeState interface +func (v *GrpcProvider) Status() (api.ChargeStatus, error) { + res, err := v.batteryG() + if err != nil { + return api.StatusNone, err + } + + switch res.GetChargingStatus() { + case pb.ChargingStatus_CHARGING_STATUS_CHARGING, pb.ChargingStatus_CHARGING_STATUS_SMART_CHARGING: + return api.StatusC, nil + } + + if res.GetChargerConnectionStatus() == pb.ChargerConnectionStatus_CHARGER_CONNECTION_STATUS_CONNECTED { + return api.StatusB, nil + } + + return api.StatusA, nil +} + +var _ api.VehicleRange = (*GrpcProvider)(nil) + +// Range implements the api.VehicleRange interface +func (v *GrpcProvider) Range() (int64, error) { + res, err := v.batteryG() + if err != nil { + return 0, err + } + return int64(res.GetEstimatedDistanceToEmptyKm()), nil +} + +var _ api.VehicleFinishTimer = (*GrpcProvider)(nil) + +// FinishTime implements the api.VehicleFinishTimer interface +func (v *GrpcProvider) FinishTime() (time.Time, error) { + res, err := v.batteryG() + if err != nil { + return time.Time{}, err + } + + minutes := res.GetEstimatedChargingTimeToFullMinutes() + if minutes <= 0 { + return time.Time{}, api.ErrNotAvailable + } + + // anchor the relative remaining time to the API's capture timestamp + base := time.Now() + if ts := res.GetTimestamp(); ts.GetSeconds() > 0 { + base = time.Unix(ts.GetSeconds(), int64(ts.GetNanos())) + } + + return base.Add(time.Duration(minutes) * time.Minute), nil +} + +var _ api.VehicleOdometer = (*GrpcProvider)(nil) + +// Odometer implements the api.VehicleOdometer interface +func (v *GrpcProvider) Odometer() (float64, error) { + return v.odometerG() +} diff --git a/vehicle/polestar/provider_grpc_test.go b/vehicle/polestar/provider_grpc_test.go new file mode 100644 index 000000000..f29320933 --- /dev/null +++ b/vehicle/polestar/provider_grpc_test.go @@ -0,0 +1,73 @@ +package polestar + +import ( + "testing" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/vehicle/polestar/pb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGrpcStatus(t *testing.T) { + tc := []struct { + name string + connection pb.ChargerConnectionStatus + charging pb.ChargingStatus + expected api.ChargeStatus + }{ + {"disconnected", pb.ChargerConnectionStatus_CHARGER_CONNECTION_STATUS_DISCONNECTED, pb.ChargingStatus_CHARGING_STATUS_IDLE, api.StatusA}, + {"unspecified", pb.ChargerConnectionStatus_CHARGER_CONNECTION_STATUS_UNSPECIFIED, pb.ChargingStatus_CHARGING_STATUS_UNSPECIFIED, api.StatusA}, + {"connected idle", pb.ChargerConnectionStatus_CHARGER_CONNECTION_STATUS_CONNECTED, pb.ChargingStatus_CHARGING_STATUS_IDLE, api.StatusB}, + {"connected done", pb.ChargerConnectionStatus_CHARGER_CONNECTION_STATUS_CONNECTED, pb.ChargingStatus_CHARGING_STATUS_DONE, api.StatusB}, + {"charging", pb.ChargerConnectionStatus_CHARGER_CONNECTION_STATUS_CONNECTED, pb.ChargingStatus_CHARGING_STATUS_CHARGING, api.StatusC}, + {"smart charging", pb.ChargerConnectionStatus_CHARGER_CONNECTION_STATUS_CONNECTED, pb.ChargingStatus_CHARGING_STATUS_SMART_CHARGING, api.StatusC}, + } + + for _, tc := range tc { + t.Run(tc.name, func(t *testing.T) { + v := &GrpcProvider{ + batteryG: func() (*pb.Battery, error) { + return &pb.Battery{ + ChargerConnectionStatus: tc.connection, + ChargingStatus: tc.charging, + }, nil + }, + } + + status, err := v.Status() + require.NoError(t, err) + assert.Equal(t, tc.expected, status) + }) + } +} + +func TestGrpcFinishTime(t *testing.T) { + captured := time.Date(2026, 5, 22, 12, 0, 0, 0, time.UTC) + + v := &GrpcProvider{ + batteryG: func() (*pb.Battery, error) { + return &pb.Battery{ + EstimatedChargingTimeToFullMinutes: 30, + Timestamp: &pb.Timestamp{Seconds: captured.Unix()}, + }, nil + }, + } + + // finish time is anchored to the API capture timestamp, not time.Now() + ft, err := v.FinishTime() + require.NoError(t, err) + assert.True(t, captured.Add(30*time.Minute).Equal(ft)) +} + +func TestGrpcFinishTimeNotCharging(t *testing.T) { + v := &GrpcProvider{ + batteryG: func() (*pb.Battery, error) { + return &pb.Battery{EstimatedChargingTimeToFullMinutes: 0}, nil + }, + } + + _, err := v.FinishTime() + assert.ErrorIs(t, err, api.ErrNotAvailable) +} diff --git a/vehicle/polestargrpc.go b/vehicle/polestargrpc.go new file mode 100644 index 000000000..db2320a60 --- /dev/null +++ b/vehicle/polestargrpc.go @@ -0,0 +1,75 @@ +package vehicle + +import ( + "context" + "fmt" + "time" + + "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/polestar" +) + +// PolestarGRPC is an api.Vehicle implementation for Polestar cars using the +// gRPC battery API. It restores the charging status that Polestar removed from +// the GraphQL API, see https://github.com/evcc-io/evcc/issues/30071. +type PolestarGRPC struct { + *embed + *polestar.GrpcProvider +} + +func init() { + registry.Add("polestar-grpc", NewPolestarGRPCFromConfig) +} + +// NewPolestarGRPCFromConfig creates a new vehicle +func NewPolestarGRPCFromConfig(other map[string]any) (api.Vehicle, error) { + cc := struct { + embed `mapstructure:",squash"` + User, Password string + VIN string + Cache time.Duration + Timeout time.Duration + }{ + Cache: interval, + Timeout: request.Timeout, + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + log := util.NewLogger("polestar").Redact(cc.User, cc.Password, cc.VIN) + + v := &PolestarGRPC{ + embed: &cc.embed, + } + + identity, err := polestar.NewIdentity(log, cc.User, cc.Password) + if err != nil { + return v, fmt.Errorf("login failed: %w", err) + } + + api := polestar.NewAPI(log, identity) + + vehicle, err := ensureVehicleEx(cc.VIN, func() ([]polestar.ConsumerCar, error) { + ctx, cancel := context.WithTimeout(context.Background(), cc.Timeout) + defer cancel() + return api.Vehicles(ctx) + }, func(v polestar.ConsumerCar) (string, error) { + return v.VIN, nil + }) + if err != nil { + return v, err + } + + grpcAPI, err := polestar.NewGrpcAPI(log, identity) + if err != nil { + return v, fmt.Errorf("grpc: %w", err) + } + + v.GrpcProvider = polestar.NewGrpcProvider(grpcAPI, api, vehicle.VIN, cc.Timeout, cc.Cache) + + return v, nil +} From a2ebac9fcfe553f49e6249fe94a15e21eb38ab2e Mon Sep 17 00:00:00 2001 From: premultiply <4681172+premultiply@users.noreply.github.com> Date: Sat, 23 May 2026 13:26:35 +0200 Subject: [PATCH 0020/1007] Add Indevolt inverter (#30128) --- .../definition/meter/indevolt-battery.yaml | 94 ++++++++++++++ .../definition/meter/indevolt-hybrid.yaml | 117 ++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 templates/definition/meter/indevolt-battery.yaml create mode 100644 templates/definition/meter/indevolt-hybrid.yaml diff --git a/templates/definition/meter/indevolt-battery.yaml b/templates/definition/meter/indevolt-battery.yaml new file mode 100644 index 000000000..b61bc04ce --- /dev/null +++ b/templates/definition/meter/indevolt-battery.yaml @@ -0,0 +1,94 @@ +template: indevolt-battery +products: + - brand: Indevolt + description: + generic: SolidFlex 2000 Eco + - brand: Indevolt + description: + generic: SolidFlex 3000 AC + - brand: Indevolt + description: + generic: PowerFlex 2000 Eco + - brand: Indevolt + description: + generic: PowerFlex 3000 AC +requirements: + description: + de: | + Lokaler Direktzugriff auf Indevolt-Batteriespeicher (Non-PV Varianten) per HTTP API auf Port 8080. + + Die IP-Adresse des Geräts kann in der Indevolt-App, im Router oder per UDP-Broadcast (Port 10000) ermittelt werden. Es empfiehlt sich, dem Gerät im Router eine feste IP zuzuweisen. + + Vorzeichenkonvention der Batterieleistung sowie Verfügbarkeit eines Netzbezugs-/-einspeisepunktes (Register 2101/2108) hängen von der Installation (CT-Klemme) ab und sind ggf. zu prüfen. + + Siehe [docs.indevolt.com](https://docs.indevolt.com/docs/hardware/geek/open-data/). + en: | + Local direct access to Indevolt battery storage devices (Non-PV variants) via HTTP API on port 8080. + + The device's IP address can be obtained from the Indevolt app, the router, or via UDP broadcast (port 10000). Assigning a static IP in the router is recommended. + + Battery power sign convention and availability of grid import/export readings (registers 2101/2108) depend on installation (CT clamp) and should be verified. + + See [docs.indevolt.com](https://docs.indevolt.com/docs/hardware/geek/open-data/). +params: + - name: usage + choice: ["grid", "battery"] + - name: host + - name: user + advanced: true + - name: password + advanced: true + mask: true + - name: cache + default: 5s + - preset: battery-params +render: | + {{- define "auth" }} + {{- if .user }} + auth: + type: digest + user: {{ .user }} + password: {{ .password }} + {{- end }} + {{- end }} + type: custom + {{- if eq .usage "grid" }} + power: + source: http + uri: http://{{ .host }}:8080/rpc/Indevolt.GetData?config={{ urlEncode `{"t":[2101,2108]}` }} + method: POST + {{- include "auth" . | indent 2 }} + jq: ."2101" - ."2108" # AC Input Power minus AC Output Power + cache: {{ .cache }} + energy: + source: http + uri: http://{{ .host }}:8080/rpc/Indevolt.GetData?config={{ urlEncode `{"t":[2107]}` }} + method: POST + {{- include "auth" . | indent 2 }} + jq: ."2107" # Total AC Input Energy (kWh) + cache: {{ .cache }} + {{- end }} + {{- if eq .usage "battery" }} + power: + source: http + uri: http://{{ .host }}:8080/rpc/Indevolt.GetData?config={{ urlEncode `{"t":[6000]}` }} + method: POST + {{- include "auth" . | indent 2 }} + jq: ."6000" # Battery Power (W); ggf. scale: -1 falls Vorzeichen invertiert + cache: {{ .cache }} + soc: + source: http + uri: http://{{ .host }}:8080/rpc/Indevolt.GetData?config={{ urlEncode `{"t":[6002]}` }} + method: POST + {{- include "auth" . | indent 2 }} + jq: ."6002" # Battery SOC (%) + cache: {{ .cache }} + energy: + source: http + uri: http://{{ .host }}:8080/rpc/Indevolt.GetData?config={{ urlEncode `{"t":[6007]}` }} + method: POST + {{- include "auth" . | indent 2 }} + jq: ."6007" # Battery Total Discharging Energy (kWh) + cache: {{ .cache }} + {{- include "battery-params" . }} + {{- end }} diff --git a/templates/definition/meter/indevolt-hybrid.yaml b/templates/definition/meter/indevolt-hybrid.yaml new file mode 100644 index 000000000..3b9e5319f --- /dev/null +++ b/templates/definition/meter/indevolt-hybrid.yaml @@ -0,0 +1,117 @@ +template: indevolt-hybrid +products: + - brand: Indevolt + description: + generic: SolidFlex 1200 + - brand: Indevolt + description: + generic: SolidFlex 2000 + - brand: Indevolt + description: + generic: SolidFlex 3000 Hybrid + - brand: Indevolt + description: + generic: PowerFlex 1200 + - brand: Indevolt + description: + generic: PowerFlex 2000 + - brand: Indevolt + description: + generic: PowerFlex 3000 Hybrid +requirements: + description: + de: | + Lokaler Direktzugriff auf Indevolt-Geräte (z.B. Solidflex-Serie) per HTTP API auf Port 8080. + + Die IP-Adresse des Geräts kann in der Indevolt-App, im Router oder per UDP-Broadcast (Port 10000) ermittelt werden. Es empfiehlt sich, dem Gerät im Router eine feste IP zuzuweisen. + + Vorzeichenkonvention der Batterieleistung sowie Verfügbarkeit eines Netzbezugs-/-einspeisepunktes (Register 2101/2108) hängen von der Installation (CT-Klemme) ab und sind ggf. zu prüfen. + + Siehe [docs.indevolt.com](https://docs.indevolt.com/docs/hardware/geek/open-data/). + en: | + Local direct access to Indevolt devices (e.g. Solidflex series) via HTTP API on port 8080. + + The device's IP address can be obtained from the Indevolt app, the router, or via UDP broadcast (port 10000). Assigning a static IP in the router is recommended. + + Battery power sign convention and availability of grid import/export readings (registers 2101/2108) depend on installation (CT clamp) and should be verified. + + See [docs.indevolt.com](https://docs.indevolt.com/docs/hardware/geek/open-data/). +params: + - name: usage + choice: ["grid", "pv", "battery"] + - name: host + - name: user + advanced: true + - name: password + advanced: true + mask: true + - name: cache + default: 5s + - preset: battery-params +render: | + {{- define "auth" }} + {{- if .user }} + auth: + type: digest + user: {{ .user }} + password: {{ .password }} + {{- end }} + {{- end }} + type: custom + {{- if eq .usage "grid" }} + power: + source: http + uri: http://{{ .host }}:8080/rpc/Indevolt.GetData?config={{ urlEncode `{"t":[2101,2108]}` }} + method: POST + {{- include "auth" . | indent 2 }} + jq: ."2101" - ."2108" # AC Input Power minus AC Output Power + cache: {{ .cache }} + energy: + source: http + uri: http://{{ .host }}:8080/rpc/Indevolt.GetData?config={{ urlEncode `{"t":[2107]}` }} + method: POST + {{- include "auth" . | indent 2 }} + jq: ."2107" # Total AC Input Energy (kWh) + cache: {{ .cache }} + {{- end }} + {{- if eq .usage "pv" }} + power: + source: http + uri: http://{{ .host }}:8080/rpc/Indevolt.GetData?config={{ urlEncode `{"t":[1501]}` }} + method: POST + {{- include "auth" . | indent 2 }} + jq: ."1501" # Total DC Output Power (W) + cache: {{ .cache }} + energy: + source: http + uri: http://{{ .host }}:8080/rpc/Indevolt.GetData?config={{ urlEncode `{"t":[1505]}` }} + method: POST + {{- include "auth" . | indent 2 }} + jq: ."1505" # Cumulative PV Production (Wh) + scale: 0.001 + cache: {{ .cache }} + {{- end }} + {{- if eq .usage "battery" }} + power: + source: http + uri: http://{{ .host }}:8080/rpc/Indevolt.GetData?config={{ urlEncode `{"t":[6000]}` }} + method: POST + {{- include "auth" . | indent 2 }} + jq: ."6000" # Battery Power (W); ggf. scale: -1 falls Vorzeichen invertiert + cache: {{ .cache }} + soc: + source: http + uri: http://{{ .host }}:8080/rpc/Indevolt.GetData?config={{ urlEncode `{"t":[6002]}` }} + method: POST + {{- include "auth" . | indent 2 }} + jq: ."6002" # Battery SOC (%) + cache: {{ .cache }} + energy: + source: http + uri: http://{{ .host }}:8080/rpc/Indevolt.GetData?config={{ urlEncode `{"t":[6007]}` }} + method: POST + {{- include "auth" . | indent 2 }} + jq: ."6007" # Battery Total Discharging Energy (kWh) + cache: {{ .cache }} + {{- include "battery-params" . }} + {{- end }} From bf187b9cb04fbec44d70dc4aa82845f3ba842324 Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 23 May 2026 14:51:05 +0200 Subject: [PATCH 0021/1007] chore: upgrade gosunspec (#30149) --- cmd/sunspec.go | 4 ++-- go.mod | 2 +- go.sum | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/sunspec.go b/cmd/sunspec.go index e6bb9778a..029161073 100644 --- a/cmd/sunspec.go +++ b/cmd/sunspec.go @@ -8,7 +8,7 @@ import ( sunspec "github.com/andig/gosunspec" bus "github.com/andig/gosunspec/modbus" - "github.com/andig/gosunspec/smdx" + "github.com/andig/gosunspec/types" "github.com/evcc-io/evcc/util" "github.com/spf13/cobra" "github.com/volkszaehler/mbmd/meters" @@ -37,7 +37,7 @@ func pf(format string, v ...any) { } func modelName(m sunspec.Model) string { - model := smdx.GetModel(uint16(m.Id())) + model := types.GetModel(uint16(m.Id())) if model == nil { return "" } diff --git a/go.mod b/go.mod index 17f1781a0..f4c272885 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/PuerkitoBio/goquery v1.12.0 github.com/WulfgarW/sensonet v0.0.7 github.com/andig/go-powerwall v0.3.0 - github.com/andig/gosunspec v0.0.0-20240918203654-860ce51d602b + github.com/andig/gosunspec v0.0.0-20260523124210-852310938891 github.com/andig/mbserver v0.0.0-20230310211055-1d29cbb5820e github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef github.com/aws/aws-sdk-go-v2 v1.41.5 diff --git a/go.sum b/go.sum index dd266eb1f..bf705488b 100644 --- a/go.sum +++ b/go.sum @@ -37,8 +37,8 @@ github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRF github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= github.com/andig/go-powerwall v0.3.0 h1:6g50+8xEwxrnzoSaPsxNRpc2fld8gARk0X78NEexFGI= github.com/andig/go-powerwall v0.3.0/go.mod h1:Xk09mD+7RTCuuHMX5RxlqgEUeh9oZdIX0rL0qiY6l/4= -github.com/andig/gosunspec v0.0.0-20240918203654-860ce51d602b h1:81UMfM949I7StrRay7YDUZazY8M1u/JHkzwcFEjiilQ= -github.com/andig/gosunspec v0.0.0-20240918203654-860ce51d602b/go.mod h1:c6P6szcR+ROkqZruOR4f6qbDKFjZX6OitPpj+yJ/r8k= +github.com/andig/gosunspec v0.0.0-20260523124210-852310938891 h1:UpjccgGAJV7ce6SmNZn1hVvff3sZ7Fgc4v4TDjUXhkk= +github.com/andig/gosunspec v0.0.0-20260523124210-852310938891/go.mod h1:c6P6szcR+ROkqZruOR4f6qbDKFjZX6OitPpj+yJ/r8k= github.com/andig/mbserver v0.0.0-20230310211055-1d29cbb5820e h1:m/NTP3JWpR7M0ljLxiQU4fzR25jjhe1LDtxLMNcoNJQ= github.com/andig/mbserver v0.0.0-20230310211055-1d29cbb5820e/go.mod h1:4VtYzTm//oUipwvO3yh0g/udTE7pYJM+U/kyAuFDsgM= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= From e5c314adf6ccc5501b03e57a681d9335f72dfe63 Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 23 May 2026 17:37:06 +0200 Subject: [PATCH 0022/1007] OCPP: coalesce BootNotification channel to fix reboot-loop hang (#30137) --- charger/ocpp/cp_core.go | 12 +++++++-- charger/ocpp/cp_core_test.go | 51 ++++++++++++++++++++++++++++++------ charger/ocpp_test.go | 14 ++++++---- 3 files changed, 62 insertions(+), 15 deletions(-) diff --git a/charger/ocpp/cp_core.go b/charger/ocpp/cp_core.go index 8ff4f7236..d7f544100 100644 --- a/charger/ocpp/cp_core.go +++ b/charger/ocpp/cp_core.go @@ -28,11 +28,19 @@ func (cp *CP) OnBootNotification(request *core.BootNotificationRequest) (*core.B // mark charge point as ready for communication cp.connect(true) - // notify channel for Setup (initial) or monitorReboot (reconnection) + // Notify the reboot monitor (and the initial Setup). The channel is + // buffered (size 1) and coalescing: if an older notification is still + // queued, drop it so the consumer always re-initializes against the most + // recent BootNotification. This matters when a charge point reboot-loops + // (e.g. issue #30113) faster than the monitor consumes notifications, or + // before the monitor has started at all - the channel must never overflow. + select { + case <-cp.bootNotificationRequestC: + default: + } select { case cp.bootNotificationRequestC <- request: default: - cp.log.DEBUG.Printf("boot notification channel full, discarding") } return res, nil diff --git a/charger/ocpp/cp_core_test.go b/charger/ocpp/cp_core_test.go index 59553ae81..b68624ee8 100644 --- a/charger/ocpp/cp_core_test.go +++ b/charger/ocpp/cp_core_test.go @@ -1,6 +1,7 @@ package ocpp import ( + "fmt" "sync/atomic" "testing" "time" @@ -123,18 +124,19 @@ func TestDisconnectCancelsTimer(t *testing.T) { "should not be connected after cancelled timer") } -func TestBootNotificationChannelFull(t *testing.T) { +func TestBootNotificationChannelCoalesces(t *testing.T) { log := util.NewLogger("test") cp := NewChargePoint(log, "test-cp") - // fill the channel (buffer size 1) + // pre-fill the channel (buffer size 1) with a stale notification cp.bootNotificationRequestC <- &core.BootNotificationRequest{ - ChargePointModel: "First", + ChargePointModel: "Stale", } - // second notification should be dropped (channel full, non-blocking send) + // a fresh BootNotification must replace the stale one rather than be + // discarded - the consumer needs the charge point's current state bootReq := &core.BootNotificationRequest{ - ChargePointModel: "Second", + ChargePointModel: "Fresh", ChargePointVendor: "TestVendor", } @@ -142,13 +144,46 @@ func TestBootNotificationChannelFull(t *testing.T) { require.NoError(t, err) assert.Equal(t, core.RegistrationStatusAccepted, res.Status) - // result should still be updated even though channel was full assert.Equal(t, bootReq, cp.BootNotificationResult) assert.True(t, cp.Connected()) - // channel should have the first message (second was dropped) + // channel must hold the freshest notification, exactly once req := <-cp.bootNotificationRequestC - assert.Equal(t, "First", req.ChargePointModel) + assert.Equal(t, "Fresh", req.ChargePointModel) + + select { + case extra := <-cp.bootNotificationRequestC: + t.Fatalf("channel should hold exactly one notification, got extra: %s", extra.ChargePointModel) + default: + } +} + +// TestBootNotificationRebootLoopKeepsLatest reproduces the EN+ reboot loop from +// issue #30113: a charge point reconnects repeatedly, sending a BootNotification +// each time, before the reboot monitor consumes any of them. The buffered +// channel must never overflow and must always retain the most recent +// notification so a later Setup re-runs against the charge point's real state. +func TestBootNotificationRebootLoopKeepsLatest(t *testing.T) { + log := util.NewLogger("test") + cp := NewChargePoint(log, "test-cp") + + for i := range 5 { + _, err := cp.OnBootNotification(&core.BootNotificationRequest{ + ChargePointModel: "EN+", + FirmwareVersion: fmt.Sprintf("1.1.%d", i), + }) + require.NoError(t, err) + } + + // exactly one notification queued, and it is the latest + req := <-cp.bootNotificationRequestC + assert.Equal(t, "1.1.4", req.FirmwareVersion) + + select { + case extra := <-cp.bootNotificationRequestC: + t.Fatalf("channel must buffer at most one notification, got extra: %s", extra.FirmwareVersion) + default: + } } func TestReconnectAfterReboot(t *testing.T) { diff --git a/charger/ocpp_test.go b/charger/ocpp_test.go index c68df8e73..dc4e14f47 100644 --- a/charger/ocpp_test.go +++ b/charger/ocpp_test.go @@ -58,9 +58,12 @@ func (suite *ocppTestSuite) TearDownSuite() { } func (suite *ocppTestSuite) startChargePoint(id string, connectorId int) (ocpp16.ChargePoint, *ocppj.Client) { - // Buffered generously: handlers in ocpp_test_handler.go dispatch sends via - // `go func() { triggerC <- … }()`, so a small buffer leaks one extra - // goroutine per concurrent trigger until the drain catches up. + // Buffered generously: the handlers in ocpp_test_handler.go send to + // triggerC synchronously (via defer) on the charge point's WebSocket + // read-loop goroutine. If that send blocks, the read loop cannot deliver + // the CALL_RESULT for the CP→CS request the drain goroutine is waiting + // on, deadlocking the test. The buffer keeps the send from blocking + // until the drain catches up. handler := &ChargePointHandler{ triggerC: make(chan remotetrigger.MessageTrigger, 16), } @@ -79,8 +82,9 @@ func (suite *ocppTestSuite) startChargePoint(id string, connectorId int) (ocpp16 // let cs handle the trigger messages; exit on done so we do not leak a // drain goroutine into subsequent subtests on the shared ocpp.Instance(). - // Cannot close triggerC because the async senders in ocpp_test_handler.go - // would panic on `send on closed channel`. + // triggerC is deliberately left open: the handlers in ocpp_test_handler.go + // send to it from the charge point's read loop, which we do not synchronize + // with on shutdown, so closing it could panic on `send on closed channel`. done := make(chan struct{}) finished := make(chan struct{}) go func() { From d1214665531e25fdfb15f6bc2f6479e9ec3c80a8 Mon Sep 17 00:00:00 2001 From: okuegow Date: Sat, 23 May 2026 21:49:08 +0200 Subject: [PATCH 0023/1007] Add Genesis Europe Bluelink (#30158) --- README.md | 2 +- templates/definition/vehicle/genesis.yaml | 18 ++++++++++++++++++ vehicle/bluelink.go | 19 +++++++++++++++++++ vehicle/bluelink/identity.go | 1 + 4 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 templates/definition/vehicle/genesis.yaml diff --git a/README.md b/README.md index 0c66d6742..b710ca2de 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Featured in [PV Magazine](https://www.pv-magazine.de/2022/01/14/mit-open-source- - **sunspec**-compatible inverter or home battery devices - **mbmd**-compatible devices, see [volkszaehler/mbmd](https://github.com/volkszaehler/mbmd#supported-devices) for a complete list - [vehicle](https://docs.evcc.io/en/docs/devices/vehicles) integrations (state of charge, remote charge, battery and preconditioning status): - - Aiways, Audi, BMW, Citroën, Dacia, DS, Fiat, Ford, Hyundai, Jeep, Kia, Mercedes-Benz, MG, Mini, Nissan, NIU, Opel, Peugeot, Polestar, Renault, Seat, Skoda, Smart, Subaru, Tesla, Toyota, Volkswagen, Volvo, Zero Motorcycles. [Read more.](https://docs.evcc.io/en/docs/devices/vehicles) + - Aiways, Audi, BMW, Citroën, Dacia, DS, Fiat, Ford, Genesis, Hyundai, Jeep, Kia, Mercedes-Benz, MG, Mini, Nissan, NIU, Opel, Peugeot, Polestar, Renault, Seat, Skoda, Smart, Subaru, Tesla, Toyota, Volkswagen, Volvo, Zero Motorcycles. [Read more.](https://docs.evcc.io/en/docs/devices/vehicles) - **services:** OVMS, Tronity, evNotify, ioBroker.bmw, mg2mqtt, mz2mqtt, TeslaLogger, TeslaMate, Tessi, volvo2mqtt - [plugins](https://docs.evcc.io/en/docs/devices/plugins) for integrating with any charger, smartswitch, heatpump, electric heater, meter, solar- / battery-inverter or vehicle: - Modbus, HTTP, MQTT, JavaScript, WebSocket, Go and shell scripts diff --git a/templates/definition/vehicle/genesis.yaml b/templates/definition/vehicle/genesis.yaml new file mode 100644 index 000000000..879dd75d3 --- /dev/null +++ b/templates/definition/vehicle/genesis.yaml @@ -0,0 +1,18 @@ +template: genesis +products: + - brand: Genesis + description: + generic: Bluelink +requirements: + description: + en: | + Instead of your account's password, the password field needs to be filled with a `refresh_token` ([instructions](https://github.com/evcc-io/evcc/wiki/Hyundai-Kia:-Refresh-Token)). + de: | + Anstelle des Passworts muss in das Passwort-Feld ein `refresh_token` eingetragen werden ([Anleitung](https://github.com/evcc-io/evcc/wiki/Hyundai-Kia:-Refresh-Token)). +params: + - preset: vehicle-base + - preset: vehicle-language +render: | + type: genesis + {{ include "vehicle-base" . }} + {{ include "vehicle-language" . }} diff --git a/vehicle/bluelink.go b/vehicle/bluelink.go index 22c55f05e..0548ace9c 100644 --- a/vehicle/bluelink.go +++ b/vehicle/bluelink.go @@ -20,6 +20,7 @@ type Bluelink struct { func init() { registry.Add("kia", NewKiaFromConfig) registry.Add("hyundai", NewHyundaiFromConfig) + registry.Add("genesis", NewGenesisFromConfig) } // NewHyundaiFromConfig creates a new vehicle @@ -56,6 +57,24 @@ func NewKiaFromConfig(other map[string]any) (api.Vehicle, error) { return newBluelinkFromConfig("kia", other, settings) } +// NewGenesisFromConfig creates a new vehicle +// Constants sourced from hyundai_kia_connect_api (KiaUvoApiEU.py, BRAND_GENESIS, Europe) +func NewGenesisFromConfig(other map[string]any) (api.Vehicle, error) { + settings := bluelink.Config{ + URI: "https://prd-eu-ccapi.genesis.com:443", + CCSPServiceID: "3020afa2-30ff-412a-aa51-d28fbe901e10", + CCSPServiceSecret: "FKDdlef2ffdleFEweELFKERiLER2FED21sDdwdgQz6hFESE3", + CCSPApplicationID: "f11f2b86-e0e7-4851-90df-5600b01d8b70", + Cfb: "RFtoRq/vDXJmRndoZaZQyYo3/qFLtVReW8P7utRPcc0ZxOzOELm9mexvviBk/qqIp4A=", + BasicToken: "MzAyMGFmYTItMzBmZi00MTJhLWFhNTEtZDI4ZmJlOTAxZTEwOkZLRGRsZWYyZmZkbGVGRXdlRUxGS0VSaUxFUjJGRUQyMXNEZHdkZ1F6NmhGRVNFMw==", + PushType: "GCM", + LoginFormHost: "https://idpconnect-eu.genesis.com", + Brand: "genesis", + } + + return newBluelinkFromConfig("genesis", other, settings) +} + // newBluelinkFromConfig creates a new Vehicle func newBluelinkFromConfig(brand string, other map[string]any, settings bluelink.Config) (api.Vehicle, error) { cc := struct { diff --git a/vehicle/bluelink/identity.go b/vehicle/bluelink/identity.go index 13f712872..d7021b3b1 100644 --- a/vehicle/bluelink/identity.go +++ b/vehicle/bluelink/identity.go @@ -140,6 +140,7 @@ func (v *Identity) Login(user, password, language, brand string) (err error) { switch brand { case "kia": case "hyundai": + case "genesis": default: return fmt.Errorf("unknown brand (%s)", brand) } From 34a0bfc0c00b5a83ece017a8f09130a8797ded83 Mon Sep 17 00:00:00 2001 From: FrankvdAa Date: Sun, 24 May 2026 10:20:11 +0200 Subject: [PATCH 0024/1007] Atmoce: remove rs485 (#30163) --- templates/definition/meter/atmoce.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/templates/definition/meter/atmoce.yaml b/templates/definition/meter/atmoce.yaml index 173b96321..9b41735b0 100644 --- a/templates/definition/meter/atmoce.yaml +++ b/templates/definition/meter/atmoce.yaml @@ -14,8 +14,7 @@ params: - name: usage choice: ["grid", "pv", "battery"] - name: modbus - choice: ["rs485", "tcpip"] - baudrate: 9600 + choice: ["tcpip"] id: 1 - name: maxacpower advanced: true From 0b20540f7485d56429c93867c7e25cafdd34c9b5 Mon Sep 17 00:00:00 2001 From: FrankvdAa Date: Sun, 24 May 2026 10:20:45 +0200 Subject: [PATCH 0025/1007] Enphase: modbus interface (SunSpec 700) (#30121) Co-authored-by: andig Co-authored-by: andig Co-authored-by: Claude Opus 4.7 (1M context) --- .../definition/meter/enphase-modbus.yaml | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 templates/definition/meter/enphase-modbus.yaml diff --git a/templates/definition/meter/enphase-modbus.yaml b/templates/definition/meter/enphase-modbus.yaml new file mode 100644 index 000000000..af30fe624 --- /dev/null +++ b/templates/definition/meter/enphase-modbus.yaml @@ -0,0 +1,51 @@ +template: enphase-modbus +products: + - brand: Enphase + description: + generic: IQ Gateway (Modbus) +requirements: + description: + en: | + Requires Enphase support to enable modbus functionality on IQ Gateway. + de: | + Erfordert Enphase-Unterstützung, um die Modbus-Funktionalität auf dem IQ Gateway zu aktivieren. +params: + - name: usage + choice: ["pv"] + - name: modbus + choice: ["tcpip"] + id: 1 +render: | + type: custom + # sunspec model 701 (DER AC Measurement) + {{- if eq .usage "pv" }} + power: + source: sunspec + {{- include "modbus" . | indent 2 }} + value: 701:W + energy: + source: sunspec + {{- include "modbus" . | indent 2 }} + value: 701:TotWhInj + scale: 0.001 + currents: + - source: sunspec + {{- include "modbus" . | indent 4 }} + value: 701:AL1 + - source: sunspec + {{- include "modbus" . | indent 4 }} + value: 701:AL2 + - source: sunspec + {{- include "modbus" . | indent 4 }} + value: 701:AL3 + voltages: + - source: sunspec + {{- include "modbus" . | indent 4 }} + value: 701:VL1 + - source: sunspec + {{- include "modbus" . | indent 4 }} + value: 701:VL2 + - source: sunspec + {{- include "modbus" . | indent 4 }} + value: 701:VL3 + {{- end }} From f2a69620ccc9951510c7d64659abbdcc46bc83c6 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 24 May 2026 10:26:36 +0200 Subject: [PATCH 0026/1007] OCPP: survive mid-setup disconnect on buggy chargers (#30113) (#30165) --- charger/ocpp/cp.go | 22 ++++++++++++++++++++++ charger/ocpp/cp_core_test.go | 26 ++++++++++++++++++++++++++ charger/ocpp/cp_setup.go | 7 +++++++ charger/ocpp/helper.go | 5 +++++ charger/ocpp/helper_test.go | 9 +++++++++ 5 files changed, 69 insertions(+) diff --git a/charger/ocpp/cp.go b/charger/ocpp/cp.go index f200eceae..90f2f2673 100644 --- a/charger/ocpp/cp.go +++ b/charger/ocpp/cp.go @@ -6,6 +6,7 @@ import ( "sync" "time" + "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/util" "github.com/lorenzodonini/ocpp-go/ocpp1.6/core" "github.com/lorenzodonini/ocpp-go/ocpp1.6/remotetrigger" @@ -212,6 +213,27 @@ func (cp *CP) HasConnected() <-chan struct{} { return cp.connectC } +// waitConnected blocks until the charge point is connected (i.e. a +// BootNotification has been received after the most recent disconnect). +// Returns immediately if already connected, otherwise waits for the next +// BootNotification on the coalescing request channel, bounded by Timeout. +func (cp *CP) waitConnected(ctx context.Context) error { + if cp.Connected() { + return nil + } + + cp.log.DEBUG.Printf("charge point disconnected, waiting for reconnect") + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(Timeout): + return api.ErrTimeout + case <-cp.bootNotificationRequestC: + return nil + } +} + // MonitorReboot ensures the given function runs only once per CP instance. // Used to start the reboot monitor goroutine for multi-connector charge points. func (cp *CP) MonitorReboot(ctx context.Context, setup func() error) { diff --git a/charger/ocpp/cp_core_test.go b/charger/ocpp/cp_core_test.go index b68624ee8..c5c9e5a32 100644 --- a/charger/ocpp/cp_core_test.go +++ b/charger/ocpp/cp_core_test.go @@ -239,3 +239,29 @@ func TestMonitorRebootOnlyOnce(t *testing.T) { require.Eventually(t, func() bool { return callCount.Load() == 1 }, time.Second, 10*time.Millisecond, "setup should be called exactly once") } + +func TestWaitConnectedReturnsImmediatelyWhenConnected(t *testing.T) { + log := util.NewLogger("test") + cp := NewChargePoint(log, "test-cp") + + cp.connect(true) + require.NoError(t, cp.waitConnected(t.Context())) +} + +func TestWaitConnectedResumesOnReconnectBoot(t *testing.T) { + log := util.NewLogger("test") + cp := NewChargePoint(log, "test-cp") + + // not connected, simulates the CP having dropped mid-setup + require.False(t, cp.Connected()) + + // schedule a reconnect's BootNotification after a short delay + go func() { + time.Sleep(20 * time.Millisecond) + _, _ = cp.OnBootNotification(&core.BootNotificationRequest{ + ChargePointModel: "TestModel", + }) + }() + + require.NoError(t, cp.waitConnected(t.Context())) +} diff --git a/charger/ocpp/cp_setup.go b/charger/ocpp/cp_setup.go index b5d4bae8c..b2d246adf 100644 --- a/charger/ocpp/cp_setup.go +++ b/charger/ocpp/cp_setup.go @@ -18,6 +18,13 @@ func (cp *CP) Setup(ctx context.Context, meterValues string, meterInterval time. cp.log.DEBUG.Printf("failed configuring availability: %v", err) } + // Some chargers (e.g. EN+/EVSEDO FW 1.1.805, issue #30113) hang on ChangeAvailability + // and drop the WebSocket. Without this, every subsequent request fails with + // "no client exists" and aborts charger creation fatally. + if err := cp.waitConnected(ctx); err != nil { + return err + } + // auto configuration desiredMeasurands := "Power.Active.Import,Energy.Active.Import.Register,Current.Import,Voltage,Current.Offered,Power.Offered,SoC" diff --git a/charger/ocpp/helper.go b/charger/ocpp/helper.go index 894965fad..13834f042 100644 --- a/charger/ocpp/helper.go +++ b/charger/ocpp/helper.go @@ -31,6 +31,11 @@ func wait(err error, rc chan error) error { if oe, ok := errors.AsType[*ocpp.Error](err); ok && oe.Code == ocppj.GenericError { err = api.ErrTimeout } + } else if strings.Contains(err.Error(), "no client") { + // The ocpp-go dispatcher returns "cannot send request ..., no client ... exists" + // when the CP disconnected before the request could be queued. Treat as transient + // timeout so callers can react to a reconnect instead of aborting setup fatally. + err = api.ErrTimeout } return err } diff --git a/charger/ocpp/helper_test.go b/charger/ocpp/helper_test.go index 6a7acf587..65d274afb 100644 --- a/charger/ocpp/helper_test.go +++ b/charger/ocpp/helper_test.go @@ -1,13 +1,22 @@ package ocpp import ( + "errors" "testing" "time" + "github.com/evcc-io/evcc/api" "github.com/lorenzodonini/ocpp-go/ocpp1.6/types" "github.com/stretchr/testify/assert" ) +func TestWaitMapsNoClientToTimeout(t *testing.T) { + // dispatcher returns this verbatim when the CP disconnected before the request could be queued + err := errors.New("cannot send request 4711, no client ABC123 exists") + got := wait(err, nil) + assert.ErrorIs(t, got, api.ErrTimeout) +} + func TestSortByAge(t *testing.T) { assert.Equal(t, []types.MeterValue{ {Timestamp: nil}, From 8e0d991455a6e21a3b0160599e2dc3e7c8bc71b7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 11:11:52 +0200 Subject: [PATCH 0027/1007] chore(deps-dev): bump qs from 6.15.0 to 6.15.2 (#30151) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3d9c13826..a49574cc7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9926,9 +9926,9 @@ } }, "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { From cf7a95b61af7be9d387fea4ec2af67ebcb7923ec Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Sun, 24 May 2026 11:13:05 +0200 Subject: [PATCH 0028/1007] fix: device color MQTT encoding (#30168) --- assets/js/colors.test.ts | 19 ++++++++- assets/js/colors.ts | 8 +++- assets/js/components/History/GroupChart.vue | 4 +- assets/js/types/evcc.ts | 3 +- assets/js/views/History.vue | 4 +- assets/js/views/Optimize.vue | 4 +- assets/js/views/Sessions.vue | 7 ++-- cmd/root.go | 2 +- server/http_devicecolors_handler.go | 17 +++----- ui/colors.go | 20 ++++++++- ui/colors_test.go | 45 +++++++++++++++++++++ 11 files changed, 107 insertions(+), 26 deletions(-) create mode 100644 ui/colors_test.go diff --git a/assets/js/colors.test.ts b/assets/js/colors.test.ts index 4957fda9a..82b60addd 100644 --- a/assets/js/colors.test.ts +++ b/assets/js/colors.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import colors, { dimColor, lighterColor, fullColor, resolveColors } from "./colors"; +import colors, { dimColor, lighterColor, fullColor, resolveColors, deviceColorMap } from "./colors"; import type { DeviceColors } from "./types/evcc"; describe("setAlpha helpers", () => { @@ -88,3 +88,20 @@ describe("resolveColors", () => { expect(res["extra"]).toBe(palette[0]); }); }); + +describe("deviceColorMap", () => { + it("returns empty map for undefined", () => { + expect(deviceColorMap(undefined)).toEqual({}); + }); + it("returns empty map for empty list", () => { + expect(deviceColorMap([])).toEqual({}); + }); + it("converts list of entries to title→color map", () => { + expect( + deviceColorMap([ + { title: "WP-SG+", color: "#2563EB" }, + { title: "Heizung", color: "#DC2626" }, + ]) + ).toEqual({ "WP-SG+": "#2563EB", Heizung: "#DC2626" }); + }); +}); diff --git a/assets/js/colors.ts b/assets/js/colors.ts index 937b34cf5..619d1f1c0 100644 --- a/assets/js/colors.ts +++ b/assets/js/colors.ts @@ -1,5 +1,11 @@ import { reactive } from "vue"; -import type { DeviceColors } from "./types/evcc"; +import type { DeviceColorEntry, DeviceColors } from "./types/evcc"; + +export function deviceColorMap(list: DeviceColorEntry[] | undefined): DeviceColors { + const m: DeviceColors = {}; + for (const { title, color } of list ?? []) m[title] = color; + return m; +} // alternatives // const COLORS = [ "#40916C", "#52B788", "#74C69D", "#95D5B2", "#B7E4C7", "#D8F3DC", "#081C15", "#1B4332", "#2D6A4F"]; diff --git a/assets/js/components/History/GroupChart.vue b/assets/js/components/History/GroupChart.vue index 79af34805..8a7b93cca 100644 --- a/assets/js/components/History/GroupChart.vue +++ b/assets/js/components/History/GroupChart.vue @@ -13,7 +13,7 @@ import { forecastYAxis, tooltipStyle, } from "../Forecast/echarts"; -import colors, { lighterColor, resolveColors } from "@/colors"; +import colors, { lighterColor, resolveColors, deviceColorMap } from "@/colors"; import store from "@/store"; import formatter, { POWER_UNIT } from "@/mixins/formatter"; import { PERIODS } from "../Sessions/types"; @@ -205,7 +205,7 @@ export default defineComponent({ for (const s of this.series) { if (!s.virtual && !titles.includes(s.name)) titles.push(s.name); } - const palette = resolveColors(titles, store.state.deviceColors ?? {}); + const palette = resolveColors(titles, deviceColorMap(store.state.deviceColors)); return this.series.map((s) => { // Virtual "other consumers" entity renders in a neutral gray to set // it apart from explicit meter entities. diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index c1b036b41..5ee75011f 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -25,6 +25,7 @@ declare global { export type AuthProviders = Record; export type DeviceColors = Record; +export type DeviceColorEntry = { title: string; color: string }; export interface MqttConfig { broker: string; @@ -111,7 +112,7 @@ export interface State { smartCostAvailable?: boolean; smartCostType?: SMART_COST_TYPE; siteTitle?: string; - deviceColors?: DeviceColors; + deviceColors?: DeviceColorEntry[]; vehicles: Record; statistics?: Statistics; authDisabled?: boolean; diff --git a/assets/js/views/History.vue b/assets/js/views/History.vue index f4bac7ff5..b8fa80235 100644 --- a/assets/js/views/History.vue +++ b/assets/js/views/History.vue @@ -124,7 +124,7 @@ import type { Legend } from "../components/Sessions/types"; import type { DeviceColors } from "@/types/evcc"; import { PERIODS } from "../components/Sessions/types"; import { GROUP_ORDER, groupColor } from "../components/History/groups"; -import colors, { resolveColors } from "../colors"; +import colors, { resolveColors, deviceColorMap } from "../colors"; import LegendList from "../components/Sessions/LegendList.vue"; import formatter, { POWER_UNIT } from "../mixins/formatter"; import api from "../api"; @@ -170,7 +170,7 @@ export default defineComponent({ }, computed: { deviceColors(): DeviceColors { - return store.state.deviceColors ?? {}; + return deviceColorMap(store.state.deviceColors); }, effectivePeriod(): PERIODS { return this.period && HISTORY_PERIODS.includes(this.period) diff --git a/assets/js/views/Optimize.vue b/assets/js/views/Optimize.vue index 1d6f011d4..dfcee23c5 100644 --- a/assets/js/views/Optimize.vue +++ b/assets/js/views/Optimize.vue @@ -147,7 +147,7 @@ import { formatCompactJson } from "../components/Optimize/compactJson"; import api from "../api"; import store from "../store"; import formatter from "../mixins/formatter"; -import { resolveColors } from "../colors"; +import { resolveColors, deviceColorMap } from "../colors"; import { CURRENCY } from "../types/evcc"; export default defineComponent({ @@ -192,7 +192,7 @@ export default defineComponent({ } }, deviceColors() { - return store.state.deviceColors ?? {}; + return deviceColorMap(store.state.deviceColors); }, batteryTitles(): string[] { const details = this.evopt?.details?.batteryDetails || []; diff --git a/assets/js/views/Sessions.vue b/assets/js/views/Sessions.vue index 2ca3bf132..752986107 100644 --- a/assets/js/views/Sessions.vue +++ b/assets/js/views/Sessions.vue @@ -205,7 +205,8 @@ import IconSelectGroup from "../components/Helper/IconSelectGroup.vue"; import IconSelectItem from "../components/Helper/IconSelectItem.vue"; import SelectGroup from "../components/Helper/SelectGroup.vue"; import CustomSelect from "../components/Helper/CustomSelect.vue"; -import colors, { resolveColors } from "../colors"; +import colors, { resolveColors, deviceColorMap } from "../colors"; +import type { DeviceColors } from "@/types/evcc"; import settings from "../settings"; import PeriodSelector from "../components/Sessions/PeriodSelector.vue"; import DateNavigator from "../components/Sessions/DateNavigator.vue"; @@ -503,8 +504,8 @@ export default defineComponent({ } return this.csvHrefLink(); }, - deviceColors() { - return store.state.deviceColors ?? {}; + deviceColors(): DeviceColors { + return deviceColorMap(store.state.deviceColors); }, colorMappings() { const lastThreeMonths = new Date(); diff --git a/cmd/root.go b/cmd/root.go index 0d43db9de..e58ac265a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -361,7 +361,7 @@ func runRoot(cmd *cobra.Command, args []string) { } // publish initial settings - valueChan <- util.Param{Key: keys.DeviceColors, Val: ui.GetDeviceColors()} + valueChan <- util.Param{Key: keys.DeviceColors, Val: ui.DeviceColorList()} valueChan <- util.Param{Key: keys.EEBus, Val: globalconfig.ConfigStatus{ Config: conf.EEBus.Redacted(), Status: eebus.GetStatus(), diff --git a/server/http_devicecolors_handler.go b/server/http_devicecolors_handler.go index bdcede30c..17262d38d 100644 --- a/server/http_devicecolors_handler.go +++ b/server/http_devicecolors_handler.go @@ -3,7 +3,6 @@ package server import ( "encoding/json" "errors" - "maps" "net/http" "regexp" @@ -14,7 +13,6 @@ import ( var hexColorRE = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) -// updateDeviceColor sets/removes a single title→hex association. func updateDeviceColor(site site.API) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req struct { @@ -29,26 +27,23 @@ func updateDeviceColor(site site.API) http.HandlerFunc { jsonError(w, http.StatusBadRequest, errors.New("title required")) return } - color := req.Color - if color != "" && !hexColorRE.MatchString(color) { + if req.Color != "" && !hexColorRE.MatchString(req.Color) { jsonError(w, http.StatusBadRequest, errors.New("invalid hex color")) return } m := ui.GetDeviceColors() - m[req.Title] = color - - // clone — Publish serializes async - site.Publish(keys.DeviceColors, maps.Clone(m)) - - // delete only after publish to enforce update in client - if color == "" { + if req.Color == "" { delete(m, req.Title) + } else { + m[req.Title] = req.Color } if err := ui.SaveDeviceColors(m); err != nil { jsonError(w, http.StatusInternalServerError, err) return } + + site.Publish(keys.DeviceColors, ui.DeviceColorList()) jsonWrite(w, m) } } diff --git a/ui/colors.go b/ui/colors.go index b4960d09c..597e219d1 100644 --- a/ui/colors.go +++ b/ui/colors.go @@ -3,15 +3,22 @@ package ui import ( "maps" + "slices" + "strings" "sync" "github.com/evcc-io/evcc/core/keys" "github.com/evcc-io/evcc/server/db/settings" ) +// DeviceColor is the MQTT-safe publish format (map keys would leak into topic segments). +type DeviceColor struct { + Title string `json:"title"` + Color string `json:"color"` +} + var colorsMu sync.RWMutex -// GetDeviceColors returns the persisted title→hex map (never nil). func GetDeviceColors() map[string]string { colorsMu.RLock() defer colorsMu.RUnlock() @@ -20,7 +27,6 @@ func GetDeviceColors() map[string]string { return m } -// SaveDeviceColors persists the title→hex map. func SaveDeviceColors(m map[string]string) error { colorsMu.Lock() defer colorsMu.Unlock() @@ -28,3 +34,13 @@ func SaveDeviceColors(m map[string]string) error { maps.Copy(clean, m) return settings.SetJson(keys.DeviceColors, clean) } + +func DeviceColorList() []DeviceColor { + m := GetDeviceColors() + list := make([]DeviceColor, 0, len(m)) + for title, color := range m { + list = append(list, DeviceColor{Title: title, Color: color}) + } + slices.SortFunc(list, func(a, b DeviceColor) int { return strings.Compare(a.Title, b.Title) }) + return list +} diff --git a/ui/colors_test.go b/ui/colors_test.go new file mode 100644 index 000000000..2329765bb --- /dev/null +++ b/ui/colors_test.go @@ -0,0 +1,45 @@ +package ui + +import ( + "path/filepath" + "testing" + + "github.com/evcc-io/evcc/server/db" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupDB(t *testing.T) { + t.Helper() + require.NoError(t, db.NewInstance("sqlite", filepath.Join(t.TempDir(), "test.db"))) +} + +func TestDeviceColors_RoundTrip(t *testing.T) { + setupDB(t) + + in := map[string]string{"WP-SG+": "#2563EB", "Heizung": "#DC2626"} + require.NoError(t, SaveDeviceColors(in)) + + assert.Equal(t, in, GetDeviceColors()) +} + +func TestDeviceColors_EmptyWhenAbsent(t *testing.T) { + setupDB(t) + assert.Empty(t, GetDeviceColors()) +} + +func TestDeviceColorList_SortedAndSafe(t *testing.T) { + setupDB(t) + + require.NoError(t, SaveDeviceColors(map[string]string{ + "WP-SG+": "#2563EB", + "Heizung": "#DC2626", + "Carport": "#10B981", + })) + + assert.Equal(t, []DeviceColor{ + {Title: "Carport", Color: "#10B981"}, + {Title: "Heizung", Color: "#DC2626"}, + {Title: "WP-SG+", Color: "#2563EB"}, + }, DeviceColorList()) +} From 5857cde809ad659b7924643cd91f0b5a26bc1c1b Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 24 May 2026 11:14:23 +0200 Subject: [PATCH 0029/1007] Metrics: tolerate NULL energy buckets in importProfile (#30169) --- core/metrics/db_profile.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/metrics/db_profile.go b/core/metrics/db_profile.go index 0130986db..78d9570ec 100644 --- a/core/metrics/db_profile.go +++ b/core/metrics/db_profile.go @@ -18,7 +18,8 @@ func importProfile(entity entity, from time.Time) (*[96]float64, error) { return nil, err } - rows, err := db.Query(`SELECT min(ts) AS ts, avg(energy) AS energy + // COALESCE guards against legacy rows with NULL energy + rows, err := db.Query(`SELECT min(ts) AS ts, COALESCE(avg(energy), 0) AS energy FROM meters WHERE meter = ? AND ts >= ? GROUP BY strftime("%H:%M", ts, 'unixepoch', 'localtime') From f5cbb12ab49a12addbbbf1b9710c19a09f8e2ddb Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 24 May 2026 12:12:25 +0200 Subject: [PATCH 0030/1007] Hyundai Bluelink (US): expose required PIN (#30054) --- templates/definition/vehicle/hyundai-us.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/templates/definition/vehicle/hyundai-us.yaml b/templates/definition/vehicle/hyundai-us.yaml index 8617ff685..78a251010 100644 --- a/templates/definition/vehicle/hyundai-us.yaml +++ b/templates/definition/vehicle/hyundai-us.yaml @@ -5,6 +5,12 @@ products: generic: Bluelink (US) params: - preset: vehicle-base + - name: pin + required: true + help: + en: Required by Bluelink (US) to authorize remote commands. + de: Wird von Bluelink (US) benötigt, um Fernkommandos zu autorisieren. render: | type: hyundai-us {{ include "vehicle-base" . }} + pin: {{ quote .pin }} From a77bbf8abb6b71277b2f62c03c69dcd6f0b851cc Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 24 May 2026 13:46:59 +0200 Subject: [PATCH 0031/1007] Revert "OCPP: survive mid-setup disconnect on buggy chargers (#30113)" (#30178) --- charger/ocpp/cp.go | 22 ---------------------- charger/ocpp/cp_core_test.go | 26 -------------------------- charger/ocpp/cp_setup.go | 7 ------- charger/ocpp/helper.go | 5 ----- charger/ocpp/helper_test.go | 9 --------- 5 files changed, 69 deletions(-) diff --git a/charger/ocpp/cp.go b/charger/ocpp/cp.go index 90f2f2673..f200eceae 100644 --- a/charger/ocpp/cp.go +++ b/charger/ocpp/cp.go @@ -6,7 +6,6 @@ import ( "sync" "time" - "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/util" "github.com/lorenzodonini/ocpp-go/ocpp1.6/core" "github.com/lorenzodonini/ocpp-go/ocpp1.6/remotetrigger" @@ -213,27 +212,6 @@ func (cp *CP) HasConnected() <-chan struct{} { return cp.connectC } -// waitConnected blocks until the charge point is connected (i.e. a -// BootNotification has been received after the most recent disconnect). -// Returns immediately if already connected, otherwise waits for the next -// BootNotification on the coalescing request channel, bounded by Timeout. -func (cp *CP) waitConnected(ctx context.Context) error { - if cp.Connected() { - return nil - } - - cp.log.DEBUG.Printf("charge point disconnected, waiting for reconnect") - - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(Timeout): - return api.ErrTimeout - case <-cp.bootNotificationRequestC: - return nil - } -} - // MonitorReboot ensures the given function runs only once per CP instance. // Used to start the reboot monitor goroutine for multi-connector charge points. func (cp *CP) MonitorReboot(ctx context.Context, setup func() error) { diff --git a/charger/ocpp/cp_core_test.go b/charger/ocpp/cp_core_test.go index c5c9e5a32..b68624ee8 100644 --- a/charger/ocpp/cp_core_test.go +++ b/charger/ocpp/cp_core_test.go @@ -239,29 +239,3 @@ func TestMonitorRebootOnlyOnce(t *testing.T) { require.Eventually(t, func() bool { return callCount.Load() == 1 }, time.Second, 10*time.Millisecond, "setup should be called exactly once") } - -func TestWaitConnectedReturnsImmediatelyWhenConnected(t *testing.T) { - log := util.NewLogger("test") - cp := NewChargePoint(log, "test-cp") - - cp.connect(true) - require.NoError(t, cp.waitConnected(t.Context())) -} - -func TestWaitConnectedResumesOnReconnectBoot(t *testing.T) { - log := util.NewLogger("test") - cp := NewChargePoint(log, "test-cp") - - // not connected, simulates the CP having dropped mid-setup - require.False(t, cp.Connected()) - - // schedule a reconnect's BootNotification after a short delay - go func() { - time.Sleep(20 * time.Millisecond) - _, _ = cp.OnBootNotification(&core.BootNotificationRequest{ - ChargePointModel: "TestModel", - }) - }() - - require.NoError(t, cp.waitConnected(t.Context())) -} diff --git a/charger/ocpp/cp_setup.go b/charger/ocpp/cp_setup.go index b2d246adf..b5d4bae8c 100644 --- a/charger/ocpp/cp_setup.go +++ b/charger/ocpp/cp_setup.go @@ -18,13 +18,6 @@ func (cp *CP) Setup(ctx context.Context, meterValues string, meterInterval time. cp.log.DEBUG.Printf("failed configuring availability: %v", err) } - // Some chargers (e.g. EN+/EVSEDO FW 1.1.805, issue #30113) hang on ChangeAvailability - // and drop the WebSocket. Without this, every subsequent request fails with - // "no client exists" and aborts charger creation fatally. - if err := cp.waitConnected(ctx); err != nil { - return err - } - // auto configuration desiredMeasurands := "Power.Active.Import,Energy.Active.Import.Register,Current.Import,Voltage,Current.Offered,Power.Offered,SoC" diff --git a/charger/ocpp/helper.go b/charger/ocpp/helper.go index 13834f042..894965fad 100644 --- a/charger/ocpp/helper.go +++ b/charger/ocpp/helper.go @@ -31,11 +31,6 @@ func wait(err error, rc chan error) error { if oe, ok := errors.AsType[*ocpp.Error](err); ok && oe.Code == ocppj.GenericError { err = api.ErrTimeout } - } else if strings.Contains(err.Error(), "no client") { - // The ocpp-go dispatcher returns "cannot send request ..., no client ... exists" - // when the CP disconnected before the request could be queued. Treat as transient - // timeout so callers can react to a reconnect instead of aborting setup fatally. - err = api.ErrTimeout } return err } diff --git a/charger/ocpp/helper_test.go b/charger/ocpp/helper_test.go index 65d274afb..6a7acf587 100644 --- a/charger/ocpp/helper_test.go +++ b/charger/ocpp/helper_test.go @@ -1,22 +1,13 @@ package ocpp import ( - "errors" "testing" "time" - "github.com/evcc-io/evcc/api" "github.com/lorenzodonini/ocpp-go/ocpp1.6/types" "github.com/stretchr/testify/assert" ) -func TestWaitMapsNoClientToTimeout(t *testing.T) { - // dispatcher returns this verbatim when the CP disconnected before the request could be queued - err := errors.New("cannot send request 4711, no client ABC123 exists") - got := wait(err, nil) - assert.ErrorIs(t, got, api.ErrTimeout) -} - func TestSortByAge(t *testing.T) { assert.Equal(t, []types.MeterValue{ {Timestamp: nil}, From 4d80489986d5ab7e61adc633e953c9d1a5c70065 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 24 May 2026 23:22:05 +0200 Subject: [PATCH 0032/1007] Loadpoint: keep mode on integrated device disconnect (#30189) --- core/loadpoint.go | 6 +++++- core/loadpoint_vehicle_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/core/loadpoint.go b/core/loadpoint.go index fba363314..ee84555e0 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -572,7 +572,11 @@ func (lp *Loadpoint) evVehicleDisconnectHandler() { lp.stopVehicleDetection() // set default mode on disconnect - lp.defaultMode() + // skip for integrated devices: the "disconnect" here is just the socket + // being switched off, not a vehicle being unplugged - keep the user's mode (#30187) + if !lp.chargerHasFeature(api.IntegratedDevice) { + lp.defaultMode() + } // set default vehicle (may be nil) lp.setActiveVehicle(lp.defaultVehicle) diff --git a/core/loadpoint_vehicle_test.go b/core/loadpoint_vehicle_test.go index 2cdcfd10f..2e6cd9e5c 100644 --- a/core/loadpoint_vehicle_test.go +++ b/core/loadpoint_vehicle_test.go @@ -323,6 +323,36 @@ func TestDefaultVehicle(t *testing.T) { assert.Nil(t, lp.vehicle, "expected no vehicle") } +// integratedDeviceCharger is a minimal charger advertising the IntegratedDevice feature. +type integratedDeviceCharger struct{} + +func (c *integratedDeviceCharger) Status() (api.ChargeStatus, error) { return api.StatusA, nil } +func (c *integratedDeviceCharger) Enabled() (bool, error) { return false, nil } +func (c *integratedDeviceCharger) Enable(bool) error { return nil } +func (c *integratedDeviceCharger) MaxCurrent(int64) error { return nil } +func (c *integratedDeviceCharger) Features() []api.Feature { + return []api.Feature{api.IntegratedDevice} +} + +// TestDisconnectIntegratedDeviceKeepsMode is a regression test for #30187: +// switching an integrated-device loadpoint to "off" makes a switch socket report +// StatusA (disconnect). The disconnect handler must NOT reset the mode to the +// configured DefaultMode in that case, otherwise the loadpoint immediately flips +// back to pv and the socket re-enables. +func TestDisconnectIntegratedDeviceKeepsMode(t *testing.T) { + lp := NewLoadpoint(util.NewLogger("foo"), settings.NewDatabaseSettingsAdapter("foo")) + lp.charger = &integratedDeviceCharger{} + lp.DefaultMode = api.ModePV + lp.setMode(api.ModeOff) + + x, y, z := createChannels(t) + attachChannels(lp, x, y, z) + + lp.evVehicleDisconnectHandler() + + assert.Equal(t, api.ModeOff, lp.GetMode(), "integrated device disconnect must not reset mode") +} + func TestReconnectVehicle(t *testing.T) { tc := []struct { name string From 070e27fc21442124c535e0fb8193b1124e745334 Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 25 May 2026 08:00:09 +0200 Subject: [PATCH 0033/1007] Meter (Home Assistant): grid charging via switch entities (#30191) --- meter/homeassistant.go | 42 +++++++++++++++++++ templates/definition/meter/homeassistant.yaml | 36 ++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/meter/homeassistant.go b/meter/homeassistant.go index 58c14175b..8f42dad89 100644 --- a/meter/homeassistant.go +++ b/meter/homeassistant.go @@ -3,6 +3,7 @@ package meter import ( "errors" "fmt" + "strings" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/api/implement" @@ -34,6 +35,11 @@ func NewHomeAssistantFromConfig(other map[string]any) (api.Meter, error) { batteryCapacity `mapstructure:",squash"` batterySocLimits `mapstructure:",squash"` batteryPowerLimits `mapstructure:",squash"` + + // battery mode control - optional switch-like entities per mode + ModeNormal string + ModeHold string + ModeCharge string }{ batterySocLimits: batterySocLimits{ MinSoc: 20, @@ -98,6 +104,26 @@ func NewHomeAssistantFromConfig(other map[string]any) (api.Meter, error) { implement.May(m, implement.BatteryCapacity(cc.batteryCapacity.Decorator())) implement.May(m, implement.BatterySocLimiter(cc.batterySocLimits.Decorator())) implement.May(m, implement.BatteryPowerLimiter(cc.batteryPowerLimits.Decorator())) + + if cc.ModeHold != "" || cc.ModeCharge != "" { + if cc.ModeNormal == "" { + return nil, errors.New("modeNormal is required when modeHold or modeCharge is configured") + } + modes := map[api.BatteryMode]string{ + api.BatteryNormal: cc.ModeNormal, + api.BatteryHold: cc.ModeHold, + api.BatteryCharge: cc.ModeCharge, + } + for _, entity := range modes { + if entity != "" && !strings.HasPrefix(entity, "script.") { + return nil, fmt.Errorf("battery mode entity must be a script: %s", entity) + } + } + implement.Has(m, implement.BatteryController(batteryModeController(conn, modes))) + } else if cc.ModeNormal != "" { + return nil, errors.New("modeNormal alone has no effect; configure modeHold and/or modeCharge") + } + return m, nil } @@ -108,3 +134,19 @@ func NewHomeAssistantFromConfig(other map[string]any) (api.Meter, error) { return m, nil } + +// batteryModeController returns a BatteryController function that activates +// the switch-like Home Assistant entity configured for the requested evcc +// battery mode. Each mode is self-contained: evcc only triggers the matching +// entity and never deactivates others - any mutual exclusion is the HA side's +// responsibility. modeHold and modeCharge are optional and return +// api.ErrNotAvailable when requested without a backing entity. +func batteryModeController(conn *homeassistant.Connection, modes map[api.BatteryMode]string) func(api.BatteryMode) error { + return func(mode api.BatteryMode) error { + target, ok := modes[mode] + if !ok || target == "" { + return api.ErrNotAvailable + } + return conn.CallSwitchService(target, true) + } +} diff --git a/templates/definition/meter/homeassistant.yaml b/templates/definition/meter/homeassistant.yaml index 97a44a8a6..f62309f2b 100644 --- a/templates/definition/meter/homeassistant.yaml +++ b/templates/definition/meter/homeassistant.yaml @@ -117,6 +117,39 @@ params: help: en: Entity ID for battery state of charge in percent de: Entitäts-ID für Batterieladestand in Prozent + - name: modeNormal + usages: ["battery"] + description: + de: Normalbetrieb Skript + en: Normal Mode Script + service: homeassistant/entities?uri={uri}&domain=script + example: "script.battery_normal" + advanced: true + help: + en: Script to put home battery to normal operation. Required when modeHold or modeCharge is set. The script is responsible for any necessary state changes on the HA side. + de: Skript um die Heimbatterie in den Normalbetrieb zu versetzen. Erforderlich, wenn modeHold oder modeCharge gesetzt ist. Das Skript ist verantwortlich für alle notwendigen Statusänderungen auf der HA-Seite. + - name: modeHold + usages: ["battery"] + description: + de: Halten Skript + en: Hold Mode Script + service: homeassistant/entities?uri={uri}&domain=script + example: "script.battery_hold" + advanced: true + help: + en: Optional script to prevent the home battery from discharging. + de: Optionales Skript um Entladen der Heimbatterie zu verhindern. + - name: modeCharge + usages: ["battery"] + description: + de: Netzladung Skript + en: Grid Charge Mode Script + service: homeassistant/entities?uri={uri}&domain=script + example: "script.battery_grid_charge" + advanced: true + help: + en: Optional script to grid-charge the home battery. + de: Optionales Skript um die Heimbatterie aus dem Netz zu laden. - name: maxacpower - preset: battery-params render: | @@ -136,5 +169,8 @@ render: | maxacpower: {{ .maxacpower }} # W {{- if eq .usage "battery" }} soc: {{ .soc }} # W + modeNormal: {{ .modeNormal }} + modeHold: {{ .modeHold }} + modeCharge: {{ .modeCharge }} {{- include "battery-params" . }} {{- end }} From 151bee8d9d6f1411c7fe25778a089b138884611c Mon Sep 17 00:00:00 2001 From: okuegow Date: Mon, 25 May 2026 16:04:48 +0200 Subject: [PATCH 0034/1007] logstash: track ring length in O(1) instead of ring.Len() per write (#30201) --- util/logstash/log.go | 17 +++++++++++--- util/logstash/log_test.go | 49 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/util/logstash/log.go b/util/logstash/log.go index 717fbbc51..f20c05b18 100644 --- a/util/logstash/log.go +++ b/util/logstash/log.go @@ -29,13 +29,19 @@ type logger struct { mu sync.RWMutex data *ring.Ring size int + // length mirrors data.Len() so Write avoids an O(n) ring.Len() call on every + // log line (see Write). Invariant: any code that changes the number of nodes + // in data must keep length in sync. + length int } func New(size int) *logger { - return &logger{ + l := &logger{ data: ring.New(1), size: size, } + l.length = l.data.Len() // keep length in sync with the initial ring + return l } var _ io.Writer = (*logger)(nil) @@ -47,9 +53,14 @@ func (l *logger) Write(p []byte) (n int, err error) { if !strings.HasPrefix(string(p), "[cache ]") { l.data.Value = element(string(p)) - // dynamically grow the ring - if l.data.Len() < l.size { + // dynamically grow the ring until it reaches the configured size. + // Track the length in O(1) instead of calling ring.Len(), which walks + // the whole ring on every write — once the ring is full (size 10000) + // that is 10000 pointer chases per log line and dominates CPU on weak + // hardware (e.g. Victron Venus OS / ARMv7) under verbose trace logging. + if l.length < l.size { l.data.Link(ring.New(1)) + l.length++ } l.data = l.data.Next() diff --git a/util/logstash/log_test.go b/util/logstash/log_test.go index 9240a85ff..6915d6910 100644 --- a/util/logstash/log_test.go +++ b/util/logstash/log_test.go @@ -36,6 +36,55 @@ func TestLog(t *testing.T) { assert.Equal(t, []string{"test1", "test2"}, log.Areas()) } +// TestRingGrowsThenCaps writes more lines than the configured size and verifies +// the ring grows up to size and then keeps only the most recent size entries. +// This guards the grow/cap accounting in Write (length tracked in O(1)). +func TestRingGrowsThenCaps(t *testing.T) { + const size = 3 + log := New(size) + + lines := []string{ + "[test ] TRACE line1", + "[test ] TRACE line2", + "[test ] TRACE line3", + "[test ] TRACE line4", + "[test ] TRACE line5", + } + for _, l := range lines { + log.Write([]byte(l)) + } + + // only the last `size` lines survive, in chronological order + got := log.All(nil, jww.LevelTrace, 0) + assert.Len(t, got, size, "ring must not exceed configured size") + assert.Equal(t, lines[len(lines)-size:], got) +} + +// TestRingSkipCacheLinesDoesNotGrow verifies that "[cache ]" lines take the +// early-return path: they are not stored and must neither grow the ring nor the +// length counter, which has to stay in sync with data.Len(). +func TestRingSkipCacheLinesDoesNotGrow(t *testing.T) { + log := New(3) + + // grow the ring past a single node first, so a stray cursor advance on the + // cache path would actually be observable (Next() on a 1-node ring is a no-op) + log.Write([]byte(s1)) + log.Write([]byte(s2)) + + stored := log.All(nil, jww.LevelTrace, 0) + lenBefore := log.length + cursorBefore := log.data + + for range 10 { + log.Write([]byte("[cache ] cache line")) + } + + assert.Equal(t, stored, log.All(nil, jww.LevelTrace, 0), "cache lines must not change stored content") + assert.Equal(t, log.data.Len(), log.length, "length counter must stay in sync with the ring") + assert.Equal(t, lenBefore, log.length, "cache lines must not grow the ring") + assert.Same(t, cursorBefore, log.data, "cache lines must not advance the write cursor") +} + func BenchmarkLog(b *testing.B) { log := New(10000) From d57d3f9743208aca30b9d7d8e6524b22261c0c22 Mon Sep 17 00:00:00 2001 From: premultiply <4681172+premultiply@users.noreply.github.com> Date: Mon, 25 May 2026 21:52:30 +0200 Subject: [PATCH 0035/1007] OCPP: add option to skip ChangeAvailability on initialization (#30217) --- charger/ocpp.go | 38 ++++++++++--------- charger/ocpp/cp_setup.go | 8 ++-- charger/ocpp_test.go | 8 ++-- templates/definition/charger/ocpp-enplus.yaml | 1 + 4 files changed, 31 insertions(+), 24 deletions(-) diff --git a/charger/ocpp.go b/charger/ocpp.go index d0722c1c9..c7bfb86e7 100644 --- a/charger/ocpp.go +++ b/charger/ocpp.go @@ -47,9 +47,10 @@ type OCPP struct { enabled bool current float64 - stackLevelZero bool - profileKindRelative bool - lp loadpoint.API + stackLevelZero bool + profileKindRelative bool + noChangeAvailability bool + lp loadpoint.API } const defaultIdTag = "evcc" // RemoteStartTransaction only @@ -75,10 +76,11 @@ func NewOCPPFromConfig(ctx context.Context, other map[string]any) (api.Charger, AutoStart bool // TODO deprecated NoStop bool // TODO deprecated - ForcePowerCtrl bool - StackLevelZero *bool - ProfileKindRelative bool - RemoteStart bool + ForcePowerCtrl bool + StackLevelZero *bool + ProfileKindRelative bool + RemoteStart bool + NoChangeAvailability *bool }{ Connector: 1, MeterInterval: 10 * time.Second, @@ -91,11 +93,12 @@ func NewOCPPFromConfig(ctx context.Context, other map[string]any) (api.Charger, stackLevelZero := cc.StackLevelZero != nil && *cc.StackLevelZero profileKindRelative := cc.ProfileKindRelative + noChangeAvailability := cc.NoChangeAvailability != nil && *cc.NoChangeAvailability c, err := NewOCPP(ctx, cc.StationId, cc.Connector, cc.IdTag, cc.MeterValues, cc.MeterInterval, - cc.ForcePowerCtrl, stackLevelZero, profileKindRelative, cc.RemoteStart, + cc.ForcePowerCtrl, stackLevelZero, profileKindRelative, cc.RemoteStart, noChangeAvailability, cc.ConnectTimeout) if err != nil { return c, err @@ -138,7 +141,7 @@ func NewOCPPFromConfig(ctx context.Context, other map[string]any) (api.Charger, func NewOCPP(ctx context.Context, id string, connector int, idTag string, meterValues string, meterInterval time.Duration, - forcePowerCtrl, stackLevelZero, profileKindRelative, remoteStart bool, + forcePowerCtrl, stackLevelZero, profileKindRelative, remoteStart, noChangeAvailability bool, connectTimeout time.Duration, ) (*OCPP, error) { log := util.NewLogger(fmt.Sprintf("%s-%d", lo.CoalesceOrEmpty(id, "ocpp"), connector)) @@ -158,7 +161,7 @@ func NewOCPP(ctx context.Context, case <-cp.HasConnected(): } - return cp.Setup(ctx, meterValues, meterInterval, forcePowerCtrl) + return cp.Setup(ctx, meterValues, meterInterval, forcePowerCtrl, noChangeAvailability) }, ) if err != nil { @@ -179,12 +182,13 @@ func NewOCPP(ctx context.Context, } c := &OCPP{ - Caps: implement.New(), - log: log, - cp: cp, - conn: conn, - stackLevelZero: stackLevelZero, - profileKindRelative: profileKindRelative, + Caps: implement.New(), + log: log, + cp: cp, + conn: conn, + stackLevelZero: stackLevelZero, + profileKindRelative: profileKindRelative, + noChangeAvailability: noChangeAvailability, } if cp.HasRemoteTriggerFeature { @@ -193,7 +197,7 @@ func NewOCPP(ctx context.Context, // monitor for charger reboots and re-run setup (once per CP, not per connector) cp.MonitorReboot(ctx, func() error { - return c.cp.Setup(ctx, meterValues, meterInterval, forcePowerCtrl) + return c.cp.Setup(ctx, meterValues, meterInterval, forcePowerCtrl, noChangeAvailability) }) return c, conn.Initialized() diff --git a/charger/ocpp/cp_setup.go b/charger/ocpp/cp_setup.go index b5d4bae8c..12b1c7a67 100644 --- a/charger/ocpp/cp_setup.go +++ b/charger/ocpp/cp_setup.go @@ -13,9 +13,11 @@ import ( "github.com/samber/lo" ) -func (cp *CP) Setup(ctx context.Context, meterValues string, meterInterval time.Duration, forcePowerCtrl bool) error { - if err := cp.ChangeAvailabilityRequest(0, core.AvailabilityTypeOperative); err != nil { - cp.log.DEBUG.Printf("failed configuring availability: %v", err) +func (cp *CP) Setup(ctx context.Context, meterValues string, meterInterval time.Duration, forcePowerCtrl, noChangeAvailability bool) error { + if !noChangeAvailability { + if err := cp.ChangeAvailabilityRequest(0, core.AvailabilityTypeOperative); err != nil { + cp.log.DEBUG.Printf("failed configuring availability: %v", err) + } } // auto configuration diff --git a/charger/ocpp_test.go b/charger/ocpp_test.go index dc4e14f47..55908ee75 100644 --- a/charger/ocpp_test.go +++ b/charger/ocpp_test.go @@ -150,7 +150,7 @@ func (suite *ocppTestSuite) TestConnect() { suite.Require().True(cp1.IsConnected()) // 1st charge point- local - c1, err := NewOCPP(suite.T().Context(), "test-1", 1, "", "", 0, false, false, false, true, ocppTestConnectTimeout) + c1, err := NewOCPP(suite.T().Context(), "test-1", 1, "", "", 0, false, false, false, true, false, ocppTestConnectTimeout) suite.Require().NoError(err) // status and meter values @@ -199,7 +199,7 @@ func (suite *ocppTestSuite) TestConnect() { suite.Require().True(cp2.IsConnected()) // 2nd charge point - local - c2, err := NewOCPP(suite.T().Context(), "test-2", 1, "", "", 0, false, false, false, true, ocppTestConnectTimeout) + c2, err := NewOCPP(suite.T().Context(), "test-2", 1, "", "", 0, false, false, false, true, false, ocppTestConnectTimeout) suite.Require().NoError(err) { @@ -241,7 +241,7 @@ func (suite *ocppTestSuite) TestAutoStart() { suite.Require().True(cp1.IsConnected()) // 1st charge point- local - c1, err := NewOCPP(suite.T().Context(), "test-3", 1, "", "", 0, false, false, false, false, ocppTestConnectTimeout) + c1, err := NewOCPP(suite.T().Context(), "test-3", 1, "", "", 0, false, false, false, false, false, ocppTestConnectTimeout) suite.Require().NoError(err) // status and meter values @@ -288,7 +288,7 @@ func (suite *ocppTestSuite) TestTimeout() { }) // 1st charge point- local - _, err := NewOCPP(suite.T().Context(), "test-4", 1, "", "", 0, false, false, false, false, ocppTestConnectTimeout) + _, err := NewOCPP(suite.T().Context(), "test-4", 1, "", "", 0, false, false, false, false, false, ocppTestConnectTimeout) suite.Require().NoError(err) } diff --git a/templates/definition/charger/ocpp-enplus.yaml b/templates/definition/charger/ocpp-enplus.yaml index 127c23567..0776241ec 100644 --- a/templates/definition/charger/ocpp-enplus.yaml +++ b/templates/definition/charger/ocpp-enplus.yaml @@ -10,3 +10,4 @@ params: - preset: ocpp render: | {{ include "ocpp" . }} + nochangeavailability: true From 933a57b2ac84883d550d7537ad883c7ce4036900 Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 25 May 2026 22:17:54 +0200 Subject: [PATCH 0036/1007] Meter (HomeWizard): expose per-phase power for signed grid currents (#30218) --- meter/homewizard.go | 7 +++++++ meter/homewizard/connection.go | 9 +++++++++ meter/homewizard/types.go | 3 +++ meter/homewizard/types_test.go | 6 ++++++ 4 files changed, 25 insertions(+) diff --git a/meter/homewizard.go b/meter/homewizard.go index 7e72b7cd8..3b01f8571 100644 --- a/meter/homewizard.go +++ b/meter/homewizard.go @@ -70,6 +70,13 @@ func (c *HomeWizard) Currents() (float64, float64, float64, error) { return c.conn.Currents() } +var _ api.PhasePowers = (*HomeWizard)(nil) + +// Powers implements the api.PhasePowers interface +func (c *HomeWizard) Powers() (float64, float64, float64, error) { + return c.conn.Powers() +} + var _ api.PhaseVoltages = (*HomeWizard)(nil) // Voltages implements the api.PhaseVoltages interface diff --git a/meter/homewizard/connection.go b/meter/homewizard/connection.go index 526712ef9..a79f5d4a1 100644 --- a/meter/homewizard/connection.go +++ b/meter/homewizard/connection.go @@ -125,6 +125,15 @@ func (c *Connection) Currents() (float64, float64, float64, error) { return res.ActiveCurrentL1A, res.ActiveCurrentL2A, res.ActiveCurrentL3A, err } +// Powers implements the api.PhasePowers interface +func (c *Connection) Powers() (float64, float64, float64, error) { + res, err := c.dataG.Get() + if c.usage == "pv" { + return -res.ActivePowerL1W, -res.ActivePowerL2W, -res.ActivePowerL3W, err + } + return res.ActivePowerL1W, res.ActivePowerL2W, res.ActivePowerL3W, err +} + // Voltages implements the api.PhaseVoltages interface func (c *Connection) Voltages() (float64, float64, float64, error) { res, err := c.dataG.Get() diff --git a/meter/homewizard/types.go b/meter/homewizard/types.go index 68a3110cf..20c3e4e86 100644 --- a/meter/homewizard/types.go +++ b/meter/homewizard/types.go @@ -17,6 +17,9 @@ type StateResponse struct { // https://homewizard-energy-api.readthedocs.io/endpoints.html#state-api-v1-state type DataResponse struct { ActivePowerW float64 `json:"active_power_w"` + ActivePowerL1W float64 `json:"active_power_l1_w"` + ActivePowerL2W float64 `json:"active_power_l2_w"` + ActivePowerL3W float64 `json:"active_power_l3_w"` TotalPowerImportT1kWh float64 `json:"total_power_import_t1_kwh"` TotalPowerImportT2kWh float64 `json:"total_power_import_t2_kwh"` TotalPowerImportT3kWh float64 `json:"total_power_import_t3_kwh"` diff --git a/meter/homewizard/types_test.go b/meter/homewizard/types_test.go index 381c5844f..0a5ee4555 100644 --- a/meter/homewizard/types_test.go +++ b/meter/homewizard/types_test.go @@ -44,6 +44,9 @@ func TestUnmarshalKwhDataResponse(t *testing.T) { assert.Equal(t, float64(30.511), res.TotalPowerImportT1kWh+res.TotalPowerImportT2kWh+res.TotalPowerImportT3kWh+res.TotalPowerImportT4kWh) assert.Equal(t, float64(85.951), res.TotalPowerExportT1kWh+res.TotalPowerExportT2kWh+res.TotalPowerExportT3kWh+res.TotalPowerExportT4kWh) assert.Equal(t, float64(543), res.ActivePowerW) + assert.Equal(t, float64(28), res.ActivePowerL1W) + assert.Equal(t, float64(0), res.ActivePowerL2W) + assert.Equal(t, float64(-181), res.ActivePowerL3W) assert.Equal(t, float64(235.4), res.ActiveVoltageL1V) assert.Equal(t, float64(235.8), res.ActiveVoltageL2V) assert.Equal(t, float64(236.1), res.ActiveVoltageL3V) @@ -64,6 +67,9 @@ func TestUnmarshalP1DataResponse(t *testing.T) { assert.Equal(t, float64(18664.997), res.TotalPowerImportT1kWh+res.TotalPowerImportT2kWh+res.TotalPowerImportT3kWh+res.TotalPowerImportT4kWh) assert.Equal(t, float64(13823.608), res.TotalPowerExportT1kWh+res.TotalPowerExportT2kWh+res.TotalPowerExportT3kWh+res.TotalPowerExportT4kWh) assert.Equal(t, float64(203), res.ActivePowerW) + assert.Equal(t, float64(-21), res.ActivePowerL1W) + assert.Equal(t, float64(57), res.ActivePowerL2W) + assert.Equal(t, float64(168), res.ActivePowerL3W) assert.Equal(t, float64(228), res.ActiveVoltageL1V) assert.Equal(t, float64(226), res.ActiveVoltageL2V) assert.Equal(t, float64(225), res.ActiveVoltageL3V) From 5e1bd2025c3596ee25d1f1be6af19b303c7fc076 Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 26 May 2026 08:21:37 +0200 Subject: [PATCH 0037/1007] Energyflow UI: standardize battery forecast wording (#30197) --- assets/js/components/Energyflow/ForecastMessage.vue | 11 ++++++----- i18n/de.json | 8 ++++---- i18n/en.json | 8 ++++---- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/assets/js/components/Energyflow/ForecastMessage.vue b/assets/js/components/Energyflow/ForecastMessage.vue index fda166b87..030552e75 100644 --- a/assets/js/components/Energyflow/ForecastMessage.vue +++ b/assets/js/components/Energyflow/ForecastMessage.vue @@ -19,7 +19,11 @@ export default defineComponent({ computed: { label(): string { if (this.point.limit) { - return this.$t("main.energyflow.forecast"); + return this.$t( + this.high + ? "main.energyflow.batteryForecastFull" + : "main.energyflow.batteryForecastEmpty" + ); } return this.$t( this.high @@ -30,10 +34,7 @@ export default defineComponent({ message(): string { const time = this.fmtAbsoluteDate(new Date(this.point.time)); if (this.point.limit) { - const key = this.high - ? "main.energyflow.batteryForecastFull" - : "main.energyflow.batteryForecastEmpty"; - return this.$t(key, { time }); + return time; } const soc = this.fmtPercentage(this.point.soc); return `${time} (${soc})`; diff --git a/i18n/de.json b/i18n/de.json index f2770b245..da04eae7c 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -1156,10 +1156,10 @@ "battery": "Batterie", "batteryCharge": "Batterie laden", "batteryDischarge": "Batterie entladen", - "batteryForecastEmpty": "Leer ab {time}", - "batteryForecastFull": "Voll ab {time}", - "batteryForecastNextHigh": "Nächstes Hoch", - "batteryForecastNextLow": "Nächstes Tief", + "batteryForecastEmpty": "Leer", + "batteryForecastFull": "Voll", + "batteryForecastNextHigh": "Hoch", + "batteryForecastNextLow": "Tief", "batteryGridChargeActive": "Netzladen: aktiv", "batteryGridChargeLimit": "Netzladen: wenn", "batteryHold": "Batterie (gesperrt)", diff --git a/i18n/en.json b/i18n/en.json index 582e88b45..a153c0775 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1156,10 +1156,10 @@ "battery": "Battery", "batteryCharge": "Battery charging", "batteryDischarge": "Battery discharging", - "batteryForecastEmpty": "Empty from {time}", - "batteryForecastFull": "Full from {time}", - "batteryForecastNextHigh": "Next high", - "batteryForecastNextLow": "Next low", + "batteryForecastEmpty": "Empty", + "batteryForecastFull": "Full", + "batteryForecastNextHigh": "High", + "batteryForecastNextLow": "Low", "batteryGridChargeActive": "Grid charging: active", "batteryGridChargeLimit": "Grid charging: when", "batteryHold": "Battery (locked)", From 20afb737ce62baa6271857abb33f0299a93ad4f2 Mon Sep 17 00:00:00 2001 From: Brian V Date: Tue, 26 May 2026 06:38:20 -0700 Subject: [PATCH 0038/1007] Config UI: use km/milage user setting in vehicle card Co-authored-by: Michael Geers --- assets/js/components/Config/DeviceTags.vue | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/assets/js/components/Config/DeviceTags.vue b/assets/js/components/Config/DeviceTags.vue index a7b8bf2c2..8de28da32 100644 --- a/assets/js/components/Config/DeviceTags.vue +++ b/assets/js/components/Config/DeviceTags.vue @@ -70,6 +70,7 @@ import formatter, { POWER_UNIT } from "@/mixins/formatter"; import TariffChart from "../Tariff/TariffChart.vue"; import { generateRateSlots, calculateCostRange } from "@/utils/tariffSlots"; +import { distanceValue, distanceUnit } from "@/units"; const HIDDEN_TAGS = ["icon", "heating", "integratedDevice"]; @@ -208,7 +209,7 @@ export default { return this.fmtTemperature(value); case "odometer": case "range": - return `${this.fmtNumber(value, 0)} km`; + return `${this.fmtNumber(distanceValue(value), 0)} ${distanceUnit()}`; case "chargeStatus": return value ? this.$t(`config.deviceValue.chargeStatus${value}`) : "-"; case "price": From 45ee243be0fb6a17088f17414f6c7b21ede8a089 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Tue, 26 May 2026 17:18:02 +0200 Subject: [PATCH 0039/1007] Color Picker: add neutral colors (#30233) --- assets/js/colors.test.ts | 2 +- assets/js/colors.ts | 3 +++ assets/js/components/Helper/ColorPickerPopover.vue | 14 +++++++------- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/assets/js/colors.test.ts b/assets/js/colors.test.ts index 82b60addd..0de0ad3af 100644 --- a/assets/js/colors.test.ts +++ b/assets/js/colors.test.ts @@ -27,7 +27,7 @@ describe("setAlpha helpers", () => { }); describe("resolveColors", () => { - const palette = colors.palette; // 18 entries + const palette = colors.palette; it("returns empty map for empty ids", () => { expect(resolveColors([], {})).toEqual({}); diff --git a/assets/js/colors.ts b/assets/js/colors.ts index 619d1f1c0..bcbd6775d 100644 --- a/assets/js/colors.ts +++ b/assets/js/colors.ts @@ -51,6 +51,7 @@ const colors: { "#F472B6", // pink "#34D399", // green "#A78BFA", // violet + "#94A3B8", // gray // mid "#2563EB", // blue "#F59E0B", // amber @@ -58,6 +59,7 @@ const colors: { "#EC4899", // pink "#10B981", // green "#8B5CF6", // violet + "#64748B", // gray // dark "#1E40AF", // blue "#B45309", // amber @@ -65,6 +67,7 @@ const colors: { "#BE185D", // pink "#047857", // green "#6D28D9", // violet + "#334155", // gray ], }); diff --git a/assets/js/components/Helper/ColorPickerPopover.vue b/assets/js/components/Helper/ColorPickerPopover.vue index 460bf72e7..f97822cef 100644 --- a/assets/js/components/Helper/ColorPickerPopover.vue +++ b/assets/js/components/Helper/ColorPickerPopover.vue @@ -106,12 +106,12 @@ export default defineComponent({ }, computed: { palette(): string[] { - // de-interleave warm/cool palette into 3×6 grid (rows: light/mid/dark, cols: hue) - const grid: string[][] = Array.from({ length: 3 }, () => Array(6).fill("")); + // de-interleave warm/cool palette into 3×7 grid (rows: light/mid/dark, cols: hue) + const grid: string[][] = Array.from({ length: 3 }, () => Array(7).fill("")); colors.palette.forEach((hex, i) => { - const row = Math.floor(i / 6); - const step = i % 6; - const col = Math.floor(step / 2) + (step % 2) * 3; + const row = Math.floor(i / 7); + const step = i % 7; + const col = step < 6 ? Math.floor(step / 2) + (step % 2) * 3 : 6; grid[row][col] = hex; }); return grid.flat(); @@ -240,8 +240,8 @@ export default defineComponent({ } .swatch-grid { display: grid; - grid-template-columns: repeat(6, 1fr); - gap: 0.5rem; + grid-template-columns: repeat(7, 1fr); + gap: 0.4rem; } .swatch { cursor: pointer; From 41b4de8783c21643995b02a568d0c559af8e776b Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Tue, 26 May 2026 18:29:05 +0200 Subject: [PATCH 0040/1007] UI: fix failed api calls after restart (#30234) --- assets/js/app.ts | 12 +++++++++--- assets/js/components/Auth/auth.ts | 4 ++-- assets/js/types/evcc.ts | 1 + cmd/root.go | 4 ++++ core/keys/global.go | 1 + 5 files changed, 17 insertions(+), 5 deletions(-) diff --git a/assets/js/app.ts b/assets/js/app.ts index 4d1860636..023d254fc 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -7,6 +7,7 @@ import setupRouter from "./router.ts"; import setupI18n from "./i18n.ts"; import { watchThemeChanges } from "./theme.ts"; import { appDetection, sendToApp } from "./utils/native"; +import store from "./store"; import type { Notification } from "./types/evcc"; // lazy load smoothscroll polyfill. mainly for safari < 15.4 @@ -20,7 +21,12 @@ if (!window.CSS.supports("scroll-behavior", "smooth")) { const app = createApp( defineComponent({ data() { - return { notifications: [] as Notification[], offline: false }; + return { notifications: [] as Notification[], wsOffline: false }; + }, + computed: { + offline(): boolean { + return this.wsOffline || store.state.apiReady !== true; + }, }, watch: { offline(value) { @@ -54,11 +60,11 @@ const app = createApp( this.notifications = []; }, setOnline() { - this.offline = false; + this.wsOffline = false; sendToApp({ type: "online" }); }, setOffline() { - this.offline = true; + this.wsOffline = true; sendToApp({ type: "offline" }); }, }, diff --git a/assets/js/components/Auth/auth.ts b/assets/js/components/Auth/auth.ts index e8d72e3b6..490095d59 100644 --- a/assets/js/components/Auth/auth.ts +++ b/assets/js/components/Auth/auth.ts @@ -116,8 +116,8 @@ watch( (offline) => offline && debounedUpdateAuthStatus() ); watch( - () => store.state.startupCompleted, - (startupCompleted) => startupCompleted && debounedUpdateAuthStatus() + () => store.state.apiReady, + (ready) => ready && debounedUpdateAuthStatus() ); export default auth; diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index 5ee75011f..f2b54fbd7 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -71,6 +71,7 @@ export interface State { experimental?: boolean; setupRequired?: boolean; startupCompleted?: boolean; + apiReady?: boolean; loadpoints: Loadpoint[]; forecast: Forecast; currency?: CURRENCY; diff --git a/cmd/root.go b/cmd/root.go index e58ac265a..c6b555071 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -244,6 +244,7 @@ func runRoot(cmd *cobra.Command, args []string) { // signal ui listening valueChan <- util.Param{Key: keys.StartupCompleted, Val: false} + valueChan <- util.Param{Key: keys.ApiReady, Val: false} // metrics if viper.GetBool("metrics") { @@ -464,6 +465,9 @@ func runRoot(cmd *cobra.Command, args []string) { }() } + // signal HTTP API ready + valueChan <- util.Param{Key: keys.ApiReady, Val: true} + if err != nil { if uw, ok := err.(interface{ Unwrap() []error }); ok { valueChan <- util.Param{Key: keys.Fatal, Val: uw.Unwrap()} diff --git a/core/keys/global.go b/core/keys/global.go index dc33185cf..695a92bb3 100644 --- a/core/keys/global.go +++ b/core/keys/global.go @@ -25,6 +25,7 @@ const ( Timezone = "timezone" Fatal = "fatal" StartupCompleted = "startupCompleted" // false: starting, true: started + ApiReady = "apiReady" // http handlers are registered SetupRequired = "setupRequired" // initial setup is required (lp = 0), fresh installation Plant = "plant" Telemetry = "telemetry" From c00c21aa040591281a74545d605ba5bd2e0c2e7b Mon Sep 17 00:00:00 2001 From: premultiply <4681172+premultiply@users.noreply.github.com> Date: Tue, 26 May 2026 18:29:44 +0200 Subject: [PATCH 0041/1007] OCPP: remove unused struct fields (#30228) --- charger/ocpp.go | 27 +++++++-------------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/charger/ocpp.go b/charger/ocpp.go index c7bfb86e7..25465fe94 100644 --- a/charger/ocpp.go +++ b/charger/ocpp.go @@ -29,7 +29,6 @@ import ( "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/api/implement" "github.com/evcc-io/evcc/charger/ocpp" - "github.com/evcc-io/evcc/core/loadpoint" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/sponsor" "github.com/lorenzodonini/ocpp-go/ocpp1.6/core" @@ -40,17 +39,14 @@ import ( // OCPP charger implementation type OCPP struct { implement.Caps - log *util.Logger cp *ocpp.CP conn *ocpp.Connector phases int enabled bool current float64 - stackLevelZero bool - profileKindRelative bool - noChangeAvailability bool - lp loadpoint.API + stackLevelZero bool + profileKindRelative bool } const defaultIdTag = "evcc" // RemoteStartTransaction only @@ -182,13 +178,11 @@ func NewOCPP(ctx context.Context, } c := &OCPP{ - Caps: implement.New(), - log: log, - cp: cp, - conn: conn, - stackLevelZero: stackLevelZero, - profileKindRelative: profileKindRelative, - noChangeAvailability: noChangeAvailability, + Caps: implement.New(), + cp: cp, + conn: conn, + stackLevelZero: stackLevelZero, + profileKindRelative: profileKindRelative, } if cp.HasRemoteTriggerFeature { @@ -442,10 +436,3 @@ func (c *OCPP) Diagnose() { } } } - -var _ loadpoint.Controller = (*OCPP)(nil) - -// LoadpointControl implements loadpoint.Controller -func (c *OCPP) LoadpointControl(lp loadpoint.API) { - c.lp = lp -} From dcc8ed6f7e6c6e4cc5b39f00019525fa940e6015 Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 26 May 2026 18:30:10 +0200 Subject: [PATCH 0042/1007] Prioritizer: no flexibility for switch devices in MinPV (#30194) --- core/loadpoint_api.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go index 6f44de9de..69963265a 100644 --- a/core/loadpoint_api.go +++ b/core/loadpoint_api.go @@ -662,7 +662,11 @@ func (lp *Loadpoint) GetChargePowerFlexibility(rates api.Rates) float64 { return lp.GetChargePower() } - // MinPV mode + // MinPV mode: a charger without current control (switch socket or heatpump) cannot release power. + if lp.chargerHasFeature(api.SwitchDevice) || lp.chargerHasFeature(api.Continuous) { + return 0 + } + return max(0, lp.GetChargePower()-lp.EffectiveMinPower()) } From 2e682c689f5ff1a016cdfe596be9e8379af7c5f9 Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 26 May 2026 18:31:20 +0200 Subject: [PATCH 0043/1007] feat(app): emit structured download event for CSV exports (#30175) --- AGENTS.md | 17 ++++++++++ .../components/Config/BackupRestoreModal.vue | 5 +++ assets/js/components/Config/HemsModal.vue | 9 ++++- assets/js/types/evcc.ts | 1 + assets/js/utils/native.ts | 23 ++++++++++++- assets/js/views/History.vue | 3 ++ assets/js/views/Log.vue | 3 ++ assets/js/views/Sessions.vue | 3 ++ tests/backup-restore.spec.ts | 33 ++++++++++++++++++- tests/hems.spec.ts | 28 +++++++++++++++- tests/logs.spec.ts | 14 +++++++- tests/sessions.spec.ts | 22 ++++++++++++- tests/utils.ts | 30 +++++++++++++++++ 13 files changed, 185 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index aaff844dc..ea5e80d5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -270,3 +270,20 @@ Deep documentation on specific subsystems is available in `docs/agents/`. Load w - Implement proper caching strategies and connection pooling - Avoid blocking operations in main application loop - Include appropriate comments for complex business logic + +## Pull Request Descriptions + +Structure PR descriptions in this order. No headlines. Be concise. + +1. **References first line**: link related issues or PRs (`fixes #1123`, `replaces #222`, `pairs with org/repo#345`). PRs should almost always reference an issue or related PR — only skip in rare exceptions (e.g. trivial typo fixes). +2. **Intro**: one or a few concise sentences framing what the PR does and why it was created this way. The full problem description belongs in the linked issue, not here. +3. **Bullet list**: most significant changes or user-facing implications. Lead with the most significant. +4. **TODO section** (only if open points remain): + + ``` + **TODO** + - [ ] item a + - [ ] item b + ``` + +Avoid file paths, line numbers, or code listings reproduced from the diff. Include a code snippet only when it conveys the contract (event shape, API signature) more clearly than prose. No testing checklists, no co-author footers, no generator footers. diff --git a/assets/js/components/Config/BackupRestoreModal.vue b/assets/js/components/Config/BackupRestoreModal.vue index 88c2105e4..67d673c02 100644 --- a/assets/js/components/Config/BackupRestoreModal.vue +++ b/assets/js/components/Config/BackupRestoreModal.vue @@ -170,6 +170,7 @@ import { defineComponent } from "vue"; import GenericModal from "../Helper/GenericModal.vue"; import api, { downloadFile } from "@/api"; +import { dispatchDownload } from "@/utils/native"; import PropertyFileField from "./PropertyFileField.vue"; import FormRow from "./FormRow.vue"; import { isLoggedIn } from "../Auth/auth"; @@ -296,6 +297,10 @@ export default defineComponent({ return r; }, async downloadBackup() { + if (dispatchDownload("/api/system/backup", "POST", { password: this.password })) { + this.closeConfirmModal(); + return; + } const res = await this.call( api.post( "/system/backup", diff --git a/assets/js/components/Config/HemsModal.vue b/assets/js/components/Config/HemsModal.vue index 8f1b99fd4..d215cafc5 100644 --- a/assets/js/components/Config/HemsModal.vue +++ b/assets/js/components/Config/HemsModal.vue @@ -25,7 +25,12 @@ $t("config.hems.lastEvent", { timeAgo: formatLastEvent(lastEvent.created) }) }} - + {{ $t("config.hems.downloadCsv") }} @@ -40,6 +45,7 @@ import YamlModal from "./YamlModal.vue"; import defaultYaml from "./defaultYaml/hems.yaml?raw"; import api from "../../api"; +import { handleDownloadClick } from "../../utils/native"; import formatter from "../../mixins/formatter"; export default { @@ -78,6 +84,7 @@ export default { }, }, methods: { + handleDownloadClick, async loadSessions() { try { const response = await api.get("gridsessions", { diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index f2b54fbd7..c5cbdc156 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -19,6 +19,7 @@ declare global { } interface Window { ReactNativeWebView?: WebView; + evccAppCapabilities?: string[]; } } diff --git a/assets/js/utils/native.ts b/assets/js/utils/native.ts index 852f9d6bb..a93b334fd 100644 --- a/assets/js/utils/native.ts +++ b/assets/js/utils/native.ts @@ -9,6 +9,27 @@ export function appDetection() { } } -export function sendToApp(data: { type: string }) { +export function hasAppCapability(capability: string): boolean { + return isApp() && window.evccAppCapabilities?.includes(capability) === true; +} + +type AppMessage = + | { type: "online" | "offline" | "settings" } + | { type: "download"; url: string; method?: string; body?: unknown }; + +export function sendToApp(data: AppMessage) { window.ReactNativeWebView?.postMessage(JSON.stringify(data)); } + +export function handleDownloadClick(event: Event, url: string) { + if (dispatchDownload(url)) { + event.preventDefault(); + } +} + +export function dispatchDownload(url: string, method?: string, body?: unknown): boolean { + if (!hasAppCapability("download")) return false; + const absolute = new URL(url, window.location.href).toString(); + sendToApp({ type: "download", url: absolute, method, body }); + return true; +} diff --git a/assets/js/views/History.vue b/assets/js/views/History.vue index b8fa80235..edac95fc2 100644 --- a/assets/js/views/History.vue +++ b/assets/js/views/History.vue @@ -100,6 +100,7 @@ download class="text-muted small history-csv-link" data-testid="history-csv-download" + @click="handleDownloadClick($event, csvLink)" > {{ $t("main.history.downloadCsv") }} @@ -126,6 +127,7 @@ import { PERIODS } from "../components/Sessions/types"; import { GROUP_ORDER, groupColor } from "../components/History/groups"; import colors, { resolveColors, deviceColorMap } from "../colors"; import LegendList from "../components/Sessions/LegendList.vue"; +import { handleDownloadClick } from "@/utils/native"; import formatter, { POWER_UNIT } from "../mixins/formatter"; import api from "../api"; import store from "../store"; @@ -368,6 +370,7 @@ export default defineComponent({ }, methods: { groupColor, + handleDownloadClick, legendsForGroup(group: string): Legend[] { const items = this.entityLegends(group); const focused = this.focusedEntity[group] ?? null; diff --git a/assets/js/views/Log.vue b/assets/js/views/Log.vue index 436e8e0a7..bc99a324a 100644 --- a/assets/js/views/Log.vue +++ b/assets/js/views/Log.vue @@ -28,6 +28,7 @@ :aria-label="$t('log.download')" :href="downloadUrl" download + @click="handleDownloadClick($event, downloadUrl)" > l.toUpperCase()).join("|")})`); @@ -222,6 +224,7 @@ export default defineComponent({ this.stopInterval(); }, methods: { + handleDownloadClick, async updateLogs(showAll: boolean = false) { // prevent concurrent requests if (this.busy) return; diff --git a/assets/js/views/Sessions.vue b/assets/js/views/Sessions.vue index 752986107..a8fe22f48 100644 --- a/assets/js/views/Sessions.vue +++ b/assets/js/views/Sessions.vue @@ -159,6 +159,7 @@ :href="csvLink" download data-testid="sessions-download" + @click="handleDownloadClick($event, csvLink)" > {{ csvLinkLabel }} @@ -211,6 +212,7 @@ import settings from "../settings"; import PeriodSelector from "../components/Sessions/PeriodSelector.vue"; import DateNavigator from "../components/Sessions/DateNavigator.vue"; import PeriodHeader from "../components/Sessions/PeriodHeader.vue"; +import { handleDownloadClick } from "@/utils/native"; import DynamicPriceIcon from "../components/MaterialIcon/DynamicPrice.vue"; import TotalIcon from "../components/MaterialIcon/Total.vue"; import { TYPES, GROUPS, PERIODS, type Session } from "../components/Sessions/types"; @@ -671,6 +673,7 @@ export default defineComponent({ this.loadSessions(); }, methods: { + handleDownloadClick, changePeriod(newPeriod: PERIODS) { let month: number | undefined = this.month; let year: number | undefined = this.year; diff --git a/tests/backup-restore.spec.ts b/tests/backup-restore.spec.ts index 3c9660f0c..8b9c02333 100644 --- a/tests/backup-restore.spec.ts +++ b/tests/backup-restore.spec.ts @@ -1,6 +1,12 @@ import { test, expect } from "@playwright/test"; import { start, stop, baseUrl, restart } from "./evcc"; -import { openMoreMenu, expectModalVisible, expectModalHidden } from "./utils"; +import { + openMoreMenu, + expectModalVisible, + expectModalHidden, + enableAppContext, + expectAppEvent, +} from "./utils"; import fs from "fs"; import path from "path"; @@ -247,3 +253,28 @@ test.describe("backup and restore", async () => { await stop(); }); }); + +test.describe("backup in app context", async () => { + test("download backup dispatches POST event with password body", async ({ page }) => { + await enableAppContext(page); + await start(); + await page.goto("/#/config"); + + await page.getByRole("button", { name: "Backup & Restore" }).click(); + const backupModal = page.getByTestId("backup-restore-modal"); + await expectModalVisible(backupModal); + + await backupModal.getByRole("button", { name: "Download backup..." }).click(); + const backupConfirmModal = page.getByTestId("backup-restore-confirm-modal"); + await expectModalVisible(backupConfirmModal); + + await backupConfirmModal.getByRole("button", { name: "Download backup" }).click(); + expect(await expectAppEvent(page)).toMatchObject({ + type: "download", + url: expect.stringContaining("/api/system/backup"), + method: "POST", + body: { password: "" }, + }); + await stop(); + }); +}); diff --git a/tests/hems.spec.ts b/tests/hems.spec.ts index 0f7324cf2..6a406b75f 100644 --- a/tests/hems.spec.ts +++ b/tests/hems.spec.ts @@ -1,6 +1,13 @@ import { test, expect } from "@playwright/test"; import { start, stop, restart, baseUrl } from "./evcc"; -import { expectModalVisible, expectModalHidden, editorClear, editorPaste } from "./utils"; +import { + expectModalVisible, + expectModalHidden, + editorClear, + editorPaste, + enableAppContext, + expectAppEvent, +} from "./utils"; import { startSimulator, stopSimulator, simulatorUrl, simulatorApply } from "./simulator"; test.use({ baseURL: baseUrl() }); @@ -109,6 +116,25 @@ limit: await stopSimulator(); }); + test.describe("grid sessions CSV in app context", () => { + test("dispatches download event", async ({ page }) => { + await enableAppContext(page); + await start(CONFIG, "hems.sql"); + await page.goto("/#/config"); + + await page.getByTestId("hems").getByRole("button", { name: "edit" }).click(); + const hemsModal = page.getByTestId("hems-modal"); + await expectModalVisible(hemsModal); + + const csvLink = hemsModal.getByRole("link", { name: "Download CSV" }); + await csvLink.click(); + expect(await expectAppEvent(page)).toMatchObject({ + type: "download", + url: expect.stringContaining("/api/gridsessions?format=csv&lang=en"), + }); + }); + }); + test("external control with circuits", async ({ page }) => { const GRID_CONFIG = "hems-grid.evcc.yaml"; await start(GRID_CONFIG); diff --git a/tests/logs.spec.ts b/tests/logs.spec.ts index 111571727..f9337918b 100644 --- a/tests/logs.spec.ts +++ b/tests/logs.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from "@playwright/test"; import { start, stop, baseUrl } from "./evcc"; -import { openMoreMenu } from "./utils"; +import { openMoreMenu, enableAppContext, expectAppEvent } from "./utils"; test.use({ baseURL: baseUrl() }); @@ -49,3 +49,15 @@ test.describe("features", async () => { await expect(page.getByTestId("log-content")).toContainText("UI local"); }); }); + +test.describe("log download in app context", async () => { + test("dispatches download event", async ({ page }) => { + await enableAppContext(page); + await page.goto("/#/log"); + await page.getByRole("link", { name: "Download complete log" }).click(); + expect(await expectAppEvent(page)).toMatchObject({ + type: "download", + url: expect.stringContaining("/api/system/log?level=debug&format=txt"), + }); + }); +}); diff --git a/tests/sessions.spec.ts b/tests/sessions.spec.ts index 4e4efe7dd..ee2010db0 100644 --- a/tests/sessions.spec.ts +++ b/tests/sessions.spec.ts @@ -1,6 +1,6 @@ import { test, expect, devices, type Page } from "@playwright/test"; import { start, stop, baseUrl } from "./evcc"; -import { expectModalVisible, expectModalHidden } from "./utils"; +import { expectModalVisible, expectModalHidden, enableAppContext, expectAppEvent } from "./utils"; test.use({ baseURL: baseUrl() }); @@ -309,6 +309,26 @@ test.describe("csv export", async () => { }); }); +test.describe("csv export download", async () => { + test("in browser context", async ({ page }) => { + await page.goto("/#/sessions?period=total"); + const downloadPromise = page.waitForEvent("download"); + await page.getByRole("link", { name: "Download total CSV" }).click(); + const download = await downloadPromise; + expect(download.url()).toContain("/api/sessions?format=csv"); + }); + + test("in app context", async ({ page }) => { + await enableAppContext(page); + await page.goto("/#/sessions?period=total"); + await page.getByRole("link", { name: "Download total CSV" }).click(); + expect(await expectAppEvent(page)).toMatchObject({ + type: "download", + url: expect.stringContaining("/api/sessions?format=csv&lang=en"), + }); + }); +}); + test.describe("session details", async () => { test("show session details (session 5)", async ({ page }) => { await page.goto("/#/sessions?year=2023&month=5"); diff --git a/tests/utils.ts b/tests/utils.ts index 8977b1e3d..0d9403daa 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -130,3 +130,33 @@ export async function getDatalistOptions(input: Locator): Promise { return Array.from(datalist?.querySelectorAll("option") || []).map((opt) => opt.value); }); } + +type AppState = { + evccAppCapabilities: string[]; + __appEvent: unknown; + ReactNativeWebView: { postMessage: (m: string) => void }; +}; + +export async function enableAppContext(page: Page): Promise { + await page.addInitScript(() => { + Object.defineProperty(navigator, "userAgent", { + value: "evcc/playwright", + configurable: true, + }); + const w = window as unknown as AppState; + w.evccAppCapabilities = ["download"]; + w.__appEvent = undefined; + w.ReactNativeWebView = { + postMessage: (msg: string) => { + w.__appEvent = JSON.parse(msg); + }, + }; + }); +} + +export async function expectAppEvent(page: Page): Promise { + await expect + .poll(async () => page.evaluate(() => (window as unknown as AppState).__appEvent !== undefined)) + .toBe(true); + return page.evaluate(() => (window as unknown as AppState).__appEvent); +} From 4d64a073e53b6d1a915962493aa7918438f8a53a Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 26 May 2026 18:41:19 +0200 Subject: [PATCH 0044/1007] Vehicle: suppress climater error log when vehicle is asleep (#30239) --- core/loadpoint_vehicle.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/loadpoint_vehicle.go b/core/loadpoint_vehicle.go index 0eec36f2d..168b74ac2 100644 --- a/core/loadpoint_vehicle.go +++ b/core/loadpoint_vehicle.go @@ -372,7 +372,7 @@ func (lp *Loadpoint) vehicleClimateActive() bool { return active } - if !errors.Is(err, api.ErrNotAvailable) { + if !errors.Is(err, api.ErrNotAvailable) && !errors.Is(err, api.ErrAsleep) { lp.log.ERROR.Printf("climater: %v", err) } } From 206b1a6a89a58709c9b70904714629538b33fae0 Mon Sep 17 00:00:00 2001 From: andig Date: Wed, 27 May 2026 09:01:25 +0200 Subject: [PATCH 0045/1007] chore: prevent time watcher from clobbering in-flight edits (#30245) --- assets/js/components/ChargingPlans/PlanStaticSettings.vue | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/assets/js/components/ChargingPlans/PlanStaticSettings.vue b/assets/js/components/ChargingPlans/PlanStaticSettings.vue index 907c40a5c..32f614119 100644 --- a/assets/js/components/ChargingPlans/PlanStaticSettings.vue +++ b/assets/js/components/ChargingPlans/PlanStaticSettings.vue @@ -242,7 +242,10 @@ export default defineComponent({ }, }, watch: { - time() { + time(newTime: Date | undefined, oldTime: Date | undefined) { + if (newTime && oldTime && newTime.getTime() === oldTime.getTime()) { + return; + } this.initInputFields(); }, soc() { From 805fded258bde23f39c6d9caa3af2130d0fb5753 Mon Sep 17 00:00:00 2001 From: andig Date: Wed, 27 May 2026 09:54:08 +0200 Subject: [PATCH 0046/1007] chore: writing/comment style and posting-on-behalf rules (#30252) --- AGENTS.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index ea5e80d5d..831e0adfd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,6 +83,20 @@ Deep documentation on specific subsystems is available in `docs/agents/`. Load w - **tests/** contains Playwright integration tests and test configuration files - **dist/** contains built frontend assets (generated) +## Writing Style + +- No em dashes (—) in comments, commit messages, or docs. Use periods, commas, or colons +- Project name is `evcc`, always lowercase +- Acronyms uppercase in prose: OCPP, MQTT, HEMS, SoC +- Commit subjects: `Component: short description`, no trailing period. Sub-scope in parens: `Meter (Home Assistant): ...`. Use `chore:`/`fix:`/`docs:` only for non-feature changes + +## Comment Style + +- Prefer self-documenting code over comments; comment the *why*, not the *what* +- Default to no comment. Only add one for a non-obvious constraint, invariant, workaround, or surprising behavior. Keep it to one line, two if necessary +- Skip refs to the current task, PR, issue, or caller ("added for X flow", "see #1234"). Git history covers that +- Exception: Go exported identifiers follow godoc convention. Short `// FuncName does X` summary starting with the identifier name + ## Go Coding Standards ### Core Principles @@ -269,7 +283,6 @@ Deep documentation on specific subsystems is available in `docs/agents/`. Load w - Handle concurrent operations safely with Go's concurrency primitives - Implement proper caching strategies and connection pooling - Avoid blocking operations in main application loop -- Include appropriate comments for complex business logic ## Pull Request Descriptions From a7170a2d3e0ea05939c41843e48d105dec567614 Mon Sep 17 00:00:00 2001 From: andig Date: Wed, 27 May 2026 14:29:02 +0200 Subject: [PATCH 0047/1007] E3DC: reconnect on RSCP error (#30257) --- charger/e3dc.go | 108 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 87 insertions(+), 21 deletions(-) diff --git a/charger/e3dc.go b/charger/e3dc.go index 06efcb27f..bc30993ab 100644 --- a/charger/e3dc.go +++ b/charger/e3dc.go @@ -54,9 +54,11 @@ import ( // E3dc charger implementation using RSCP protocol. // Communicates with the E3DC Hauskraftwerk via TCP connection. type E3dc struct { - log *util.Logger // Logger instance for debug/warning output - conn *rscp.Client // RSCP client connection to E3DC system - id uint8 // Wallbox index (0 = first wallbox, 1 = second, etc.) + mu sync.Mutex // serializes RSCP exchanges; rscp.Client is not reentrant + log *util.Logger // Logger instance for debug/warning output + conn *rscp.Client // RSCP client connection to E3DC system + id uint8 // Wallbox index (0 = first wallbox, 1 = second, etc.) + retry func() error // recreates the RSCP client on connection errors } func init() { @@ -142,6 +144,12 @@ func NewE3dc(ctx context.Context, cfg rscp.ClientConfig, id uint8) (*E3dc, error id: id, } + wb.retry = func() (err error) { + wb.conn.Disconnect() + wb.conn, err = rscp.NewClient(cfg) + return err + } + // Check wallbox configuration and warn if not optimal for evcc control if err := wb.checkConfiguration(); err != nil { return nil, err @@ -150,9 +158,25 @@ func NewE3dc(ctx context.Context, cfg rscp.ClientConfig, id uint8) (*E3dc, error return wb, nil } +// retrySend executes a single message request with one retry on error. +// The E3DC Hauskraftwerk occasionally drops the RSCP socket (manifests as +// "authentication error: EOF"). A fresh client recovers from this state. +func (wb *E3dc) retrySend(msg rscp.Message) (*rscp.Message, error) { + res, err := wb.conn.Send(msg) + if err == nil { + return res, nil + } + + if err := wb.retry(); err != nil { + return nil, err + } + + return wb.conn.Send(msg) +} + // checkConfiguration verifies wallbox settings and adjusts them for evcc control func (wb *E3dc) checkConfiguration() error { - res, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ + res, err := wb.retrySend(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ *rscp.NewMessage(rscp.WB_INDEX, wb.id), *rscp.NewMessage(rscp.WB_REQ_SUN_MODE_ACTIVE, nil), *rscp.NewMessage(rscp.WB_REQ_AUTO_PHASE_SWITCH_ENABLED, nil), @@ -191,7 +215,7 @@ func (wb *E3dc) checkConfiguration() error { // disableSunMode sends the command to disable sun mode func (wb *E3dc) disableSunMode() { - if _, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ + if _, err := wb.retrySend(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ *rscp.NewMessage(rscp.WB_INDEX, wb.id), *rscp.NewMessage(rscp.WB_REQ_SET_SUN_MODE_ACTIVE, false), })); err != nil { @@ -203,7 +227,7 @@ func (wb *E3dc) disableSunMode() { // Called before control commands (Enable, MaxCurrent) because the user could // re-enable sun mode in the E3DC portal at any time without restarting evcc. func (wb *E3dc) ensureSunModeDisabled() { - res, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ + res, err := wb.retrySend(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ *rscp.NewMessage(rscp.WB_INDEX, wb.id), *rscp.NewMessage(rscp.WB_REQ_SUN_MODE_ACTIVE, nil), })) @@ -226,7 +250,7 @@ func (wb *E3dc) ensureSunModeDisabled() { func (wb *E3dc) disableAutoPhaseSwitch() { // Note: WB_REQ_SET_AUTO_PHASE_SWITCH_ENABLED has wrong DataType in go-rscp (None instead of Bool) // We must create the message with explicit DataType - if _, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ + if _, err := wb.retrySend(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ *rscp.NewMessage(rscp.WB_INDEX, wb.id), {Tag: rscp.WB_REQ_SET_AUTO_PHASE_SWITCH_ENABLED, DataType: rscp.Bool, Value: false}, })); err != nil { @@ -238,7 +262,7 @@ func (wb *E3dc) disableAutoPhaseSwitch() { // Called before phase switch commands because the user could re-enable it // in the E3DC portal at any time without restarting evcc. func (wb *E3dc) ensureAutoPhaseDisabled() { - res, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ + res, err := wb.retrySend(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ *rscp.NewMessage(rscp.WB_INDEX, wb.id), *rscp.NewMessage(rscp.WB_REQ_AUTO_PHASE_SWITCH_ENABLED, nil), })) @@ -269,7 +293,7 @@ func (wb *E3dc) ensureAutoPhaseDisabled() { // Used by Status() and Enabled() to determine charging state. func (wb *E3dc) getExternDataAlg() ([]byte, error) { // RSCP request pattern: WB_REQ_DATA container with WB_INDEX + request tags - res, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ + res, err := wb.retrySend(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ *rscp.NewMessage(rscp.WB_INDEX, wb.id), *rscp.NewMessage(rscp.WB_REQ_EXTERN_DATA_ALG, nil), })) @@ -303,6 +327,9 @@ func (wb *E3dc) getExternDataAlg() ([]byte, error) { // Enabled implements the api.Charger interface func (wb *E3dc) Enabled() (bool, error) { + wb.mu.Lock() + defer wb.mu.Unlock() + b, err := wb.getExternDataAlg() if err != nil { return false, err @@ -315,9 +342,12 @@ func (wb *E3dc) Enabled() (bool, error) { // Enable implements the api.Charger interface // Controls charging by setting the abort flag (inverted logic: abort=false means enabled) func (wb *E3dc) Enable(enable bool) error { + wb.mu.Lock() + defer wb.mu.Unlock() + wb.ensureSunModeDisabled() - _, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ + _, err := wb.retrySend(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ *rscp.NewMessage(rscp.WB_INDEX, wb.id), *rscp.NewMessage(rscp.WB_REQ_SET_ABORT_CHARGING, !enable), })) @@ -328,6 +358,9 @@ func (wb *E3dc) Enable(enable bool) error { // Status implements the api.Charger interface // Returns the charging state by reading status flags from WB_EXTERN_DATA_ALG func (wb *E3dc) Status() (api.ChargeStatus, error) { + wb.mu.Lock() + defer wb.mu.Unlock() + b, err := wb.getExternDataAlg() if err != nil { return api.StatusNone, err @@ -356,9 +389,12 @@ func (wb *E3dc) Status() (api.ChargeStatus, error) { // MaxCurrent implements the api.Charger interface. // Sets the maximum charging current in Ampere (whole numbers only, 6-32A typical range). func (wb *E3dc) MaxCurrent(current int64) error { + wb.mu.Lock() + defer wb.mu.Unlock() + wb.ensureSunModeDisabled() - _, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ + _, err := wb.retrySend(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ *rscp.NewMessage(rscp.WB_INDEX, wb.id), *rscp.NewMessage(rscp.WB_REQ_SET_MAX_CHARGE_CURRENT, uint8(current)), })) @@ -371,6 +407,9 @@ var _ api.Meter = (*E3dc)(nil) // CurrentPower implements the api.Meter interface // Returns the total charging power by summing all three phases func (wb *E3dc) CurrentPower() (float64, error) { + wb.mu.Lock() + defer wb.mu.Unlock() + p1, p2, p3, err := wb.powers() if err != nil { return 0, err @@ -390,8 +429,11 @@ var _ api.MeterEnergy = (*E3dc)(nil) // The sum of both values matches the total energy shown in the E3DC portal. // Testing showed: DB_TEC (8319 kWh) + WB_ENERGY (699 kWh) = 9018 kWh ≈ Portal (9019 kWh) func (wb *E3dc) TotalEnergy() (float64, error) { + wb.mu.Lock() + defer wb.mu.Unlock() + // Query both energy sources sequentially - res, err := wb.conn.Send(*rscp.NewMessage(rscp.DB_REQ_TEC_WALLBOX_VALUES, nil)) + res, err := wb.retrySend(*rscp.NewMessage(rscp.DB_REQ_TEC_WALLBOX_VALUES, nil)) if err != nil { return 0, err } @@ -436,7 +478,7 @@ func (wb *E3dc) TotalEnergy() (float64, error) { } // Query WB_ENERGY_ALL for energy since last DB sync - res, err = wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ + res, err = wb.retrySend(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ *rscp.NewMessage(rscp.WB_INDEX, wb.id), *rscp.NewMessage(rscp.WB_REQ_ENERGY_ALL, nil), })) @@ -462,7 +504,7 @@ func (wb *E3dc) TotalEnergy() (float64, error) { // Used internally by CurrentPower() and Currents(). // Returns (L1, L2, L3) power values - unused phases return 0. func (wb *E3dc) powers() (float64, float64, float64, error) { - res, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ + res, err := wb.retrySend(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ *rscp.NewMessage(rscp.WB_INDEX, wb.id), *rscp.NewMessage(rscp.WB_REQ_PM_POWER_L1, nil), *rscp.NewMessage(rscp.WB_REQ_PM_POWER_L2, nil), @@ -523,7 +565,10 @@ var _ api.PhaseGetter = (*E3dc)(nil) // Returns the configured number of phases (1 or 3) // Note: WB_PM_ACTIVE_PHASES reports physical wiring, WB_NUMBER_PHASES reports actual configuration func (wb *E3dc) GetPhases() (int, error) { - res, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ + wb.mu.Lock() + defer wb.mu.Unlock() + + res, err := wb.retrySend(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ *rscp.NewMessage(rscp.WB_INDEX, wb.id), *rscp.NewMessage(rscp.WB_REQ_NUMBER_PHASES, nil), })) @@ -549,7 +594,10 @@ var _ api.CurrentLimiter = (*E3dc)(nil) // GetMinMaxCurrent implements the api.CurrentLimiter interface // Returns the wallbox's hardware current limits (typically 6-32A) func (wb *E3dc) GetMinMaxCurrent() (float64, float64, error) { - res, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ + wb.mu.Lock() + defer wb.mu.Unlock() + + res, err := wb.retrySend(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ *rscp.NewMessage(rscp.WB_INDEX, wb.id), *rscp.NewMessage(rscp.WB_REQ_LOWER_CURRENT_LIMIT, nil), *rscp.NewMessage(rscp.WB_REQ_UPPER_CURRENT_LIMIT, nil), @@ -581,7 +629,10 @@ var _ api.CurrentGetter = (*E3dc)(nil) // GetMaxCurrent implements the api.CurrentGetter interface // Returns the currently configured maximum charging current func (wb *E3dc) GetMaxCurrent() (float64, error) { - res, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ + wb.mu.Lock() + defer wb.mu.Unlock() + + res, err := wb.retrySend(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ *rscp.NewMessage(rscp.WB_INDEX, wb.id), *rscp.NewMessage(rscp.WB_REQ_MAX_CHARGE_CURRENT, nil), })) @@ -606,7 +657,7 @@ func (wb *E3dc) GetMaxCurrent() (float64, error) { // Returns all session-related messages (energy, time, RFID, etc.). // If no vehicle is connected, returns only WB_INDEX with no session data. func (wb *E3dc) getSessionData() ([]rscp.Message, error) { - res, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_SESSION, nil)) + res, err := wb.retrySend(*rscp.NewMessage(rscp.WB_REQ_SESSION, nil)) if err != nil { return nil, err } @@ -638,6 +689,9 @@ var _ api.ChargeRater = (*E3dc)(nil) // ChargedEnergy implements the api.ChargeRater interface // Returns the energy charged in the current session from WB_SESSION_CHARGED_ENERGY func (wb *E3dc) ChargedEnergy() (float64, error) { + wb.mu.Lock() + defer wb.mu.Unlock() + msg, found, err := wb.sessionMessage(rscp.WB_SESSION_CHARGED_ENERGY) if err != nil || !found { return 0, err @@ -656,6 +710,9 @@ var _ api.ChargeTimer = (*E3dc)(nil) // ChargeDuration implements the api.ChargeTimer interface // Returns the active charging duration from WB_SESSION_ACTIVE_CHARGE_TIME func (wb *E3dc) ChargeDuration() (time.Duration, error) { + wb.mu.Lock() + defer wb.mu.Unlock() + msg, found, err := wb.sessionMessage(rscp.WB_SESSION_ACTIVE_CHARGE_TIME) if err != nil || !found { return 0, err @@ -675,6 +732,9 @@ var _ api.Identifier = (*E3dc)(nil) // Identify implements the api.Identifier interface // Returns the RFID tag ID from WB_SESSION_AUTH_DATA if a session is active func (wb *E3dc) Identify() (string, error) { + wb.mu.Lock() + defer wb.mu.Unlock() + msg, found, err := wb.sessionMessage(rscp.WB_SESSION_AUTH_DATA) if err != nil || !found { return "", err @@ -689,6 +749,9 @@ var _ api.PhaseSwitcher = (*E3dc)(nil) // Switches between 1-phase and 3-phase charging // The wallbox handles the safe switching sequence internally (reduce current, switch, ramp up) func (wb *E3dc) Phases1p3p(phases int) error { + wb.mu.Lock() + defer wb.mu.Unlock() + if phases != 1 && phases != 3 { return fmt.Errorf("invalid phases: %d (must be 1 or 3)", phases) } @@ -697,7 +760,7 @@ func (wb *E3dc) Phases1p3p(phases int) error { wb.ensureAutoPhaseDisabled() // Perform phase switch - _, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ + _, err := wb.retrySend(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ *rscp.NewMessage(rscp.WB_INDEX, wb.id), *rscp.NewMessage(rscp.WB_REQ_SET_NUMBER_PHASES, uint8(phases)), })) @@ -711,7 +774,10 @@ var _ api.Diagnosis = (*E3dc)(nil) // Outputs wallbox information for debugging via evcc's "evcc charger" command. // Shows device name, firmware, current limits, phase config, and status flags. func (wb *E3dc) Diagnose() { - res, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ + wb.mu.Lock() + defer wb.mu.Unlock() + + res, err := wb.retrySend(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ *rscp.NewMessage(rscp.WB_INDEX, wb.id), *rscp.NewMessage(rscp.WB_REQ_DEVICE_NAME, nil), *rscp.NewMessage(rscp.WB_REQ_FIRMWARE_VERSION, nil), @@ -786,7 +852,7 @@ func (wb *E3dc) Diagnose() { // RSCP messages contain typed values that need to be extracted and validated. // // Typical usage pattern: -// 1. Send request via wb.conn.Send() +// 1. Send request via wb.retrySend() // 2. Parse response container via rscpContainer() // 3. Extract typed values via rscpFloat64(), rscpBool(), rscpString(), etc. // =========================================================================== From ec830ef20d674128a8fa5b49ccba53e947d1bdab Mon Sep 17 00:00:00 2001 From: andig Date: Wed, 27 May 2026 19:12:12 +0200 Subject: [PATCH 0048/1007] VW/Audi: migrate token endpoint to /auth/v1/idk/oidc/token (#30260) --- vehicle/vag/idkproxy/endpoint.go | 2 +- vehicle/vag/loginapps/endpoint.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/vehicle/vag/idkproxy/endpoint.go b/vehicle/vag/idkproxy/endpoint.go index 8f310e81f..1c062e5cb 100644 --- a/vehicle/vag/idkproxy/endpoint.go +++ b/vehicle/vag/idkproxy/endpoint.go @@ -22,7 +22,7 @@ const WellKnown = cariad.BaseURL + "/login/v1/idk/openid-configuration" var Config = &oidc.ProviderConfig{ AuthURL: "https://identity.vwgroup.io/oidc/v1/authorize", - TokenURL: cariad.BaseURL + "/login/v1/idk/token", + TokenURL: cariad.BaseURL + "/auth/v1/idk/oidc/token", } var _ vag.TokenExchanger = (*Service)(nil) diff --git a/vehicle/vag/loginapps/endpoint.go b/vehicle/vag/loginapps/endpoint.go index c172eff42..c2acc2ca7 100644 --- a/vehicle/vag/loginapps/endpoint.go +++ b/vehicle/vag/loginapps/endpoint.go @@ -62,7 +62,7 @@ func (v *Service) Refresh(token *Token) (*Token, error) { req, err := request.New( http.MethodPost, - cariad.BaseURL+"/login/v1/idk/token", + cariad.BaseURL+"/auth/v1/idk/oidc/token", strings.NewReader(body.Encode()), request.URLEncoding, map[string]string{ From 4eada9ba5ce71ce44bfae16fcb673df6e777e208 Mon Sep 17 00:00:00 2001 From: andig Date: Thu, 28 May 2026 08:28:04 +0200 Subject: [PATCH 0049/1007] VW: migrate WeConnect auth to OIDC token exchange (#30277) --- vehicle/vag/loginapps/endpoint.go | 96 ----------------------------- vehicle/vag/loginapps/token.go | 28 --------- vehicle/vag/loginapps/token_test.go | 31 ---------- vehicle/vw.go | 8 +-- vehicle/vw/weconnect/oauth.go | 71 +++++++++++++++++++++ vehicle/vw/weconnect/params.go | 8 ++- 6 files changed, 79 insertions(+), 163 deletions(-) delete mode 100644 vehicle/vag/loginapps/endpoint.go delete mode 100644 vehicle/vag/loginapps/token.go delete mode 100644 vehicle/vag/loginapps/token_test.go create mode 100644 vehicle/vw/weconnect/oauth.go diff --git a/vehicle/vag/loginapps/endpoint.go b/vehicle/vag/loginapps/endpoint.go deleted file mode 100644 index c2acc2ca7..000000000 --- a/vehicle/vag/loginapps/endpoint.go +++ /dev/null @@ -1,96 +0,0 @@ -package loginapps - -import ( - "net/http" - "net/url" - "strings" - "time" - - "github.com/evcc-io/evcc/util" - "github.com/evcc-io/evcc/util/oauth" - "github.com/evcc-io/evcc/util/request" - "github.com/evcc-io/evcc/util/urlvalues" - "github.com/evcc-io/evcc/vehicle/vag/cariad" - "golang.org/x/oauth2" -) - -var Endpoint = &oauth2.Endpoint{ - AuthURL: cariad.BaseURL + "/user-login/login/v1", - TokenURL: cariad.BaseURL + "/user-login/refresh/v1", -} - -type Service struct { - *request.Helper -} - -func New(log *util.Logger) *Service { - return &Service{ - Helper: request.NewHelper(log), - } -} - -func (v *Service) Exchange(q url.Values) (*Token, error) { - if err := urlvalues.Require(q, "state", "id_token", "access_token", "code"); err != nil { - return nil, err - } - - data := map[string]string{ - "region": "emea", - "redirect_uri": "weconnect://authenticated", - "state": q.Get("state"), - "id_token": q.Get("id_token"), - "access_token": q.Get("access_token"), - "authorizationCode": q.Get("code"), - } - - var res Token - - req, err := request.New(http.MethodPost, Endpoint.AuthURL, request.MarshalJSON(data), request.JSONEncoding) - if err == nil { - err = v.DoJSON(req, &res) - } - - return &res, err -} - -func (v *Service) Refresh(token *Token) (*Token, error) { - body := url.Values{ - "grant_type": []string{"refresh_token"}, - "refresh_token": []string{token.RefreshToken}, - "client_id": []string{cariad.ClientID}, - } - - req, err := request.New( - http.MethodPost, - cariad.BaseURL+"/auth/v1/idk/oidc/token", - strings.NewReader(body.Encode()), - request.URLEncoding, - map[string]string{ - "Connection": "keep-alive", - "User-Agent": cariad.UserAgent, - "Accept": "application/json", - "x-android-package-name": cariad.AndroidPackageName, - }, - ) - - var res oauth2.Token - if err == nil { - err = v.DoJSON(req, &res) - } - - res.Expiry = time.Now().Add(time.Duration(res.ExpiresIn) * time.Second) - - t := Token(res) - return &t, err -} - -// refreshToken renews the LoginApps token -func (v *Service) refreshToken(token *oauth2.Token) (*oauth2.Token, error) { - res, err := v.Refresh((*Token)(token)) - return (*oauth2.Token)(res), err -} - -// TokenSource creates a refreshing oauth2 token source -func (v *Service) TokenSource(token *Token) oauth2.TokenSource { - return oauth.RefreshTokenSource((*oauth2.Token)(token), v.refreshToken) -} diff --git a/vehicle/vag/loginapps/token.go b/vehicle/vag/loginapps/token.go deleted file mode 100644 index cd1c6ebc3..000000000 --- a/vehicle/vag/loginapps/token.go +++ /dev/null @@ -1,28 +0,0 @@ -package loginapps - -import ( - "encoding/json" - "time" - - "golang.org/x/oauth2" -) - -// Token is the loginapps token -type Token oauth2.Token - -func (t *Token) UnmarshalJSON(data []byte) error { - var s struct { - AccessToken string - RefreshToken string - } - - err := json.Unmarshal(data, &s) - if err == nil { - t.TokenType = "bearer" - t.AccessToken = s.AccessToken - t.RefreshToken = s.RefreshToken - t.Expiry = time.Now().Add(time.Hour) - } - - return err -} diff --git a/vehicle/vag/loginapps/token_test.go b/vehicle/vag/loginapps/token_test.go deleted file mode 100644 index ef4fb12e0..000000000 --- a/vehicle/vag/loginapps/token_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package loginapps - -import ( - "encoding/json" - "testing" -) - -func TestUnmarshalJSON(t *testing.T) { - var tok Token - str := `{"accesstoken":"access","refreshtoken":"refresh"}` - - if err := json.Unmarshal([]byte(str), &tok); err != nil { - t.Error(err) - } - - if tok.AccessToken != "access" { - t.Error("AccessToken") - } - - if tok.RefreshToken != "refresh" { - t.Error("RefreshToken") - } - - if tok.TokenType != "bearer" { - t.Error("TokenType") - } - - if tok.Expiry.IsZero() { - t.Error("Expiry") - } -} diff --git a/vehicle/vw.go b/vehicle/vw.go index 5807f5857..4d5b3c92d 100644 --- a/vehicle/vw.go +++ b/vehicle/vw.go @@ -6,7 +6,6 @@ import ( "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/loginapps" "github.com/evcc-io/evcc/vehicle/vag/vwidentity" "github.com/evcc-io/evcc/vehicle/vw/weconnect" ) @@ -49,18 +48,17 @@ func NewVWFromConfig(other map[string]any) (api.Vehicle, error) { log := util.NewLogger("vw").Redact(cc.User, cc.Password, cc.VIN) - q, err := vwidentity.LoginWithAuthURL(log, weconnect.LoginURL, weconnect.AuthParams, cc.User, cc.Password) + q, err := vwidentity.Login(log, weconnect.AuthParams, cc.User, cc.Password) if err != nil { return nil, err } - apps := loginapps.New(log) - token, err := apps.Exchange(q) + token, err := weconnect.ExchangeCode(log, q) if err != nil { return nil, err } - api := weconnect.NewAPI(log, apps.TokenSource(token)) + api := weconnect.NewAPI(log, weconnect.TokenSource(log, token)) api.Client.Timeout = cc.Timeout vehicle, err := ensureVehicleEx( diff --git a/vehicle/vw/weconnect/oauth.go b/vehicle/vw/weconnect/oauth.go new file mode 100644 index 000000000..206e7a893 --- /dev/null +++ b/vehicle/vw/weconnect/oauth.go @@ -0,0 +1,71 @@ +package weconnect + +import ( + "net/http" + "net/url" + "strings" + "time" + + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/oauth" + "github.com/evcc-io/evcc/util/request" + "github.com/evcc-io/evcc/util/urlvalues" + "github.com/evcc-io/evcc/vehicle/vag/cariad" + "golang.org/x/oauth2" +) + +const tokenURL = cariad.BaseURL + "/auth/v1/idk/oidc/token" + +// ExchangeCode swaps the authorization code from the OIDC callback for an +// access/refresh token pair at the cariad BFF OIDC token endpoint. Replaces +// the legacy WeConnect SSO exchange (/user-login/login/v1), which VW removed +// in May 2026. +func ExchangeCode(log *util.Logger, q url.Values) (*oauth2.Token, error) { + if err := urlvalues.Require(q, "code", "code_verifier"); err != nil { + return nil, err + } + + data := url.Values{ + "grant_type": {"authorization_code"}, + "code": {q.Get("code")}, + "code_verifier": {q.Get("code_verifier")}, + "redirect_uri": {"weconnect://authenticated"}, + "client_id": {cariad.ClientID}, + } + + return postToken(log, data) +} + +// TokenSource returns a refreshing oauth2.TokenSource that swaps refresh +// tokens for fresh access tokens at the same OIDC token endpoint. +func TokenSource(log *util.Logger, token *oauth2.Token) oauth2.TokenSource { + return oauth.RefreshTokenSource(token, func(t *oauth2.Token) (*oauth2.Token, error) { + data := url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {t.RefreshToken}, + "client_id": {cariad.ClientID}, + } + return postToken(log, data) + }) +} + +func postToken(log *util.Logger, data url.Values) (*oauth2.Token, error) { + req, err := request.New(http.MethodPost, tokenURL, strings.NewReader(data.Encode()), + map[string]string{ + "Content-Type": request.FormContent, + "Accept": request.JSONContent, + "User-Agent": cariad.UserAgent, + "x-android-package-name": cariad.AndroidPackageName, + }) + if err != nil { + return nil, err + } + + var token oauth2.Token + if err := request.NewHelper(log).DoJSON(req, &token); err != nil { + return nil, err + } + + token.Expiry = time.Now().Add(time.Duration(token.ExpiresIn) * time.Second) + return &token, nil +} diff --git a/vehicle/vw/weconnect/params.go b/vehicle/vw/weconnect/params.go index a6bf9f2a9..65f41fb68 100644 --- a/vehicle/vw/weconnect/params.go +++ b/vehicle/vw/weconnect/params.go @@ -6,11 +6,13 @@ import ( "github.com/evcc-io/evcc/vehicle/vag/cariad" ) -const LoginURL = cariad.BaseURL + "/user-login/v1/authorize" - +// AuthParams are the OIDC authorize parameters for the WeConnect ID client. +// The authorize endpoint is the legacy identity.vwgroup.io OIDC entry point +// (vwidentity.Config.AuthURL); the token endpoint is the cariad BFF (see +// oauth.go). var AuthParams = url.Values{ "response_type": {"code id_token token"}, - "client_id": {"a24fba63-34b3-4d43-b181-942111e6bda8@apps_vw-dilab_com"}, + "client_id": {cariad.ClientID}, "redirect_uri": {"weconnect://authenticated"}, "scope": {"openid profile badge cars vin"}, // dealers } From 498fbaba468d91ddcb24659066d5976cfc26715c Mon Sep 17 00:00:00 2001 From: andig Date: Thu, 28 May 2026 09:14:25 +0200 Subject: [PATCH 0050/1007] Vehicle (Nissan Ariya/Micra): expose expiry as advanced parameter (#30269) --- templates/definition/vehicle/nissan-ariya.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/templates/definition/vehicle/nissan-ariya.yaml b/templates/definition/vehicle/nissan-ariya.yaml index e16f0978b..0f0fbb7f3 100644 --- a/templates/definition/vehicle/nissan-ariya.yaml +++ b/templates/definition/vehicle/nissan-ariya.yaml @@ -8,7 +8,18 @@ products: generic: Micra params: - preset: vehicle-base + - name: expiry + description: + de: Ablauf + en: Expiry + help: + de: Maximales Alter der Daten vor erneuter Abfrage + en: Maximum data age before refresh + advanced: true + type: duration + example: 5m render: | type: nissan version: v2 {{ include "vehicle-base" . }} + expiry: {{ .expiry }} From 4f943650b2aed78c600ba572d7e7231920710603 Mon Sep 17 00:00:00 2001 From: thecem <46648579+thecem@users.noreply.github.com> Date: Thu, 28 May 2026 12:05:10 +0200 Subject: [PATCH 0051/1007] Hems/FNN: add curtail and dim for TAB26 Steuerbox relay standard (#29886) Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> Co-authored-by: andig Co-authored-by: andig Co-authored-by: Claude Opus 4.7 (1M context) --- hems/config.go | 2 +- hems/fnn/fnn-3.go | 153 ------------------------------ hems/fnn/fnn.go | 231 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 232 insertions(+), 154 deletions(-) delete mode 100644 hems/fnn/fnn-3.go create mode 100644 hems/fnn/fnn.go diff --git a/hems/config.go b/hems/config.go index f97e7ceaf..89119487c 100644 --- a/hems/config.go +++ b/hems/config.go @@ -19,7 +19,7 @@ func NewFromConfig(ctx context.Context, typ string, other map[string]any, site s return nil, errors.New("breaking change: Sunny Home Manager integration is always on. See https://github.com/evcc-io/evcc/releases and https://docs.evcc.io/en/docs/integrations/sma-sunny-home-manager") case "eebus": return eebus.NewFromConfig(ctx, other, site) - case "fnn-3": + case "fnn", "fnn-3": return fnn.NewFromConfig(ctx, other, site) case "relay": return relay.NewFromConfig(ctx, other, site) diff --git a/hems/fnn/fnn-3.go b/hems/fnn/fnn-3.go deleted file mode 100644 index d78d92e3f..000000000 --- a/hems/fnn/fnn-3.go +++ /dev/null @@ -1,153 +0,0 @@ -package fnn - -import ( - "context" - "sync" - "time" - - "github.com/evcc-io/evcc/api" - "github.com/evcc-io/evcc/core/site" - "github.com/evcc-io/evcc/hems/smartgrid" - "github.com/evcc-io/evcc/plugin" - "github.com/evcc-io/evcc/util" -) - -type Fnn3 struct { - mu sync.Mutex - log *util.Logger - - root api.Circuit - s1, s2, w3 func() (bool, error) - - smartgridID uint - limit *float64 - maxPower float64 - interval time.Duration -} - -// NewFromConfig creates an Fnn3 HEMS from generic config -func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*Fnn3, error) { - cc := struct { - MaxPower float64 - W3 plugin.Config - S1 *plugin.Config - S2 *plugin.Config - Interval time.Duration - }{ - Interval: 10 * time.Second, - } - - if err := util.DecodeOther(other, &cc); err != nil { - return nil, err - } - - // setup grid control circuit - gridcontrol, err := smartgrid.SetupCircuit() - if err != nil { - return nil, err - } - - site.SetCircuit(gridcontrol) - - // s1 getter - s1G, err := cc.S1.BoolGetter(ctx) - if err != nil { - return nil, err - } - - s2G, err := cc.S2.BoolGetter(ctx) - if err != nil { - return nil, err - } - - w3G, err := cc.W3.BoolGetter(ctx) - if err != nil { - return nil, err - } - - return NewFnn3(gridcontrol, s1G, s2G, w3G, cc.MaxPower, cc.Interval) -} - -// NewFnn3 creates Fnn3 HEMS -func NewFnn3(root api.Circuit, s1, s2, w3 func() (bool, error), maxPower float64, interval time.Duration) (*Fnn3, error) { - c := &Fnn3{ - log: util.NewLogger("fnn3"), - root: root, - maxPower: maxPower, - s1: s1, - s2: s2, - w3: w3, - interval: interval, - } - - return c, nil -} - -func (c *Fnn3) Run() { - for range time.Tick(c.interval) { - if err := c.run(); err != nil { - c.log.ERROR.Println(err) - } - } -} - -func (c *Fnn3) run() error { - w3, err := c.w3() - if err != nil { - return err - } - - if w3 { - // 0% - return c.curtail(0.0) - } - - if c.s2 != nil { - s2, err := c.s2() - if err != nil { - return err - } - - if s2 { - // 30% - return c.curtail(0.3) - } - } - - if c.s1 != nil { - s1, err := c.s1() - if err != nil { - return err - } - - if s1 { - // 60% - return c.curtail(0.6) - } - } - - // 100% - return c.curtail(1.0) -} - -func (c *Fnn3) curtail(frac float64) error { - c.mu.Lock() - defer c.mu.Unlock() - - active := frac < 1.0 - - c.limit = nil - if active { - c.limit = new(c.maxPower * frac) - } - - c.root.Curtail(active) - // TODO make ProductionNominalMax configurable (Site kWp) - // c.root.SetMaxPower(c.maxPower*frac) - - if err := smartgrid.UpdateSession(&c.smartgridID, smartgrid.Curtail, c.root.GetChargePower(), c.maxPower*frac, active); err != nil { - c.log.ERROR.Printf("smartgrid session: %v", err) - } - - return nil -} diff --git a/hems/fnn/fnn.go b/hems/fnn/fnn.go new file mode 100644 index 000000000..6d3d6c341 --- /dev/null +++ b/hems/fnn/fnn.go @@ -0,0 +1,231 @@ +package fnn + +import ( + "context" + "errors" + "math" + "sync" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/core/site" + "github.com/evcc-io/evcc/hems/smartgrid" + "github.com/evcc-io/evcc/plugin" + "github.com/evcc-io/evcc/util" +) + +// NewFromConfig creates an FNN HEMS from generic config. +func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*Fnn, error) { + cc := struct { + MaxPower float64 // TODO deprecated + MaxDimPower float64 + MaxCurtailPower *float64 + W3 *plugin.Config + S1 *plugin.Config + S2 *plugin.Config + W4 *plugin.Config + Interval time.Duration + }{ + Interval: 10 * time.Second, + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + // setup grid control circuit + gridcontrol, err := smartgrid.SetupCircuit() + if err != nil { + return nil, err + } + + site.SetCircuit(gridcontrol) + + w3G, err := cc.W3.BoolGetter(ctx) + if err != nil { + return nil, err + } + + w4G, err := cc.W4.BoolGetter(ctx) + if err != nil { + return nil, err + } + + // either dim or curtail must be configured + if w3G == nil && w4G == nil { + return nil, errors.New("must have either W3 or W4") + } + + s1G, err := cc.S1.BoolGetter(ctx) + if err != nil { + return nil, err + } + + s2G, err := cc.S2.BoolGetter(ctx) + if err != nil { + return nil, err + } + + maxDimPower := math.Abs(cc.MaxDimPower) + + var maxCurtailPower *float64 + switch { + case cc.MaxCurtailPower != nil: + maxCurtailPower = new(math.Abs(*cc.MaxCurtailPower)) + case cc.MaxPower > 0: + // fnn-3 backwards compatibility: legacy MaxPower was the PV/curtail cap + maxCurtailPower = new(math.Abs(cc.MaxPower)) + } + + return &Fnn{ + log: util.NewLogger("fnn"), + root: gridcontrol, + maxDimPower: maxDimPower, + maxCurtailPower: maxCurtailPower, + s1: s1G, + s2: s2G, + w3: w3G, + w4: w4G, + interval: cc.Interval, + }, nil +} + +// Fnn implements the FNN HEMS logic for curtailment and dimming. +type Fnn struct { + mu sync.Mutex + log *util.Logger + + root api.Circuit + s1, s2, w3 func() (bool, error) + w4 func() (bool, error) + + smartgridConsumptionID uint + smartgridProductionID uint + + maxDimPower float64 + maxCurtailPower *float64 + interval time.Duration +} + +// Run starts the FNN control loop. +func (c *Fnn) Run() { + ticker := time.NewTicker(c.interval) + defer ticker.Stop() + + for range ticker.C { + if err := c.runCurtail(); err != nil { + c.log.ERROR.Println(err) + } + + if err := c.runDim(); err != nil { + c.log.ERROR.Println(err) + } + } +} + +// runCurtail evaluates curtailment rules and applies the appropriate limit. +// No-op if no curtail input is configured. +func (c *Fnn) runCurtail() error { + if c.w3 == nil { + return nil + } + + rules := []struct { + get func() (bool, error) + frac float64 + }{ + {get: c.w3, frac: 0.0}, + {get: c.s2, frac: 0.3}, + {get: c.s1, frac: 0.6}, + } + + for _, rule := range rules { + if rule.get == nil { + continue + } + + active, err := rule.get() + if err != nil { + return err + } + + if active { + return c.setProductionLimit(rule.frac) + } + } + + // 100% + return c.setProductionLimit(1.0) +} + +// runDim evaluates the dimming rule and applies the dim limit. +// No-op if dim input is not configured. +func (c *Fnn) runDim() error { + if c.w4 == nil { + return nil + } + + active, err := c.w4() + if err != nil { + return err + } + + limit := 0.0 + if active { + if c.maxDimPower <= 0 { + return errors.New("dim active but no limit configured") + } + + limit = c.maxDimPower + } + + return c.setConsumptionLimit(limit) +} + +func (c *Fnn) applyMode(id *uint, typ smartgrid.Type, active bool, limit float64, applyRoot func()) { + applyRoot() + + if err := smartgrid.UpdateSession(id, typ, c.root.GetChargePower(), limit, active); err != nil { + c.log.ERROR.Printf("smartgrid session: %v", err) + } +} + +// setProductionLimit applies the curtailment limit to the circuit. +func (c *Fnn) setProductionLimit(frac float64) error { + c.mu.Lock() + defer c.mu.Unlock() + + limit := 0.0 + active := frac < 1.0 + + if active { + if c.maxCurtailPower == nil { + return errors.New("curtail active but no limit configured") + } + + limit = *c.maxCurtailPower * frac + } + + c.applyMode(&c.smartgridProductionID, smartgrid.Curtail, active, limit, func() { + c.root.Curtail(active) + // TODO make ProductionNominalMax configurable (Site kWp) + // c.root.SetMaxPower(c.maxPower*frac) + }) + + return nil +} + +// setConsumptionLimit applies the dimming limit to the circuit. +func (c *Fnn) setConsumptionLimit(limit float64) error { + c.mu.Lock() + defer c.mu.Unlock() + + active := limit > 0 + + c.applyMode(&c.smartgridConsumptionID, smartgrid.Dim, active, limit, func() { + c.root.Dim(active) + c.root.SetMaxPower(limit) + }) + + return nil +} From 8ea620d36844665372c448effd4c296866e14b7f Mon Sep 17 00:00:00 2001 From: andig Date: Thu, 28 May 2026 14:13:21 +0200 Subject: [PATCH 0052/1007] Audi: rotate qmauth and add assertion headers for IDK token exchange (#30292) --- vehicle/vag/idkproxy/endpoint.go | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/vehicle/vag/idkproxy/endpoint.go b/vehicle/vag/idkproxy/endpoint.go index 1c062e5cb..eefe5ae8b 100644 --- a/vehicle/vag/idkproxy/endpoint.go +++ b/vehicle/vag/idkproxy/endpoint.go @@ -42,8 +42,9 @@ func New(log *util.Logger, q url.Values) *Service { // https://github.com/arjenvrh/audi_connect_ha/issues/133 const ( - qmSecret = "e47866378ef0658ce75d71007a809f34616b9635e2ec228245784c1f63e88d06" - qmClientId = "c95f4fd2" + qmSecret = "1ab69925ac179aaa4e83abe671a9476d176418b85bd706f1436ca15be647989c" + qmClientId = "01da27b0" + userAgent = "Android/4.31.0 (Build 800341641.root project 'myaudi_android'.ext.buildTime) Android/13" ) func qmauth(ts int64) string { @@ -57,6 +58,21 @@ func qmauthNow() string { return "v1:" + qmClientId + ":" + qmauth(time.Now().Unix()/100) } +// tokenHeaders returns the assertion header set the IDK token endpoint +// validates for both authorization_code and refresh_token grants. +func tokenHeaders() map[string]string { + return map[string]string{ + "Content-Type": request.FormContent, + "Accept": request.JSONContent, + "Accept-Charset": "utf-8", + "User-Agent": userAgent, + "x-qmauth": qmauthNow(), + "x-platform": "android", + "x-android-package-name": "de.myaudi.mobile.assistant", + "x-assertion": "0", + } +} + // Exchange exchanges an VAG identity token for an IDK token func (v *Service) Exchange(q url.Values) (*vag.Token, error) { if err := urlvalues.Require(q, "code", "code_verifier"); err != nil { @@ -74,11 +90,7 @@ func (v *Service) Exchange(q url.Values) (*vag.Token, error) { var res vag.Token - req, err := request.New(http.MethodPost, Config.TokenURL, strings.NewReader(data.Encode()), map[string]string{ - "Content-Type": request.FormContent, - "Accept": request.JSONContent, - "x-qmauth": qmauthNow(), - }) + req, err := request.New(http.MethodPost, Config.TokenURL, strings.NewReader(data.Encode()), tokenHeaders()) if err == nil { err = v.DoJSON(req, &res) } @@ -98,11 +110,7 @@ func (v *Service) Refresh(token *vag.Token) (*vag.Token, error) { var res vag.Token - req, err := request.New(http.MethodPost, Config.TokenURL, strings.NewReader(data.Encode()), map[string]string{ - "Content-Type": request.FormContent, - "Accept": request.JSONContent, - "x-qmauth": qmauthNow(), - }) + req, err := request.New(http.MethodPost, Config.TokenURL, strings.NewReader(data.Encode()), tokenHeaders()) if err == nil { err = v.DoJSON(req, &res) } From ceb93b0db679cb4361f86e02cdb0bbc91619d310 Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 29 May 2026 12:10:59 +0200 Subject: [PATCH 0053/1007] chore: upgrade endpoint --- vehicle/vag/idkproxy/endpoint.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vehicle/vag/idkproxy/endpoint.go b/vehicle/vag/idkproxy/endpoint.go index eefe5ae8b..14d821a54 100644 --- a/vehicle/vag/idkproxy/endpoint.go +++ b/vehicle/vag/idkproxy/endpoint.go @@ -18,7 +18,7 @@ import ( "github.com/evcc-io/evcc/vehicle/vag/cariad" ) -const WellKnown = cariad.BaseURL + "/login/v1/idk/openid-configuration" +const WellKnown = cariad.BaseURL + "/auth/v1/idk/oidc/openid-configuration" var Config = &oidc.ProviderConfig{ AuthURL: "https://identity.vwgroup.io/oidc/v1/authorize", From 24e0e1044338453669916291305c84da21d8a6c4 Mon Sep 17 00:00:00 2001 From: tbaehler Date: Fri, 29 May 2026 13:29:24 +0200 Subject: [PATCH 0054/1007] Zaptec Go2: use ThreeToOnePhaseSwitchCurrent for switching (#29587) --- charger/zaptec.go | 74 +++++++++++++++++++++-------------------- charger/zaptec/types.go | 7 ++-- 2 files changed, 42 insertions(+), 39 deletions(-) diff --git a/charger/zaptec.go b/charger/zaptec.go index 39f726bd4..859a83888 100644 --- a/charger/zaptec.go +++ b/charger/zaptec.go @@ -41,17 +41,20 @@ import ( // https://api.zaptec.com/.well-known/openid-configuration/ // Zaptec charger implementation + +const _ZaptecGo2PhaseSwitchCurrent = 32.0 // threshold (in A) at which Zaptec Go 2 switches to single-phase according to product/protocol specs + type Zaptec struct { *request.Helper implement.Caps log *util.Logger statusG util.Cacheable[zaptec.StateResponse] instance zaptec.Charger - maxCurrent float64 version int enabled bool priority bool passive bool + lastStatus int } func init() { @@ -82,7 +85,7 @@ func NewZaptecFromConfig(ctx context.Context, other map[string]any) (api.Charger } // NewZaptec creates Zaptec charger -func NewZaptec(ctx context.Context, user, password, id string, priority bool, passive bool, cache time.Duration) (api.Charger, error) { +func NewZaptec(_ context.Context, user, password, id string, priority bool, passive bool, cache time.Duration) (api.Charger, error) { log := util.NewLogger("zaptec").Redact(user, password) if !sponsor.IsAuthorized() { @@ -144,9 +147,8 @@ func NewZaptec(ctx context.Context, user, password, id string, priority bool, pa return nil, err } - if maxCurrent, err := c.getInstallationMaxCurrent(); err == nil { + if err := c.testInstallationPermission(); err == nil || c.version == zaptec.ZaptecGo1_Pro { implement.Has(c, implement.PhaseSwitcher(c.phases1p3p)) - c.maxCurrent = maxCurrent } return c, nil @@ -189,8 +191,13 @@ func (c *Zaptec) Status() (api.ChargeStatus, error) { if err != nil { return api.StatusA, err } + currentStatus, err := res.ObservationByID(zaptec.ChargerOperationMode).Int() + if err == nil && currentStatus == zaptec.OpModeDisconnected && currentStatus != c.lastStatus { + err = c.MaxCurrentMillis(0) + } + c.lastStatus = currentStatus - switch i, err := res.ObservationByID(zaptec.ChargerOperationMode).Int(); i { + switch currentStatus { case zaptec.OpModeDisconnected: return api.StatusA, err case zaptec.OpModeConnectedRequesting, zaptec.OpModeConnectedFinished: @@ -199,7 +206,7 @@ func (c *Zaptec) Status() (api.ChargeStatus, error) { return api.StatusC, err default: if err == nil { - err = fmt.Errorf("unknown status: %d", i) + err = fmt.Errorf("unknown status: %d", currentStatus) } return api.StatusNone, err } @@ -336,19 +343,21 @@ func (c *Zaptec) phases1p3p(phases int) error { return err } - // adjust the current by +/- 0.1A; otherwise, the phase change will not happen - current, err := res.ObservationByID(zaptec.ChargerMaxCurrent).Float64() - if err != nil { - return err - } + if c.version == zaptec.ZaptecGo1_Pro { + // adjust the current by +/- 0.1A; otherwise, the phase change will not happen + current, err := res.ObservationByID(zaptec.ChargerMaxCurrent).Float64() + if err != nil { + return err + } - current -= 0.1 - if current < 6 { - current += 0.2 - } + current -= 0.1 + if current < 6 { + current += 0.2 + } - if err := c.MaxCurrentMillis(current); err != nil { - return err + if err := c.MaxCurrentMillis(current); err != nil { + return err + } } if !c.priority { @@ -368,7 +377,7 @@ func (c *Zaptec) phases1p3p(phases int) error { } func (c *Zaptec) switchPhases(phases int) error { - if c.version != zaptec.ZaptecGo2 { + if c.version == zaptec.ZaptecGo1_Pro { data := zaptec.Update{ MaxChargePhases: &phases, } @@ -376,20 +385,17 @@ func (c *Zaptec) switchPhases(phases int) error { return c.chargerUpdate(data) } - var zero float64 - data := zaptec.UpdateInstallation{ - AvailableCurrentPhase1: &c.maxCurrent, - AvailableCurrentPhase2: &zero, - AvailableCurrentPhase3: &zero, - } - if phases == 3 { - data = zaptec.UpdateInstallation{ - AvailableCurrentPhase1: &c.maxCurrent, - AvailableCurrentPhase2: &c.maxCurrent, - AvailableCurrentPhase3: &c.maxCurrent, - } + var phaseSwitchCurrent float64 + + if phases == 1 { + phaseSwitchCurrent = _ZaptecGo2PhaseSwitchCurrent + } else { + phaseSwitchCurrent = 0 } + data := zaptec.UpdateInstallation{ + ThreeToOnePhaseSwitchCurrent: &phaseSwitchCurrent, + } return c.installationUpdate(data) } @@ -409,15 +415,11 @@ func (c *Zaptec) Identify() (string, error) { return "", nil } -func (c *Zaptec) getInstallationMaxCurrent() (float64, error) { +func (c *Zaptec) testInstallationPermission() error { var res zaptec.Installation uri := fmt.Sprintf("%s/api/installation/%s", zaptec.ApiURL, c.instance.InstallationId) - if err := c.GetJSON(uri, &res); err != nil { - return 0, err - } - - return res.MaxCurrent, nil + return c.GetJSON(uri, &res) } func (c *Zaptec) installationUpdate(data zaptec.UpdateInstallation) error { diff --git a/charger/zaptec/types.go b/charger/zaptec/types.go index 51a11ec8c..df5837598 100644 --- a/charger/zaptec/types.go +++ b/charger/zaptec/types.go @@ -90,9 +90,10 @@ type Installation struct { } type UpdateInstallation struct { - AvailableCurrentPhase1 *float64 `json:"availableCurrentPhase1,omitempty"` - AvailableCurrentPhase2 *float64 `json:"availableCurrentPhase2,omitempty"` - AvailableCurrentPhase3 *float64 `json:"availableCurrentPhase3,omitempty"` + AvailableCurrentPhase1 *float64 `json:"availableCurrentPhase1,omitempty"` + AvailableCurrentPhase2 *float64 `json:"availableCurrentPhase2,omitempty"` + AvailableCurrentPhase3 *float64 `json:"availableCurrentPhase3,omitempty"` + ThreeToOnePhaseSwitchCurrent *float64 `json:"threeToOnePhaseSwitchCurrent,omitempty"` } type CapabilitiesResponse struct { From 5c04eed865e910b74a1de4290d30b80bddc9a61b Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 09:05:06 +0200 Subject: [PATCH 0055/1007] Cupra: map ChargePurposeReachedAndNotConservationCharging to StatusB (#30343) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- vehicle/seat/cupra/provider.go | 2 +- vehicle/seat/cupra/provider_test.go | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/vehicle/seat/cupra/provider.go b/vehicle/seat/cupra/provider.go index 9fa3f82b2..984a8d5ae 100644 --- a/vehicle/seat/cupra/provider.go +++ b/vehicle/seat/cupra/provider.go @@ -76,7 +76,7 @@ func (v *Provider) Status() (api.ChargeStatus, error) { } switch strings.ToLower(res.Services.Charging.Status) { - case "connected", "readyforcharging", "notreadyforcharging", "error": + case "connected", "readyforcharging", "notreadyforcharging", "error", "chargepurposereachedandnotconservationcharging": return api.StatusB, nil case "charging": return api.StatusC, nil diff --git a/vehicle/seat/cupra/provider_test.go b/vehicle/seat/cupra/provider_test.go index 119743c12..22974c8b5 100644 --- a/vehicle/seat/cupra/provider_test.go +++ b/vehicle/seat/cupra/provider_test.go @@ -31,6 +31,8 @@ func TestProviderStatus(t *testing.T) { {"connected", "Connected", "", api.StatusB}, // fault / error while plugged {"error", "error", "", api.StatusB}, + // target SoC reached in myCupra, cable still connected (#30118) + {"soc_reached", "ChargePurposeReachedAndNotConservationCharging", "", api.StatusB}, // unknown future status with no card info defaults to disconnected {"unknown", "SomethingNew", "", api.StatusA}, } From 086023f7b8d83d4915225287f8a63087a96cc9f9 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 31 May 2026 09:07:56 +0200 Subject: [PATCH 0056/1007] Charger (OpenEVSE): scale CurrentPower by loadpoint phases (#30330) --- charger/openevse.go | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/charger/openevse.go b/charger/openevse.go index c2939e9f6..d5be22c2b 100644 --- a/charger/openevse.go +++ b/charger/openevse.go @@ -11,6 +11,7 @@ import ( "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/api/implement" "github.com/evcc-io/evcc/charger/openevse" + "github.com/evcc-io/evcc/core/loadpoint" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/request" "github.com/evcc-io/evcc/util/transport" @@ -24,6 +25,7 @@ type OpenEVSE struct { statusG util.Cacheable[openevse.Status] current int enabled bool + lp loadpoint.API } func init() { @@ -233,7 +235,22 @@ func (c *OpenEVSE) CurrentPower() (float64, error) { return 0, err } - return res.Amp * res.Voltage / 1e3, err + // status only reports a single phase current, scale by the loadpoint phases + phases := 1 + if c.lp != nil { + if p := c.lp.GetPhases(); p != 0 { + phases = p + } + } + + return res.Amp * res.Voltage / 1e3 * float64(phases), err +} + +var _ loadpoint.Controller = (*OpenEVSE)(nil) + +// LoadpointControl implements loadpoint.Controller +func (c *OpenEVSE) LoadpointControl(lp loadpoint.API) { + c.lp = lp } // phases1p3p implements the api.PhaseSwitcher interface From 8ee26a281ae436b1289b098ca84415b0f671dc01 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 09:11:34 +0200 Subject: [PATCH 0057/1007] chore(deps): bump axios from 1.15.2 to 1.16.1 (#30334) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 38 +++++++++++++++++++++++++++++++------- package.json | 2 +- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index a49574cc7..ab577de2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@h2d2/shopicons": "^1.9.0", "@popperjs/core": "^2.11.8", "@unhead/vue": "^2.1.13", - "axios": "^1.15.2", + "axios": "^1.16.1", "bootstrap": "^5.3.8", "canvas-confetti": "^1.9.4", "chart.js": "^4.5.1", @@ -4432,6 +4432,18 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -4731,13 +4743,14 @@ } }, "node_modules/axios": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", - "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, @@ -5667,7 +5680,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -7390,6 +7402,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -8903,7 +8928,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/muggle-string": { diff --git a/package.json b/package.json index 6b943a287..9220cda59 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "@h2d2/shopicons": "^1.9.0", "@popperjs/core": "^2.11.8", "@unhead/vue": "^2.1.13", - "axios": "^1.15.2", + "axios": "^1.16.1", "bootstrap": "^5.3.8", "canvas-confetti": "^1.9.4", "chart.js": "^4.5.1", From 791bfae0559ad5b7b7c7dfc33b5e706a32a6db8a Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 09:49:24 +0200 Subject: [PATCH 0058/1007] Toyota: fall back to `evRange` when `evRangeWithAc` is unusable (#30347) --- vehicle/toyota/provider.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/vehicle/toyota/provider.go b/vehicle/toyota/provider.go index 85d3c5e5b..24ca89174 100644 --- a/vehicle/toyota/provider.go +++ b/vehicle/toyota/provider.go @@ -75,5 +75,11 @@ func (v *Provider) Range() (int64, error) { if err != nil { return 0, err } - return res.Payload.EvRangeWithAc.ValueInKilometers() + + rng, err := res.Payload.EvRangeWithAc.ValueInKilometers() + if err == nil { + return rng, nil + } + + return res.Payload.EvRange.ValueInKilometers() } From 72f6861c6e47a17cd05f91d8d655e793964399ca Mon Sep 17 00:00:00 2001 From: CiNcH83 Date: Sun, 31 May 2026 10:37:55 +0200 Subject: [PATCH 0059/1007] Huawei SUN2000: fix energy yield (#30353) --- templates/definition/meter/huawei-sun2000-hybrid.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/definition/meter/huawei-sun2000-hybrid.yaml b/templates/definition/meter/huawei-sun2000-hybrid.yaml index 36cc5fc02..9fd6d8c8e 100644 --- a/templates/definition/meter/huawei-sun2000-hybrid.yaml +++ b/templates/definition/meter/huawei-sun2000-hybrid.yaml @@ -127,7 +127,7 @@ render: | {{- include "modbus" . | indent 2 }} timeout: {{ .timeout }} register: - address: 32106 # Accumulated energy yield + address: 32108 # Total DC Input Energy type: holding decode: uint32nan scale: 0.01 From 54077151542c53c6e3ce8c9069e9175bc7fe84ff Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 31 May 2026 10:46:52 +0200 Subject: [PATCH 0060/1007] Vehicle: add drivesomethinggreater (VW group EU Data Act) (#30362) --- .../vehicle/drivesomethinggreater.yaml | 59 ++++ vehicle/drivesomethinggreater.go | 67 ++++ vehicle/template_test.go | 1 + vehicle/vw/eudataact/api.go | 317 ++++++++++++++++++ vehicle/vw/eudataact/eudataact_test.go | 118 +++++++ vehicle/vw/eudataact/provider.go | 122 +++++++ vehicle/vw/eudataact/types.go | 178 ++++++++++ 7 files changed, 862 insertions(+) create mode 100644 templates/definition/vehicle/drivesomethinggreater.yaml create mode 100644 vehicle/drivesomethinggreater.go create mode 100644 vehicle/vw/eudataact/api.go create mode 100644 vehicle/vw/eudataact/eudataact_test.go create mode 100644 vehicle/vw/eudataact/provider.go create mode 100644 vehicle/vw/eudataact/types.go 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 +} From 5fa62d520d9dc10c99e37316196f9c4197374772 Mon Sep 17 00:00:00 2001 From: friko21 Date: Sun, 31 May 2026 11:05:05 +0200 Subject: [PATCH 0061/1007] Add Kaco Blueplanet (#30293) Co-authored-by: andig --- templates/definition/meter/kaco-blueplanet.yaml | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 templates/definition/meter/kaco-blueplanet.yaml diff --git a/templates/definition/meter/kaco-blueplanet.yaml b/templates/definition/meter/kaco-blueplanet.yaml new file mode 100644 index 000000000..86a09190b --- /dev/null +++ b/templates/definition/meter/kaco-blueplanet.yaml @@ -0,0 +1,13 @@ +params: + - name: usage + choice: ["pv"] + - name: host + required: true + - name: serial + required: true + example: 8.0NX312001234 +render: | + type: custom + power: + source: http + uri: http://{{ .host }}/getdevdata.cgi?device=2&sn={{ .serial }} From fd94f8a376b947e951e84a7bfef18cb85c15edf8 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 31 May 2026 11:05:16 +0200 Subject: [PATCH 0062/1007] Loadpoint: update odometer on disconnect (#30361) --- core/loadpoint.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/core/loadpoint.go b/core/loadpoint.go index ee84555e0..6b77ec77c 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -547,6 +547,9 @@ func (lp *Loadpoint) evVehicleDisconnectHandler() { // re-read energy from charger and re-persist session if values improved lp.finalizeSessionEnergy() + // re-read odometer to catch delayed update (#30225) + lp.vehicleOdometer() + // session is persisted during evChargeStopHandler which runs before lp.clearSession() From 545d239495564d0f35c65ebe5021908cc48444b6 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 31 May 2026 11:45:04 +0200 Subject: [PATCH 0063/1007] Loadpoint: scale down to 1p for feed-in priority in min+pv mode (#30327) --- core/loadpoint.go | 21 +++++++++++-- core/loadpoint_phases_test.go | 56 +++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/core/loadpoint.go b/core/loadpoint.go index 6b77ec77c..9ad9002dc 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -1324,6 +1324,19 @@ func (lp *Loadpoint) fastCharging() error { return lp.setLimit(lp.effectiveMaxCurrent()) } +// minCharging scales to 1p if available and sets minimum current +func (lp *Loadpoint) minCharging() error { + if lp.hasPhaseSwitching() { + // ignore api.ErrNotAvailable: the phase switch could not be performed + // right now, continue with the current phase configuration + if err := lp.scalePhasesIfAvailable(1); err != nil && !errors.Is(err, api.ErrNotAvailable) { + return err + } + } + + return lp.setLimit(lp.effectiveMinCurrent()) +} + // pvScalePhases switches phases if necessary and returns number of phases switched to func (lp *Loadpoint) pvScalePhases(sitePower, minCurrent, maxCurrent float64) int { phases := lp.GetPhases() @@ -2096,11 +2109,13 @@ func (lp *Loadpoint) Update(sitePower, batteryBoostPower float64, consumption, f rate, _ := feedin.At(time.Now()) lp.log.DEBUG.Printf("smart feed-in active: %.2f", rate.Value) - var targetCurrent float64 if mode == api.ModeMinPV { - targetCurrent = lp.GetMinCurrent() + // minimize self-consumption to maximize feed-in by scaling down + // to the absolute minimum of 1 phase at min current + err = lp.minCharging() + } else { + err = lp.setLimit(0) } - err = lp.setLimit(targetCurrent) lp.resetPhaseTimer() lp.elapsePVTimer() // let PV mode disable immediately afterwards diff --git a/core/loadpoint_phases_test.go b/core/loadpoint_phases_test.go index 9d82bc866..21afac059 100644 --- a/core/loadpoint_phases_test.go +++ b/core/loadpoint_phases_test.go @@ -515,6 +515,62 @@ func TestScalePhasesNotAvailable(t *testing.T) { require.Equal(t, 1, lp.GetPhases()) } +// TestMinChargingPhaseScaling verifies that minCharging scales down to 1 phase +// (the absolute minimum) when phase switching is available, so feed-in priority +// in min+pv mode drops to 1p min current instead of staying on 3p (issue #30298). +func TestMinChargingPhaseScaling(t *testing.T) { + Voltage = 230 + + tc := []struct { + desc string + phasesConfigured int + phases int + expectedPhases int + }{ + {desc: "auto, 3p active scales down to 1p", phasesConfigured: 0, phases: 3, expectedPhases: 1}, + {desc: "auto, already 1p", phasesConfigured: 0, phases: 1, expectedPhases: 1}, + {desc: "fixed 3p cannot scale down", phasesConfigured: 3, phases: 3, expectedPhases: 3}, + {desc: "fixed 1p stays 1p", phasesConfigured: 1, phases: 1, expectedPhases: 1}, + } + + for _, tc := range tc { + t.Run(tc.desc, func(t *testing.T) { + ctrl := gomock.NewController(t) + + lp := NewLoadpoint(util.NewLogger("foo"), nil) + lp.minCurrent = 6 + lp.maxCurrent = 16 + lp.phasesConfigured = tc.phasesConfigured + lp.phases = tc.phases + lp.offeredCurrent = 0 // ensure MaxCurrent is called + lp.wakeUpTimer = NewTimer() + + plainCharger := api.NewMockCharger(ctrl) + phaseCharger := api.NewMockPhaseSwitcher(ctrl) + lp.charger = struct { + *api.MockCharger + *api.MockPhaseSwitcher + }{plainCharger, phaseCharger} + + plainCharger.EXPECT().Enabled().Return(true, nil).AnyTimes() + plainCharger.EXPECT().Enable(gomock.Any()).Return(nil).AnyTimes() + + if tc.phases != tc.expectedPhases { + phaseCharger.EXPECT().Phases1p3p(tc.expectedPhases).Return(nil) + } + + // minimum current is offered at the scaled phase count + plainCharger.EXPECT().MaxCurrent(int64(lp.minCurrent)).Return(nil) + + err := lp.minCharging() + require.NoError(t, err) + require.Equal(t, tc.expectedPhases, lp.phases, tc.desc) + + ctrl.Finish() + }) + } +} + func TestFastChargingCircuitBasedPhaseScaling(t *testing.T) { Voltage = 230 From e3a79f4618c439721cc8791fe9d3b9c56fb20715 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 31 May 2026 11:48:43 +0200 Subject: [PATCH 0064/1007] Metrics: distinguish missing meter energy from zero reading (#30297) --- core/site.go | 61 +++++++++++++++++++-------------------------- core/types/types.go | 4 +-- server/mqtt_test.go | 2 +- 3 files changed, 28 insertions(+), 39 deletions(-) diff --git a/core/site.go b/core/site.go index 48a8dbbc7..63dc02bfe 100644 --- a/core/site.go +++ b/core/site.go @@ -496,10 +496,18 @@ func (site *Site) collectMeters(key string, meters []config.Device[api.Meter]) [ fun := func(i int, dev config.Device[api.Meter]) { meter := dev.Instance() + props := deviceProperties(dev) + mm[i] = types.Measurement{ + Name: dev.Config().Name, + Title: props.Title, + Icon: props.Icon, + } + // power var b bytes.Buffer power, err := backoff.RetryWithData(meter.CurrentPower, modbus.Backoff()) if err == nil { + mm[i].Power = power site.log.DEBUG.Printf("%s %d power: %.0fW", key, i+1, power) } else { if b.Len() > 0 { @@ -509,22 +517,13 @@ func (site *Site) collectMeters(key string, meters []config.Device[api.Meter]) [ } // energy (production) - var energy float64 if m, ok := api.Cap[api.MeterEnergy](meter); err == nil && ok { - energy, err = m.TotalEnergy() - if err != nil { + if f, err := m.TotalEnergy(); err == nil { + mm[i].Energy = &f + } else { site.log.ERROR.Printf("%s %d energy: %v", key, i+1, err) } } - - props := deviceProperties(dev) - mm[i] = types.Measurement{ - Name: dev.Config().Name, - Title: props.Title, - Icon: props.Icon, - Power: power, - Energy: energy, - } } var wg sync.WaitGroup @@ -570,7 +569,10 @@ func (site *Site) updatePvMeters() { return math.Abs(m.ExcessDCPower) }) totalEnergy := lo.SumBy(mm, func(m types.Measurement) float64 { - return m.Energy + if m.Energy == nil { + return 0 + } + return *m.Energy }) if len(site.pvMeters) > 1 { @@ -589,13 +591,7 @@ func (site *Site) updatePvMeters() { // persist per-meter PV energy slots (used for history and forecast scaling) for i, dev := range site.pvMeters { c := site.collectors[dev.Config().Name] - - var importEnergy *float64 - if mm[i].Energy > 0 { - importEnergy = &mm[i].Energy - } - - if err := c.AddEnergy(importEnergy, nil, mm[i].Power); err != nil { + if err := c.AddEnergy(mm[i].Energy, nil, mm[i].Power); err != nil { site.log.ERROR.Printf("persist pv %d energy: %v", i+1, err) } } @@ -652,7 +648,10 @@ func (site *Site) updateBatteryMeters() { return m.Power }) site.battery.Energy = lo.SumBy(mm, func(m types.Measurement) float64 { - return m.Energy + if m.Energy == nil { + return 0 + } + return *m.Energy }) if len(site.batteryMeters) > 1 { @@ -669,11 +668,7 @@ func (site *Site) updateBatteryMeters() { if !ok { continue } - var exportEnergy *float64 - if mm[i].Energy > 0 { - exportEnergy = &mm[i].Energy - } - if err := c.AddEnergy(nil, exportEnergy, -mm[i].Power); err != nil { + if err := c.AddEnergy(nil, mm[i].Energy, -mm[i].Power); err != nil { site.log.ERROR.Printf("persist battery %d energy: %v", i+1, err) } } @@ -708,11 +703,7 @@ func (site *Site) addMeterEnergy(meters []config.Device[api.Meter], mm []types.M if !ok { continue } - var importEnergy *float64 - if mm[i].Energy > 0 { - importEnergy = &mm[i].Energy - } - if err := c.AddEnergy(importEnergy, nil, mm[i].Power); err != nil { + if err := c.AddEnergy(mm[i].Energy, nil, mm[i].Power); err != nil { site.log.ERROR.Printf("persist meter %s energy: %v", ref, err) } } @@ -790,18 +781,16 @@ func (site *Site) updateGridMeter() error { } } - // grid energy (import) - var importEnergy *float64 + // grid energy (import); nil when the device has no MeterEnergy capability or the read fails if energyMeter, ok := api.Cap[api.MeterEnergy](site.gridMeter); ok { if f, err := energyMeter.TotalEnergy(); err == nil { - mm.Energy = f - importEnergy = &f + mm.Energy = &f } else { site.log.ERROR.Printf("grid energy: %v", err) } } - site.collectors[site.Meters.GridMeterRef].AddEnergy(importEnergy, nil, mm.Power) + site.collectors[site.Meters.GridMeterRef].AddEnergy(mm.Energy, nil, mm.Power) site.publish(keys.Grid, mm) diff --git a/core/types/types.go b/core/types/types.go index e4a808a67..fe3b3e724 100644 --- a/core/types/types.go +++ b/core/types/types.go @@ -12,8 +12,8 @@ type Measurement struct { Title string `json:"title,omitempty"` Icon string `json:"icon,omitempty"` Power float64 `json:"power"` - Energy float64 `json:"energy,omitempty"` - ReturnEnergy float64 `json:"returnEnergy,omitempty"` + Energy *float64 `json:"energy,omitempty"` + ReturnEnergy *float64 `json:"returnEnergy,omitempty"` Powers []float64 `json:"powers,omitempty"` Currents []float64 `json:"currents,omitempty"` ExcessDCPower float64 `json:"excessdcpower,omitempty"` diff --git a/server/mqtt_test.go b/server/mqtt_test.go index c262c5a21..e9def0cda 100644 --- a/server/mqtt_test.go +++ b/server/mqtt_test.go @@ -115,7 +115,7 @@ func (suite *mqttSuite) TestMeasurement() { suite.Equal(topics, suite.topics, "topics") suite.Equal([]string{"", "", "", "0", "", "", "", "", "", "", "", ""}, suite.payloads, "empty payloads") - suite.publish("test", false, types.Measurement{Energy: 1}) + suite.publish("test", false, types.Measurement{Energy: new(1.0)}) suite.Equal(topics, suite.topics, "topics") suite.Equal([]string{"", "", "", "0", "1", "", "", "", "", "", "", ""}, suite.payloads, "energy payloads") From 17c758db034454cb8ced0739ba706de83be293e5 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 31 May 2026 11:57:36 +0200 Subject: [PATCH 0065/1007] Vehicle: route VW/Audi through drivesomethinggreater (BC) (#30364) --- templates/definition/vehicle/audi.yaml | 3 ++- templates/definition/vehicle/vw.yaml | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/templates/definition/vehicle/audi.yaml b/templates/definition/vehicle/audi.yaml index bf6445f0b..2c2147580 100644 --- a/templates/definition/vehicle/audi.yaml +++ b/templates/definition/vehicle/audi.yaml @@ -8,6 +8,7 @@ params: example: WAUZZZ... - preset: vehicle-features render: | - type: audi + type: drivesomethinggreater + brand: Audi {{ include "vehicle-base" . }} {{ include "vehicle-features" . }} diff --git a/templates/definition/vehicle/vw.yaml b/templates/definition/vehicle/vw.yaml index 8c583c023..5912295ab 100644 --- a/templates/definition/vehicle/vw.yaml +++ b/templates/definition/vehicle/vw.yaml @@ -13,10 +13,10 @@ params: - name: vin example: WVWZZZ... - name: timeout - default: 10s + deprecated: true - preset: vehicle-features render: | - type: vw + type: drivesomethinggreater + brand: Volkswagen {{ include "vehicle-base" . }} {{ include "vehicle-features" . }} - timeout: {{ .timeout }} From 529f1b1cb9d9ce243bdbaebec012f2e998ace487 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 31 May 2026 12:07:38 +0200 Subject: [PATCH 0066/1007] Meter: allow battery dimming (#30365) --- meter/meter.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/meter/meter.go b/meter/meter.go index a7f959933..63fde31ed 100644 --- a/meter/meter.go +++ b/meter/meter.go @@ -52,6 +52,14 @@ func NewConfigurableFromConfig(ctx context.Context, other map[string]any) (api.M m, _ := NewConfigurable(powerG) implement.May(m, implement.MeterEnergy(energyG)) + // dim/curtail + if err := cc.Dimmer.Implement(ctx, m); err != nil { + return nil, err + } + if err := cc.Curtailer.Implement(ctx, m); err != nil { + return nil, err + } + // decorate soc socG, err := cc.Soc.FloatGetter(ctx) if err != nil { @@ -97,14 +105,6 @@ func NewConfigurableFromConfig(ctx context.Context, other map[string]any) (api.M implement.May(m, implement.PhasePowers(powersG)) implement.May(m, implement.MaxACPowerGetter(cc.pvMaxACPower.Decorator())) - // dim/curtail - if err := cc.Dimmer.Implement(ctx, m); err != nil { - return nil, err - } - if err := cc.Curtailer.Implement(ctx, m); err != nil { - return nil, err - } - return m, nil } From f0cabc6850c7d63dfbd5eb499b69d388563b194c Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 31 May 2026 16:12:42 +0200 Subject: [PATCH 0067/1007] Vehicle: refresh drivesomethinggreater on portal schedule (#30368) --- vehicle/vw/eudataact/api.go | 44 ++----- vehicle/vw/eudataact/eudataact_test.go | 162 +++++++++++++++++++++--- vehicle/vw/eudataact/provider.go | 58 +++++++-- vehicle/vw/eudataact/store.go | 166 +++++++++++++++++++++++++ vehicle/vw/eudataact/types.go | 93 ++++++++++---- 5 files changed, 433 insertions(+), 90 deletions(-) create mode 100644 vehicle/vw/eudataact/store.go diff --git a/vehicle/vw/eudataact/api.go b/vehicle/vw/eudataact/api.go index 30415adbf..c108d77dd 100644 --- a/vehicle/vw/eudataact/api.go +++ b/vehicle/vw/eudataact/api.go @@ -41,6 +41,7 @@ var portalHost = strings.TrimPrefix(BaseURL, "https://") // the newest dataset and decoding its flat list of data points. type API struct { *request.Helper + log *util.Logger brand brand user, password string } @@ -54,6 +55,7 @@ func NewAPI(log *util.Logger, brandName, user, password string) (*API, error) { v := &API{ Helper: request.NewHelper(log), + log: log, brand: b, user: user, password: password, @@ -261,6 +263,11 @@ func (v *API) datasets(vin, identifier string) ([]dataset, error) { b, err := v.get(uri, map[string]string{"Accept": request.JSONContent, "type": "partial"}) if err != nil { + // the portal answers 404 "No files available for this request" until the + // vehicle has delivered its first dataset + if se, ok := errors.AsType[*request.StatusError](err); ok && se.HasStatus(http.StatusNotFound) { + return nil, nil + } return nil, err } @@ -269,14 +276,14 @@ func (v *API) datasets(vin, identifier string) ([]dataset, error) { return arr, nil } - var wrap struct { + var res struct { Files []dataset `json:"files"` } - if err := json.Unmarshal(b, &wrap); err != nil { + if err := json.Unmarshal(b, &res); err != nil { return nil, err } - return wrap.Files, nil + return res.Files, nil } // download fetches the dataset zip archive @@ -284,34 +291,3 @@ 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 index 668a4949f..a4328a045 100644 --- a/vehicle/vw/eudataact/eudataact_test.go +++ b/vehicle/vw/eudataact/eudataact_test.go @@ -4,7 +4,9 @@ import ( "archive/zip" "bytes" "encoding/json" + "fmt" "testing" + "time" "github.com/evcc-io/evcc/api" "github.com/stretchr/testify/assert" @@ -29,29 +31,38 @@ func zipJSON(t *testing.T, doc datasetFile) []byte { return buf.Bytes() } +// testProvider returns a provider serving the given static data +func testProvider(data map[string]point) *Provider { + return &Provider{ + statusG: func() (map[string]point, error) { + return data, nil + }, + } +} + 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 + {DataFieldName: FieldSoc, Value: "73", TimestampUtc: "2026-05-31T07:00:00Z"}, + {DataFieldName: FieldSoc, Value: "80", TimestampUtc: "2026-05-31T08:00:00Z"}, // newest timestamp wins + {DataFieldName: FieldOdometer, Value: "12345", TimestampUtc: "2026-05-31T08:00:00Z"}, + {DataFieldName: FieldRange, Value: "210", TimestampUtc: "2026-05-31T08:00:00Z"}, + {DataFieldName: FieldChargingState, Value: "charging", TimestampUtc: "2026-05-31T08:00:00Z"}, + {DataFieldName: FieldPlugState, Value: "connected", TimestampUtc: "2026-05-31T08:00:00Z"}, + {DataFieldName: FieldTargetSoc, Value: "90", TimestampUtc: "2026-05-31T08:00:00Z"}, + {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]) + assert.Equal(t, "80", data[FieldSoc].Value, "newest timestamp must win") + assert.Equal(t, "12345", data[FieldOdometer].Value) + assert.Equal(t, "210", data[FieldRange].Value) - p := &Provider{statusG: func() (map[string]string, error) { return data, nil }} + p := testProvider(data) soc, err := p.Soc() require.NoError(t, err) @@ -86,8 +97,11 @@ func TestStatusPlugStates(t *testing.T) { } 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 }} + data := map[string]point{ + FieldPlugState: {Value: tc.plug}, + FieldChargingState: {Value: tc.charge}, + } + p := testProvider(data) status, err := p.Status() require.NoError(t, err) @@ -106,13 +120,121 @@ func TestResolveBrand(t *testing.T) { assert.False(t, ok) } -func TestNewestDataset(t *testing.T) { +func TestContentDatasets(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"}, + {Name: "20260531090000_WVWZZZ.zip"}, + {Name: "20260531080000_WVWZZZ.zip"}, + {Name: "20260531091500_WVWZZZ_no_content_found.zip"}, } - 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"}})) + 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), + }) // hour i, oldest first + } + + // first poll (zero high-water): only the latest maxBackfill + got := pending(content, time.Time{}) + require.Len(t, got, maxBackfill) + assert.Equal(t, content[len(content)-maxBackfill].Name, got[0].Name, "oldest within the backfill window") + assert.Equal(t, content[len(content)-1].Name, got[len(got)-1].Name, "newest") + + // fewer datasets than the backfill cap: all returned + 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 + 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), + }) + got = pending(content, after) + require.Len(t, got, 1) + assert.Equal(t, "20260531100000_WVWZZZ.zip", got[0].Name, "only the newer dataset is pending") +} + +func TestMerge(t *testing.T) { + t0 := time.Date(2026, 5, 31, 7, 0, 0, 0, time.UTC) + t1 := time.Date(2026, 5, 31, 8, 0, 0, 0, time.UTC) + + dst := map[string]point{ + FieldSoc: {Value: "70", Timestamp: t0}, + FieldOdometer: {Value: "100", Timestamp: t1}, + } + src := map[string]point{ + FieldSoc: {Value: "80", Timestamp: t1}, // newer -> wins + FieldOdometer: {Value: "90", Timestamp: t0}, // older -> ignored + FieldRange: {Value: "200", Timestamp: t1}, // new field -> added + } + + merge(dst, src) + + assert.Equal(t, "80", dst[FieldSoc].Value, "newer datapoint wins") + assert.Equal(t, "100", dst[FieldOdometer].Value, "older datapoint ignored") + 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) { + now := time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC) + + // fresh dataset: reset one interval + latency later + assert.Equal(t, portalInterval+portalLatency, resetDelay(now, now)) + + // dataset already 5 min old: reset interval + latency after its timestamp + assert.Equal(t, portalInterval+portalLatency-5*time.Minute, resetDelay(now.Add(-5*time.Minute), now)) + + // next dataset already due: never reset sooner than the latency margin + assert.Equal(t, portalLatency, resetDelay(now.Add(-portalInterval), now)) + assert.Equal(t, portalLatency, resetDelay(now.Add(-time.Hour), now)) } diff --git a/vehicle/vw/eudataact/provider.go b/vehicle/vw/eudataact/provider.go index 29fae5263..53269fcd4 100644 --- a/vehicle/vw/eudataact/provider.go +++ b/vehicle/vw/eudataact/provider.go @@ -9,25 +9,65 @@ import ( "github.com/evcc-io/evcc/util" ) -// Provider implements the vehicle api on top of the EU Data Act dataset +const ( + // portalInterval is the cadence at which the portal delivers a new dataset + portalInterval = 15 * time.Minute + // portalLatency is the margin added to a dataset's timestamp before the + // following dataset is expected to be available for download + portalLatency = 30 * time.Second +) + +// Provider implements the vehicle api on top of the EU Data Act dataset. +// +// The portal is not a live api: it stores a new dataset roughly every +// portalInterval and only ever appends. Rather than re-reading the full history +// on every poll, a store downloads each dataset once and merges its data points +// into a single map, keeping the newest value per field across all datasets. The +// status getter is cached; instead of relying on the cache ttl alone, each read +// schedules a reset for the moment the next dataset is expected (the dataset's +// timestamp plus portalInterval and a latency margin), so the map is updated as +// soon as the portal delivers a new dataset. type Provider struct { - statusG func() (map[string]string, error) + statusG func() (map[string]point, 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), + v := &Provider{} + s := newStore(api, vin) + + var cached util.Cacheable[map[string]point] + cached = util.ResettableCached(func() (map[string]point, error) { + ts, err := s.update() + if err != nil { + return nil, err + } + if !ts.IsZero() { + time.AfterFunc(resetDelay(ts, time.Now()), cached.Reset) + } + return s.snapshot(), nil + }, cache) + + v.statusG = cached.Get + + return v +} + +// resetDelay returns the delay until the dataset following the one delivered at +// ts is expected to be available. It never returns less than portalLatency so a +// late or repeated dataset does not cause immediate re-polling. +func resetDelay(ts, now time.Time) time.Duration { + if d := ts.Add(portalInterval + portalLatency).Sub(now); d > portalLatency { + return d } + return portalLatency } // lookup returns the first present, non-empty value among the given field names -func lookup(data map[string]string, fields ...string) (string, bool) { +func lookup(data map[string]point, fields ...string) (string, bool) { for _, f := range fields { - if v, ok := data[f]; ok && v != "" { - return v, true + if v, ok := data[f]; ok && v.Value != "" { + return v.Value, true } } return "", false diff --git a/vehicle/vw/eudataact/store.go b/vehicle/vw/eudataact/store.go new file mode 100644 index 000000000..1b4270ee7 --- /dev/null +++ b/vehicle/vw/eudataact/store.go @@ -0,0 +1,166 @@ +package eudataact + +import ( + "maps" + "slices" + "sync" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" +) + +// maxBackfill bounds how many of the most recent content datasets are downloaded +// on the first poll of a session to seed the merged map. +const maxBackfill = 8 + +// store holds the merged dataset state for a single vehicle across a session. +// The portal only ever appends datasets. Each dataset is downloaded at most once; +// its data points are merged into data, keeping the newest value per field. after +// is the delivery time of the newest dataset already merged, so later polls only +// fetch datasets delivered after it and nothing is downloaded twice. +type store struct { + mu sync.Mutex + api *API + vin string + identifier string + data map[string]point + after time.Time +} + +// newStore creates an empty store for the given vehicle +func newStore(api *API, vin string) *store { + return &store{ + api: api, + vin: vin, + data: make(map[string]point), + } +} + +// update downloads any datasets delivered after the newest one already merged +// and merges them into the map oldest to newest. It returns the newest dataset's +// delivery time (used to schedule the next poll); the merged data is read with +// snapshot. On the first poll only the latest maxBackfill content datasets are +// downloaded. +func (s *store) update() (time.Time, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.identifier == "" { + id, err := s.api.identifier(s.vin) + if err != nil { + return time.Time{}, err + } + s.identifier = id + } + + list, err := s.api.datasets(s.vin, s.identifier) + if err != nil { + return time.Time{}, err + } + + content, err := contentDatasets(list) + if err != nil { + return time.Time{}, err + } + + 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 + } + } + + // on the first poll the backfilled datasets are logged once as the final + // merged map below; afterwards each newly received dataset is logged as it + // arrives + initial := s.after.IsZero() + + for _, d := range pending(content, s.after) { + b, err := s.api.download(s.vin, s.identifier, d.Name) + if err != nil { + return newest, err + } + + data, err := parseDataset(b) + if err != nil { + return newest, err + } + + 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 !initial { + logData(s.api.log, data) + } + } + + if len(s.data) == 0 { + return time.Time{}, api.ErrNotAvailable + } + + if initial { + logData(s.api.log, s.data) + } + + return newest, nil +} + +// snapshot returns a copy of the merged data +func (s *store) snapshot() map[string]point { + s.mu.Lock() + defer s.mu.Unlock() + + return maps.Clone(s.data) +} + +// logData logs every field of data at DEBUG level, sorted by field name, with +// its value and own timestamp in local time. +func logData(log *util.Logger, data map[string]point) { + for _, k := range slices.Sorted(maps.Keys(data)) { + p := data[k] + log.DEBUG.Printf("recv %s: %s (%s)", k, p.Value, p.Timestamp.Local().Format("2006-01-02 15:04:05")) + } +} + +// pending returns the content datasets that still need downloading, oldest to +// newest. content must be sorted oldest to newest. On the first poll (after is +// zero) only the latest maxBackfill datasets are returned to seed the map; +// afterwards only datasets delivered after the newest one already merged are +// returned, so no dataset is downloaded twice. +func pending(content []dataset, after time.Time) []dataset { + if after.IsZero() { + if len(content) > maxBackfill { + return content[len(content)-maxBackfill:] + } + return content + } + + res := make([]dataset, 0, len(content)) + for _, d := range content { + if d.Timestamp.After(after) { + res = append(res, d) + } + } + + return res +} + +// merge copies the data points from src into dst, keeping the newest value per +// field across datasets. +func merge(dst, src map[string]point) { + for k, p := range src { + if cur, ok := dst[k]; ok && cur.Timestamp.After(p.Timestamp) { + continue + } + dst[k] = p + } +} diff --git a/vehicle/vw/eudataact/types.go b/vehicle/vw/eudataact/types.go index f542889ee..0a0c3e806 100644 --- a/vehicle/vw/eudataact/types.go +++ b/vehicle/vw/eudataact/types.go @@ -5,8 +5,11 @@ import ( "bytes" "encoding/json" "errors" + "fmt" "io" + "slices" "strings" + "time" ) // brand holds the OIDC client id and state suffix for a VW group brand. @@ -70,25 +73,44 @@ func (v Vehicle) Name() string { return "" } -// dataset describes a single delivered dataset file +// dataset describes a single delivered dataset file. Timestamp is the parsed +// delivery time; it is populated by contentDatasets from the file name or the +// createdOn field. type dataset struct { - Name string `json:"name"` - CreatedOn string `json:"createdOn"` + Name string `json:"name"` + CreatedOn string `json:"createdOn"` + Timestamp time.Time `json:"-"` } -// sortKey returns the value used to find the newest dataset -func (d dataset) sortKey() string { - if d.CreatedOn != "" { - return d.CreatedOn +// 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 d.Name + return time.Parse(time.RFC3339, d.CreatedOn) } -// dataPoint is a single decoded telemetry value of a dataset +// dataPoint is a single data point as delivered in the dataset JSON document type dataPoint struct { - Key string `json:"key"` DataFieldName string `json:"dataFieldName"` Value string `json:"value"` + TimestampUtc string `json:"timestampUtc"` +} + +// point is a decoded data point: its value and the time it was recorded +type point struct { + Value string + Timestamp time.Time } // datasetFile is the JSON document contained in a dataset zip archive @@ -105,30 +127,42 @@ const ( FieldRangePrimary = "cruising_range_primary_engine" FieldOdometer = "mileage" FieldChargingState = "charging_state" - FieldPlugState = "charging_plug1_connectionstate" + FieldPlugState = "plug_state" 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 +// contentDatasets returns the datasets that actually carry content, with their +// delivery time parsed into Timestamp and sorted from oldest to newest. The +// portal emits "..._no_content_found.zip" placeholders while the vehicle is +// asleep, which are skipped. An error is returned when a content dataset's +// timestamp cannot be parsed. +func contentDatasets(list []dataset) ([]dataset, error) { + content := make([]dataset, 0, len(list)) 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 + + t, err := d.time() + if err != nil { + return nil, fmt.Errorf("dataset %q: %w", d.Name, err) } + d.Timestamp = t + + content = append(content, d) } - return best.Name + + slices.SortStableFunc(content, func(a, b dataset) int { + return a.Timestamp.Compare(b.Timestamp) + }) + + return content, nil } // 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) { +// decodes it into a map of data points keyed by the dotted data field name. On +// duplicate field names the entry with the newest timestamp wins. +func parseDataset(b []byte) (map[string]point, error) { zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b))) if err != nil { return nil, err @@ -161,17 +195,22 @@ func parseDataset(b []byte) (map[string]string, error) { return nil, err } - res := make(map[string]string, len(ds.Data)) - keys := make(map[string]string, len(ds.Data)) + res := make(map[string]point, len(ds.Data)) for _, p := range ds.Data { if p.DataFieldName == "" { continue } - if k, ok := keys[p.DataFieldName]; ok && k <= p.Key { + + ts, err := time.Parse(time.RFC3339, p.TimestampUtc) + if err != nil { + return nil, err + } + + if cur, ok := res[p.DataFieldName]; ok && cur.Timestamp.After(ts) { continue } - res[p.DataFieldName] = p.Value - keys[p.DataFieldName] = p.Key + + res[p.DataFieldName] = point{Value: p.Value, Timestamp: ts} } return res, nil From 7f0a49d819a79a85e576c17ceffd0be46fedec6a Mon Sep 17 00:00:00 2001 From: premultiply <4681172+premultiply@users.noreply.github.com> Date: Mon, 1 Jun 2026 10:52:53 +0200 Subject: [PATCH 0068/1007] chore: fix status caching (#30386) --- charger/go-e/api.go | 73 ++++++++++++++++++++++----------------------- 1 file changed, 35 insertions(+), 38 deletions(-) diff --git a/charger/go-e/api.go b/charger/go-e/api.go index 2c6edbb5c..98399f49c 100644 --- a/charger/go-e/api.go +++ b/charger/go-e/api.go @@ -36,9 +36,7 @@ type LocalAPI struct { *request.Helper uri string v2 bool - status Response - updated time.Time - cache time.Duration + statusG util.Cacheable[Response] } var _ API = (*LocalAPI)(nil) @@ -50,10 +48,10 @@ func NewLocal(log *util.Logger, uri string, cache time.Duration) *LocalAPI { api := &LocalAPI{ Helper: request.NewHelper(log), uri: uri, - cache: cache, } api.upgradeV2() + api.statusG = util.ResettableCached(api.status, cache) return api } @@ -83,53 +81,53 @@ func (c *LocalAPI) response(partial string, res any) error { return c.GetJSON(url, &res) } -// Status reads a v1/v2 api response -func (c *LocalAPI) Status() (res Response, err error) { - if time.Since(c.updated) > c.cache { - if c.v2 { - c.status = new(StatusResponse2) - err = c.response("status?filter=alw,car,eto,nrg,wh,trx,cards", &c.status) - } else { - c.status = new(StatusResponse) - err = c.response("status", &c.status) - } - if err == nil { - c.updated = time.Now() - } +// status fetches a fresh v1/v2 api response +func (c *LocalAPI) status() (Response, error) { + if c.v2 { + var res StatusResponse2 + err := c.response("status?filter=alw,car,eto,nrg,wh,trx,cards", &res) + return &res, err } - return c.status, err + + var res StatusResponse + err := c.response("status", &res) + return &res, err +} + +// Status reads a cached v1/v2 api response +func (c *LocalAPI) Status() (Response, error) { + return c.statusG.Get() } // Update executes a v1/v2 api update and returns the response func (c *LocalAPI) Update(payload string) error { - c.updated = time.Time{} + c.statusG.Reset() // invalidate cache so the next Status refetches + res := new(UpdateResponse) if c.v2 { - err := c.response(fmt.Sprintf("set?%s", payload), &res) - return err + return c.response(fmt.Sprintf("set?%s", payload), &res) } - err := c.response(fmt.Sprintf("mqtt?payload=%s", payload), &res) - return err + return c.response(fmt.Sprintf("mqtt?payload=%s", payload), &res) } type cloud struct { *request.Helper token string - cache time.Duration - updated time.Time - status Response + statusG util.Cacheable[Response] } var _ API = (*cloud)(nil) func NewCloud(log *util.Logger, token string, cache time.Duration) API { - return &cloud{ + c := &cloud{ Helper: request.NewHelper(log), token: token, - cache: cache, } + c.statusG = util.ResettableCached(c.status, cache) + + return c } func (c *cloud) IsV2() bool { @@ -152,20 +150,19 @@ func (c *cloud) response(function, payload string) (*StatusResponse, error) { return &status.Data, err } -func (c *cloud) Status() (status Response, err error) { - if time.Since(c.updated) >= c.cache { - status, err = c.response("api_status", "") - if err == nil { - c.updated = time.Now() - c.status = status - } - } +// status fetches a fresh cloud api response +func (c *cloud) status() (Response, error) { + return c.response("api_status", "") +} - return c.status, err +// Status reads a cached cloud api response +func (c *cloud) Status() (Response, error) { + return c.statusG.Get() } func (c *cloud) Update(payload string) error { - c.updated = time.Time{} + c.statusG.Reset() // invalidate cache so the next Status refetches + _, err := c.response("api", payload) return err } From 535dab993547338e642451c370c7b5a27ce7371e Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 1 Jun 2026 14:42:51 +0200 Subject: [PATCH 0069/1007] Meter (Growatt TL-XH): write battery-first time slot atomically (#30403) --- .../definition/meter/growatt-hybrid-tlxh.yaml | 47 +++++-------------- 1 file changed, 13 insertions(+), 34 deletions(-) diff --git a/templates/definition/meter/growatt-hybrid-tlxh.yaml b/templates/definition/meter/growatt-hybrid-tlxh.yaml index 69ce4b042..602b12176 100644 --- a/templates/definition/meter/growatt-hybrid-tlxh.yaml +++ b/templates/definition/meter/growatt-hybrid-tlxh.yaml @@ -124,24 +124,19 @@ render: | set: source: sequence set: + # Time-1 "battery first" slot: start (3038) and end (3039) time words must be written + # in a single FC16 multi-register transaction. The firmware rejects a standalone write + # to the end-time register 3039 with modbus exception 0. + # value = (start << 16) | end = (8192 << 16) | 5947 = 0x2000173B; bit15=0 -> slot disabled - source: const - value: 8192 + value: 536876859 set: source: modbus {{- include "modbus" . | indent 10 }} register: address: 3038 type: writemultiple - decode: uint16 - - source: const - value: 5947 - set: - source: modbus - {{- include "modbus" . | indent 10 }} - register: - address: 3039 - type: writemultiple - decode: uint16 + decode: uint32 - source: const value: 0 set: @@ -155,24 +150,16 @@ render: | set: source: sequence set: + # value = (start << 16) | end = (40960 << 16) | 5947 = 0xA000173B; bit15=1 -> slot enabled - source: const - value: 40960 + value: 2684360507 set: source: modbus {{- include "modbus" . | indent 10 }} register: address: 3038 type: writemultiple - decode: uint16 - - source: const - value: 5947 - set: - source: modbus - {{- include "modbus" . | indent 10 }} - register: - address: 3039 - type: writemultiple - decode: uint16 + decode: uint32 - source: const value: 0 set: @@ -186,24 +173,16 @@ render: | set: source: sequence set: + # value = (start << 16) | end = (40960 << 16) | 5947 = 0xA000173B; bit15=1 -> slot enabled - source: const - value: 40960 + value: 2684360507 set: source: modbus {{- include "modbus" . | indent 10 }} register: address: 3038 type: writemultiple - decode: uint16 - - source: const - value: 5947 - set: - source: modbus - {{- include "modbus" . | indent 10 }} - register: - address: 3039 - type: writemultiple - decode: uint16 + decode: uint32 - source: const value: 1 set: @@ -212,6 +191,6 @@ render: | register: address: 3049 type: writemultiple - decode: uint16 + decode: uint16 {{- include "battery-params" . }} {{- end }} From 88f533f41f00036bfe5231e3818e1bae5e2f64e1 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Mon, 1 Jun 2026 14:49:47 +0200 Subject: [PATCH 0070/1007] chore: upgrade playwright (#30402) --- .github/workflows/default.yml | 2 ++ package-lock.json | 24 ++++++++++++------------ package.json | 2 +- tests/config-loadpoint.spec.ts | 1 + 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml index 3c134457d..370affa1b 100644 --- a/.github/workflows/default.yml +++ b/.github/workflows/default.yml @@ -255,10 +255,12 @@ jobs: - name: Install Playwright (browsers + deps) if: steps.playwright-cache.outputs.cache-hit != 'true' run: npx playwright install --with-deps chromium + timeout-minutes: 5 - name: Install Playwright (deps only) if: steps.playwright-cache.outputs.cache-hit == 'true' run: npx playwright install-deps chromium + timeout-minutes: 5 - name: Run tests run: npx playwright test diff --git a/package-lock.json b/package-lock.json index ab577de2e..c5627ac18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,7 +34,7 @@ "@eslint/eslintrc": "^3.3.5", "@eslint/js": "^9.39.4", "@jest/types": "^30.3.0", - "@playwright/test": "^1.59.1", + "@playwright/test": "^1.60.0", "@storybook/vue3": "^10.3.5", "@storybook/vue3-vite": "^10.3.5", "@types/body-parser": "^1.19.6", @@ -2771,13 +2771,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", - "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.59.1" + "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" @@ -9386,13 +9386,13 @@ } }, "node_modules/playwright": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", - "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.59.1" + "playwright-core": "1.60.0" }, "bin": { "playwright": "cli.js" @@ -9405,9 +9405,9 @@ } }, "node_modules/playwright-core": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", - "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index 9220cda59..b2a4a839a 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "@eslint/eslintrc": "^3.3.5", "@eslint/js": "^9.39.4", "@jest/types": "^30.3.0", - "@playwright/test": "^1.59.1", + "@playwright/test": "^1.60.0", "@storybook/vue3": "^10.3.5", "@storybook/vue3-vite": "^10.3.5", "@types/body-parser": "^1.19.6", diff --git a/tests/config-loadpoint.spec.ts b/tests/config-loadpoint.spec.ts index d376748a6..a6149cde6 100644 --- a/tests/config-loadpoint.spec.ts +++ b/tests/config-loadpoint.spec.ts @@ -103,6 +103,7 @@ test.describe("charging loadpoint", async () => { await chargerModal.getByLabel("Charge status").selectOption("A"); await chargerModal.getByRole("button", { name: "Save" }).click(); await expectModalHidden(chargerModal); + await expectModalVisible(lpModal); await lpModal.getByRole("button", { name: "Save" }).click(); await expectModalHidden(lpModal); From 471cebdb125b9f7bcdd5fb9609e7e1510d8db694 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Mon, 1 Jun 2026 14:51:05 +0200 Subject: [PATCH 0071/1007] docs: drivesomethinggreater wording (#30399) --- templates/definition/vehicle/audi.yaml | 2 ++ .../definition/vehicle/drivesomethinggreater.yaml | 12 +++--------- templates/definition/vehicle/seat-cupra.yaml | 2 +- templates/definition/vehicle/skoda.yaml | 4 +++- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/templates/definition/vehicle/audi.yaml b/templates/definition/vehicle/audi.yaml index 2c2147580..e28bb53bd 100644 --- a/templates/definition/vehicle/audi.yaml +++ b/templates/definition/vehicle/audi.yaml @@ -2,6 +2,8 @@ template: audi covers: ["etron"] products: - brand: Audi + description: + generic: myAudi params: - preset: vehicle-base - name: vin diff --git a/templates/definition/vehicle/drivesomethinggreater.yaml b/templates/definition/vehicle/drivesomethinggreater.yaml index 3c8646e05..64f246600 100644 --- a/templates/definition/vehicle/drivesomethinggreater.yaml +++ b/templates/definition/vehicle/drivesomethinggreater.yaml @@ -19,7 +19,7 @@ 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 + [VW-Konzern-Portal](https://eu-data-act.drivesomethinggreater.com). Die Anmeldung erfolgt mit den Zugangsdaten der jeweiligen Marken-App (We Connect, myAudi, MyŠkoda, SEAT/CUPRA Connect). @@ -27,21 +27,15 @@ requirements: 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 + Uses the vehicle data provided under the EU Data Act through the [VW group + portal](https://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 diff --git a/templates/definition/vehicle/seat-cupra.yaml b/templates/definition/vehicle/seat-cupra.yaml index f82d23f5c..b78d107bd 100644 --- a/templates/definition/vehicle/seat-cupra.yaml +++ b/templates/definition/vehicle/seat-cupra.yaml @@ -2,7 +2,7 @@ template: cupra products: - brand: Seat description: - generic: CupraConnect Gen4 (Born, Formentor, Tavascan) + generic: CUPRA Connect Gen4 (Born, Formentor, Tavascan) params: - preset: vehicle-base - preset: vehicle-features diff --git a/templates/definition/vehicle/skoda.yaml b/templates/definition/vehicle/skoda.yaml index 6682ff798..9a47ab6de 100644 --- a/templates/definition/vehicle/skoda.yaml +++ b/templates/definition/vehicle/skoda.yaml @@ -1,7 +1,9 @@ template: skoda covers: ["enyaq"] products: - - brand: Skoda + - brand: Škoda + description: + generic: MyŠkoda params: - preset: vehicle-base - name: timeout From 5409ed21a83865bf682a64ae12efde8d677db048 Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 1 Jun 2026 14:51:35 +0200 Subject: [PATCH 0072/1007] chore: simplify drivesomethinggreater (#30391) --- util/request/roundtrip.go | 14 ++++++- vehicle/vw/eudataact/eudataact_test.go | 56 ++------------------------ vehicle/vw/eudataact/provider.go | 46 ++++++++++++++------- vehicle/vw/eudataact/store.go | 14 +++---- vehicle/vw/eudataact/types.go | 31 ++------------ 5 files changed, 55 insertions(+), 106 deletions(-) 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 From 0ffbe0ea782056e75bea292f738d08704fbeda88 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Mon, 1 Jun 2026 14:52:19 +0200 Subject: [PATCH 0073/1007] Session UI: small improvements (#30389) --- .../js/components/Savings/LiveCommunity.vue | 4 +-- .../components/Sessions/CostHistoryChart.vue | 26 ++++++++++---- assets/js/views/Sessions.vue | 35 ------------------- i18n/de.json | 12 +++---- i18n/en.json | 10 +++--- 5 files changed, 33 insertions(+), 54 deletions(-) diff --git a/assets/js/components/Savings/LiveCommunity.vue b/assets/js/components/Savings/LiveCommunity.vue index 868928f36..57caaef02 100644 --- a/assets/js/components/Savings/LiveCommunity.vue +++ b/assets/js/components/Savings/LiveCommunity.vue @@ -59,10 +59,10 @@ export default defineComponent({ }, computed: { totalClients() { - return this.result.totalClients; + return this.fmtNumber(this.result.totalClients || 0, 0); }, activeClients() { - return this.result.activeClients; + return this.fmtNumber(this.result.activeClients || 0, 0); }, chargePower() { const { chargePower = 0 } = this.result; diff --git a/assets/js/components/Sessions/CostHistoryChart.vue b/assets/js/components/Sessions/CostHistoryChart.vue index eedb19560..023809735 100644 --- a/assets/js/components/Sessions/CostHistoryChart.vue +++ b/assets/js/components/Sessions/CostHistoryChart.vue @@ -53,8 +53,6 @@ export default defineComponent({ currency: { type: String as PropType, default: CURRENCY.EUR }, colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {} }) }, deviceColors: { type: Object as PropType, default: () => ({}) }, - suggestedMaxAvgCost: { type: Number, default: 0 }, - suggestedMaxCost: { type: Number, default: 0 }, }, computed: { firstDay() { @@ -179,7 +177,9 @@ export default defineComponent({ this.costType === TYPES.PRICE ? this.$t("sessions.avgPrice") : this.$t("sessions.co2"), - data: Object.values(result).map((index) => index.avgCost), + data: Object.values(result).map((index) => + index.totalKWh > 0 ? index.avgCost : null + ), yAxisID: "y1", tension: 0.25, pointRadius: 0, @@ -231,6 +231,22 @@ export default defineComponent({ }; }); }, + maxBarCost() { + if (this.costType !== TYPES.PRICE) return 0; + const barDatasets = this.chartData.datasets.filter( + (d: any) => (d as any).type === "bar" + ); + const labelCount = this.chartData.labels?.length || 0; + let max = 0; + for (let i = 0; i < labelCount; i++) { + const total = barDatasets.reduce( + (sum: number, d: any) => sum + ((d.data[i] as number) || 0), + 0 + ); + if (total > max) max = total; + } + return max; + }, options() { // capture vue component this to be used in chartjs callbacks // eslint-disable-next-line @typescript-eslint/no-this-alias @@ -331,7 +347,7 @@ export default defineComponent({ ticks: { callback: (value: number) => { if (this.costType === TYPES.PRICE) { - const showDecimals = this.suggestedMaxCost < 4; + const showDecimals = this.maxBarCost < 4; return this.fmtMoney(value, this.currency, showDecimals, true); } else { return this.fmtNumber(value / 1e3, 0); @@ -340,13 +356,11 @@ export default defineComponent({ color: colors.muted, maxTicksLimit: 6, }, - suggestedMax: this.suggestedMaxCost, suggestedMin: 0, }, y1: { position: "left", border: { display: false }, - suggestedMax: this.suggestedMaxAvgCost, grid: { drawOnChartArea: false, }, diff --git a/assets/js/views/Sessions.vue b/assets/js/views/Sessions.vue index a8fe22f48..4bb87725f 100644 --- a/assets/js/views/Sessions.vue +++ b/assets/js/views/Sessions.vue @@ -92,8 +92,6 @@ :cost-type="activeType" :currency="currency" :period="period" - :suggested-max-avg-cost="suggestedMaxAvgCost" - :suggested-max-cost="suggestedMaxCost" />
@@ -640,29 +638,6 @@ export default defineComponent({ ? this.suggestedMaxAvgPrice : this.suggestedMaxAvgCo2; }, - suggestedMaxCo2() { - // returns the 98th percentile of total co2 emissions by time period - const sessionsWithCo2 = this.sessions.filter((s) => s.co2PerKWh !== null); - const co2Map = sessionsWithCo2.reduce((acc: Record, s) => { - const key = this.dateToPeriodKey(new Date(s.created)); - acc[key] = (acc[key] || 0) + (s.co2PerKWh ?? 0) * s.chargedEnergy; - return acc; - }, {}); - return Math.max(this.percentile(Object.values(co2Map), 98) ?? 0, 5); // 5kg default - }, - suggestedMaxPrice() { - // returns the 98th percentile of total price by time period - const sessionsWithPrice = this.sessions.filter((s) => s.price !== null); - const priceMap = sessionsWithPrice.reduce((acc: Record, s) => { - const key = this.dateToPeriodKey(new Date(s.created)); - acc[key] = (acc[key] || 0) + (s.price || 0); - return acc; - }, {}); - return Math.max(this.percentile(Object.values(priceMap), 98) ?? 0, 1); // 1 CURRENCY default - }, - suggestedMaxCost() { - return this.activeType === TYPES.PRICE ? this.suggestedMaxPrice : this.suggestedMaxCo2; - }, }, watch: { offline() { @@ -691,16 +666,6 @@ export default defineComponent({ } this.$router.push({ query: { ...this.$route.query, period, month, year } }); }, - dateToPeriodKey(date: Date) { - const options: Intl.DateTimeFormatOptions = { - year: "numeric", - month: "numeric", - day: "numeric", - }; - if (this.period === PERIODS.YEAR) options.day = undefined; - if (this.period === PERIODS.TOTAL) options.month = undefined; - return date.toLocaleDateString(undefined, options); - }, async loadSessions() { const response = await api.get("sessions"); // ensure sessions are sorted by created date diff --git a/i18n/de.json b/i18n/de.json index da04eae7c..b8a8c4d2c 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -881,7 +881,7 @@ "greenShareSub2": "PV und Speicher", "power": "Ladeleistung", "powerSub1": "{activeClients} von {totalClients} Nutzern", - "powerSub2": "laden…", + "powerSub2": "laden gerade.", "tabTitle": "Live-Community" }, "savings": { @@ -1412,7 +1412,7 @@ "meterstart": "Anfangszählerstand", "meterstop": "Endzählerstand", "odometer": "Kilometerstand", - "price": "Preis", + "price": "Kosten", "started": "Startzeit", "title": "Ladevorgang" }, @@ -1451,7 +1451,7 @@ "meterstart": "Anfangszählerstand (kWh)", "meterstop": "Endzählerstand (kWh)", "odometer": "Kilometerstand (km)", - "price": "Preis", + "price": "Kosten", "priceperkwh": "Preis/kWh", "solarpercentage": "Sonne (%)", "vehicle": "Fahrzeug" @@ -1468,7 +1468,7 @@ "group": { "co2": "Emissionen", "grid": "Netz", - "price": "Preis", + "price": "Kosten", "self": "Sonne" }, "groupBy": { @@ -1491,10 +1491,10 @@ "showIndividualEntries": "Einzelne Ladevorgänge anzeigen", "solar": "Sonne", "title": "Ladevorgänge", - "total": "Insgesamt", + "total": "Gesamt", "type": { "co2": "CO₂", - "price": "Preis", + "price": "Kosten", "solar": "Sonne" }, "vehicle": "Fahrzeug" diff --git a/i18n/en.json b/i18n/en.json index a153c0775..a1c853986 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -881,7 +881,7 @@ "greenShareSub2": "solar, and battery storage", "power": "Charging power", "powerSub1": "{activeClients} of {totalClients} participants", - "powerSub2": "charging…", + "powerSub2": "are charging.", "tabTitle": "Live community" }, "savings": { @@ -1412,7 +1412,7 @@ "meterstart": "Meter start", "meterstop": "Meter stop", "odometer": "Mileage", - "price": "Price", + "price": "Cost", "started": "Started", "title": "Charging Session" }, @@ -1451,7 +1451,7 @@ "meterstart": "Meter start (kWh)", "meterstop": "Meter stop (kWh)", "odometer": "Mileage (km)", - "price": "Price", + "price": "Cost", "priceperkwh": "Price/kWh", "solarpercentage": "Solar (%)", "vehicle": "Vehicle" @@ -1468,7 +1468,7 @@ "group": { "co2": "Emissions", "grid": "Grid", - "price": "Price", + "price": "Cost", "self": "Solar" }, "groupBy": { @@ -1494,7 +1494,7 @@ "total": "Total", "type": { "co2": "CO₂", - "price": "Price", + "price": "Cost", "solar": "Solar" }, "vehicle": "Vehicle" From b0c56c0a0d9162946a16fdafbffaa8cb1c9daa05 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Mon, 1 Jun 2026 14:54:22 +0200 Subject: [PATCH 0074/1007] unflake --- assets/js/components/Savings/Savings.vue | 19 ++++++++++++++++++- tests/statistics.spec.ts | 19 ++++++++++--------- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/assets/js/components/Savings/Savings.vue b/assets/js/components/Savings/Savings.vue index ff8c4e0d1..b58de4e84 100644 --- a/assets/js/components/Savings/Savings.vue +++ b/assets/js/components/Savings/Savings.vue @@ -31,7 +31,7 @@ data-bs-backdrop="true" tabindex="-1" role="dialog" - aria-hidden="true" + :aria-hidden="modalVisible ? 'false' : 'true'" data-testid="savings-modal" >