diff --git a/.github/workflows/command-backport.yml b/.github/workflows/command-backport.yml index 3c6d8139f..138a8836a 100644 --- a/.github/workflows/command-backport.yml +++ b/.github/workflows/command-backport.yml @@ -157,18 +157,15 @@ jobs: TITLE: ${{ steps.pr.outputs.title }} ACTOR: ${{ github.event.comment.user.login }} run: | - # the release line the branch was cut from, 0.313 for release/0.313.1 - line=$(echo "${TARGET#release/}" | cut -d. -f1,2) - # the pull request is authored by the deploy token, so assign the # requester. the body stays empty, squashing it would carry the text # into the release branch as the commit description. - # the marker trails the title so the changelog filters and groups still - # see the original prefix, goreleaser strips it from the release notes + # the title stays untouched so the changelog filters and groups still + # see the original prefix, the backport label identifies the pull request url=$(gh pr create \ --base "$TARGET" \ --head "$BRANCH" \ - --title "$TITLE [$line backport]" \ + --title "$TITLE" \ --label backport \ --assignee "$ACTOR" \ --body "") diff --git a/.goreleaser.yml b/.goreleaser.yml index 56c7630cf..b21d3bff2 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -71,8 +71,6 @@ snapshot: changelog: sort: asc - # drop the marker the backport command appends to the pull request title - format: '{{ .SHA }} {{ reReplaceAll ` \[\d+\.\d+ backport\]` .Message "" }}' filters: exclude: - "^chore" diff --git a/api/error.go b/api/error.go index 2b5ffe961..f71d16c34 100644 --- a/api/error.go +++ b/api/error.go @@ -7,23 +7,28 @@ import ( "github.com/cenkalti/backoff/v4" ) -// permanentError is a sentinel error that keeps matching errors.Is after -// backoff has stripped the backoff.Permanent wrapper. +// permanentError is a sentinel error that signals permanence to backoff while +// remaining distinguishable from the other permanent sentinels. type permanentError struct { msg string } func (e *permanentError) Error() string { return e.msg } -// Is matches the wrapped sentinel, too -func (e *permanentError) Is(target error) bool { - var t *permanentError - return errors.As(target, &t) && t == e +// As signals permanence to backoff. Wrapping the sentinel in backoff.Permanent +// instead would make errors.Is match any other permanent error, since +// backoff.PermanentError.Is matches by type rather than identity. +func (e *permanentError) As(target any) bool { + if p, ok := target.(**backoff.PermanentError); ok { + *p = &backoff.PermanentError{Err: e} + return true + } + return false } // permanent creates a permanent sentinel error func permanent(msg string) error { - return backoff.Permanent(&permanentError{msg}) + return &permanentError{msg} } // ErrNotAvailable indicates that a feature is not available diff --git a/api/error_test.go b/api/error_test.go index 4dfceebe1..3f8231789 100644 --- a/api/error_test.go +++ b/api/error_test.go @@ -8,20 +8,47 @@ import ( "github.com/stretchr/testify/assert" ) +// permanentSentinels are all errors created by permanent() +var permanentSentinels = []error{ + ErrNotAvailable, + ErrUnsupportedPlatform, + ErrSponsorRequired, + ErrMissingCredentials, + ErrMissingToken, +} + // Backoff returns permanent errors unwrapped. These must still match the sentinel. func TestPermanentSentinels(t *testing.T) { - for _, tc := range []struct{ err, other error }{ - {ErrNotAvailable, ErrUnsupportedPlatform}, - {ErrUnsupportedPlatform, ErrNotAvailable}, - {ErrMissingCredentials, ErrMissingToken}, - {ErrMissingToken, ErrMissingCredentials}, - } { + for _, err := range permanentSentinels { _, unwrapped := backoff.RetryWithData(func() (int, error) { - return 0, tc.err + return 0, err }, &backoff.StopBackOff{}) - assert.ErrorIs(t, unwrapped, tc.err) - assert.ErrorIs(t, fmt.Errorf("wrapped: %w", unwrapped), tc.err) - assert.NotErrorIs(t, unwrapped, tc.other) + assert.ErrorIs(t, unwrapped, err) + assert.ErrorIs(t, fmt.Errorf("wrapped: %w", unwrapped), err) + assert.ErrorIs(t, fmt.Errorf("wrapped: %w", err), err) } } + +// Permanent sentinels must not match each other, whether returned directly or +// unwrapped by backoff. +func TestPermanentSentinelIdentity(t *testing.T) { + for _, err := range permanentSentinels { + _, unwrapped := backoff.RetryWithData(func() (int, error) { + return 0, err + }, &backoff.StopBackOff{}) + + for _, other := range permanentSentinels { + if other == err { + continue + } + + assert.NotErrorIs(t, err, other) + assert.NotErrorIs(t, unwrapped, other) + assert.NotErrorIs(t, fmt.Errorf("wrapped: %w", err), other) + } + } + + // login required is permanent, too + assert.NotErrorIs(t, LoginRequiredError("foo"), ErrNotAvailable) +} diff --git a/charger/daheimladen.go b/charger/daheimladen.go index f48e92146..032f2fa67 100644 --- a/charger/daheimladen.go +++ b/charger/daheimladen.go @@ -361,27 +361,37 @@ func (wb *DaheimLaden) getPhases() (int, error) { } func (wb *DaheimLaden) checkStation() error { - b, err := wb.conn.ReadHoldingRegisters(dlRegEvseMaxCurrent, 22) - if err != nil { - return api.ErrSponsorRequired - } - // station id starts (dlRegStationId-dlRegEvseMaxCurrent) registers into the block - s, err := utf16BEBytesAsString(b[2*(dlRegStationId-dlRegEvseMaxCurrent):]) - if err != nil || s == "" { - return api.ErrSponsorRequired - } + // map may still be zero right after the wake-up call- poll briefly until populated + for range 5 { + b, err := wb.conn.ReadHoldingRegisters(dlRegEvseMaxCurrent, 22) + if err != nil { + return err + } - for _, r := range s { - if r < 0x20 || r > 0x7e { + s, err := utf16BEBytesAsString(b[2*(dlRegStationId-dlRegEvseMaxCurrent):]) + if err != nil { + return err + } + + if len(s) == 0 { + time.Sleep(200 * time.Millisecond) + continue + } + + if strings.Contains(strings.ToLower(s), "heidelbridge") { return api.ErrSponsorRequired } + + for _, r := range s { + if r < 0x20 || r > 0x7e { + return api.ErrSponsorRequired + } + } + + return nil } - if strings.Contains(strings.ToLower(s), "heidelbridge") { - return api.ErrSponsorRequired - } - - return nil + return api.ErrSponsorRequired } var _ api.Diagnosis = (*DaheimLaden)(nil) diff --git a/charger/ocpp/const.go b/charger/ocpp/const.go index ae20cf02d..893260f13 100644 --- a/charger/ocpp/const.go +++ b/charger/ocpp/const.go @@ -4,6 +4,14 @@ import "time" var Timeout = time.Minute // default request / response timeout on protocol level +const ( + heartbeatInterval = time.Minute // heartbeat interval requested in BootNotification + + // pingWait must exceed heartbeatInterval, otherwise chargers not sending + // websocket pings are disconnected while idle + pingWait = 3 * heartbeatInterval +) + // TriggerBootDelay defines how long to wait after WebSocket connect before // proactively triggering a BootNotification. This allows the connection to // stabilize and gives the charger a chance to send a spontaneous BootNotification. diff --git a/charger/ocpp/cp_core.go b/charger/ocpp/cp_core.go index d7f544100..9035b5632 100644 --- a/charger/ocpp/cp_core.go +++ b/charger/ocpp/cp_core.go @@ -16,7 +16,7 @@ var ( func (cp *CP) OnBootNotification(request *core.BootNotificationRequest) (*core.BootNotificationConfirmation, error) { res := &core.BootNotificationConfirmation{ CurrentTime: types.Now(), - Interval: 60, + Interval: int(heartbeatInterval.Seconds()), Status: core.RegistrationStatusAccepted, } diff --git a/charger/ocpp/cp_core_test.go b/charger/ocpp/cp_core_test.go index f6125690f..cfdc7eec0 100644 --- a/charger/ocpp/cp_core_test.go +++ b/charger/ocpp/cp_core_test.go @@ -28,6 +28,9 @@ func TestBootNotificationStoresResultAndConnects(t *testing.T) { require.NoError(t, err) assert.Equal(t, core.RegistrationStatusAccepted, res.Status) + // heartbeat must stay below the websocket inactivity timeout + assert.Less(t, time.Duration(res.Interval)*time.Second, pingWait) + // should be connected after BootNotification assert.True(t, cp.Connected(), "should be connected after BootNotification") assert.Equal(t, bootReq, cp.BootNotificationResult, "should store boot result") diff --git a/charger/ocpp/instance.go b/charger/ocpp/instance.go index 5d5351dca..2c5176d70 100644 --- a/charger/ocpp/instance.go +++ b/charger/ocpp/instance.go @@ -140,6 +140,10 @@ func NewServer(cfg Config, networkExternalUrl string) { server := &interceptingServer{Server: ws.NewServer()} server.SetCheckOriginHandler(func(r *http.Request) bool { return true }) + timeouts := ws.NewServerTimeoutConfig() + timeouts.PingWait = pingWait + server.SetTimeoutConfig(timeouts) + dispatcher := ocppj.NewDefaultServerDispatcher(ocppj.NewFIFOQueueMap(0)) endpoint := ocppj.NewServer(server, dispatcher, nil, core.Profile, remotetrigger.Profile, smartcharging.Profile, security.Profile, firmware.Profile) diff --git a/charger/warp-ws.go b/charger/warp-ws.go index cfcec5bfe..2c8e27f6c 100644 --- a/charger/warp-ws.go +++ b/charger/warp-ws.go @@ -235,7 +235,11 @@ func parseURI(uri string) (string, error) { return "", err } - u.Scheme = "ws" + if u.Scheme == "https" { + u.Scheme = "wss" + } else { + u.Scheme = "ws" + } u.Path = path.Join(u.Path, "/ws") return u.String(), nil diff --git a/cmd/setup.go b/cmd/setup.go index 6a77f1c24..4cc932170 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -538,9 +538,14 @@ func configureVehicles(static []config.Named, names ...string) error { } } - instance, err := vehicleInstance(cc) - if err != nil { - return fmt.Errorf("cannot create vehicle '%s': %w", cc.Name, err) + // disabled vehicles are not instantiated + var instance api.Vehicle + if !conf.Disable { + var err error + instance, err = vehicleInstance(cc) + if err != nil { + return fmt.Errorf("cannot create vehicle '%s': %w", cc.Name, err) + } } mu.Lock() diff --git a/core/loadpoint.go b/core/loadpoint.go index 154686ac0..f087838da 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -253,7 +253,8 @@ func NewLoadpointFromConfig(log *util.Logger, settings settings.Settings, collec } lp.defaultVehicle = dev.Instance() if lp.defaultVehicle == nil { - return lp, errors.New("missing default vehicle instance") + // disabled vehicle + lp.log.DEBUG.Printf("default vehicle '%s' is disabled", lp.VehicleRef) } } @@ -272,8 +273,14 @@ func NewLoadpointFromConfig(log *util.Logger, settings settings.Settings, collec lp.configureChargerType(lp.charger) // add collector - if lp.chargeMeter != nil { + if lp.chargeMeter != nil && collector != nil { lp.chargeEnergy = collector + + // drop stale readings when the meter no longer reports totals + energy, returnEnergy := api.HasCap[api.MeterEnergy](lp.chargeMeter), api.HasCap[api.MeterReturnEnergy](lp.chargeMeter) + if err := collector.SetCapabilities(energy, returnEnergy); err != nil { + return lp, err + } } // set title after collector is wired to refresh the metrics entity @@ -1061,20 +1068,15 @@ func (lp *Loadpoint) charging() bool { func (lp *Loadpoint) PvChargeStarting() bool { lp.RLock() enabled := lp.enabled - pvTimer := lp.pvTimer + pvTimerRunning := !lp.pvTimer.IsZero() lp.RUnlock() if lp.GetMode() != api.ModePV || !lp.connected() || lp.chargeGoalReached(enabled) { return false } - if enabled || pvTimer.IsZero() { - return false - } - - // a timer restarting on every surplus dip never starts the loadpoint, hence - // only claim surplus once it has survived half of the enable delay (#32778) - return lp.clock.Since(pvTimer) >= lp.GetEnableDelay()/2 + // enable timer running (not yet enabled) + return !enabled && pvTimerRunning } // chargeGoalReached reports whether the loadpoint will not draw more: enabled @@ -2023,13 +2025,15 @@ func (lp *Loadpoint) publishSocAndRange() { } } - if socR != nil { - if socEstimator == nil { - lp.vehicleSoc = *socR - } else { - lp.vehicleSoc = socEstimator.Soc(socR, lp.GetChargedEnergy()) + if socEstimator != nil { + // nil soc extrapolates from charged energy while vehicle api is unavailable; + // don't overwrite a known soc while a freshly created estimator returns 0 + if soc := socEstimator.Soc(socR, lp.GetChargedEnergy()); socR != nil || soc > 0 { + lp.vehicleSoc = soc lp.log.DEBUG.Printf("vehicle soc (estimator): %.0f%%", lp.vehicleSoc) } + } else if socR != nil { + lp.vehicleSoc = *socR } lp.publish(keys.VehicleSoc, lp.vehicleSoc) diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go index 59f55c5c9..d427b0f1d 100644 --- a/core/loadpoint_test.go +++ b/core/loadpoint_test.go @@ -12,7 +12,9 @@ import ( "github.com/evcc-io/evcc/core/soc" "github.com/evcc-io/evcc/messenger" "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/config" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" ) @@ -882,3 +884,26 @@ func TestBatteryBoostHold(t *testing.T) { // which is what keeps the sitePower priority adjustment applied to the loadpoint assert.NotEqual(t, boostDisabled, lp.GetBatteryBoost(), "hold is active") } + +// default vehicle referencing a disabled vehicle must not fail loadpoint creation +func TestNewLoadpointFromConfigDisabledVehicle(t *testing.T) { + config.Reset() + t.Cleanup(config.Reset) + + ctrl := gomock.NewController(t) + + // disabled vehicle: device registered without instance + var vehicle api.Vehicle + require.NoError(t, config.Vehicles().Add(config.NewStaticDevice(config.Named{Name: "vehicle"}, vehicle))) + require.NoError(t, config.Chargers().Add(config.NewStaticDevice(config.Named{Name: "charger"}, api.Charger(api.NewMockCharger(ctrl))))) + + lp, err := NewLoadpointFromConfig(util.NewLogger("foo"), nil, nil, map[string]any{ + "charger": "charger", + "vehicle": "vehicle", + }) + require.NoError(t, err) + require.Nil(t, lp.defaultVehicle) + + // disabled vehicle is filtered from instances + require.Empty(t, config.Instances(config.Vehicles().Devices())) +} diff --git a/core/loadpoint_vehicle.go b/core/loadpoint_vehicle.go index 1860df617..a7dbc0f7e 100644 --- a/core/loadpoint_vehicle.go +++ b/core/loadpoint_vehicle.go @@ -149,6 +149,9 @@ func (lp *Loadpoint) setActiveVehicle(v api.Vehicle) { // resolve optional config if v.Capacity() > 0 && (lp.Soc.Estimate == nil || *lp.Soc.Estimate) { lp.socEstimator = soc.NewEstimator(lp.log, v) + } else { + // drop the previous vehicle's estimator + lp.socEstimator = nil } lp.publish(keys.VehicleName, vehicle.Settings(lp.log, v).Name()) diff --git a/core/metrics/collector.go b/core/metrics/collector.go index 50deccd29..253f36311 100644 --- a/core/metrics/collector.go +++ b/core/metrics/collector.go @@ -202,6 +202,35 @@ func (c *Collector) SetEnergy(energy float64) error { return nil } +// SetCapabilities drops the persisted reading for a direction the device no longer +// reports, so its energy falls back to power integration instead of freezing. +func (c *Collector) SetCapabilities(energy, returnEnergy bool) error { + cols := make(map[string]any, 2) + + // keyed on the entity, since an incomplete state is left unrestored and would + // otherwise resurface once the other direction is checkpointed again + if !energy && c.entity.EnergyMeter != nil { + c.accu.energyMeter = nil + c.entity.EnergyMeter = nil + cols["energy_meter"] = nil + } + if !returnEnergy && c.entity.ReturnEnergyMeter != nil { + c.accu.returnEnergyMeter = nil + c.entity.ReturnEnergyMeter = nil + cols["return_energy_meter"] = nil + } + + if len(cols) == 0 { + return nil + } + + // a surviving reading still covers the downtime for its own direction, so + // keep the restore rather than discarding that delta with the cleared one + c.restored = c.accu.energyMeter != nil || c.accu.returnEnergyMeter != nil + + return db.Instance.Model(&c.entity).UpdateColumns(cols).Error +} + func (c *Collector) SetEnergyMeterTotal(v float64) error { return c.process(func() { c.accu.SetEnergyMeterTotal(v) diff --git a/core/metrics/collector_test.go b/core/metrics/collector_test.go index 84b0e1149..01cbf1ecb 100644 --- a/core/metrics/collector_test.go +++ b/core/metrics/collector_test.go @@ -600,3 +600,112 @@ func TestCollectorSetEnergy(t *testing.T) { require.True(t, ok) require.Equal(t, 1.5, v) } + +// TestCollectorFallsBackToPowerAfterCapabilityLoss verifies that a meter which +// lost its energy register stops using the stale checkpoint and integrates power +// instead (https://github.com/evcc-io/evcc/issues/33091). +func TestCollectorFallsBackToPowerAfterCapabilityLoss(t *testing.T) { + clk := clock.NewMock() // 1970-01-01 00:00:00 UTC, on a slot boundary + + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + require.NoError(t, SetupSchema()) + + col, err := NewCollector(PV, "capability", "", WithClock(clk)) + require.NoError(t, err) + + // meter reports totals, checkpoint is persisted at the slot boundary + require.NoError(t, col.AddEnergy(new(54716.0), nil, 1e3)) + clk.Add(15 * time.Minute) // 00:15 + require.NoError(t, col.AddEnergy(new(54716.0), nil, 1e3)) + + var e entity + require.NoError(t, db.Instance.First(&e, col.entity.Id).Error) + require.Equal(t, 54716.0, *e.EnergyMeter) + + // restart without the energy register: stale reading would freeze energy + col2, err := NewCollector(PV, "capability", "", WithClock(clk)) + require.NoError(t, err) + require.True(t, col2.restored) + + require.NoError(t, col2.SetCapabilities(false, false)) + require.Nil(t, col2.accu.energyMeter) + require.False(t, col2.restored, "no readings left to seed a restore") + + require.NoError(t, db.Instance.First(&e, col2.entity.Id).Error) + require.Nil(t, e.EnergyMeter, "stale checkpoint must be cleared") + + // power is integrated again + require.NoError(t, col2.AddEnergy(nil, nil, 1e3)) + clk.Add(5 * time.Minute) + require.NoError(t, col2.AddEnergy(nil, nil, 1e3)) + require.InDelta(t, 1e3*5/60/1e3, col2.accu.Energy, 1e-10) +} + +// TestCollectorClearsUnrestoredCheckpoint verifies that a bidirectional group +// also drops a checkpoint that was too incomplete to be restored, so it cannot +// resurface once the other direction is checkpointed again. +func TestCollectorClearsUnrestoredCheckpoint(t *testing.T) { + clk := clock.NewMock() // 1970-01-01 00:00:00 UTC, on a slot boundary + + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + require.NoError(t, SetupSchema()) + + col, err := NewCollector(Grid, "incomplete", "", WithClock(clk)) + require.NoError(t, err) + + // only the energy direction is metered, so the state stays incomplete + require.NoError(t, col.AddEnergy(new(100.0), nil, 0)) + clk.Add(15 * time.Minute) // 00:15 + require.NoError(t, col.AddEnergy(new(100.5), nil, 0)) + + col2, err := NewCollector(Grid, "incomplete", "", WithClock(clk)) + require.NoError(t, err) + require.False(t, col2.restored, "incomplete state must not restore") + require.Nil(t, col2.accu.energyMeter) + + require.NoError(t, col2.SetCapabilities(false, false)) + + var e entity + require.NoError(t, db.Instance.First(&e, col2.entity.Id).Error) + require.Nil(t, e.EnergyMeter, "unrestored checkpoint must be cleared too") +} + +// TestCollectorKeepsRestoreForSurvivingDirection verifies that clearing one +// direction of a bidirectional group does not discard the downtime delta the +// other direction still covers. +func TestCollectorKeepsRestoreForSurvivingDirection(t *testing.T) { + clk := clock.NewMock() // 1970-01-01 00:00:00 UTC, on a slot boundary + + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + require.NoError(t, SetupSchema()) + + col, err := NewCollector(Grid, "surviving", "", WithClock(clk)) + require.NoError(t, err) + + // checkpoint both directions + require.NoError(t, col.AddEnergy(new(100.0), new(200.0), 0)) + clk.Add(15 * time.Minute) // 00:15 + require.NoError(t, col.AddEnergy(new(100.5), new(200.2), 0)) + clk.Add(15 * time.Minute) // 00:30 + require.NoError(t, col.AddEnergy(new(101.0), new(200.4), 0)) + + // restart after 1h downtime, joining slot 01:30 mid-way, without export + clk.Add(65 * time.Minute) // 01:35 + col2, err := NewCollector(Grid, "surviving", "", WithClock(clk)) + require.NoError(t, err) + require.NoError(t, col2.SetCapabilities(true, false)) + require.True(t, col2.restored, "the surviving import reading still seeds a restore") + require.Nil(t, col2.accu.returnEnergyMeter) + + // the import delta across the downtime is kept + require.NoError(t, col2.AddEnergy(new(111.0), nil, 0)) + require.InDelta(t, 10.0, col2.accu.Energy, 1e-10) + + clk.Add(10 * time.Minute) // 01:45 + require.NoError(t, col2.AddEnergy(new(111.0), nil, 0)) + + var m meter + require.NoError(t, db.Instance.Where("meter = ? AND ts = ?", col2.entity.Id, 90*60).First(&m).Error) + require.InDelta(t, 10.0, m.Energy, 1e-10) + require.True(t, m.Recovered, "catchup slot must be flagged recovered") +} diff --git a/core/site.go b/core/site.go index 6f0191616..3cc641417 100644 --- a/core/site.go +++ b/core/site.go @@ -167,6 +167,24 @@ func activeMeters(refs []string) ([]config.Device[api.Meter], error) { return res, nil } +// newMeterCollector creates a meter collector and reconciles the persisted meter +// readings with the device's capabilities, so a device that lost its energy +// registers falls back to power integration instead of freezing. +func newMeterCollector(group, ref, title string, meter api.Meter) (*metrics.Collector, error) { + energy, returnEnergy := api.HasCap[api.MeterEnergy](meter), api.HasCap[api.MeterReturnEnergy](meter) + if group == metrics.Battery { + // batteries map discharge to energy, see updateBatteryMeters + energy, returnEnergy = returnEnergy, energy + } + + c, err := metrics.NewCollector(group, ref, title) + if err != nil { + return nil, err + } + + return c, c.SetCapabilities(energy, returnEnergy) +} + func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tariff.Tariffs) error { site.loadpoints = loadpoints site.tariffs = tariffs @@ -233,7 +251,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif } else { site.gridMeter = dev - me, err := metrics.NewCollector(metrics.Grid, site.Meters.GridMeterRef, metrics.Grid) + me, err := newMeterCollector(metrics.Grid, site.Meters.GridMeterRef, metrics.Grid, dev.Instance()) if err != nil { return err } @@ -253,7 +271,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif site.pvMeters = append(site.pvMeters, dev) // energy collector (for history persistence and forecast scaling) - me, err := metrics.NewCollector(metrics.PV, ref, deviceTitleOrName(dev)) + me, err := newMeterCollector(metrics.PV, ref, deviceTitleOrName(dev), dev.Instance()) if err != nil { return err } @@ -282,7 +300,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif site.batteryMeters = mm for _, dev := range mm { ref := dev.Config().Name - me, err := metrics.NewCollector(metrics.Battery, ref, deviceTitleOrName(dev)) + me, err := newMeterCollector(metrics.Battery, ref, deviceTitleOrName(dev), dev.Instance()) if err != nil { return err } @@ -297,7 +315,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif site.extMeters = mm for _, dev := range mm { ref := dev.Config().Name - me, err := metrics.NewCollector(metrics.Meter, ref, deviceTitleOrName(dev)) + me, err := newMeterCollector(metrics.Meter, ref, deviceTitleOrName(dev), dev.Instance()) if err != nil { return err } @@ -312,7 +330,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif site.auxMeters = mm for _, dev := range mm { ref := dev.Config().Name - me, err := metrics.NewCollector(metrics.Consumer, ref, deviceTitleOrName(dev)) + me, err := newMeterCollector(metrics.Consumer, ref, deviceTitleOrName(dev), dev.Instance()) if err != nil { return err } @@ -327,7 +345,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif site.consumerMeters = mm for _, dev := range mm { ref := dev.Config().Name - me, err := metrics.NewCollector(metrics.Consumer, ref, deviceTitleOrName(dev)) + me, err := newMeterCollector(metrics.Consumer, ref, deviceTitleOrName(dev), dev.Instance()) if err != nil { return err } @@ -1127,9 +1145,7 @@ func (site *Site) reservedPVPower(lp updater) float64 { continue } if other.EffectivePriority() > prio && other.PvChargeStarting() { - // min power is what the loadpoint needs to start, reserving its max - // power would starve this loadpoint beyond that (#32778) - reserved += other.EffectiveMinPower() + reserved += other.EffectiveMaxPower() } } diff --git a/core/site_optimizer.go b/core/site_optimizer.go index 0af96ef38..0b82b0d85 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -1237,6 +1237,15 @@ func applyPrecondition(lp loadpoint.API, demand []float32, minLen int) []float32 return demand } + // limit to the required charging duration, i.e. "all" must not demand beyond the plan goal + goal, _ := lp.GetPlanGoal() + if required := lp.GetPlanRequiredDuration(goal, lp.EffectiveMaxPower()); required < precondition { + precondition = required + } + if precondition <= 0 { + return demand + } + // TODO precise slot placement end := time.Until(ts) start := end - precondition diff --git a/core/site_optimizer_test.go b/core/site_optimizer_test.go index 634e90ebe..a459af0ab 100644 --- a/core/site_optimizer_test.go +++ b/core/site_optimizer_test.go @@ -47,6 +47,8 @@ func TestApplyPrecondition(t *testing.T) { // plan in 1h, 40min precondition: slots 1 (10min) and 2, 3 (full) lp.EXPECT().EffectivePlanTime().Return(time.Now().Add(time.Hour)).Times(1) lp.EXPECT().EffectivePlanStrategy().Return(api.PlanStrategy{Precondition: 40 * time.Minute}).Times(1) + lp.EXPECT().GetPlanGoal().Return(80.0, true).Times(1) + lp.EXPECT().GetPlanRequiredDuration(80.0, 8000.0).Return(2 * time.Hour).Times(1) res := applyPrecondition(lp, nil, 8) require.Len(t, res, 8) assert.InDeltaSlice(t, []float32{0, 2000. / 1.5, 2000, 2000, 0, 0, 0, 0}, res, 1) @@ -54,12 +56,32 @@ func TestApplyPrecondition(t *testing.T) { // existing demand is kept where higher lp.EXPECT().EffectivePlanTime().Return(time.Now().Add(time.Hour)).Times(1) lp.EXPECT().EffectivePlanStrategy().Return(api.PlanStrategy{Precondition: 30 * time.Minute}).Times(1) + lp.EXPECT().GetPlanGoal().Return(80.0, true).Times(1) + lp.EXPECT().GetPlanRequiredDuration(80.0, 8000.0).Return(2 * time.Hour).Times(1) res = applyPrecondition(lp, []float32{3000, 3000, 3000, 3000, 0, 0, 0, 0}, 8) assert.InDeltaSlice(t, []float32{3000, 3000, 3000, 3000, 0, 0, 0, 0}, res, 1) // plan beyond horizon lp.EXPECT().EffectivePlanTime().Return(time.Now().Add(24 * time.Hour)).Times(1) lp.EXPECT().EffectivePlanStrategy().Return(api.PlanStrategy{Precondition: time.Hour}).Times(1) + lp.EXPECT().GetPlanGoal().Return(80.0, true).Times(1) + lp.EXPECT().GetPlanRequiredDuration(80.0, 8000.0).Return(2 * time.Hour).Times(1) + assert.Nil(t, applyPrecondition(lp, nil, 8)) + + // "all" precondition is limited to the required charging duration (#33135) + lp.EXPECT().EffectivePlanTime().Return(time.Now().Add(time.Hour)).Times(1) + lp.EXPECT().EffectivePlanStrategy().Return(api.PlanStrategy{Precondition: 7 * 24 * time.Hour}).Times(1) + lp.EXPECT().GetPlanGoal().Return(80.0, true).Times(1) + lp.EXPECT().GetPlanRequiredDuration(80.0, 8000.0).Return(40 * time.Minute).Times(1) + res = applyPrecondition(lp, nil, 8) + require.Len(t, res, 8) + assert.InDeltaSlice(t, []float32{0, 2000. / 1.5, 2000, 2000, 0, 0, 0, 0}, res, 1) + + // goal already reached: no demand + lp.EXPECT().EffectivePlanTime().Return(time.Now().Add(time.Hour)).Times(1) + lp.EXPECT().EffectivePlanStrategy().Return(api.PlanStrategy{Precondition: 7 * 24 * time.Hour}).Times(1) + lp.EXPECT().GetPlanGoal().Return(80.0, true).Times(1) + lp.EXPECT().GetPlanRequiredDuration(80.0, 8000.0).Return(time.Duration(0)).Times(1) assert.Nil(t, applyPrecondition(lp, nil, 8)) } diff --git a/core/site_prioritize_test.go b/core/site_prioritize_test.go index b6d4bcb20..aeaae88b8 100644 --- a/core/site_prioritize_test.go +++ b/core/site_prioritize_test.go @@ -10,10 +10,8 @@ import ( "github.com/evcc-io/evcc/util" ) -const testEnableDelay = time.Minute - func newPVLoadpoint(prio int, mode api.ChargeMode, status api.ChargeStatus, enabled bool, timer time.Time) *Loadpoint { - lp := &Loadpoint{ + return &Loadpoint{ log: util.NewLogger("lp"), clock: clock.NewMock(), minCurrent: minA, @@ -25,21 +23,13 @@ func newPVLoadpoint(prio int, mode api.ChargeMode, status api.ChargeStatus, enab pvTimer: timer, priority: prio, } - lp.Enable.Delay = testEnableDelay - return lp -} - -// pvTimerStarted returns a timer start that has been running for the given duration -func pvTimerStarted(running time.Duration) time.Time { - return clock.NewMock().Now().Add(-running) } func TestPvChargeStarting(t *testing.T) { now := clock.NewMock().Now() - settled := pvTimerStarted(testEnableDelay / 2) // enable timer running but car already full (soc at default 100% limit): not starting up - enablePendingFull := newPVLoadpoint(0, api.ModePV, api.StatusB, false, settled) + enablePendingFull := newPVLoadpoint(0, api.ModePV, api.StatusB, false, now) enablePendingFull.vehicleSoc = 100 tc := []struct { @@ -47,13 +37,12 @@ func TestPvChargeStarting(t *testing.T) { lp *Loadpoint starting bool }{ - {"enable timer running", newPVLoadpoint(0, api.ModePV, api.StatusB, false, settled), true}, - {"enable timer just restarted", newPVLoadpoint(0, api.ModePV, api.StatusB, false, now), false}, + {"enable timer running", newPVLoadpoint(0, api.ModePV, api.StatusB, false, now), true}, {"enabled not charging", newPVLoadpoint(0, api.ModePV, api.StatusB, true, time.Time{}), false}, {"enabled and charging", newPVLoadpoint(0, api.ModePV, api.StatusC, true, time.Time{}), false}, {"disabled idle", newPVLoadpoint(0, api.ModePV, api.StatusB, false, time.Time{}), false}, - {"disconnected", newPVLoadpoint(0, api.ModePV, api.StatusA, false, settled), false}, - {"not pv mode", newPVLoadpoint(0, api.ModeNow, api.StatusB, false, settled), false}, + {"disconnected", newPVLoadpoint(0, api.ModePV, api.StatusA, false, now), false}, + {"not pv mode", newPVLoadpoint(0, api.ModeNow, api.StatusB, false, now), false}, {"enable pending but car full", enablePendingFull, false}, } @@ -68,7 +57,7 @@ func TestReservedPVPower(t *testing.T) { Voltage = 230 // higher-priority loadpoint (prio 1) starting up - high := newPVLoadpoint(1, api.ModePV, api.StatusB, false, pvTimerStarted(testEnableDelay/2)) + high := newPVLoadpoint(1, api.ModePV, api.StatusB, false, clock.NewMock().Now()) // lower-priority loadpoint (prio 0) in PV mode low := newPVLoadpoint(0, api.ModePV, api.StatusB, false, time.Time{}) @@ -77,20 +66,10 @@ func TestReservedPVPower(t *testing.T) { loadpoints: []*Loadpoint{high, low}, } - // low reserves the power high needs to start, not its max power (#32778) - if got, want := site.reservedPVPower(low), high.EffectiveMinPower(); got != want { + // low reserves high's anticipated max power while high is starting up + if got, want := site.reservedPVPower(low), high.EffectiveMaxPower(); got != want { t.Errorf("low: want %.0f, got %.0f", want, got) } - if high.EffectiveMinPower() == high.EffectiveMaxPower() { - t.Fatal("test requires min and max power to differ") - } - - // a timer restarting on every surplus dip must not reserve at all (#32778) - high.pvTimer = clock.NewMock().Now() - if got := site.reservedPVPower(low); got != 0 { - t.Errorf("low while high timer restarts: want 0, got %.0f", got) - } - high.pvTimer = pvTimerStarted(testEnableDelay / 2) // high (top priority) reserves nothing if got := site.reservedPVPower(high); got != 0 { diff --git a/core/site_vehicles.go b/core/site_vehicles.go index aa457808c..cedccb264 100644 --- a/core/site_vehicles.go +++ b/core/site_vehicles.go @@ -104,14 +104,7 @@ type vehicles struct { } func (vv *vehicles) Instances() []api.Vehicle { - devs := config.Vehicles().Devices() - - res := make([]api.Vehicle, 0, len(devs)) - for _, dev := range devs { - res = append(res, dev.Instance()) - } - - return res + return config.Instances(config.Vehicles().Devices()) } func (vv *vehicles) Settings() []vehicle.API { @@ -119,6 +112,10 @@ func (vv *vehicles) Settings() []vehicle.API { res := make([]vehicle.API, 0, len(devs)) for _, dev := range devs { + // skip disabled vehicles + if dev.Instance() == nil { + continue + } res = append(res, vehicle.Adapter(vv.log, dev)) } diff --git a/core/soc/estimator.go b/core/soc/estimator.go index fdc747cd6..e02c30739 100644 --- a/core/soc/estimator.go +++ b/core/soc/estimator.go @@ -30,6 +30,7 @@ type Estimator struct { initialEnergy float64 // energy counter at first valid soc in Wh prevSoc float64 // vehicle soc at last soc change in % prevChargedEnergy float64 // charged energy at last soc change in Wh + sampled bool // a valid vehicle soc was received } // NewEstimator creates new estimator @@ -92,7 +93,12 @@ func remainingChargeEnergy(targetSoc, vehicleSoc, virtualCapacity float64) float // Soc replaces the api.Vehicle.Soc interface to take charged energy into account func (s *Estimator) Soc(fetchedSoc *float64, chargedEnergy float64) float64 { if fetchedSoc == nil { - s.log.WARN.Println("missing vehicle soc- ignored by estimator") + // extrapolate soc from charged energy while no vehicle soc is available, + // never below the current estimate to stay monotonic across energy resets + if energyDelta := max(chargedEnergy, 0) - s.prevChargedEnergy; s.sampled && energyDelta >= 0 { + s.vehicleSoc = min(max(s.vehicleSoc, s.prevSoc+energyDelta/s.energyPerSocStep), 100) + s.log.DEBUG.Printf("soc extrapolated: %.2f%%", s.vehicleSoc) + } return s.vehicleSoc } @@ -100,13 +106,15 @@ func (s *Estimator) Soc(fetchedSoc *float64, chargedEnergy float64) float64 { socDelta := *fetchedSoc - s.prevSoc energyDelta := chargedEnergy - s.prevChargedEnergy - // no soc change and no energy reset: interpolate soc from charged energy - if socDelta == 0 && energyDelta >= 0 { + // no soc change and no energy reset: interpolate soc from charged energy. + // the first valid soc always takes the sampling path below to seed the baseline. + if s.sampled && socDelta == 0 && energyDelta >= 0 { s.vehicleSoc = min(*fetchedSoc+energyDelta/s.energyPerSocStep, 100) s.log.DEBUG.Printf("soc estimated: %.2f%% (vehicle: %.2f%%)", s.vehicleSoc, *fetchedSoc) return s.vehicleSoc } + s.sampled = true s.vehicleSoc = *fetchedSoc if s.initialSoc == 0 { diff --git a/core/soc/estimator_test.go b/core/soc/estimator_test.go index ee76ecd63..ba28868ec 100644 --- a/core/soc/estimator_test.go +++ b/core/soc/estimator_test.go @@ -88,13 +88,48 @@ func TestMissingSoc(t *testing.T) { ce := NewEstimator(util.NewLogger("foo"), vehicle) + // missing soc without any prior sample keeps the zero estimate + assert.Equal(t, 0.0, ce.Soc(nil, 100)) + soc := 20.0 assert.Equal(t, 20.0, ce.Soc(&soc, 0)) assert.Equal(t, 21.0, ce.Soc(&soc, 100)) - // missing soc keeps the estimate and must not corrupt the sampled state - assert.Equal(t, 21.0, ce.Soc(nil, 200)) + // missing soc extrapolates from charged energy and must not corrupt the sampled state + assert.Equal(t, 22.0, ce.Soc(nil, 200)) assert.Equal(t, 22.0, ce.Soc(&soc, 200)) + + // energy reset while soc is missing keeps the estimate (monotonic clamp) + assert.Equal(t, 22.0, ce.Soc(nil, 50)) + + // resample the baseline at a higher energy, then reset the energy below it: + // the reset guard must hold the estimate + soc = 25.0 + assert.Equal(t, 25.0, ce.Soc(&soc, 400)) + assert.Equal(t, 25.0, ce.Soc(nil, 50)) + + // extrapolation resumes once the energy passes the sampled baseline again + assert.Equal(t, 26.0, ce.Soc(nil, 500)) +} + +func TestMissingSocFromZero(t *testing.T) { + ctrl := gomock.NewController(t) + vehicle := api.NewMockVehicle(ctrl) + vehicle.EXPECT().Capacity().Return(8.5) + + ce := NewEstimator(util.NewLogger("foo"), vehicle) + + // a fetched soc of exactly 0% must still enable extrapolation + soc := 0.0 + assert.Equal(t, 0.0, ce.Soc(&soc, 0)) + assert.Equal(t, 1.0, ce.Soc(nil, 100)) + + // the first sample seeds the energy baseline: a 0% soc at nonzero session + // energy must not jump ahead by the pre-baseline energy + vehicle.EXPECT().Capacity().Return(8.5) + ce = NewEstimator(util.NewLogger("foo"), vehicle) + assert.Equal(t, 0.0, ce.Soc(&soc, 5000)) + assert.Equal(t, 1.0, ce.Soc(nil, 5100)) } func TestImprovedEstimatorRemainingChargeDuration(t *testing.T) { diff --git a/go.mod b/go.mod index bf53915a1..044665c48 100644 --- a/go.mod +++ b/go.mod @@ -269,3 +269,5 @@ replace github.com/lorenzodonini/ocpp-go => github.com/evcc-io/ocpp-go v0.0.0-20 replace github.com/enbility/spine-go => github.com/andig/spine-go v0.7.1-0.20260804073026-f9a8d8e8fd18 replace github.com/enbility/eebus-go => github.com/andig/eebus-go v0.0.0-20260725155950-e735091ff165 + +replace github.com/enbility/ship-go => github.com/andig/ship-go v0.6.1-0.20260818072438-bade5ec9fbd7 diff --git a/go.sum b/go.sum index 642b08740..8ee8c4fc8 100644 --- a/go.sum +++ b/go.sum @@ -30,6 +30,8 @@ github.com/andig/gosunspec v0.0.0-20260705113727-6d585e133512 h1:1y8dS4GaBB9WUu/ github.com/andig/gosunspec v0.0.0-20260705113727-6d585e133512/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/andig/ship-go v0.6.1-0.20260818072438-bade5ec9fbd7 h1:YXXxRvtVSTLATXSvMYBtf8PBNeVRBu7WzxfL4PCqN+8= +github.com/andig/ship-go v0.6.1-0.20260818072438-bade5ec9fbd7/go.mod h1:EvRFx83phCwPQrOuWw2CX5t0c6mcFVzIk6gQEz4Lijg= github.com/andig/spine-go v0.7.1-0.20260804073026-f9a8d8e8fd18 h1:FVKqjwJJLo8vV47jXt1lNN4EVJXCyV3LSTNFt9eeimU= github.com/andig/spine-go v0.7.1-0.20260804073026-f9a8d8e8fd18/go.mod h1:ddWZU5BQGyzRGF20Z7rUKFFzbCp+cfu9mZgYtOMW/fM= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= @@ -140,8 +142,6 @@ github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2I github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= github.com/enbility/go-avahi v0.0.0-20240909195612-d5de6b280d7a h1:foChWb8lhzqa6lWDRs6COYMdp649YlUirFP8GqoT0JQ= github.com/enbility/go-avahi v0.0.0-20240909195612-d5de6b280d7a/go.mod h1:H64mhYcAQUGUUnVqMdZQf93kPecH4M79xwH95Lddt3U= -github.com/enbility/ship-go v0.6.1-0.20260720110450-0aa90f64ac76 h1:gDrV6vBG3luAgsyX4sXYShITT41CDKXrtyluLeRmglE= -github.com/enbility/ship-go v0.6.1-0.20260720110450-0aa90f64ac76/go.mod h1:EvRFx83phCwPQrOuWw2CX5t0c6mcFVzIk6gQEz4Lijg= github.com/enbility/zeroconf/v2 v2.0.0-20240920094356-be1cae74fda6 h1:XOYvxKtT1oxT37w/5oEiRLuPbm9FuJPt3fiYhX0h8Po= github.com/enbility/zeroconf/v2 v2.0.0-20240920094356-be1cae74fda6/go.mod h1:BszP9qFV14mPXgyIREbgIdQtWxbAj3OKqvK02HihMoM= github.com/evcc-io/modbus v0.0.0-20250501165638-8b6f1fbdb7ea h1:F6eyC8V8wvc3ranlsR4coAls+OPBkVvxyX0a2VqdlPc= diff --git a/meter/shelly/gen1.go b/meter/shelly/gen1.go index a8e007ef5..d57784cf4 100644 --- a/meter/shelly/gen1.go +++ b/meter/shelly/gen1.go @@ -149,9 +149,11 @@ func (c *gen1) IsReversed() bool { return false } -// HasReturnEnergy reports whether the device measures energy in the return direction +// HasReturnEnergy reports whether the device measures energy in the return direction. +// Only the EM variants have a total_returned register, relay/plug meters don't. func (c *gen1) HasReturnEnergy() bool { - return true + res, err := c.status.Get() + return err == nil && c.channel >= len(res.Meters) && c.channel < len(res.EMeters) } // IsThreePhase reports whether the device is a three-phase energy meter. diff --git a/meter/shelly/gen1_test.go b/meter/shelly/gen1_test.go index 7f9dc6b21..28129d992 100644 --- a/meter/shelly/gen1_test.go +++ b/meter/shelly/gen1_test.go @@ -3,7 +3,9 @@ package shelly import ( "encoding/json" "testing" + "time" + "github.com/evcc-io/evcc/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -46,3 +48,21 @@ func TestUnmarshalGen1Status(t *testing.T) { assert.Equal(t, -620.34, res.EMeters[0].Power) } } + +// TestGen1HasReturnEnergy asserts that only the EM variants report a return +// register - a 1PM's production must not be booked in return direction (#33062). +func TestGen1HasReturnEnergy(t *testing.T) { + status := func(s string) util.Cacheable[Gen1Status] { + var res Gen1Status + require.NoError(t, json.Unmarshal([]byte(s), &res)) + return util.ResettableCached(func() (Gen1Status, error) { return res, nil }, time.Minute) + } + + // Shelly 1PM: meters without total_returned + g := &gen1{status: status(`{"meters":[{"power":198.0,"total":31510486}]}`)} + assert.False(t, g.HasReturnEnergy(), "1PM has no return register") + + // Shelly EM: emeters with total_returned + g = &gen1{status: status(`{"emeters":[{"power":-620.34,"total":401472.9,"total_returned":653673.7}]}`)} + assert.True(t, g.HasReturnEnergy()) +} diff --git a/server/http.go b/server/http.go index c4725820b..ed83e21bc 100644 --- a/server/http.go +++ b/server/http.go @@ -191,7 +191,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API) { "smartcostdelete": {"DELETE", "/smartcostlimit", updateSmartCostLimit(site, smartCostLimit)}, "smartfeedin": {"POST", "/smartfeedinprioritylimit/{value:-?[0-9.]+}", updateSmartCostLimit(site, smartFeedInPriorityLimit)}, "smartfeedindelete": {"DELETE", "/smartfeedinprioritylimit", updateSmartCostLimit(site, smartFeedInPriorityLimit)}, - "tariff": {"GET", "/tariff/{tariff:[a-z]+}", tariffHandler(site)}, + "tariff": {"GET", "/tariff/{tariff:[a-z0-9]+}", tariffHandler(site)}, "sessions": {"GET", "/sessions", sessionHandler}, "updatesession": {"PUT", "/session/{id:[0-9]+}", updateSessionHandler}, "deletesession": {"DELETE", "/session/{id:[0-9]+}", deleteSessionHandler}, diff --git a/server/http_config_loadpoint_handler.go b/server/http_config_loadpoint_handler.go index 23f875c48..c30359536 100644 --- a/server/http_config_loadpoint_handler.go +++ b/server/http_config_loadpoint_handler.go @@ -13,6 +13,7 @@ import ( "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/config" "github.com/evcc-io/evcc/util/templates" + "github.com/go-viper/mapstructure/v2" "github.com/gorilla/mux" ) @@ -119,8 +120,9 @@ func loadpointConfig(dev config.Device[loadpoint.API]) (loadpointFullConfig, err return loadpointFullConfig{}, err } + // non-strict: runtime settings (mode, ...) are persisted alongside the config var static loadpoint.StaticConfig - if err := util.DecodeOther(staticMap, &static); err != nil { + if err := mapstructure.Decode(staticMap, &static); err != nil { return loadpointFullConfig{}, err } diff --git a/server/http_config_loadpoint_handler_test.go b/server/http_config_loadpoint_handler_test.go index e9ddf4a49..a8050bd62 100644 --- a/server/http_config_loadpoint_handler_test.go +++ b/server/http_config_loadpoint_handler_test.go @@ -25,6 +25,7 @@ func TestLoadpointConfigDisabledNilInstance(t *testing.T) { "charger": "wallbox", "meter": "lp-meter", "title": "Garage", + "mode": "pv", // runtime setting persisted by the settings adapter }, } diff --git a/tariff/corrently/types.go b/tariff/corrently/types.go index c26e10873..4243c1b35 100644 --- a/tariff/corrently/types.go +++ b/tariff/corrently/types.go @@ -18,8 +18,8 @@ type Forecast struct { Gsi float64 `json:"gsi"` TimeStamp int64 `json:"timeStamp"` Energyprice string `json:"energyprice"` - Co2GStandard int `json:"co2_g_standard"` - Co2GOekostrom int `json:"co2_g_oekostrom"` + Co2GStandard *int `json:"co2_g_standard"` + Co2GOekostrom *int `json:"co2_g_oekostrom"` Timeframe struct { Start int64 `json:"start"` End int64 `json:"end"` diff --git a/tariff/gruenstromindex.go b/tariff/gruenstromindex.go index d47968f03..d3cd7b64d 100644 --- a/tariff/gruenstromindex.go +++ b/tariff/gruenstromindex.go @@ -74,6 +74,13 @@ func (t *GrünStromIndex) run(done chan error) { } } + var data api.Rates + if err == nil { + if data = gsiRates(res); len(data) == 0 { + err = api.ErrNotAvailable + } + } + if err != nil { if reportError(&once, done, err) { return @@ -83,20 +90,33 @@ func (t *GrünStromIndex) run(done chan error) { continue } - data := make(api.Rates, 0, len(res.Forecast)) - for _, r := range res.Forecast { - data = append(data, api.Rate{ - Start: time.UnixMilli(r.Timeframe.Start).Local(), - End: time.UnixMilli(r.Timeframe.End).Local(), - Value: float64(r.Co2GStandard), - }) - } - mergeRates(t.data, data) once.Do(func() { close(done) }) } } +// gsiRates maps the forecast to rates. co2_g_standard is null since mid-2026, +// fall back to co2_g_oekostrom and skip slots without either value. +func gsiRates(res corrently.Forecast) api.Rates { + data := make(api.Rates, 0, len(res.Forecast)) + for _, r := range res.Forecast { + co2 := r.Co2GStandard + if co2 == nil { + co2 = r.Co2GOekostrom + } + if co2 == nil { + continue + } + + data = append(data, api.Rate{ + Start: time.UnixMilli(r.Timeframe.Start).Local(), + End: time.UnixMilli(r.Timeframe.End).Local(), + Value: float64(*co2), + }) + } + return data +} + // Rates implements the api.Tariff interface func (t *GrünStromIndex) Rates() (api.Rates, error) { var res api.Rates diff --git a/tariff/gruenstromindex_test.go b/tariff/gruenstromindex_test.go new file mode 100644 index 000000000..76b6e8b9e --- /dev/null +++ b/tariff/gruenstromindex_test.go @@ -0,0 +1,25 @@ +package tariff + +import ( + "encoding/json" + "testing" + + "github.com/evcc-io/evcc/tariff/corrently" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGsiRates(t *testing.T) { + // co2_g_standard is null since mid-2026, co2_g_oekostrom is the fallback + var res corrently.Forecast + require.NoError(t, json.Unmarshal([]byte(`{"forecast":[ + {"co2_g_standard":348,"co2_g_oekostrom":23,"timeframe":{"start":1787652000000,"end":1787655600000}}, + {"co2_g_standard":null,"co2_g_oekostrom":25,"timeframe":{"start":1787655600000,"end":1787659200000}}, + {"co2_g_standard":null,"co2_g_oekostrom":null,"timeframe":{"start":1787659200000,"end":1787662800000}} + ]}`), &res)) + + rates := gsiRates(res) + require.Len(t, rates, 2) + assert.Equal(t, 348.0, rates[0].Value) + assert.Equal(t, 25.0, rates[1].Value) +} diff --git a/templates/definition/meter/tesla-powerwall-fleet.yaml b/templates/definition/meter/tesla-powerwall-fleet.yaml index 92f806fe5..84c577edd 100644 --- a/templates/definition/meter/tesla-powerwall-fleet.yaml +++ b/templates/definition/meter/tesla-powerwall-fleet.yaml @@ -19,6 +19,8 @@ requirements: The [myteslamate.com](https://www.myteslamate.com/tesla-api-application-registration/) guide explains the process and generates a free Access and Refresh Token. With this token pair and your Client ID created in the Tesla Developer Account, evcc can directly communicate with the Tesla Fleet API. params: + - name: usage + choice: ["battery"] - name: host - name: password required: true diff --git a/tests/config-disable.spec.ts b/tests/config-disable.spec.ts index 4d94087ed..5f5b66e19 100644 --- a/tests/config-disable.spec.ts +++ b/tests/config-disable.spec.ts @@ -59,6 +59,21 @@ test.describe("disable / enable", async () => { await page.reload(); await createLoadpoint(page, "Garage"); + // restart to bring both loadpoints live + await restart(); + await page.reload(); + + // switch charge mode on dashboard; runtime setting is persisted next to the loadpoint config + await page.goto("/"); + const modeButton = page + .getByTestId("loadpoint") + .nth(0) + .getByTestId("mode") + .getByRole("button", { name: "Fast" }); + await modeButton.click(); + await expect(modeButton).toHaveClass(/active/); + await page.goto("/#/config"); + const target = page.getByTestId("loadpoint").nth(0); await toggleLoadpointDisable(page, 0, "Disable"); @@ -66,10 +81,11 @@ test.describe("disable / enable", async () => { // card shows disabled state await expect(disabledBadge(target)).toBeVisible(); - // restart, no fatal + // restart, no fatal, both loadpoints still listed await restart(); await page.reload(); await expectNoFatal(page); + await expect(page.getByTestId("loadpoint")).toHaveCount(2); await expect(disabledBadge(target)).toBeVisible(); // re-enable by clicking the disabled card @@ -223,6 +239,10 @@ test.describe("disable / enable", async () => { await start(); await page.goto("/#/config"); + // loadpoint so the vehicle shows up on the dashboard + await createLoadpoint(page, "Carport"); + await page.reload(); + // add user-defined vehicle await page.getByTestId("add-vehicle").click(); const modal = page.getByTestId("vehicle-modal"); @@ -261,6 +281,23 @@ soc: await expectNoFatal(page); await expect(disabledBadge(vehicleCard)).toBeVisible(); + // disabled vehicle is hidden from main ui + await page.goto("/"); + await expect(page.getByTestId("loadpoint")).toBeVisible(); + await expect( + page.getByTestId("change-vehicle").getByRole("option", { name: "blue Honda" }) + ).toHaveCount(0); + + // no vehicles entry in more menu + const moreTab = page.getByTestId("tab-more"); + await moreTab.click(); + await expect(moreTab.getByRole("button", { name: "User Interface" })).toBeVisible(); + await expect(moreTab.getByRole("button", { name: "Vehicles" })).not.toBeVisible(); + // close more menu + await moreTab.click(); + + await page.goto("/#/config"); + // re-enable by clicking the disabled card await disabledBadge(vehicleCard).click(); await expect(disabledBadge(vehicleCard)).toHaveCount(0); @@ -270,6 +307,19 @@ soc: await page.reload(); await expectNoFatal(page); await expect(vehicleCard).toContainText("blue Honda"); + + // vehicle is selectable on the dashboard again + await page.goto("/"); + await expect( + page.getByTestId("change-vehicle").getByRole("option", { name: "blue Honda" }) + ).toHaveCount(1); + + // vehicle is back in the more menu vehicles modal + await moreTab.click(); + await moreTab.getByRole("button", { name: "Vehicles" }).click(); + const settingsModal = page.getByTestId("vehicle-settings-modal"); + await expectModalVisible(settingsModal); + await expect(settingsModal.getByRole("group", { name: "blue Honda" })).toBeVisible(); }); });