Compare commits

...

17 commits

Author SHA1 Message Date
Michael Geers
2f8462f0d3
warp-ws: use wss when charger URI is https (#33198)
Some checks failed
Release / Validate Tag (push) Has been cancelled
Release / call-build-workflow (push) Has been cancelled
Release / Publish Docker :release (push) Has been cancelled
Release / Github & APT (push) Has been cancelled
Release / Demo (push) Has been cancelled
Release / Hassio Addon (push) Has been cancelled
Release / Delete Release Branch (push) Has been cancelled
2026-08-26 16:19:13 +02:00
Michael Geers
2aad760769
Keep permanent sentinel errors distinguishable (#33197) 2026-08-26 15:01:39 +02:00
Michael Geers
1210b36132
Grünstromindex: fall back to co2_g_oekostrom when co2_g_standard is null (#33165) 2026-08-25 13:43:21 +02:00
Michael Geers
a6db138dee
Fix tariff API route not matching co2 (#33167) 2026-08-25 13:42:54 +02:00
Michael Geers
bafcd4f5dc
Soc: extrapolate from charged energy when vehicle api is unavailable (#33149) 2026-08-24 21:27:09 +02:00
Michael Geers
661693952a
Optimizer: limit precondition demand to required charging duration (#33139) 2026-08-24 13:51:38 +02:00
Michael Geers
e395dcf371
Shelly: no return energy register on gen1 relay and plug meters (#33112)
Some checks failed
Release / Validate Tag (push) Has been cancelled
Release / call-build-workflow (push) Has been cancelled
Release / Publish Docker :release (push) Has been cancelled
Release / Github & APT (push) Has been cancelled
Release / Demo (push) Has been cancelled
Release / Hassio Addon (push) Has been cancelled
Release / Delete Release Branch (push) Has been cancelled
2026-08-23 12:48:02 +02:00
Michael Geers
db206de2f9
Metrics: fall back to power integration when a meter loses its energy register (#33110) 2026-08-23 12:43:11 +02:00
Michael Geers
5f5b33289c
OCPP: keep websocket alive between heartbeats (#33101) 2026-08-23 11:32:22 +02:00
Michael Geers
c4fbffa55e
DaheimLaden: fix firmware init (#33067) 2026-08-22 09:50:27 +02:00
Michael Geers
6e121c778f
chore: error handling in checkStation method (#33066) 2026-08-22 09:48:44 +02:00
Michael Geers
bbb7d704f6
Revert "PV: reserve only min power for higher-priority loadpoints starting up" (#32948)
Some checks failed
Release / Validate Tag (push) Has been cancelled
Release / call-build-workflow (push) Has been cancelled
Release / Publish Docker :release (push) Has been cancelled
Release / Github & APT (push) Has been cancelled
Release / Demo (push) Has been cancelled
Release / Hassio Addon (push) Has been cancelled
Release / Delete Release Branch (push) Has been cancelled
2026-08-19 09:37:21 +02:00
Michael Geers
d284a2f9af
Vehicle: don't instantiate disabled vehicles (#32943) 2026-08-18 11:21:08 +02:00
Michael Geers
96fb302147
chore: add ship-go rollup with mdns re-announcement leak fix (#32941) 2026-08-18 09:51:14 +02:00
Michael Geers
eb836e9ec8
Tesla Powerwall Fleet: fix template not selectable in GUI (#32936) 2026-08-18 08:50:29 +02:00
Michael Geers
52c0cfaa5d
Loadpoint: fix config error for disabled loadpoints (#32923) 2026-08-17 15:25:12 +02:00
Michael Geers
d1b1ded0e6
chore: fix release workflow (#32878)
Some checks failed
Release / Validate Tag (push) Has been cancelled
Release / call-build-workflow (push) Has been cancelled
Release / Publish Docker :release (push) Has been cancelled
Release / Github & APT (push) Has been cancelled
Release / Demo (push) Has been cancelled
Release / Hassio Addon (push) Has been cancelled
Release / Delete Release Branch (push) Has been cancelled
2026-08-16 11:24:40 +02:00
35 changed files with 551 additions and 130 deletions

View file

@ -157,18 +157,15 @@ jobs:
TITLE: ${{ steps.pr.outputs.title }} TITLE: ${{ steps.pr.outputs.title }}
ACTOR: ${{ github.event.comment.user.login }} ACTOR: ${{ github.event.comment.user.login }}
run: | 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 # the pull request is authored by the deploy token, so assign the
# requester. the body stays empty, squashing it would carry the text # requester. the body stays empty, squashing it would carry the text
# into the release branch as the commit description. # into the release branch as the commit description.
# the marker trails the title so the changelog filters and groups still # the title stays untouched so the changelog filters and groups still
# see the original prefix, goreleaser strips it from the release notes # see the original prefix, the backport label identifies the pull request
url=$(gh pr create \ url=$(gh pr create \
--base "$TARGET" \ --base "$TARGET" \
--head "$BRANCH" \ --head "$BRANCH" \
--title "$TITLE [$line backport]" \ --title "$TITLE" \
--label backport \ --label backport \
--assignee "$ACTOR" \ --assignee "$ACTOR" \
--body "") --body "")

View file

@ -71,8 +71,6 @@ snapshot:
changelog: changelog:
sort: asc sort: asc
# drop the marker the backport command appends to the pull request title
format: '{{ .SHA }} {{ reReplaceAll ` \[\d+\.\d+ backport\]` .Message "" }}'
filters: filters:
exclude: exclude:
- "^chore" - "^chore"

View file

@ -7,23 +7,28 @@ import (
"github.com/cenkalti/backoff/v4" "github.com/cenkalti/backoff/v4"
) )
// permanentError is a sentinel error that keeps matching errors.Is after // permanentError is a sentinel error that signals permanence to backoff while
// backoff has stripped the backoff.Permanent wrapper. // remaining distinguishable from the other permanent sentinels.
type permanentError struct { type permanentError struct {
msg string msg string
} }
func (e *permanentError) Error() string { return e.msg } func (e *permanentError) Error() string { return e.msg }
// Is matches the wrapped sentinel, too // As signals permanence to backoff. Wrapping the sentinel in backoff.Permanent
func (e *permanentError) Is(target error) bool { // instead would make errors.Is match any other permanent error, since
var t *permanentError // backoff.PermanentError.Is matches by type rather than identity.
return errors.As(target, &t) && t == e 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 // permanent creates a permanent sentinel error
func permanent(msg string) error { func permanent(msg string) error {
return backoff.Permanent(&permanentError{msg}) return &permanentError{msg}
} }
// ErrNotAvailable indicates that a feature is not available // ErrNotAvailable indicates that a feature is not available

View file

@ -8,20 +8,47 @@ import (
"github.com/stretchr/testify/assert" "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. // Backoff returns permanent errors unwrapped. These must still match the sentinel.
func TestPermanentSentinels(t *testing.T) { func TestPermanentSentinels(t *testing.T) {
for _, tc := range []struct{ err, other error }{ for _, err := range permanentSentinels {
{ErrNotAvailable, ErrUnsupportedPlatform},
{ErrUnsupportedPlatform, ErrNotAvailable},
{ErrMissingCredentials, ErrMissingToken},
{ErrMissingToken, ErrMissingCredentials},
} {
_, unwrapped := backoff.RetryWithData(func() (int, error) { _, unwrapped := backoff.RetryWithData(func() (int, error) {
return 0, tc.err return 0, err
}, &backoff.StopBackOff{}) }, &backoff.StopBackOff{})
assert.ErrorIs(t, unwrapped, tc.err) assert.ErrorIs(t, unwrapped, err)
assert.ErrorIs(t, fmt.Errorf("wrapped: %w", unwrapped), tc.err) assert.ErrorIs(t, fmt.Errorf("wrapped: %w", unwrapped), err)
assert.NotErrorIs(t, unwrapped, tc.other) 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)
}

View file

@ -361,27 +361,37 @@ func (wb *DaheimLaden) getPhases() (int, error) {
} }
func (wb *DaheimLaden) checkStation() error { func (wb *DaheimLaden) checkStation() error {
b, err := wb.conn.ReadHoldingRegisters(dlRegEvseMaxCurrent, 22) // map may still be zero right after the wake-up call- poll briefly until populated
if err != nil { for range 5 {
return api.ErrSponsorRequired b, err := wb.conn.ReadHoldingRegisters(dlRegEvseMaxCurrent, 22)
} if err != nil {
// station id starts (dlRegStationId-dlRegEvseMaxCurrent) registers into the block return err
s, err := utf16BEBytesAsString(b[2*(dlRegStationId-dlRegEvseMaxCurrent):]) }
if err != nil || s == "" {
return api.ErrSponsorRequired
}
for _, r := range s { s, err := utf16BEBytesAsString(b[2*(dlRegStationId-dlRegEvseMaxCurrent):])
if r < 0x20 || r > 0x7e { 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 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 api.ErrSponsorRequired
}
return nil
} }
var _ api.Diagnosis = (*DaheimLaden)(nil) var _ api.Diagnosis = (*DaheimLaden)(nil)

View file

@ -4,6 +4,14 @@ import "time"
var Timeout = time.Minute // default request / response timeout on protocol level 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 // TriggerBootDelay defines how long to wait after WebSocket connect before
// proactively triggering a BootNotification. This allows the connection to // proactively triggering a BootNotification. This allows the connection to
// stabilize and gives the charger a chance to send a spontaneous BootNotification. // stabilize and gives the charger a chance to send a spontaneous BootNotification.

View file

@ -16,7 +16,7 @@ var (
func (cp *CP) OnBootNotification(request *core.BootNotificationRequest) (*core.BootNotificationConfirmation, error) { func (cp *CP) OnBootNotification(request *core.BootNotificationRequest) (*core.BootNotificationConfirmation, error) {
res := &core.BootNotificationConfirmation{ res := &core.BootNotificationConfirmation{
CurrentTime: types.Now(), CurrentTime: types.Now(),
Interval: 60, Interval: int(heartbeatInterval.Seconds()),
Status: core.RegistrationStatusAccepted, Status: core.RegistrationStatusAccepted,
} }

View file

@ -28,6 +28,9 @@ func TestBootNotificationStoresResultAndConnects(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, core.RegistrationStatusAccepted, res.Status) 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 // should be connected after BootNotification
assert.True(t, cp.Connected(), "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") assert.Equal(t, bootReq, cp.BootNotificationResult, "should store boot result")

View file

@ -140,6 +140,10 @@ func NewServer(cfg Config, networkExternalUrl string) {
server := &interceptingServer{Server: ws.NewServer()} server := &interceptingServer{Server: ws.NewServer()}
server.SetCheckOriginHandler(func(r *http.Request) bool { return true }) server.SetCheckOriginHandler(func(r *http.Request) bool { return true })
timeouts := ws.NewServerTimeoutConfig()
timeouts.PingWait = pingWait
server.SetTimeoutConfig(timeouts)
dispatcher := ocppj.NewDefaultServerDispatcher(ocppj.NewFIFOQueueMap(0)) dispatcher := ocppj.NewDefaultServerDispatcher(ocppj.NewFIFOQueueMap(0))
endpoint := ocppj.NewServer(server, dispatcher, nil, core.Profile, remotetrigger.Profile, smartcharging.Profile, security.Profile, firmware.Profile) endpoint := ocppj.NewServer(server, dispatcher, nil, core.Profile, remotetrigger.Profile, smartcharging.Profile, security.Profile, firmware.Profile)

View file

@ -235,7 +235,11 @@ func parseURI(uri string) (string, error) {
return "", err return "", err
} }
u.Scheme = "ws" if u.Scheme == "https" {
u.Scheme = "wss"
} else {
u.Scheme = "ws"
}
u.Path = path.Join(u.Path, "/ws") u.Path = path.Join(u.Path, "/ws")
return u.String(), nil return u.String(), nil

View file

@ -538,9 +538,14 @@ func configureVehicles(static []config.Named, names ...string) error {
} }
} }
instance, err := vehicleInstance(cc) // disabled vehicles are not instantiated
if err != nil { var instance api.Vehicle
return fmt.Errorf("cannot create vehicle '%s': %w", cc.Name, err) 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() mu.Lock()

View file

@ -253,7 +253,8 @@ func NewLoadpointFromConfig(log *util.Logger, settings settings.Settings, collec
} }
lp.defaultVehicle = dev.Instance() lp.defaultVehicle = dev.Instance()
if lp.defaultVehicle == nil { 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) lp.configureChargerType(lp.charger)
// add collector // add collector
if lp.chargeMeter != nil { if lp.chargeMeter != nil && collector != nil {
lp.chargeEnergy = collector 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 // 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 { func (lp *Loadpoint) PvChargeStarting() bool {
lp.RLock() lp.RLock()
enabled := lp.enabled enabled := lp.enabled
pvTimer := lp.pvTimer pvTimerRunning := !lp.pvTimer.IsZero()
lp.RUnlock() lp.RUnlock()
if lp.GetMode() != api.ModePV || !lp.connected() || lp.chargeGoalReached(enabled) { if lp.GetMode() != api.ModePV || !lp.connected() || lp.chargeGoalReached(enabled) {
return false return false
} }
if enabled || pvTimer.IsZero() { // enable timer running (not yet enabled)
return false return !enabled && pvTimerRunning
}
// 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
} }
// chargeGoalReached reports whether the loadpoint will not draw more: enabled // chargeGoalReached reports whether the loadpoint will not draw more: enabled
@ -2023,13 +2025,15 @@ func (lp *Loadpoint) publishSocAndRange() {
} }
} }
if socR != nil { if socEstimator != nil {
if socEstimator == nil { // nil soc extrapolates from charged energy while vehicle api is unavailable;
lp.vehicleSoc = *socR // don't overwrite a known soc while a freshly created estimator returns 0
} else { if soc := socEstimator.Soc(socR, lp.GetChargedEnergy()); socR != nil || soc > 0 {
lp.vehicleSoc = socEstimator.Soc(socR, lp.GetChargedEnergy()) lp.vehicleSoc = soc
lp.log.DEBUG.Printf("vehicle soc (estimator): %.0f%%", lp.vehicleSoc) lp.log.DEBUG.Printf("vehicle soc (estimator): %.0f%%", lp.vehicleSoc)
} }
} else if socR != nil {
lp.vehicleSoc = *socR
} }
lp.publish(keys.VehicleSoc, lp.vehicleSoc) lp.publish(keys.VehicleSoc, lp.vehicleSoc)

View file

@ -12,7 +12,9 @@ import (
"github.com/evcc-io/evcc/core/soc" "github.com/evcc-io/evcc/core/soc"
"github.com/evcc-io/evcc/messenger" "github.com/evcc-io/evcc/messenger"
"github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock" "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 // which is what keeps the sitePower priority adjustment applied to the loadpoint
assert.NotEqual(t, boostDisabled, lp.GetBatteryBoost(), "hold is active") 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()))
}

View file

@ -149,6 +149,9 @@ func (lp *Loadpoint) setActiveVehicle(v api.Vehicle) {
// resolve optional config // resolve optional config
if v.Capacity() > 0 && (lp.Soc.Estimate == nil || *lp.Soc.Estimate) { if v.Capacity() > 0 && (lp.Soc.Estimate == nil || *lp.Soc.Estimate) {
lp.socEstimator = soc.NewEstimator(lp.log, v) 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()) lp.publish(keys.VehicleName, vehicle.Settings(lp.log, v).Name())

View file

@ -202,6 +202,35 @@ func (c *Collector) SetEnergy(energy float64) error {
return nil 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 { func (c *Collector) SetEnergyMeterTotal(v float64) error {
return c.process(func() { return c.process(func() {
c.accu.SetEnergyMeterTotal(v) c.accu.SetEnergyMeterTotal(v)

View file

@ -600,3 +600,112 @@ func TestCollectorSetEnergy(t *testing.T) {
require.True(t, ok) require.True(t, ok)
require.Equal(t, 1.5, v) 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")
}

View file

@ -167,6 +167,24 @@ func activeMeters(refs []string) ([]config.Device[api.Meter], error) {
return res, nil 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 { func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tariff.Tariffs) error {
site.loadpoints = loadpoints site.loadpoints = loadpoints
site.tariffs = tariffs site.tariffs = tariffs
@ -233,7 +251,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
} else { } else {
site.gridMeter = dev 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 { if err != nil {
return err return err
} }
@ -253,7 +271,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
site.pvMeters = append(site.pvMeters, dev) site.pvMeters = append(site.pvMeters, dev)
// energy collector (for history persistence and forecast scaling) // 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 { if err != nil {
return err return err
} }
@ -282,7 +300,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
site.batteryMeters = mm site.batteryMeters = mm
for _, dev := range mm { for _, dev := range mm {
ref := dev.Config().Name 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 { if err != nil {
return err return err
} }
@ -297,7 +315,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
site.extMeters = mm site.extMeters = mm
for _, dev := range mm { for _, dev := range mm {
ref := dev.Config().Name 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 { if err != nil {
return err return err
} }
@ -312,7 +330,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
site.auxMeters = mm site.auxMeters = mm
for _, dev := range mm { for _, dev := range mm {
ref := dev.Config().Name 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 { if err != nil {
return err return err
} }
@ -327,7 +345,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
site.consumerMeters = mm site.consumerMeters = mm
for _, dev := range mm { for _, dev := range mm {
ref := dev.Config().Name 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 { if err != nil {
return err return err
} }
@ -1127,9 +1145,7 @@ func (site *Site) reservedPVPower(lp updater) float64 {
continue continue
} }
if other.EffectivePriority() > prio && other.PvChargeStarting() { if other.EffectivePriority() > prio && other.PvChargeStarting() {
// min power is what the loadpoint needs to start, reserving its max reserved += other.EffectiveMaxPower()
// power would starve this loadpoint beyond that (#32778)
reserved += other.EffectiveMinPower()
} }
} }

View file

@ -1237,6 +1237,15 @@ func applyPrecondition(lp loadpoint.API, demand []float32, minLen int) []float32
return demand 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 // TODO precise slot placement
end := time.Until(ts) end := time.Until(ts)
start := end - precondition start := end - precondition

View file

@ -47,6 +47,8 @@ func TestApplyPrecondition(t *testing.T) {
// plan in 1h, 40min precondition: slots 1 (10min) and 2, 3 (full) // 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().EffectivePlanTime().Return(time.Now().Add(time.Hour)).Times(1)
lp.EXPECT().EffectivePlanStrategy().Return(api.PlanStrategy{Precondition: 40 * time.Minute}).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) res := applyPrecondition(lp, nil, 8)
require.Len(t, res, 8) require.Len(t, res, 8)
assert.InDeltaSlice(t, []float32{0, 2000. / 1.5, 2000, 2000, 0, 0, 0, 0}, res, 1) 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 // existing demand is kept where higher
lp.EXPECT().EffectivePlanTime().Return(time.Now().Add(time.Hour)).Times(1) 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().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) 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) assert.InDeltaSlice(t, []float32{3000, 3000, 3000, 3000, 0, 0, 0, 0}, res, 1)
// plan beyond horizon // plan beyond horizon
lp.EXPECT().EffectivePlanTime().Return(time.Now().Add(24 * time.Hour)).Times(1) 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().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)) assert.Nil(t, applyPrecondition(lp, nil, 8))
} }

View file

@ -10,10 +10,8 @@ import (
"github.com/evcc-io/evcc/util" "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 { func newPVLoadpoint(prio int, mode api.ChargeMode, status api.ChargeStatus, enabled bool, timer time.Time) *Loadpoint {
lp := &Loadpoint{ return &Loadpoint{
log: util.NewLogger("lp"), log: util.NewLogger("lp"),
clock: clock.NewMock(), clock: clock.NewMock(),
minCurrent: minA, minCurrent: minA,
@ -25,21 +23,13 @@ func newPVLoadpoint(prio int, mode api.ChargeMode, status api.ChargeStatus, enab
pvTimer: timer, pvTimer: timer,
priority: prio, 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) { func TestPvChargeStarting(t *testing.T) {
now := clock.NewMock().Now() now := clock.NewMock().Now()
settled := pvTimerStarted(testEnableDelay / 2)
// enable timer running but car already full (soc at default 100% limit): not starting up // 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 enablePendingFull.vehicleSoc = 100
tc := []struct { tc := []struct {
@ -47,13 +37,12 @@ func TestPvChargeStarting(t *testing.T) {
lp *Loadpoint lp *Loadpoint
starting bool starting bool
}{ }{
{"enable timer running", newPVLoadpoint(0, api.ModePV, api.StatusB, false, settled), true}, {"enable timer running", newPVLoadpoint(0, api.ModePV, api.StatusB, false, now), true},
{"enable timer just restarted", newPVLoadpoint(0, api.ModePV, api.StatusB, false, now), false},
{"enabled not charging", newPVLoadpoint(0, api.ModePV, api.StatusB, true, time.Time{}), false}, {"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}, {"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}, {"disabled idle", newPVLoadpoint(0, api.ModePV, api.StatusB, false, time.Time{}), false},
{"disconnected", newPVLoadpoint(0, api.ModePV, api.StatusA, false, settled), false}, {"disconnected", newPVLoadpoint(0, api.ModePV, api.StatusA, false, now), false},
{"not pv mode", newPVLoadpoint(0, api.ModeNow, api.StatusB, false, settled), false}, {"not pv mode", newPVLoadpoint(0, api.ModeNow, api.StatusB, false, now), false},
{"enable pending but car full", enablePendingFull, false}, {"enable pending but car full", enablePendingFull, false},
} }
@ -68,7 +57,7 @@ func TestReservedPVPower(t *testing.T) {
Voltage = 230 Voltage = 230
// higher-priority loadpoint (prio 1) starting up // 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 // lower-priority loadpoint (prio 0) in PV mode
low := newPVLoadpoint(0, api.ModePV, api.StatusB, false, time.Time{}) low := newPVLoadpoint(0, api.ModePV, api.StatusB, false, time.Time{})
@ -77,20 +66,10 @@ func TestReservedPVPower(t *testing.T) {
loadpoints: []*Loadpoint{high, low}, loadpoints: []*Loadpoint{high, low},
} }
// low reserves the power high needs to start, not its max power (#32778) // low reserves high's anticipated max power while high is starting up
if got, want := site.reservedPVPower(low), high.EffectiveMinPower(); got != want { if got, want := site.reservedPVPower(low), high.EffectiveMaxPower(); got != want {
t.Errorf("low: want %.0f, got %.0f", want, got) 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 // high (top priority) reserves nothing
if got := site.reservedPVPower(high); got != 0 { if got := site.reservedPVPower(high); got != 0 {

View file

@ -104,14 +104,7 @@ type vehicles struct {
} }
func (vv *vehicles) Instances() []api.Vehicle { func (vv *vehicles) Instances() []api.Vehicle {
devs := config.Vehicles().Devices() return config.Instances(config.Vehicles().Devices())
res := make([]api.Vehicle, 0, len(devs))
for _, dev := range devs {
res = append(res, dev.Instance())
}
return res
} }
func (vv *vehicles) Settings() []vehicle.API { func (vv *vehicles) Settings() []vehicle.API {
@ -119,6 +112,10 @@ func (vv *vehicles) Settings() []vehicle.API {
res := make([]vehicle.API, 0, len(devs)) res := make([]vehicle.API, 0, len(devs))
for _, dev := range devs { for _, dev := range devs {
// skip disabled vehicles
if dev.Instance() == nil {
continue
}
res = append(res, vehicle.Adapter(vv.log, dev)) res = append(res, vehicle.Adapter(vv.log, dev))
} }

View file

@ -30,6 +30,7 @@ type Estimator struct {
initialEnergy float64 // energy counter at first valid soc in Wh initialEnergy float64 // energy counter at first valid soc in Wh
prevSoc float64 // vehicle soc at last soc change in % prevSoc float64 // vehicle soc at last soc change in %
prevChargedEnergy float64 // charged energy at last soc change in Wh prevChargedEnergy float64 // charged energy at last soc change in Wh
sampled bool // a valid vehicle soc was received
} }
// NewEstimator creates new estimator // 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 // Soc replaces the api.Vehicle.Soc interface to take charged energy into account
func (s *Estimator) Soc(fetchedSoc *float64, chargedEnergy float64) float64 { func (s *Estimator) Soc(fetchedSoc *float64, chargedEnergy float64) float64 {
if fetchedSoc == nil { 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 return s.vehicleSoc
} }
@ -100,13 +106,15 @@ func (s *Estimator) Soc(fetchedSoc *float64, chargedEnergy float64) float64 {
socDelta := *fetchedSoc - s.prevSoc socDelta := *fetchedSoc - s.prevSoc
energyDelta := chargedEnergy - s.prevChargedEnergy energyDelta := chargedEnergy - s.prevChargedEnergy
// no soc change and no energy reset: interpolate soc from charged energy // no soc change and no energy reset: interpolate soc from charged energy.
if socDelta == 0 && energyDelta >= 0 { // 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.vehicleSoc = min(*fetchedSoc+energyDelta/s.energyPerSocStep, 100)
s.log.DEBUG.Printf("soc estimated: %.2f%% (vehicle: %.2f%%)", s.vehicleSoc, *fetchedSoc) s.log.DEBUG.Printf("soc estimated: %.2f%% (vehicle: %.2f%%)", s.vehicleSoc, *fetchedSoc)
return s.vehicleSoc return s.vehicleSoc
} }
s.sampled = true
s.vehicleSoc = *fetchedSoc s.vehicleSoc = *fetchedSoc
if s.initialSoc == 0 { if s.initialSoc == 0 {

View file

@ -88,13 +88,48 @@ func TestMissingSoc(t *testing.T) {
ce := NewEstimator(util.NewLogger("foo"), vehicle) 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 soc := 20.0
assert.Equal(t, 20.0, ce.Soc(&soc, 0)) assert.Equal(t, 20.0, ce.Soc(&soc, 0))
assert.Equal(t, 21.0, ce.Soc(&soc, 100)) assert.Equal(t, 21.0, ce.Soc(&soc, 100))
// missing soc keeps the estimate and must not corrupt the sampled state // missing soc extrapolates from charged energy and must not corrupt the sampled state
assert.Equal(t, 21.0, ce.Soc(nil, 200)) assert.Equal(t, 22.0, ce.Soc(nil, 200))
assert.Equal(t, 22.0, ce.Soc(&soc, 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) { func TestImprovedEstimatorRemainingChargeDuration(t *testing.T) {

2
go.mod
View file

@ -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/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/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

4
go.sum
View file

@ -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/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 h1:m/NTP3JWpR7M0ljLxiQU4fzR25jjhe1LDtxLMNcoNJQ=
github.com/andig/mbserver v0.0.0-20230310211055-1d29cbb5820e/go.mod h1:4VtYzTm//oUipwvO3yh0g/udTE7pYJM+U/kyAuFDsgM= 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 h1:FVKqjwJJLo8vV47jXt1lNN4EVJXCyV3LSTNFt9eeimU=
github.com/andig/spine-go v0.7.1-0.20260804073026-f9a8d8e8fd18/go.mod h1:ddWZU5BQGyzRGF20Z7rUKFFzbCp+cfu9mZgYtOMW/fM= 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= 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/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 h1:foChWb8lhzqa6lWDRs6COYMdp649YlUirFP8GqoT0JQ=
github.com/enbility/go-avahi v0.0.0-20240909195612-d5de6b280d7a/go.mod h1:H64mhYcAQUGUUnVqMdZQf93kPecH4M79xwH95Lddt3U= 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 h1:XOYvxKtT1oxT37w/5oEiRLuPbm9FuJPt3fiYhX0h8Po=
github.com/enbility/zeroconf/v2 v2.0.0-20240920094356-be1cae74fda6/go.mod h1:BszP9qFV14mPXgyIREbgIdQtWxbAj3OKqvK02HihMoM= 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= github.com/evcc-io/modbus v0.0.0-20250501165638-8b6f1fbdb7ea h1:F6eyC8V8wvc3ranlsR4coAls+OPBkVvxyX0a2VqdlPc=

View file

@ -149,9 +149,11 @@ func (c *gen1) IsReversed() bool {
return false 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 { 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. // IsThreePhase reports whether the device is a three-phase energy meter.

View file

@ -3,7 +3,9 @@ package shelly
import ( import (
"encoding/json" "encoding/json"
"testing" "testing"
"time"
"github.com/evcc-io/evcc/util"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -46,3 +48,21 @@ func TestUnmarshalGen1Status(t *testing.T) {
assert.Equal(t, -620.34, res.EMeters[0].Power) 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())
}

View file

@ -191,7 +191,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API) {
"smartcostdelete": {"DELETE", "/smartcostlimit", updateSmartCostLimit(site, smartCostLimit)}, "smartcostdelete": {"DELETE", "/smartcostlimit", updateSmartCostLimit(site, smartCostLimit)},
"smartfeedin": {"POST", "/smartfeedinprioritylimit/{value:-?[0-9.]+}", updateSmartCostLimit(site, smartFeedInPriorityLimit)}, "smartfeedin": {"POST", "/smartfeedinprioritylimit/{value:-?[0-9.]+}", updateSmartCostLimit(site, smartFeedInPriorityLimit)},
"smartfeedindelete": {"DELETE", "/smartfeedinprioritylimit", 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}, "sessions": {"GET", "/sessions", sessionHandler},
"updatesession": {"PUT", "/session/{id:[0-9]+}", updateSessionHandler}, "updatesession": {"PUT", "/session/{id:[0-9]+}", updateSessionHandler},
"deletesession": {"DELETE", "/session/{id:[0-9]+}", deleteSessionHandler}, "deletesession": {"DELETE", "/session/{id:[0-9]+}", deleteSessionHandler},

View file

@ -13,6 +13,7 @@ import (
"github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config" "github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/util/templates" "github.com/evcc-io/evcc/util/templates"
"github.com/go-viper/mapstructure/v2"
"github.com/gorilla/mux" "github.com/gorilla/mux"
) )
@ -119,8 +120,9 @@ func loadpointConfig(dev config.Device[loadpoint.API]) (loadpointFullConfig, err
return loadpointFullConfig{}, err return loadpointFullConfig{}, err
} }
// non-strict: runtime settings (mode, ...) are persisted alongside the config
var static loadpoint.StaticConfig var static loadpoint.StaticConfig
if err := util.DecodeOther(staticMap, &static); err != nil { if err := mapstructure.Decode(staticMap, &static); err != nil {
return loadpointFullConfig{}, err return loadpointFullConfig{}, err
} }

View file

@ -25,6 +25,7 @@ func TestLoadpointConfigDisabledNilInstance(t *testing.T) {
"charger": "wallbox", "charger": "wallbox",
"meter": "lp-meter", "meter": "lp-meter",
"title": "Garage", "title": "Garage",
"mode": "pv", // runtime setting persisted by the settings adapter
}, },
} }

View file

@ -18,8 +18,8 @@ type Forecast struct {
Gsi float64 `json:"gsi"` Gsi float64 `json:"gsi"`
TimeStamp int64 `json:"timeStamp"` TimeStamp int64 `json:"timeStamp"`
Energyprice string `json:"energyprice"` Energyprice string `json:"energyprice"`
Co2GStandard int `json:"co2_g_standard"` Co2GStandard *int `json:"co2_g_standard"`
Co2GOekostrom int `json:"co2_g_oekostrom"` Co2GOekostrom *int `json:"co2_g_oekostrom"`
Timeframe struct { Timeframe struct {
Start int64 `json:"start"` Start int64 `json:"start"`
End int64 `json:"end"` End int64 `json:"end"`

View file

@ -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 err != nil {
if reportError(&once, done, err) { if reportError(&once, done, err) {
return return
@ -83,20 +90,33 @@ func (t *GrünStromIndex) run(done chan error) {
continue 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) mergeRates(t.data, data)
once.Do(func() { close(done) }) 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 // Rates implements the api.Tariff interface
func (t *GrünStromIndex) Rates() (api.Rates, error) { func (t *GrünStromIndex) Rates() (api.Rates, error) {
var res api.Rates var res api.Rates

View file

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

View file

@ -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. 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. With this token pair and your Client ID created in the Tesla Developer Account, evcc can directly communicate with the Tesla Fleet API.
params: params:
- name: usage
choice: ["battery"]
- name: host - name: host
- name: password - name: password
required: true required: true

View file

@ -59,6 +59,21 @@ test.describe("disable / enable", async () => {
await page.reload(); await page.reload();
await createLoadpoint(page, "Garage"); 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); const target = page.getByTestId("loadpoint").nth(0);
await toggleLoadpointDisable(page, 0, "Disable"); await toggleLoadpointDisable(page, 0, "Disable");
@ -66,10 +81,11 @@ test.describe("disable / enable", async () => {
// card shows disabled state // card shows disabled state
await expect(disabledBadge(target)).toBeVisible(); await expect(disabledBadge(target)).toBeVisible();
// restart, no fatal // restart, no fatal, both loadpoints still listed
await restart(); await restart();
await page.reload(); await page.reload();
await expectNoFatal(page); await expectNoFatal(page);
await expect(page.getByTestId("loadpoint")).toHaveCount(2);
await expect(disabledBadge(target)).toBeVisible(); await expect(disabledBadge(target)).toBeVisible();
// re-enable by clicking the disabled card // re-enable by clicking the disabled card
@ -223,6 +239,10 @@ test.describe("disable / enable", async () => {
await start(); await start();
await page.goto("/#/config"); await page.goto("/#/config");
// loadpoint so the vehicle shows up on the dashboard
await createLoadpoint(page, "Carport");
await page.reload();
// add user-defined vehicle // add user-defined vehicle
await page.getByTestId("add-vehicle").click(); await page.getByTestId("add-vehicle").click();
const modal = page.getByTestId("vehicle-modal"); const modal = page.getByTestId("vehicle-modal");
@ -261,6 +281,23 @@ soc:
await expectNoFatal(page); await expectNoFatal(page);
await expect(disabledBadge(vehicleCard)).toBeVisible(); 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 // re-enable by clicking the disabled card
await disabledBadge(vehicleCard).click(); await disabledBadge(vehicleCard).click();
await expect(disabledBadge(vehicleCard)).toHaveCount(0); await expect(disabledBadge(vehicleCard)).toHaveCount(0);
@ -270,6 +307,19 @@ soc:
await page.reload(); await page.reload();
await expectNoFatal(page); await expectNoFatal(page);
await expect(vehicleCard).toContainText("blue Honda"); 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();
}); });
}); });