From b21ebda8d624cd5521ade80537e758dbeb8516d4 Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 26 Oct 2026 08:28:08 +0100 Subject: [PATCH 01/21] chore: speed up the Playwright run (#35325) Co-authored-by: Claude Opus 5.5 --- .github/workflows/default.yml | 6 +++--- tests/config-circuit.spec.ts | 1 + tests/config-onboarding.spec.ts | 1 + tests/vehicle-settings.spec.ts | 1 + 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml index d074b4608b..9c95342678 100644 --- a/.github/workflows/default.yml +++ b/.github/workflows/default.yml @@ -183,7 +183,7 @@ jobs: test -z "$(git status --porcelain)" || (git status; git diff; false) integration-shard: - name: Integration ${{ matrix.shard }}/2 + name: Integration ${{ matrix.shard }}/3 runs-on: depot-ubuntu-24.04-arm-32 permissions: @@ -194,7 +194,7 @@ jobs: strategy: fail-fast: false matrix: - shard: [1, 2] + shard: [1, 2, 3] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -258,7 +258,7 @@ jobs: key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }} - name: Run tests - run: vpx playwright test --shard=${{ matrix.shard }}/2 + run: vpx playwright test --shard=${{ matrix.shard }}/3 timeout-minutes: 20 env: TZ: Europe/Berlin diff --git a/tests/config-circuit.spec.ts b/tests/config-circuit.spec.ts index b90ca8d15b..3de3197e6d 100644 --- a/tests/config-circuit.spec.ts +++ b/tests/config-circuit.spec.ts @@ -15,6 +15,7 @@ const CONFIG_FAST = "fast.evcc.yaml"; const CONFIG_CIRCUIT_TREE = "config-circuit-tree.sql"; test.use({ baseURL: baseUrl() }); +test.describe.configure({ mode: "parallel" }); test.afterEach(async () => { await stop(); diff --git a/tests/config-onboarding.spec.ts b/tests/config-onboarding.spec.ts index e4f243bee3..4d7977772d 100644 --- a/tests/config-onboarding.spec.ts +++ b/tests/config-onboarding.spec.ts @@ -3,6 +3,7 @@ import { start, stop, restart, baseUrl } from "./evcc"; import { expectModalHidden, expectModalVisible, finishLoadpoint, skipOnboarding } from "./utils"; test.use({ baseURL: baseUrl() }); +test.describe.configure({ mode: "parallel" }); const PASSWORD = "secret"; diff --git a/tests/vehicle-settings.spec.ts b/tests/vehicle-settings.spec.ts index dff88adcd3..931ae29039 100644 --- a/tests/vehicle-settings.spec.ts +++ b/tests/vehicle-settings.spec.ts @@ -10,6 +10,7 @@ import { } from "./simulator"; test.use({ baseURL: baseUrl() }); +test.describe.configure({ mode: "parallel" }); test.beforeAll(async () => { await startSimulator(); From d217b8b5289dc6705ffe248e91f70e0a689a2c88 Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 26 Oct 2026 09:42:30 +0100 Subject: [PATCH 02/21] Optimizer: estimate vehicle state from charged energy without soc (#35366) --- core/site_optimizer.go | 6 +++++- core/site_optimizer_test.go | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/core/site_optimizer.go b/core/site_optimizer.go index dd70d3ff15..a53fbc4bf3 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -1058,7 +1058,11 @@ func (site *Site) loadpointRequest(lp loadpoint.API, minLen int, firstSlotDurati } else { maxSoc := capacity * float64(lp.EffectiveLimitSoc()) * 10 // Wh bat.SInitial = float32(capacity * soc * 10) // Wh - bat.SMax = max(bat.SInitial, float32(maxSoc)) // prevent infeasible if current soc above maximum + if soc == 0 { + // vehicle without soc: estimate stored energy from session charged energy + bat.SInitial = float32(lp.GetChargedEnergy() * eta) // Wh + } + bat.SMax = max(bat.SInitial, float32(maxSoc)) // prevent infeasible if current soc above maximum } detail.Type = batteryTypeVehicle diff --git a/core/site_optimizer_test.go b/core/site_optimizer_test.go index 912bef3a06..c82c0a1392 100644 --- a/core/site_optimizer_test.go +++ b/core/site_optimizer_test.go @@ -625,6 +625,7 @@ func TestLoadpointRequestChargeGoal(t *testing.T) { {"no capacity, limit exceeded", 0, 0, 100, 10, 11000, 11000, 11000}, {"capacity but no soc, energy limit", 50, 0, 100, 10, 0, 0, 10000}, {"capacity but no soc, no energy limit", 50, 0, 100, 0, 0, 0, 50000}, + {"capacity but no soc, partially charged", 50, 0, 100, 0, 10000, 9000, 50000}, } { t.Run(tc.name, func(t *testing.T) { ctrl := gomock.NewController(t) From 15f843dcbedb1e2aa916a491b788bb0a98b6cb96 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:14:32 +0100 Subject: [PATCH 03/21] Tariff (Octopus DE): fix time-of-use slots shifting after DST change (#35353) --- tariff/octopusde.go | 10 +++++----- tariff/octopusde_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/tariff/octopusde.go b/tariff/octopusde.go index 5dda31263b..9ba1086271 100644 --- a/tariff/octopusde.go +++ b/tariff/octopusde.go @@ -272,17 +272,17 @@ func computeHorizon(now time.Time, agreement krakengql.Agreement, planDays int) // computePeriod converts day-relative time offsets into absolute start/end times, // handling the midnight-wrapping convention ("00:00:00" means end-of-day). func computePeriod(day time.Time, fromOffset, toOffset time.Duration) (time.Time, time.Time) { - start := day.Add(fromOffset) + start := wallClock(day, int(fromOffset/time.Minute)) var end time.Time switch { case toOffset == 0: // "00:00:00" as end means end of day (midnight) - end = day.Add(24 * time.Hour) + end = day.AddDate(0, 0, 1) case toOffset < fromOffset: // wraps past midnight - end = day.Add(toOffset).Add(24 * time.Hour) + end = wallClock(day.AddDate(0, 0, 1), int(toOffset/time.Minute)) default: - end = day.Add(toOffset) + end = wallClock(day, int(toOffset/time.Minute)) } return start, end } @@ -332,7 +332,7 @@ func generateTouRates(rates []krakengql.TouRate, horizon planningHorizon) ([]Rat startDay := now.With(horizon.start).BeginningOfDay() var result []RatePeriod - for day := startDay; day.Before(horizon.end); day = day.Add(24 * time.Hour) { + for day := startDay; day.Before(horizon.end); day = day.AddDate(0, 0, 1) { for _, r := range rates { dayPeriods, err := ratePeriodsForDay(day, horizon, r) if err != nil { diff --git a/tariff/octopusde_test.go b/tariff/octopusde_test.go index 66de51c2ab..3d42478920 100644 --- a/tariff/octopusde_test.go +++ b/tariff/octopusde_test.go @@ -257,3 +257,29 @@ func TestRatesForAgreement_InvalidAgreement_AlreadyCompleted(t *testing.T) { assert.Contains(t, err.Error(), "agreement is not valid for the planning horizon") assert.Nil(t, rates) } + +// TestRatesForAgreement_TimeOfUseDSTChange verifies that slots stay on wall clock time +// across the 25 hour day at the end of DST. +func TestRatesForAgreement_TimeOfUseDSTChange(t *testing.T) { + loc, err := time.LoadLocation("Europe/Berlin") + require.NoError(t, err) + + rates, err := ratesForAgreement(touAgreement(), time.Date(2026, 10, 25, 0, 0, 0, 0, loc)) + require.NoError(t, err) + require.Len(t, rates, 14) + + at := func(day, hour int) time.Time { + return time.Date(2026, 10, 25+day, hour, 0, 0, 0, loc) + } + + for i, r := range rates { + day := i / 2 + if i%2 == 0 { + assert.Equal(t, at(day, 6), r.ValidFrom, "day slot %d", i) + assert.Equal(t, at(day, 22), r.ValidTo, "day slot %d", i) + } else { + assert.Equal(t, at(day, 22), r.ValidFrom, "night slot %d", i) + assert.Equal(t, at(day+1, 6), r.ValidTo, "night slot %d", i) + } + } +} From 5498ea2d2f76fb21f3f01116d40b56f3cf079467 Mon Sep 17 00:00:00 2001 From: mjr_kong Date: Mon, 26 Oct 2026 02:31:51 -0700 Subject: [PATCH 04/21] EEBus: support single-phase chargers and dynamic phase limits (#35133) --- charger/eebus-evse.go | 32 ++++++++++++--- charger/eebus-evse_test.go | 80 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 6 deletions(-) diff --git a/charger/eebus-evse.go b/charger/eebus-evse.go index 1a2655d283..00774e9921 100644 --- a/charger/eebus-evse.go +++ b/charger/eebus-evse.go @@ -286,7 +286,10 @@ func (c *EEBus) Enabled() (bool, error) { return c.enabled, nil } - for _, limit := range limits { + minLimits, maxLimits, _, _ := c.cem.OpEV.CurrentLimits(evEntity) + numPhases := min(len(limits), activePhases(minLimits, maxLimits)) + + for _, limit := range limits[:numPhases] { // for IEC61851 the pause limit is 0A, for ISO15118-2 it is 0.1A // instead of checking for the actual data, hardcode this, so we might run into less // timing issues as the data might not be received yet @@ -330,6 +333,16 @@ func (c *EEBus) Enable(enable bool) error { return err } +func activePhases(limitSlices ...[]float64) int { + phases := len(ucapi.PhaseNameMapping) + for _, limits := range limitSlices { + if len(limits) > 0 && len(limits) < phases { + phases = len(limits) + } + } + return phases +} + // send current charging power limits to the EV func (c *EEBus) writeCurrentLimitData(evEntity spineapi.EntityRemoteInterface, current float64) error { // check if the EVSE supports overload protection limits @@ -337,14 +350,19 @@ func (c *EEBus) writeCurrentLimitData(evEntity spineapi.EntityRemoteInterface, c return api.ErrNotAvailable } - _, maxLimits, _, err := c.cem.OpEV.CurrentLimits(evEntity) + minLimits, maxLimits, _, err := c.cem.OpEV.CurrentLimits(evEntity) if err != nil { c.log.DEBUG.Println("no limits from the EVSE are provided:", err) } + numPhases := activePhases(minLimits, maxLimits) + if numPhases < len(ucapi.PhaseNameMapping) { + c.log.DEBUG.Printf("limiting load control to %d phase(s)", numPhases) + } + // setup the obligation limit data structure var limits []ucapi.LoadLimitsPhase - for phase := range len(ucapi.PhaseNameMapping) { + for phase := range numPhases { limit := ucapi.LoadLimitsPhase{ Phase: ucapi.PhaseNameMapping[phase], IsActive: true, @@ -352,7 +370,7 @@ func (c *EEBus) writeCurrentLimitData(evEntity spineapi.EntityRemoteInterface, c } // if the limit equals to the max allowed, then the obligation limit is actually inactive - if phase < len(maxLimits) && current >= maxLimits[phase] { + if current > 0 && phase < len(maxLimits) && maxLimits[phase] > 0 && current >= maxLimits[phase] { limit.IsActive = false } @@ -390,13 +408,15 @@ func (c *EEBus) writeOscevLimits(evEntity spineapi.EntityRemoteInterface, curren return } - minLimits, _, _, err := c.cem.OscEV.CurrentLimits(evEntity) + minLimits, maxLimits, _, err := c.cem.OscEV.CurrentLimits(evEntity) if err != nil { return } + numPhases := activePhases(minLimits, maxLimits) + var limits []ucapi.LoadLimitsPhase - for phase := range len(ucapi.PhaseNameMapping) { + for phase := range numPhases { limit := ucapi.LoadLimitsPhase{ Phase: ucapi.PhaseNameMapping[phase], IsActive: false, diff --git a/charger/eebus-evse_test.go b/charger/eebus-evse_test.go index ea6aefb757..4725aed0b8 100644 --- a/charger/eebus-evse_test.go +++ b/charger/eebus-evse_test.go @@ -228,6 +228,24 @@ func TestWriteCurrentLimitData_Disable(t *testing.T) { require.NoError(t, err) } +func TestWriteCurrentLimitData_DisableWhenMaxLimitsZero(t *testing.T) { + eebus, opev, oscev, evEntity := newTestEEBus(t) + _ = eebus + + // When EVSE reports max limit as 0 (e.g. idle before charging starts) + // disabling (0A) must still produce an active obligation limit + opev.EXPECT().IsScenarioAvailableAtEntity(evEntity, uint(1)).Return(true) + opev.EXPECT().CurrentLimits(evEntity).Return([]float64{6}, []float64{0}, []float64{0}, nil) + opev.EXPECT().WriteLoadControlLimits(evEntity, mock.MatchedBy(func(limits []ucapi.LoadLimitsPhase) bool { + return len(limits) == 1 && limits[0].IsActive && limits[0].Value == 0 + }), mock.Anything).Run(ackWrite).Return(nil, nil) + + oscev.EXPECT().IsScenarioAvailableAtEntity(evEntity, uint(1)).Return(false) + + err := eebus.writeCurrentLimitData(evEntity, 0) + require.NoError(t, err) +} + func TestWriteCurrentLimitData_OscevNoLimitData(t *testing.T) { eebus, opev, oscev, evEntity := newTestEEBus(t) _ = eebus @@ -255,6 +273,38 @@ func TestWriteCurrentLimitData_OpevNotAvailable(t *testing.T) { require.ErrorIs(t, err, api.ErrNotAvailable) } +func TestWriteCurrentLimitData_SinglePhaseAutoDetect(t *testing.T) { + eebus, opev, oscev, evEntity := newTestEEBus(t) + + // OPEV available with 1 phase limit (e.g. EVBox Livo / North America 1-phase) + opev.EXPECT().IsScenarioAvailableAtEntity(evEntity, uint(1)).Return(true) + opev.EXPECT().CurrentLimits(evEntity).Return([]float64{6}, []float64{48}, []float64{0}, nil) + opev.EXPECT().WriteLoadControlLimits(evEntity, mock.MatchedBy(func(limits []ucapi.LoadLimitsPhase) bool { + return len(limits) == 1 && limits[0].Phase == ucapi.PhaseNameMapping[0] && limits[0].IsActive && limits[0].Value == 10 + }), mock.Anything).Run(ackWrite).Return(nil, nil) + + oscev.EXPECT().IsScenarioAvailableAtEntity(evEntity, uint(1)).Return(false) + + err := eebus.writeCurrentLimitData(evEntity, 10) + require.NoError(t, err) +} + +func TestWriteCurrentLimitData_PartialLimitsAutoDetect(t *testing.T) { + eebus, opev, oscev, evEntity := newTestEEBus(t) + + // OPEV returns minLimits with 1 phase and empty maxLimits + opev.EXPECT().IsScenarioAvailableAtEntity(evEntity, uint(1)).Return(true) + opev.EXPECT().CurrentLimits(evEntity).Return([]float64{6}, nil, nil, nil) + opev.EXPECT().WriteLoadControlLimits(evEntity, mock.MatchedBy(func(limits []ucapi.LoadLimitsPhase) bool { + return len(limits) == 1 && limits[0].Phase == ucapi.PhaseNameMapping[0] && limits[0].IsActive && limits[0].Value == 10 + }), mock.Anything).Run(ackWrite).Return(nil, nil) + + oscev.EXPECT().IsScenarioAvailableAtEntity(evEntity, uint(1)).Return(false) + + err := eebus.writeCurrentLimitData(evEntity, 10) + require.NoError(t, err) +} + func TestEnabledAlwaysReadsOpev(t *testing.T) { evcc := mocks.NewCemEVCCInterface(t) opev := mocks.NewCemOPEVInterface(t) @@ -270,6 +320,7 @@ func TestEnabledAlwaysReadsOpev(t *testing.T) { } evcc.EXPECT().EVConnected(evEntity).Return(true) + opev.EXPECT().CurrentLimits(evEntity).Return([]float64{6, 6, 6}, []float64{16, 16, 16}, []float64{0, 0, 0}, nil) opev.EXPECT().LoadControlLimits(evEntity).Return([]ucapi.LoadLimitsPhase{ {Phase: model.ElectricalConnectionPhaseNameTypeA, IsActive: true, Value: 10}, {Phase: model.ElectricalConnectionPhaseNameTypeB, IsActive: true, Value: 10}, @@ -281,6 +332,35 @@ func TestEnabledAlwaysReadsOpev(t *testing.T) { assert.True(t, enabled) } +func TestEnabledSinglePhaseLingeringThreePhaseLimits(t *testing.T) { + evcc := mocks.NewCemEVCCInterface(t) + opev := mocks.NewCemOPEVInterface(t) + + evEntity := spinemocks.NewEntityRemoteInterface(t) + eebus := &EEBus{ + cem: &eebus.CustomerEnergyManagement{ + EvCC: evcc, + OpEV: opev, + }, + ev: evEntity, + log: util.NewLogger("test"), + } + + evcc.EXPECT().EVConnected(evEntity).Return(true) + // EVSE reports single-phase limits + opev.EXPECT().CurrentLimits(evEntity).Return([]float64{6}, []float64{32}, []float64{0}, nil) + // EVSE table retains lingering 16A limits on phases B and C from an earlier 3-phase write + opev.EXPECT().LoadControlLimits(evEntity).Return([]ucapi.LoadLimitsPhase{ + {Phase: model.ElectricalConnectionPhaseNameTypeA, IsActive: true, Value: 0}, + {Phase: model.ElectricalConnectionPhaseNameTypeB, IsActive: true, Value: 16}, + {Phase: model.ElectricalConnectionPhaseNameTypeC, IsActive: true, Value: 16}, + }, nil) + + enabled, err := eebus.Enabled() + require.NoError(t, err) + assert.False(t, enabled) +} + func TestEEBusIsCharging(t *testing.T) { type limitStruct struct { min, max, pause float64 From e05d5bfbaa703748bb152c136001895631836aeb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:36:35 +0000 Subject: [PATCH 05/21] Shelly Top AC: handle charger_insert work state (#35345) --- charger/shelly-topac.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/charger/shelly-topac.go b/charger/shelly-topac.go index 441e391e6f..9aeceb4758 100644 --- a/charger/shelly-topac.go +++ b/charger/shelly-topac.go @@ -127,11 +127,11 @@ func (c *ShellyTopAC) Status() (api.ChargeStatus, error) { return api.StatusNone, err } - // Possible states: charger_free, charger_wait, charger_pause, charger_charging, charger_complete, charger_error + // Possible states: charger_free, charger_insert, charger_wait, charger_pause, charger_charging, charger_complete, charger_end, charger_error switch res.Result.Value { case "charger_free": return api.StatusA, nil - case "charger_wait", "charger_pause", "charger_complete", "charger_end": + case "charger_insert", "charger_wait", "charger_pause", "charger_complete", "charger_end": return api.StatusB, nil case "charger_charging": return api.StatusC, nil From 12a658a9552f888d37c968eb42652e477356f3fa Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:45:14 +0100 Subject: [PATCH 06/21] Tariff (Home Assistant): support sub-hourly solar forecast slots (#35342) --- tariff/homeassistant.go | 57 +++++++++++++++++++++++++++++------- tariff/homeassistant_test.go | 48 ++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 11 deletions(-) diff --git a/tariff/homeassistant.go b/tariff/homeassistant.go index 040b8c4bf5..c1e34995c2 100644 --- a/tariff/homeassistant.go +++ b/tariff/homeassistant.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "maps" "slices" "sync" "time" @@ -93,30 +94,64 @@ func (t *HomeAssistant) run(interval time.Duration, done chan error) { } } -// solarForecastRates sums the hourly energy of all forecasts, which equals the average power in W +// solarForecastRates converts the energy per slot of all forecasts to average power in W. +// The slot length of a forecast is its smallest timestamp distance, defaulting to one hour. func solarForecastRates(res map[string]homeassistant.SolarForecast) (api.Rates, error) { - energy := make(map[time.Time]float64) + var slots api.Rates + bounds := make(map[time.Time]struct{}) for _, f := range res { + energy := make(map[time.Time]float64, len(f.WhHours)) for k, wh := range f.WhHours { ts, err := time.Parse(time.RFC3339, k) if err != nil { return nil, fmt.Errorf("invalid timestamp: %w", err) } + // skip off-grid keys like Forecast.Solar sunrise/sunset that would shrink the slot length + if !ts.Equal(ts.Truncate(15 * time.Minute)) { + continue + } energy[ts.Local()] += wh } - } - data := make(api.Rates, 0, len(energy)) - for ts, wh := range energy { - data = append(data, api.Rate{ - Start: ts, - End: ts.Add(time.Hour), - Value: wh, - }) + ts := slices.SortedFunc(maps.Keys(energy), time.Time.Compare) + + d := time.Hour + for i := 1; i < len(ts); i++ { + if delta := ts[i].Sub(ts[i-1]); delta < d { + d = delta + } + } + + for _, t := range ts { + end := t.Add(d) + slots = append(slots, api.Rate{Start: t, End: end, Value: energy[t] / d.Hours()}) + bounds[t], bounds[end] = struct{}{}, struct{}{} + } } - data.Sort() + // sum slots of different granularity on the common time grid + edges := slices.SortedFunc(maps.Keys(bounds), time.Time.Compare) + + data := make(api.Rates, 0, len(edges)) + for i := 0; i+1 < len(edges); i++ { + start, end := edges[i], edges[i+1] + + var ( + sum float64 + found bool + ) + for _, s := range slots { + if !s.Start.After(start) && !s.End.Before(end) { + sum += s.Value + found = true + } + } + + if found { + data = append(data, api.Rate{Start: start, End: end, Value: sum}) + } + } return data, nil } diff --git a/tariff/homeassistant_test.go b/tariff/homeassistant_test.go index 35cbd75233..c99371e7d3 100644 --- a/tariff/homeassistant_test.go +++ b/tariff/homeassistant_test.go @@ -37,3 +37,51 @@ func TestSolarForecastRates(t *testing.T) { }) assert.Error(t, err) } + +func TestSolarForecastRatesHalfHourly(t *testing.T) { + res := map[string]homeassistant.SolarForecast{ + "solcast": {WhHours: map[string]float64{ + "2026-09-12T07:00:00+00:00": 100, + "2026-09-12T07:30:00+00:00": 200, + }}, + "hourly": {WhHours: map[string]float64{ + "2026-09-12T07:00:00+00:00": 50, + }}, + } + + data, err := solarForecastRates(res) + require.NoError(t, err) + require.Len(t, data, 2) + + start := time.Date(2026, 9, 12, 7, 0, 0, 0, time.UTC) + assert.True(t, data[0].Start.Equal(start)) + assert.True(t, data[0].End.Equal(start.Add(30*time.Minute))) + assert.Equal(t, 250.0, data[0].Value) + assert.True(t, data[1].Start.Equal(start.Add(30*time.Minute))) + assert.True(t, data[1].End.Equal(start.Add(time.Hour))) + assert.Equal(t, 450.0, data[1].Value) +} + +func TestSolarForecastRatesOffGrid(t *testing.T) { + // Forecast.Solar adds sunrise and sunset keys off the hourly grid + res := map[string]homeassistant.SolarForecast{ + "forecast_solar": {WhHours: map[string]float64{ + "2026-09-12T06:20:17+02:00": 0, + "2026-09-12T07:00:00+02:00": 89, + "2026-09-12T08:00:00+02:00": 376, + "2026-09-12T08:59:23+02:00": 19, + }}, + } + + data, err := solarForecastRates(res) + require.NoError(t, err) + require.Len(t, data, 2) + + start := time.Date(2026, 9, 12, 5, 0, 0, 0, time.UTC) + assert.True(t, data[0].Start.Equal(start)) + assert.True(t, data[0].End.Equal(start.Add(time.Hour))) + assert.Equal(t, 89.0, data[0].Value) + assert.True(t, data[1].Start.Equal(start.Add(time.Hour))) + assert.True(t, data[1].End.Equal(start.Add(2*time.Hour))) + assert.Equal(t, 376.0, data[1].Value) +} From 1b4e1123326694980b5397f46061ee71a47a112d Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Mon, 26 Oct 2026 11:23:53 +0100 Subject: [PATCH 07/21] chore: speed up Playwright with reduced motion and faster polling (#35373) --- playwright.config.ts | 1 + tests/evcc.ts | 10 ++++++---- tests/simulator.ts | 7 ++++--- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index 3facdb09db..abfaaf8cb5 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -17,6 +17,7 @@ export default defineConfig({ screenshot: "only-on-failure", permissions: ["clipboard-write"], actionTimeout: 20000, // 20s for individual actions + reducedMotion: "reduce", // skips animations and transitions }, projects: [ { diff --git a/tests/evcc.ts b/tests/evcc.ts index ef01f26e54..a9a3909ad1 100644 --- a/tests/evcc.ts +++ b/tests/evcc.ts @@ -11,6 +11,8 @@ import { test } from "@playwright/test"; const BINARY = "./evcc"; const IS_CI = !!process.env["GITHUB_ACTIONS"]; const LOG_ENABLED = !IS_CI; +// wait-on polls every 250ms by default, which adds up over hundreds of start/stop cycles +export const WAIT_OPTS = { interval: 50, window: 50, log: LOG_ENABLED }; // sometimes evcc startup fails due to infra issues in runner ususally fixed by retry. allowing some fails to avoid github annotations clutter let allowedStartupFails = IS_CI ? 2 : 0; @@ -104,7 +106,7 @@ async function _start(config?: string, flags: string[] = []) { log(`wait until port ${port} is available`); // wait for port to be available - await waitOn({ resources: [`tcp:${port}`], reverse: true, log: LOG_ENABLED }); + await waitOn({ resources: [`tcp:${port}`], reverse: true, ...WAIT_OPTS }); log(`starting evcc on ports ${port} (HTTP), ${ocpp} (OCPP) and ${eebus} (EEBus)`); const additionalFlags = [...flags, "--log", "debug,httpd:trace"]; log("starting evcc", { config, port, ocpp, eebus, additionalFlags }); @@ -131,7 +133,7 @@ async function _start(config?: string, flags: string[] = []) { // wait for apiReady so tests don't race a half-started backend const jq = encodeURIComponent(".apiReady|select(.)"); const ready = `${baseUrl().replace(/^http/, "http-get")}/api/state?jq=${jq}`; - await waitOn({ resources: [ready], log: LOG_ENABLED, timeout: 90000 }); + await waitOn({ resources: [ready], ...WAIT_OPTS, timeout: 90000 }); } catch (error) { instance.kill("SIGKILL"); console.error(logPrefix(), `evcc startup failed: ${error}`); @@ -151,7 +153,7 @@ async function _stop(instance?: ChildProcess) { if (instance) { log("shutting down evcc hard", { port }); instance.kill("SIGKILL"); - await waitOn({ resources: [`tcp:${port}`], reverse: true, log: LOG_ENABLED, timeout: 5000 }); + await waitOn({ resources: [`tcp:${port}`], reverse: true, ...WAIT_OPTS, timeout: 5000 }); log("evcc is down", { port }); return; } @@ -179,7 +181,7 @@ async function _stop(instance?: ChildProcess) { } } log(`wait until port ${port} is closed`); - await waitOn({ resources: [`tcp:${port}`], reverse: true, log: LOG_ENABLED, timeout: 5000 }); + await waitOn({ resources: [`tcp:${port}`], reverse: true, ...WAIT_OPTS, timeout: 5000 }); log("evcc is down", { port }); } diff --git a/tests/simulator.ts b/tests/simulator.ts index 689cca0465..9ce2b0ffe9 100644 --- a/tests/simulator.ts +++ b/tests/simulator.ts @@ -6,6 +6,7 @@ import axios from "axios"; import { spawn } from "child_process"; import { Transform } from "stream"; import type { Page } from "@playwright/test"; +import { WAIT_OPTS } from "./evcc"; const LOG_ENABLED = !process.env["GITHUB_ACTIONS"]; @@ -59,7 +60,7 @@ export async function startSimulator() { const port = workerPort(); log("starting simulator", { port }); log(`wait until port ${port} is available`); - await waitOn({ resources: [`tcp:${port}`], reverse: true, log: LOG_ENABLED }); + await waitOn({ resources: [`tcp:${port}`], reverse: true, ...WAIT_OPTS }); const instance = spawn("vp", ["preview", "tests/simulator", "--port", port.toString()]); @@ -71,7 +72,7 @@ export async function startSimulator() { steamLog.end(); }); - await waitOn({ resources: [`${simulatorUrl()}/api/state`], log: LOG_ENABLED }); + await waitOn({ resources: [`${simulatorUrl()}/api/state`], ...WAIT_OPTS }); } export async function stopSimulator() { @@ -79,7 +80,7 @@ export async function stopSimulator() { log("shutting down simulator", { port }); await axios.post(`${simulatorUrl()}/api/shutdown`); log(`wait until port ${port} is closed`); - await waitOn({ resources: [`tcp:localhost:${port}`], reverse: true, log: LOG_ENABLED }); + await waitOn({ resources: [`tcp:localhost:${port}`], reverse: true, ...WAIT_OPTS }); } export const simulatorApply = async (page: Page) => { From 223c48c8f111ccec98563d9b17c5a6f91f075c88 Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 26 Oct 2026 13:12:13 +0100 Subject: [PATCH 08/21] EcoFlow Stream: detect leader among multiple serials (#35334) --- meter/ecoflow.go | 97 +++++++++++++++---- meter/ecoflow_test.go | 40 ++++++++ .../definition/meter/ecoflow-stream.yaml | 4 +- 3 files changed, 119 insertions(+), 22 deletions(-) diff --git a/meter/ecoflow.go b/meter/ecoflow.go index 0dfcf269e3..692be1ba23 100644 --- a/meter/ecoflow.go +++ b/meter/ecoflow.go @@ -5,6 +5,9 @@ import ( "errors" "fmt" "math" + "slices" + "strings" + "sync/atomic" "time" "github.com/evcc-io/evcc/api" @@ -18,13 +21,14 @@ import ( // EcoFlow represents the EcoFlow meter type EcoFlow struct { implement.Caps - log *util.Logger - usage string - serial string - cache time.Duration - client *ecoflow.Client - dataG func() (*ecoflow.GetCmdResponse, error) - limitG func() (float64, error) + log *util.Logger + usage string + serials []string + leader atomic.Int32 + cache time.Duration + client *ecoflow.Client + dataG func() (*ecoflow.GetCmdResponse, error) + limitG func() (float64, error) power, batterySoc string } @@ -75,20 +79,31 @@ func NewEcoFlowFromConfig(other map[string]any) (api.Meter, error) { return nil, fmt.Errorf("invalid region: %s", cc.Region) } - return NewEcoFlow(cc.AccessKey, cc.SecretKey, cc.Serial, cc.Usage, uri, cc.Power, cc.Soc, cc.Cache, cc.batteryCapacity.Decorator(), cc.batterySocLimits, cc.batteryPowerLimits.Decorator()) + var serials []string + for sn := range strings.SplitSeq(cc.Serial, ",") { + if sn = strings.TrimSpace(sn); sn != "" { + serials = append(serials, sn) + } + } + + return NewEcoFlow(cc.AccessKey, cc.SecretKey, serials, cc.Usage, uri, cc.Power, cc.Soc, cc.Cache, cc.batteryCapacity.Decorator(), cc.batterySocLimits, cc.batteryPowerLimits.Decorator()) } // NewEcoFlow constructs the EcoFlow struct -func NewEcoFlow(accessKey, secretKey, serial, usage, uri string, +func NewEcoFlow(accessKey, secretKey string, serials []string, usage, uri string, power, soc string, cache time.Duration, capacity func() (float64, error), batterySocLimits batterySocLimits, batteryPowerLimits func() (float64, float64, error)) (*EcoFlow, error) { - log := util.NewLogger("ecoflow").Redact(accessKey, secretKey, serial) + if len(serials) == 0 { + return nil, errors.New("missing serial") + } + + log := util.NewLogger("ecoflow").Redact(append([]string{accessKey, secretKey}, serials...)...) m := &EcoFlow{ - Caps: implement.New(), - log: log, - serial: serial, - usage: usage, - cache: cache, + Caps: implement.New(), + log: log, + serials: serials, + usage: usage, + cache: cache, client: ecoflow.NewEcoflowClient(accessKey, secretKey, ecoflow.WithBaseUrl(uri), ecoflow.WithHttpClient(request.NewClient(log)), @@ -115,18 +130,60 @@ func NewEcoFlow(accessKey, secretKey, serial, usage, uri string, return m, nil } -// getData retrieves device parameters from EcoFlow API -func (m *EcoFlow) getData() (*ecoflow.GetCmdResponse, error) { +// ecoflowLeaderKey is only reported non-zero by the elected leader of a Stream system +const ecoflowLeaderKey = "cmsBattSoc" + +// serial returns the serial of the current leader +func (m *EcoFlow) serial() string { + return m.serials[m.leader.Load()] +} + +func (m *EcoFlow) query(serial string, params []string) (*ecoflow.GetCmdResponse, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() + return m.client.GetDeviceParameters(ctx, serial, params) +} + +// isLeader reports whether the response comes from the leader of a Stream system +func isLeader(res *ecoflow.GetCmdResponse) bool { + v, err := ecoflowValue(res.Data, ecoflowLeaderKey) + return err == nil && v > 0 +} + +// getData retrieves device parameters from EcoFlow API +func (m *EcoFlow) getData() (*ecoflow.GetCmdResponse, error) { params := []string{m.power} if m.usage == "battery" { params = append(params, m.batterySoc) } - return m.client.GetDeviceParameters(ctx, m.serial, params) + if len(m.serials) > 1 && !slices.Contains(params, ecoflowLeaderKey) { + params = append(params, ecoflowLeaderKey) + } + + cur := int(m.leader.Load()) + + res, err := m.query(m.serials[cur], params) + if err != nil || len(m.serials) == 1 || isLeader(res) { + return res, err + } + + // leader may have been re-elected, followers report zero values + for i, sn := range m.serials { + if i == cur { + continue + } + + if r, err := m.query(sn, params); err == nil && isLeader(r) { + m.log.DEBUG.Printf("leader changed to %s", sn) + m.leader.Store(int32(i)) + return r, nil + } + } + + return res, nil } var _ api.Meter = (*EcoFlow)(nil) @@ -182,7 +239,7 @@ func (m *EcoFlow) dischargeLimit() (float64, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - res, err := m.client.GetDeviceParameters(ctx, m.serial, []string{"cmsMinDsgSoc"}) + res, err := m.client.GetDeviceParameters(ctx, m.serial(), []string{"cmsMinDsgSoc"}) if err != nil { return 0, err } @@ -207,7 +264,7 @@ func (m *EcoFlow) setBackupReserve(limit float64) error { // cfgBackupReverseSoc is the vendor's spelling of the backup reserve res, err := m.client.SetDeviceParameter(ctx, map[string]any{ - "sn": m.serial, "cmdId": 17, "cmdFunc": 254, + "sn": m.serial(), "cmdId": 17, "cmdFunc": 254, "dirDest": 1, "dirSrc": 1, "dest": 2, "needAck": true, "params": map[string]any{"cfgBackupReverseSoc": int(math.Round(limit))}, }) diff --git a/meter/ecoflow_test.go b/meter/ecoflow_test.go index b26afbcea2..46334b1202 100644 --- a/meter/ecoflow_test.go +++ b/meter/ecoflow_test.go @@ -1,9 +1,16 @@ package meter import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sync/atomic" "testing" + "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestEcoflowReserveLimit(t *testing.T) { @@ -28,3 +35,36 @@ func TestEcoflowReserveLimit(t *testing.T) { }) } } + +func TestEcoflowLeaderChange(t *testing.T) { + var leader atomic.Value + leader.Store("B") + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct{ Sn string } + _ = json.NewDecoder(r.Body).Decode(&req) + + soc := 0 + if req.Sn == leader.Load() { + soc = 50 + } + fmt.Fprintf(w, `{"code":"0","data":{"powGetSysGrid":%d,"cmsBattSoc":%d}}`, soc*10, soc) + })) + defer srv.Close() + + m, err := NewEcoFlow("key", "secret", []string{"A", "B", "C"}, "grid", srv.URL, "powGetSysGrid", "", time.Nanosecond, nil, batterySocLimits{}, nil) + require.NoError(t, err) + + p, err := m.CurrentPower() + require.NoError(t, err) + assert.Equal(t, 500.0, p) + assert.Equal(t, "B", m.serial()) + + leader.Store("C") + time.Sleep(time.Millisecond) + + p, err = m.CurrentPower() + require.NoError(t, err) + assert.Equal(t, 500.0, p) + assert.Equal(t, "C", m.serial()) +} diff --git a/templates/definition/meter/ecoflow-stream.yaml b/templates/definition/meter/ecoflow-stream.yaml index 244070afbd..8a420ce7ff 100644 --- a/templates/definition/meter/ecoflow-stream.yaml +++ b/templates/definition/meter/ecoflow-stream.yaml @@ -54,8 +54,8 @@ params: - name: serial required: true help: - en: Serial number of the main device in your Stream system, normally the first device of your system - de: Seriennummer des Hauptgeräts (normalerweise das erste Gerät) Ihres Stream Systems + en: Serial number of the main device in your Stream system, normally the first device of your system. If the main device changes, list the serials of all devices separated by commas, the current main device is detected automatically. + de: Seriennummer des Hauptgeräts (normalerweise das erste Gerät) Ihres Stream Systems. Falls das Hauptgerät wechselt, die Seriennummern aller Geräte durch Kommas getrennt angeben, das aktuelle Hauptgerät wird automatisch erkannt. - name: region choice: ["auto", "europe", "america"] default: auto From b51e0fc5025c1b817bd7f224d4be1bba6c976517 Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 26 Oct 2026 14:10:37 +0100 Subject: [PATCH 09/21] Loadpoint: adopt charger phases while disabled (#35383) --- core/loadpoint.go | 12 ++++-- core/loadpoint_phases.go | 10 ++++- core/loadpoint_sync_test.go | 84 +++++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 5 deletions(-) diff --git a/core/loadpoint.go b/core/loadpoint.go index 54c526bce5..8ecec295e8 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -602,7 +602,7 @@ func (lp *Loadpoint) evVehicleConnectHandler() { lp.socUpdated = time.Time{} // charger may have reconfigured phases internally while disconnected - if err := lp.syncChargerPhases(); err != nil { + if err := lp.syncChargerPhases(!lp.enabled); err != nil { lp.log.ERROR.Println(err) } @@ -947,13 +947,19 @@ func (lp *Loadpoint) syncCharger() error { // sync phases if shouldBeConsistent { - if err := lp.syncChargerPhases(); err != nil { + if err := lp.syncChargerPhases(false); err != nil { return err } } case enabled == lp.enabled: - // sync disabled state + // sync disabled state, adopt phases so the next enable switches them + // fixed phases would be switched back right away, see scalePhasesRequired + if lp.phasesConfigured == 0 && lp.phaseSwitchCompleted() { + if err := lp.syncChargerPhases(true); err != nil { + lp.log.ERROR.Println(err) + } + } case !enabled && !lp.phaseSwitchCompleted(): // some chargers (i.E. Easee in some configurations) disable themselves to be able to switch phases diff --git a/core/loadpoint_phases.go b/core/loadpoint_phases.go index 79a1d62779..d42c64d1a8 100644 --- a/core/loadpoint_phases.go +++ b/core/loadpoint_phases.go @@ -169,7 +169,7 @@ func (lp *Loadpoint) hasPhaseSwitching() bool { // syncChargerPhases synchronizes the assumed phase state with the charger's actual state. // Chargers may reconfigure phases internally, i.e. when the vehicle is (dis)connected. // Unknown phases (0, i.e. after startup) are seeded from the charger without warning. -func (lp *Loadpoint) syncChargerPhases() error { +func (lp *Loadpoint) syncChargerPhases(disabled bool) error { phases := lp.GetPhases() if !lp.hasPhaseSwitching() { return nil @@ -185,7 +185,8 @@ func (lp *Loadpoint) syncChargerPhases() error { } if chargerPhases > 0 && chargerPhases != phases { - if phases > 0 { + // a disabled charger may switch phases without the loadpoint + if phases > 0 && !disabled { lp.log.WARN.Printf("charger logic error: phases mismatch (got %d, expected %d)", chargerPhases, phases) } lp.SetPhases(chargerPhases) @@ -194,6 +195,11 @@ func (lp *Loadpoint) syncChargerPhases() error { return nil } + // measured phases are stale while disabled, keep the enabled-only fallback + if disabled { + return nil + } + // use measured phase currents for active phases as fallback if charger does not provide phases chargerPhases := lp.GetMeasuredPhases() if chargerPhases == 2 { diff --git a/core/loadpoint_sync_test.go b/core/loadpoint_sync_test.go index 9272b397d9..82a163c33f 100644 --- a/core/loadpoint_sync_test.go +++ b/core/loadpoint_sync_test.go @@ -1,6 +1,7 @@ package core import ( + "errors" "testing" evbus "github.com/asaskevich/EventBus" @@ -222,6 +223,89 @@ func TestSyncChargerPhasesByGetter(t *testing.T) { } } +// phases a disabled charger switched without the loadpoint (Keba driver back to 1p) are adopted +// without warning, ignoring stale measured phases +func TestSyncChargerPhasesDisabled(t *testing.T) { + ctrl := gomock.NewController(t) + + ch := api.NewMockCharger(ctrl) + ps := api.NewMockPhaseSwitcher(ctrl) + pg := api.NewMockPhaseGetter(ctrl) + + charger := struct { + api.Charger + api.PhaseSwitcher + api.PhaseGetter + }{ + ch, ps, pg, + } + + ch.EXPECT().Enabled().Return(false, nil) + pg.EXPECT().GetPhases().Return(1, nil) + + lp := &Loadpoint{ + log: util.NewLogger("foo"), + bus: evbus.New(), + clock: clock.New(), + charger: charger, + status: api.StatusB, + phases: 3, + measuredPhases: 3, + } + + require.NoError(t, lp.syncCharger()) + assert.Equal(t, 1, lp.phases) + + // measured currents are stale while disabled + lp.measuredPhases = 3 + ch.EXPECT().Enabled().Return(false, nil) + lp.charger = struct { + api.Charger + api.PhaseSwitcher + }{ch, ps} + + require.NoError(t, lp.syncCharger()) + assert.Equal(t, 1, lp.phases) +} + +// fixed phases are not adopted while disabled, scalePhasesRequired would switch them back, +// and a failed phase read while disabled does not stop the update +func TestSyncChargerPhasesDisabledGates(t *testing.T) { + ctrl := gomock.NewController(t) + + ch := api.NewMockCharger(ctrl) + ps := api.NewMockPhaseSwitcher(ctrl) + pg := api.NewMockPhaseGetter(ctrl) + + charger := struct { + api.Charger + api.PhaseSwitcher + api.PhaseGetter + }{ + ch, ps, pg, + } + + lp := &Loadpoint{ + log: util.NewLogger("foo"), + bus: evbus.New(), + clock: clock.New(), + charger: charger, + status: api.StatusB, + phases: 3, + phasesConfigured: 3, + } + + ch.EXPECT().Enabled().Return(false, nil) + require.NoError(t, lp.syncCharger()) + assert.Equal(t, 3, lp.phases) + + lp.phasesConfigured = 0 + ch.EXPECT().Enabled().Return(false, nil) + pg.EXPECT().GetPhases().Return(0, errors.New("timeout")) + require.NoError(t, lp.syncCharger()) + assert.Equal(t, 3, lp.phases) +} + func TestSyncChargerPhasesByMeasurement(t *testing.T) { tc := []struct { lpPhases, actualPhases, outPhases int From c69db00a3f5970849a1a70fed8e125dce84a1eac Mon Sep 17 00:00:00 2001 From: juergenbosch <77337792+juergenbosch@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:25:03 +0100 Subject: [PATCH 10/21] fix(eudataact): support Auth0 Universal Login Page for CUPRA/VW (#35385) --- vehicle/vag/vwidentity/endpoint.go | 100 ++++++++++-------------- vehicle/vag/vwidentity/endpoint_test.go | 71 +++++++++++++++++ vehicle/vw/eudataact/api.go | 41 +--------- 3 files changed, 115 insertions(+), 97 deletions(-) create mode 100644 vehicle/vag/vwidentity/endpoint_test.go diff --git a/vehicle/vag/vwidentity/endpoint.go b/vehicle/vag/vwidentity/endpoint.go index c3a7fd2451..7da47c9030 100644 --- a/vehicle/vag/vwidentity/endpoint.go +++ b/vehicle/vag/vwidentity/endpoint.go @@ -108,16 +108,27 @@ func (v *Service) Login(uri, user, password string) (url.Values, error) { return nil, err } - // Try to extract legacy form, but don't fail if it's not found + resp, err = SubmitCredentials(v.Client, body, user, password) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + return v.authLocation(resp) +} + +// SubmitCredentials posts user and password to the signin page in body, using either the +// legacy two-step form or the Auth0 universal login. The client's redirect policy decides where it ends. +func SubmitCredentials(client *http.Client, body []byte, user, password string) (*http.Response, error) { if vars, err := FormValues(bytes.NewReader(body), "form#emailPasswordForm"); err == nil { - return v.loginLegacy(vars, user, password) + return submitLegacy(client, vars, user, password) } - return v.loginNew(body, user, password) + return submitUniversal(client, body, user, password) } -// loginLegacy performs the legacy VW identity login flow -func (v *Service) loginLegacy(vars FormVars, user, password string) (url.Values, error) { +// submitLegacy performs the legacy VW identity identifier and password steps +func submitLegacy(client *http.Client, vars FormVars, user, password string) (*http.Response, error) { var params CredentialParams // POST identity.vwgroup.io/signin-service/v1/b7a5bb47-f875-47cf-ab83-2ba3bf6bb738@apps_vw-dilab_com/login/identifier @@ -129,7 +140,7 @@ func (v *Service) loginLegacy(vars FormVars, user, password string) (url.Values, } uri := BaseURL + vars.Action - resp, err := v.PostForm(uri, data) + resp, err := client.PostForm(uri, data) if err != nil { return nil, err } @@ -155,12 +166,27 @@ func (v *Service) loginLegacy(vars FormVars, user, password string) (url.Values, // reuse url from identifier step before uri = strings.ReplaceAll(uri, params.TemplateModel.IdentifierUrl, params.TemplateModel.PostAction) - resp, err = v.PostForm(uri, data) + return client.PostForm(uri, data) +} + +// submitUniversal performs the Auth0 universal login step +func submitUniversal(client *http.Client, body []byte, user, password string) (*http.Response, error) { + state, err := extractState(body) if err != nil { return nil, err } - defer resp.Body.Close() + data := url.Values{ + "username": {user}, + "password": {password}, + "state": {state}, + } + + return client.PostForm(fmt.Sprintf("%s/u/login?state=%s", BaseURL, state), data) +} + +// authLocation returns the auth code parameters from the redirect after password submission +func (v *Service) authLocation(resp *http.Response) (url.Values, error) { if resp.StatusCode >= http.StatusBadRequest { return nil, errors.New(resp.Status) } @@ -186,11 +212,16 @@ func (v *Service) loginLegacy(vars FormVars, user, password string) (url.Values, return nil, errors.New("login redirect missing after password submission - open the myAudi app or website, accept any pending terms or consent prompts, then retry") } - parsed, err := url.Parse(location) + redirectURL, err := resolveLocation(resp.Request.URL, location) if err != nil { return nil, err } - return parseAuthLocation(parsed) + + if redirectURL.Scheme == "https" || redirectURL.Scheme == "http" { + return nil, fmt.Errorf("unexpected redirect URL scheme: %s, expected app specific url (e.g. 'weconnect://...')", redirectURL.Scheme) + } + + return parseAuthLocation(redirectURL) } // MarketingConsentCallback returns the OIDC callback url embedded in an @@ -244,55 +275,6 @@ func (v *Service) skipMarketingConsent(resp *http.Response) (*url.URL, bool, err return location, true, err } -// loginNew performs the new VW identity login flow -func (v *Service) loginNew(body []byte, user, password string) (url.Values, error) { - state, err := extractState(body) - if err != nil { - return nil, err - } - - // POST to new login endpoint - loginData := url.Values{ - "username": {user}, - "password": {password}, - "state": {state}, - } - - uri := fmt.Sprintf("%s/u/login?state=%s", BaseURL, state) - resp, err := v.PostForm(uri, loginData) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - location := resp.Header.Get("Location") - if resp.StatusCode >= http.StatusBadRequest { - return nil, errors.New(resp.Status) - } - - // skip the optional marketing consent page without consenting (#29760) - if loc, ok, err := v.skipMarketingConsent(resp); err != nil { - return nil, err - } else if ok { - return parseAuthLocation(loc) - } - - if location == "" { - return nil, errors.New("no redirect location in new login flow") - } - - redirectURL, err := resolveLocation(resp.Request.URL, location) - if err != nil { - return nil, err - } - - if redirectURL.Scheme == "https" || redirectURL.Scheme == "http" { - return nil, fmt.Errorf("unexpected redirect URL scheme: %s, expected app specific url (e.g. 'weconnect://...')", redirectURL.Scheme) - } - - return parseAuthLocation(redirectURL) -} - func resolveLocation(base *url.URL, location string) (*url.URL, error) { locURL, err := url.Parse(location) if err != nil { diff --git a/vehicle/vag/vwidentity/endpoint_test.go b/vehicle/vag/vwidentity/endpoint_test.go new file mode 100644 index 0000000000..b45e40f30b --- /dev/null +++ b/vehicle/vag/vwidentity/endpoint_test.go @@ -0,0 +1,71 @@ +package vwidentity + +import ( + "io" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type formPost struct { + url string + form url.Values +} + +// recorder answers every request with the next body and records the posted forms +type recorder struct { + bodies []string + posts []formPost +} + +func (r *recorder) RoundTrip(req *http.Request) (*http.Response, error) { + b, _ := io.ReadAll(req.Body) + form, _ := url.ParseQuery(string(b)) + r.posts = append(r.posts, formPost{req.URL.String(), form}) + + body := r.bodies[0] + r.bodies = r.bodies[1:] + + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil +} + +func TestSubmitCredentialsLegacy(t *testing.T) { + signin := `
+ + + +
` + + credentials := `` + + rec := &recorder{bodies: []string{credentials, ""}} + resp, err := SubmitCredentials(&http.Client{Transport: rec}, []byte(signin), "user", "pass") + require.NoError(t, err) + resp.Body.Close() + + require.Len(t, rec.posts, 2) + assert.Equal(t, BaseURL+"/signin-service/v1/client/login/identifier", rec.posts[0].url) + assert.Equal(t, url.Values{"_csrf": {"c1"}, "relayState": {"r1"}, "hmac": {"h1"}, "email": {"user"}}, rec.posts[0].form) + assert.Equal(t, BaseURL+"/signin-service/v1/client/login/authenticate", rec.posts[1].url) + assert.Equal(t, url.Values{"_csrf": {"c2"}, "relayState": {"r2"}, "hmac": {"h2"}, "email": {"user"}, "password": {"pass"}}, rec.posts[1].form) +} + +func TestSubmitCredentialsUniversal(t *testing.T) { + signin := `
` + + rec := &recorder{bodies: []string{""}} + resp, err := SubmitCredentials(&http.Client{Transport: rec}, []byte(signin), "user", "pass") + require.NoError(t, err) + resp.Body.Close() + + require.Len(t, rec.posts, 1) + assert.Equal(t, BaseURL+"/u/login?state=s1", rec.posts[0].url) + assert.Equal(t, url.Values{"state": {"s1"}, "username": {"user"}, "password": {"pass"}}, rec.posts[0].form) +} diff --git a/vehicle/vw/eudataact/api.go b/vehicle/vw/eudataact/api.go index 194f63a5af..96bb16805c 100644 --- a/vehicle/vw/eudataact/api.go +++ b/vehicle/vw/eudataact/api.go @@ -1,7 +1,6 @@ package eudataact import ( - "bytes" "encoding/json" "errors" "fmt" @@ -174,44 +173,10 @@ func (v *API) login(gen uint64) error { return err } - // email/identifier step - vars, err := vwidentity.FormValues(bytes.NewReader(body), "form#emailPasswordForm") + // the client follows the redirect chain back to the portal which sets the session cookie + resp, err = vwidentity.SubmitCredentials(&client, body, v.user, v.password) if err != nil { - return fmt.Errorf("identifier form: %w (portal layout may have changed)", err) - } - - uri := vwidentity.BaseURL + vars.Action - resp, err = client.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 = client.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 + return fmt.Errorf("credentials: %w", err) } defer resp.Body.Close() From 12169b7178a3d00f822143ed90644e6352a21d4f Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 26 Oct 2026 14:54:40 +0100 Subject: [PATCH 11/21] sonnenBatterie: repeat charge setpoint while charging (#35391) --- .../definition/meter/sonnenbatterie.yaml | 148 +++++++++++------- 1 file changed, 89 insertions(+), 59 deletions(-) diff --git a/templates/definition/meter/sonnenbatterie.yaml b/templates/definition/meter/sonnenbatterie.yaml index 667def42bf..d996e34a81 100644 --- a/templates/definition/meter/sonnenbatterie.yaml +++ b/templates/definition/meter/sonnenbatterie.yaml @@ -61,75 +61,105 @@ render: | cache: {{ .cache }} jq: .USOC {{- if .token }} + # the sequence hides the switch, so the modes are declared here + batterymodes: ["normal", "hold", "charge"] batterymode: - source: switch - switch: - - case: 1 # normal + # use a sequence to propagate resets to the watchdog + source: sequence + set: + # the charge setpoint is ignored when it follows the switch from hold, so it is repeated while charging + # the watchdog runs first so that normal and hold stop it before any mode request + - source: watchdog + timeout: 30s + reset: [1, 2] set: - source: http - uri: http://{{ .host }}/api/v2/configurations - insecure: true - method: PUT - headers: - - content-type: application/json - - Auth-Token: {{ .token }} - {{- if eq .defaultmode "time-of-use" }} - body: '{"EM_OperatingMode":"10"}' # Time-of-use - {{- else }} - body: '{"EM_OperatingMode":"2"}' # Self-consumption - {{- end }} - - case: 2 # hold - set: - source: sequence + source: switch + switch: + - case: 1 # normal + set: + source: sleep + duration: 0s + - case: 2 # hold + set: + source: sleep + duration: 0s + - case: 3 # charge + set: + source: http + uri: http://{{ .host }}/api/v2/setpoint/charge/{{ .maxchargepower }} + insecure: true + method: POST + headers: + - content-type: application/json + - Auth-Token: {{ .token }} + - source: switch + switch: + - case: 1 # normal set: - - source: http + source: http uri: http://{{ .host }}/api/v2/configurations insecure: true method: PUT headers: - content-type: application/json - Auth-Token: {{ .token }} - body: '{"EM_OperatingMode":"1"}' # Manual - - source: http - uri: http://{{ .host }}/api/v2/setpoint/discharge/0 - insecure: true - method: POST - headers: - - content-type: application/json - - Auth-Token: {{ .token }} - - source: http - uri: http://{{ .host }}/api/v2/setpoint/charge/0 - insecure: true - method: POST - headers: - - content-type: application/json - - Auth-Token: {{ .token }} - - case: 3 # charge - set: - source: sequence + {{- if eq .defaultmode "time-of-use" }} + body: '{"EM_OperatingMode":"10"}' # Time-of-use + {{- else }} + body: '{"EM_OperatingMode":"2"}' # Self-consumption + {{- end }} + - case: 2 # hold set: - - source: http - uri: http://{{ .host }}/api/v2/configurations - insecure: true - method: PUT - headers: - - content-type: application/json - - Auth-Token: {{ .token }} - body: '{"EM_OperatingMode":"1"}' # manual - - source: http - uri: http://{{ .host }}/api/v2/setpoint/discharge/0 - insecure: true - method: POST - headers: - - content-type: application/json - - Auth-Token: {{ .token }} - - source: http - uri: http://{{ .host }}/api/v2/setpoint/charge/{{ .maxchargepower }} - insecure: true - method: POST - headers: - - content-type: application/json - - Auth-Token: {{ .token }} + source: sequence + set: + - source: http + uri: http://{{ .host }}/api/v2/configurations + insecure: true + method: PUT + headers: + - content-type: application/json + - Auth-Token: {{ .token }} + body: '{"EM_OperatingMode":"1"}' # manual + - source: http + uri: http://{{ .host }}/api/v2/setpoint/discharge/0 + insecure: true + method: POST + headers: + - content-type: application/json + - Auth-Token: {{ .token }} + - source: http + uri: http://{{ .host }}/api/v2/setpoint/charge/0 + insecure: true + method: POST + headers: + - content-type: application/json + - Auth-Token: {{ .token }} + - case: 3 # charge + set: + source: sequence + set: + - source: http + uri: http://{{ .host }}/api/v2/configurations + insecure: true + method: PUT + headers: + - content-type: application/json + - Auth-Token: {{ .token }} + body: '{"EM_OperatingMode":"1"}' # manual + - source: http + uri: http://{{ .host }}/api/v2/setpoint/discharge/0 + insecure: true + method: POST + headers: + - content-type: application/json + - Auth-Token: {{ .token }} + - source: http + uri: http://{{ .host }}/api/v2/setpoint/charge/{{ .maxchargepower }} + insecure: true + method: POST + headers: + - content-type: application/json + - Auth-Token: {{ .token }} {{- end }} {{- include "battery-params" . }} {{- end }} From 18ad321bad0cea4ded1cdd51159c638a9d50a095 Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 26 Oct 2026 15:15:58 +0100 Subject: [PATCH 12/21] Tariff (Energinet): include current quarter-hour (#35396) --- templates/definition/tariff/energinet-co2.yaml | 2 +- templates/definition/tariff/energinet-price.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/definition/tariff/energinet-co2.yaml b/templates/definition/tariff/energinet-co2.yaml index 357d8d52cf..ba0bc4573d 100644 --- a/templates/definition/tariff/energinet-co2.yaml +++ b/templates/definition/tariff/energinet-co2.yaml @@ -20,7 +20,7 @@ render: | tariff: co2 forecast: source: http - uri: https://api.energidataservice.dk/dataset/CO2EmisProg?filter={"PriceArea":["{{ .region }}"]}&start=now + uri: https://api.energidataservice.dk/dataset/CO2EmisProg?filter={"PriceArea":["{{ .region }}"]}&start=now-PT15M jq: | [.records[] | (.Minutes5UTC | strptime("%Y-%m-%dT%H:%M:%S") | mktime) as $epoch diff --git a/templates/definition/tariff/energinet-price.yaml b/templates/definition/tariff/energinet-price.yaml index 1beac2edfa..06ea679645 100644 --- a/templates/definition/tariff/energinet-price.yaml +++ b/templates/definition/tariff/energinet-price.yaml @@ -19,7 +19,7 @@ render: | {{ include "base" . }} forecast: source: http - uri: https://api.energidataservice.dk/dataset/DayAheadPrices?start=now&filter={"PriceArea":["{{ .region }}"]} + uri: https://api.energidataservice.dk/dataset/DayAheadPrices?start=now-PT15M&filter={"PriceArea":["{{ .region }}"]} jq: | [.records[] | { From c4d457177ce9e8f96cefbbf94d96c750654661ce Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 26 Oct 2026 15:57:00 +0100 Subject: [PATCH 13/21] Optimizer: suggest the battery mode control applies (#35402) --- core/site_optimizer.go | 15 +++++++++++++-- core/site_optimizer_test.go | 5 +++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/core/site_optimizer.go b/core/site_optimizer.go index a53fbc4bf3..01451ef307 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -88,8 +88,9 @@ type batteryDetail struct { Name string `json:"name,omitempty"` Capacity float64 `json:"capacity,omitempty"` - loadpoint *int // originating loadpoint id for loadpoint/vehicle entries - controllable bool // device can act on suggestions + loadpoint *int // originating loadpoint id for loadpoint/vehicle entries + controllable bool // device can act on suggestions + modes []api.BatteryMode // modes the battery controller supports } // batteryKey and loadpointKey build the canonical device keys used for @@ -223,6 +224,13 @@ func currentSlotSuggestion(detail batteryDetail, req optimizer.BatteryConfig, re default: s.Action = api.BatteryNormal.String() } + + // suggest the mode control applies instead of one the battery does not support + if mode, err := api.BatteryModeString(s.Action); err == nil && detail.modes != nil { + if mode = supportedBatteryMode(detail.modes, mode); mode != api.BatteryUnknown { + s.Action = mode.String() + } + } } else if charge > suggestionThreshold { s.Action = actionCharge } else { @@ -1193,6 +1201,9 @@ func (site *Site) batteryRequest(dev config.Device[api.Meter], b types.Measureme Capacity: *b.Capacity, controllable: controllable, } + if controllable { + detail.modes = ctrl.BatteryModes() + } // tariff forecast-based grid charging demand if bat.ChargeFromGrid { diff --git a/core/site_optimizer_test.go b/core/site_optimizer_test.go index c82c0a1392..c01fdddf77 100644 --- a/core/site_optimizer_test.go +++ b/core/site_optimizer_test.go @@ -812,6 +812,11 @@ func TestCurrentSlotSuggestion(t *testing.T) { // solver noise on the grid is no import/export (#34657) assert.Equal(t, api.BatteryNormal.String(), currentSlotSuggestion(battery, req, res, optimizer.TimeSeries{}, 0, 4e-05, 0, 570.0/3600).Action, "idle with import noise") assert.Equal(t, api.BatteryNormal.String(), currentSlotSuggestion(battery, req, res, optimizer.TimeSeries{}, 0, 0, 4e-05, 570.0/3600).Action, "idle with export noise") + + // an unsupported mode is suggested as the mode control applies instead (#35379) + battery.modes = []api.BatteryMode{api.BatteryNormal, api.BatteryHold, api.BatteryCharge} + assert.Equal(t, api.BatteryNormal.String(), currentSlotSuggestion(battery, req, res, optimizer.TimeSeries{}, 0, 0, 1000, 1).Action, "holdcharge unsupported") + assert.Equal(t, api.BatteryHold.String(), currentSlotSuggestion(battery, req, res, optimizer.TimeSeries{}, 0, 1000, 0, 1).Action, "hold supported") } // TestCurrentSlotSuggestionHoldWorthwhile covers holding a battery without planned From 08caa0e2755c56501c5906e1f680a6dce7ef91b6 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Mon, 26 Oct 2026 17:17:28 +0100 Subject: [PATCH 14/21] chore: move Playwright simulator off the EEBus port range (#35381) --- tests/simulator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/simulator.ts b/tests/simulator.ts index 9ce2b0ffe9..022e01d1f2 100644 --- a/tests/simulator.ts +++ b/tests/simulator.ts @@ -12,7 +12,7 @@ const LOG_ENABLED = !process.env["GITHUB_ACTIONS"]; function workerPort() { const index = parseInt(process.env["TEST_WORKER_INDEX"] ?? "-1"); - return 13000 + index; + return 15000 + index; } function logPrefix() { From 3a456cb0500a9f6a5efc1f747213beda8d48a246 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:09:17 +0000 Subject: [PATCH 15/21] Config: fix legacy circuits modal docs link (#35422) Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> --- assets/js/components/Config/CircuitsLegacyModal.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/components/Config/CircuitsLegacyModal.vue b/assets/js/components/Config/CircuitsLegacyModal.vue index 15b2142c78..f062f5e63c 100644 --- a/assets/js/components/Config/CircuitsLegacyModal.vue +++ b/assets/js/components/Config/CircuitsLegacyModal.vue @@ -4,7 +4,7 @@ name="circuitslegacy" :title="`${$t('config.circuits.title')} (${$t('config.general.legacy')})`" :description="$t('config.circuits.description')" - docs="/reference/configuration/circuits" + docs="/features/loadmanagement" :defaultYaml="defaultYaml" endpoint="/config/circuits" removeKey="circuits" From 4b02df4f1e47e9b09f78005ed792c87bbe143976 Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 27 Oct 2026 14:12:17 +0100 Subject: [PATCH 16/21] UI: render markdown with marked instead of snarkdown (#35295) Co-authored-by: Claude Opus 5.5 --- assets/js/components/Config/Markdown.test.ts | 35 +++++++++++++++++++ assets/js/components/Config/Markdown.vue | 9 +++-- .../components/Optimize/PlanExplanation.vue | 8 ----- package-lock.json | 20 +++++++---- package.json | 2 +- 5 files changed, 55 insertions(+), 19 deletions(-) create mode 100644 assets/js/components/Config/Markdown.test.ts diff --git a/assets/js/components/Config/Markdown.test.ts b/assets/js/components/Config/Markdown.test.ts new file mode 100644 index 0000000000..2ef742d658 --- /dev/null +++ b/assets/js/components/Config/Markdown.test.ts @@ -0,0 +1,35 @@ +import { mount } from "@vue/test-utils"; +import { describe, expect, test } from "vite-plus/test"; +import Markdown from "./Markdown.vue"; + +const render = (markdown: string) => mount(Markdown, { props: { markdown } }).html(); + +describe("Markdown", () => { + test("keeps underscores inside words as text", () => { + const html = render("charge_before_export and a_b_c stay flat"); + expect(html).toContain("charge_before_export and a_b_c stay flat"); + expect(html).not.toContain(""); + }); + + test("keeps underscores in firmware versions", () => { + const html = render("requires M3W_3.11STP or M3W_4.03PTB"); + expect(html).toContain("requires M3W_3.11STP or M3W_4.03PTB"); + expect(html).not.toContain(""); + }); + + test("opens links in new window", () => { + expect(render("[docs](https://docs.evcc.io)")).toContain( + 'docs' + ); + }); + + test("still emphasises whole words", () => { + expect(render("so _early_ and __bold__")).toContain( + "so early and bold" + ); + }); + + test("keeps underscores inside code", () => { + expect(render("`charge_before_export`")).toContain("charge_before_export"); + }); +}); diff --git a/assets/js/components/Config/Markdown.vue b/assets/js/components/Config/Markdown.vue index 88514bec0e..3fa3cbc940 100644 --- a/assets/js/components/Config/Markdown.vue +++ b/assets/js/components/Config/Markdown.vue @@ -4,7 +4,7 @@