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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"`

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

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.
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

View file

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