diff --git a/.github/workflows/command-backport.yml b/.github/workflows/command-backport.yml index 3c6d8139f..138a8836a 100644 --- a/.github/workflows/command-backport.yml +++ b/.github/workflows/command-backport.yml @@ -157,18 +157,15 @@ jobs: TITLE: ${{ steps.pr.outputs.title }} ACTOR: ${{ github.event.comment.user.login }} run: | - # the release line the branch was cut from, 0.313 for release/0.313.1 - line=$(echo "${TARGET#release/}" | cut -d. -f1,2) - # the pull request is authored by the deploy token, so assign the # requester. the body stays empty, squashing it would carry the text # into the release branch as the commit description. - # the marker trails the title so the changelog filters and groups still - # see the original prefix, goreleaser strips it from the release notes + # the title stays untouched so the changelog filters and groups still + # see the original prefix, the backport label identifies the pull request url=$(gh pr create \ --base "$TARGET" \ --head "$BRANCH" \ - --title "$TITLE [$line backport]" \ + --title "$TITLE" \ --label backport \ --assignee "$ACTOR" \ --body "") diff --git a/.goreleaser.yml b/.goreleaser.yml index 56c7630cf..b21d3bff2 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -71,8 +71,6 @@ snapshot: changelog: sort: asc - # drop the marker the backport command appends to the pull request title - format: '{{ .SHA }} {{ reReplaceAll ` \[\d+\.\d+ backport\]` .Message "" }}' filters: exclude: - "^chore" diff --git a/charger/daheimladen.go b/charger/daheimladen.go index f48e92146..032f2fa67 100644 --- a/charger/daheimladen.go +++ b/charger/daheimladen.go @@ -361,27 +361,37 @@ func (wb *DaheimLaden) getPhases() (int, error) { } func (wb *DaheimLaden) checkStation() error { - b, err := wb.conn.ReadHoldingRegisters(dlRegEvseMaxCurrent, 22) - if err != nil { - return api.ErrSponsorRequired - } - // station id starts (dlRegStationId-dlRegEvseMaxCurrent) registers into the block - s, err := utf16BEBytesAsString(b[2*(dlRegStationId-dlRegEvseMaxCurrent):]) - if err != nil || s == "" { - return api.ErrSponsorRequired - } + // map may still be zero right after the wake-up call- poll briefly until populated + for range 5 { + b, err := wb.conn.ReadHoldingRegisters(dlRegEvseMaxCurrent, 22) + if err != nil { + return err + } - for _, r := range s { - if r < 0x20 || r > 0x7e { + s, err := utf16BEBytesAsString(b[2*(dlRegStationId-dlRegEvseMaxCurrent):]) + if err != nil { + return err + } + + if len(s) == 0 { + time.Sleep(200 * time.Millisecond) + continue + } + + if strings.Contains(strings.ToLower(s), "heidelbridge") { return api.ErrSponsorRequired } + + for _, r := range s { + if r < 0x20 || r > 0x7e { + return api.ErrSponsorRequired + } + } + + return nil } - if strings.Contains(strings.ToLower(s), "heidelbridge") { - return api.ErrSponsorRequired - } - - return nil + return api.ErrSponsorRequired } var _ api.Diagnosis = (*DaheimLaden)(nil) diff --git a/charger/ocpp/const.go b/charger/ocpp/const.go index ae20cf02d..893260f13 100644 --- a/charger/ocpp/const.go +++ b/charger/ocpp/const.go @@ -4,6 +4,14 @@ import "time" var Timeout = time.Minute // default request / response timeout on protocol level +const ( + heartbeatInterval = time.Minute // heartbeat interval requested in BootNotification + + // pingWait must exceed heartbeatInterval, otherwise chargers not sending + // websocket pings are disconnected while idle + pingWait = 3 * heartbeatInterval +) + // TriggerBootDelay defines how long to wait after WebSocket connect before // proactively triggering a BootNotification. This allows the connection to // stabilize and gives the charger a chance to send a spontaneous BootNotification. diff --git a/charger/ocpp/cp_core.go b/charger/ocpp/cp_core.go index d7f544100..9035b5632 100644 --- a/charger/ocpp/cp_core.go +++ b/charger/ocpp/cp_core.go @@ -16,7 +16,7 @@ var ( func (cp *CP) OnBootNotification(request *core.BootNotificationRequest) (*core.BootNotificationConfirmation, error) { res := &core.BootNotificationConfirmation{ CurrentTime: types.Now(), - Interval: 60, + Interval: int(heartbeatInterval.Seconds()), Status: core.RegistrationStatusAccepted, } diff --git a/charger/ocpp/cp_core_test.go b/charger/ocpp/cp_core_test.go index f6125690f..cfdc7eec0 100644 --- a/charger/ocpp/cp_core_test.go +++ b/charger/ocpp/cp_core_test.go @@ -28,6 +28,9 @@ func TestBootNotificationStoresResultAndConnects(t *testing.T) { require.NoError(t, err) assert.Equal(t, core.RegistrationStatusAccepted, res.Status) + // heartbeat must stay below the websocket inactivity timeout + assert.Less(t, time.Duration(res.Interval)*time.Second, pingWait) + // should be connected after BootNotification assert.True(t, cp.Connected(), "should be connected after BootNotification") assert.Equal(t, bootReq, cp.BootNotificationResult, "should store boot result") diff --git a/charger/ocpp/instance.go b/charger/ocpp/instance.go index 5d5351dca..2c5176d70 100644 --- a/charger/ocpp/instance.go +++ b/charger/ocpp/instance.go @@ -140,6 +140,10 @@ func NewServer(cfg Config, networkExternalUrl string) { server := &interceptingServer{Server: ws.NewServer()} server.SetCheckOriginHandler(func(r *http.Request) bool { return true }) + timeouts := ws.NewServerTimeoutConfig() + timeouts.PingWait = pingWait + server.SetTimeoutConfig(timeouts) + dispatcher := ocppj.NewDefaultServerDispatcher(ocppj.NewFIFOQueueMap(0)) endpoint := ocppj.NewServer(server, dispatcher, nil, core.Profile, remotetrigger.Profile, smartcharging.Profile, security.Profile, firmware.Profile) diff --git a/cmd/setup.go b/cmd/setup.go index 6a77f1c24..4cc932170 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -538,9 +538,14 @@ func configureVehicles(static []config.Named, names ...string) error { } } - instance, err := vehicleInstance(cc) - if err != nil { - return fmt.Errorf("cannot create vehicle '%s': %w", cc.Name, err) + // disabled vehicles are not instantiated + var instance api.Vehicle + if !conf.Disable { + var err error + instance, err = vehicleInstance(cc) + if err != nil { + return fmt.Errorf("cannot create vehicle '%s': %w", cc.Name, err) + } } mu.Lock() diff --git a/core/loadpoint.go b/core/loadpoint.go index 154686ac0..0cc621d76 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -253,7 +253,8 @@ func NewLoadpointFromConfig(log *util.Logger, settings settings.Settings, collec } lp.defaultVehicle = dev.Instance() if lp.defaultVehicle == nil { - return lp, errors.New("missing default vehicle instance") + // disabled vehicle + lp.log.DEBUG.Printf("default vehicle '%s' is disabled", lp.VehicleRef) } } @@ -272,8 +273,14 @@ func NewLoadpointFromConfig(log *util.Logger, settings settings.Settings, collec lp.configureChargerType(lp.charger) // add collector - if lp.chargeMeter != nil { + if lp.chargeMeter != nil && collector != nil { lp.chargeEnergy = collector + + // drop stale readings when the meter no longer reports totals + energy, returnEnergy := api.HasCap[api.MeterEnergy](lp.chargeMeter), api.HasCap[api.MeterReturnEnergy](lp.chargeMeter) + if err := collector.SetCapabilities(energy, returnEnergy); err != nil { + return lp, err + } } // set title after collector is wired to refresh the metrics entity @@ -1061,20 +1068,15 @@ func (lp *Loadpoint) charging() bool { func (lp *Loadpoint) PvChargeStarting() bool { lp.RLock() enabled := lp.enabled - pvTimer := lp.pvTimer + pvTimerRunning := !lp.pvTimer.IsZero() lp.RUnlock() if lp.GetMode() != api.ModePV || !lp.connected() || lp.chargeGoalReached(enabled) { return false } - if enabled || pvTimer.IsZero() { - return false - } - - // a timer restarting on every surplus dip never starts the loadpoint, hence - // only claim surplus once it has survived half of the enable delay (#32778) - return lp.clock.Since(pvTimer) >= lp.GetEnableDelay()/2 + // enable timer running (not yet enabled) + return !enabled && pvTimerRunning } // chargeGoalReached reports whether the loadpoint will not draw more: enabled diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go index 59f55c5c9..d427b0f1d 100644 --- a/core/loadpoint_test.go +++ b/core/loadpoint_test.go @@ -12,7 +12,9 @@ import ( "github.com/evcc-io/evcc/core/soc" "github.com/evcc-io/evcc/messenger" "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/config" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" ) @@ -882,3 +884,26 @@ func TestBatteryBoostHold(t *testing.T) { // which is what keeps the sitePower priority adjustment applied to the loadpoint assert.NotEqual(t, boostDisabled, lp.GetBatteryBoost(), "hold is active") } + +// default vehicle referencing a disabled vehicle must not fail loadpoint creation +func TestNewLoadpointFromConfigDisabledVehicle(t *testing.T) { + config.Reset() + t.Cleanup(config.Reset) + + ctrl := gomock.NewController(t) + + // disabled vehicle: device registered without instance + var vehicle api.Vehicle + require.NoError(t, config.Vehicles().Add(config.NewStaticDevice(config.Named{Name: "vehicle"}, vehicle))) + require.NoError(t, config.Chargers().Add(config.NewStaticDevice(config.Named{Name: "charger"}, api.Charger(api.NewMockCharger(ctrl))))) + + lp, err := NewLoadpointFromConfig(util.NewLogger("foo"), nil, nil, map[string]any{ + "charger": "charger", + "vehicle": "vehicle", + }) + require.NoError(t, err) + require.Nil(t, lp.defaultVehicle) + + // disabled vehicle is filtered from instances + require.Empty(t, config.Instances(config.Vehicles().Devices())) +} diff --git a/core/metrics/collector.go b/core/metrics/collector.go index 50deccd29..253f36311 100644 --- a/core/metrics/collector.go +++ b/core/metrics/collector.go @@ -202,6 +202,35 @@ func (c *Collector) SetEnergy(energy float64) error { return nil } +// SetCapabilities drops the persisted reading for a direction the device no longer +// reports, so its energy falls back to power integration instead of freezing. +func (c *Collector) SetCapabilities(energy, returnEnergy bool) error { + cols := make(map[string]any, 2) + + // keyed on the entity, since an incomplete state is left unrestored and would + // otherwise resurface once the other direction is checkpointed again + if !energy && c.entity.EnergyMeter != nil { + c.accu.energyMeter = nil + c.entity.EnergyMeter = nil + cols["energy_meter"] = nil + } + if !returnEnergy && c.entity.ReturnEnergyMeter != nil { + c.accu.returnEnergyMeter = nil + c.entity.ReturnEnergyMeter = nil + cols["return_energy_meter"] = nil + } + + if len(cols) == 0 { + return nil + } + + // a surviving reading still covers the downtime for its own direction, so + // keep the restore rather than discarding that delta with the cleared one + c.restored = c.accu.energyMeter != nil || c.accu.returnEnergyMeter != nil + + return db.Instance.Model(&c.entity).UpdateColumns(cols).Error +} + func (c *Collector) SetEnergyMeterTotal(v float64) error { return c.process(func() { c.accu.SetEnergyMeterTotal(v) diff --git a/core/metrics/collector_test.go b/core/metrics/collector_test.go index 84b0e1149..01cbf1ecb 100644 --- a/core/metrics/collector_test.go +++ b/core/metrics/collector_test.go @@ -600,3 +600,112 @@ func TestCollectorSetEnergy(t *testing.T) { require.True(t, ok) require.Equal(t, 1.5, v) } + +// TestCollectorFallsBackToPowerAfterCapabilityLoss verifies that a meter which +// lost its energy register stops using the stale checkpoint and integrates power +// instead (https://github.com/evcc-io/evcc/issues/33091). +func TestCollectorFallsBackToPowerAfterCapabilityLoss(t *testing.T) { + clk := clock.NewMock() // 1970-01-01 00:00:00 UTC, on a slot boundary + + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + require.NoError(t, SetupSchema()) + + col, err := NewCollector(PV, "capability", "", WithClock(clk)) + require.NoError(t, err) + + // meter reports totals, checkpoint is persisted at the slot boundary + require.NoError(t, col.AddEnergy(new(54716.0), nil, 1e3)) + clk.Add(15 * time.Minute) // 00:15 + require.NoError(t, col.AddEnergy(new(54716.0), nil, 1e3)) + + var e entity + require.NoError(t, db.Instance.First(&e, col.entity.Id).Error) + require.Equal(t, 54716.0, *e.EnergyMeter) + + // restart without the energy register: stale reading would freeze energy + col2, err := NewCollector(PV, "capability", "", WithClock(clk)) + require.NoError(t, err) + require.True(t, col2.restored) + + require.NoError(t, col2.SetCapabilities(false, false)) + require.Nil(t, col2.accu.energyMeter) + require.False(t, col2.restored, "no readings left to seed a restore") + + require.NoError(t, db.Instance.First(&e, col2.entity.Id).Error) + require.Nil(t, e.EnergyMeter, "stale checkpoint must be cleared") + + // power is integrated again + require.NoError(t, col2.AddEnergy(nil, nil, 1e3)) + clk.Add(5 * time.Minute) + require.NoError(t, col2.AddEnergy(nil, nil, 1e3)) + require.InDelta(t, 1e3*5/60/1e3, col2.accu.Energy, 1e-10) +} + +// TestCollectorClearsUnrestoredCheckpoint verifies that a bidirectional group +// also drops a checkpoint that was too incomplete to be restored, so it cannot +// resurface once the other direction is checkpointed again. +func TestCollectorClearsUnrestoredCheckpoint(t *testing.T) { + clk := clock.NewMock() // 1970-01-01 00:00:00 UTC, on a slot boundary + + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + require.NoError(t, SetupSchema()) + + col, err := NewCollector(Grid, "incomplete", "", WithClock(clk)) + require.NoError(t, err) + + // only the energy direction is metered, so the state stays incomplete + require.NoError(t, col.AddEnergy(new(100.0), nil, 0)) + clk.Add(15 * time.Minute) // 00:15 + require.NoError(t, col.AddEnergy(new(100.5), nil, 0)) + + col2, err := NewCollector(Grid, "incomplete", "", WithClock(clk)) + require.NoError(t, err) + require.False(t, col2.restored, "incomplete state must not restore") + require.Nil(t, col2.accu.energyMeter) + + require.NoError(t, col2.SetCapabilities(false, false)) + + var e entity + require.NoError(t, db.Instance.First(&e, col2.entity.Id).Error) + require.Nil(t, e.EnergyMeter, "unrestored checkpoint must be cleared too") +} + +// TestCollectorKeepsRestoreForSurvivingDirection verifies that clearing one +// direction of a bidirectional group does not discard the downtime delta the +// other direction still covers. +func TestCollectorKeepsRestoreForSurvivingDirection(t *testing.T) { + clk := clock.NewMock() // 1970-01-01 00:00:00 UTC, on a slot boundary + + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + require.NoError(t, SetupSchema()) + + col, err := NewCollector(Grid, "surviving", "", WithClock(clk)) + require.NoError(t, err) + + // checkpoint both directions + require.NoError(t, col.AddEnergy(new(100.0), new(200.0), 0)) + clk.Add(15 * time.Minute) // 00:15 + require.NoError(t, col.AddEnergy(new(100.5), new(200.2), 0)) + clk.Add(15 * time.Minute) // 00:30 + require.NoError(t, col.AddEnergy(new(101.0), new(200.4), 0)) + + // restart after 1h downtime, joining slot 01:30 mid-way, without export + clk.Add(65 * time.Minute) // 01:35 + col2, err := NewCollector(Grid, "surviving", "", WithClock(clk)) + require.NoError(t, err) + require.NoError(t, col2.SetCapabilities(true, false)) + require.True(t, col2.restored, "the surviving import reading still seeds a restore") + require.Nil(t, col2.accu.returnEnergyMeter) + + // the import delta across the downtime is kept + require.NoError(t, col2.AddEnergy(new(111.0), nil, 0)) + require.InDelta(t, 10.0, col2.accu.Energy, 1e-10) + + clk.Add(10 * time.Minute) // 01:45 + require.NoError(t, col2.AddEnergy(new(111.0), nil, 0)) + + var m meter + require.NoError(t, db.Instance.Where("meter = ? AND ts = ?", col2.entity.Id, 90*60).First(&m).Error) + require.InDelta(t, 10.0, m.Energy, 1e-10) + require.True(t, m.Recovered, "catchup slot must be flagged recovered") +} diff --git a/core/site.go b/core/site.go index 6f0191616..3cc641417 100644 --- a/core/site.go +++ b/core/site.go @@ -167,6 +167,24 @@ func activeMeters(refs []string) ([]config.Device[api.Meter], error) { return res, nil } +// newMeterCollector creates a meter collector and reconciles the persisted meter +// readings with the device's capabilities, so a device that lost its energy +// registers falls back to power integration instead of freezing. +func newMeterCollector(group, ref, title string, meter api.Meter) (*metrics.Collector, error) { + energy, returnEnergy := api.HasCap[api.MeterEnergy](meter), api.HasCap[api.MeterReturnEnergy](meter) + if group == metrics.Battery { + // batteries map discharge to energy, see updateBatteryMeters + energy, returnEnergy = returnEnergy, energy + } + + c, err := metrics.NewCollector(group, ref, title) + if err != nil { + return nil, err + } + + return c, c.SetCapabilities(energy, returnEnergy) +} + func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tariff.Tariffs) error { site.loadpoints = loadpoints site.tariffs = tariffs @@ -233,7 +251,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif } else { site.gridMeter = dev - me, err := metrics.NewCollector(metrics.Grid, site.Meters.GridMeterRef, metrics.Grid) + me, err := newMeterCollector(metrics.Grid, site.Meters.GridMeterRef, metrics.Grid, dev.Instance()) if err != nil { return err } @@ -253,7 +271,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif site.pvMeters = append(site.pvMeters, dev) // energy collector (for history persistence and forecast scaling) - me, err := metrics.NewCollector(metrics.PV, ref, deviceTitleOrName(dev)) + me, err := newMeterCollector(metrics.PV, ref, deviceTitleOrName(dev), dev.Instance()) if err != nil { return err } @@ -282,7 +300,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif site.batteryMeters = mm for _, dev := range mm { ref := dev.Config().Name - me, err := metrics.NewCollector(metrics.Battery, ref, deviceTitleOrName(dev)) + me, err := newMeterCollector(metrics.Battery, ref, deviceTitleOrName(dev), dev.Instance()) if err != nil { return err } @@ -297,7 +315,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif site.extMeters = mm for _, dev := range mm { ref := dev.Config().Name - me, err := metrics.NewCollector(metrics.Meter, ref, deviceTitleOrName(dev)) + me, err := newMeterCollector(metrics.Meter, ref, deviceTitleOrName(dev), dev.Instance()) if err != nil { return err } @@ -312,7 +330,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif site.auxMeters = mm for _, dev := range mm { ref := dev.Config().Name - me, err := metrics.NewCollector(metrics.Consumer, ref, deviceTitleOrName(dev)) + me, err := newMeterCollector(metrics.Consumer, ref, deviceTitleOrName(dev), dev.Instance()) if err != nil { return err } @@ -327,7 +345,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif site.consumerMeters = mm for _, dev := range mm { ref := dev.Config().Name - me, err := metrics.NewCollector(metrics.Consumer, ref, deviceTitleOrName(dev)) + me, err := newMeterCollector(metrics.Consumer, ref, deviceTitleOrName(dev), dev.Instance()) if err != nil { return err } @@ -1127,9 +1145,7 @@ func (site *Site) reservedPVPower(lp updater) float64 { continue } if other.EffectivePriority() > prio && other.PvChargeStarting() { - // min power is what the loadpoint needs to start, reserving its max - // power would starve this loadpoint beyond that (#32778) - reserved += other.EffectiveMinPower() + reserved += other.EffectiveMaxPower() } } diff --git a/core/site_prioritize_test.go b/core/site_prioritize_test.go index b6d4bcb20..aeaae88b8 100644 --- a/core/site_prioritize_test.go +++ b/core/site_prioritize_test.go @@ -10,10 +10,8 @@ import ( "github.com/evcc-io/evcc/util" ) -const testEnableDelay = time.Minute - func newPVLoadpoint(prio int, mode api.ChargeMode, status api.ChargeStatus, enabled bool, timer time.Time) *Loadpoint { - lp := &Loadpoint{ + return &Loadpoint{ log: util.NewLogger("lp"), clock: clock.NewMock(), minCurrent: minA, @@ -25,21 +23,13 @@ func newPVLoadpoint(prio int, mode api.ChargeMode, status api.ChargeStatus, enab pvTimer: timer, priority: prio, } - lp.Enable.Delay = testEnableDelay - return lp -} - -// pvTimerStarted returns a timer start that has been running for the given duration -func pvTimerStarted(running time.Duration) time.Time { - return clock.NewMock().Now().Add(-running) } func TestPvChargeStarting(t *testing.T) { now := clock.NewMock().Now() - settled := pvTimerStarted(testEnableDelay / 2) // enable timer running but car already full (soc at default 100% limit): not starting up - enablePendingFull := newPVLoadpoint(0, api.ModePV, api.StatusB, false, settled) + enablePendingFull := newPVLoadpoint(0, api.ModePV, api.StatusB, false, now) enablePendingFull.vehicleSoc = 100 tc := []struct { @@ -47,13 +37,12 @@ func TestPvChargeStarting(t *testing.T) { lp *Loadpoint starting bool }{ - {"enable timer running", newPVLoadpoint(0, api.ModePV, api.StatusB, false, settled), true}, - {"enable timer just restarted", newPVLoadpoint(0, api.ModePV, api.StatusB, false, now), false}, + {"enable timer running", newPVLoadpoint(0, api.ModePV, api.StatusB, false, now), true}, {"enabled not charging", newPVLoadpoint(0, api.ModePV, api.StatusB, true, time.Time{}), false}, {"enabled and charging", newPVLoadpoint(0, api.ModePV, api.StatusC, true, time.Time{}), false}, {"disabled idle", newPVLoadpoint(0, api.ModePV, api.StatusB, false, time.Time{}), false}, - {"disconnected", newPVLoadpoint(0, api.ModePV, api.StatusA, false, settled), false}, - {"not pv mode", newPVLoadpoint(0, api.ModeNow, api.StatusB, false, settled), false}, + {"disconnected", newPVLoadpoint(0, api.ModePV, api.StatusA, false, now), false}, + {"not pv mode", newPVLoadpoint(0, api.ModeNow, api.StatusB, false, now), false}, {"enable pending but car full", enablePendingFull, false}, } @@ -68,7 +57,7 @@ func TestReservedPVPower(t *testing.T) { Voltage = 230 // higher-priority loadpoint (prio 1) starting up - high := newPVLoadpoint(1, api.ModePV, api.StatusB, false, pvTimerStarted(testEnableDelay/2)) + high := newPVLoadpoint(1, api.ModePV, api.StatusB, false, clock.NewMock().Now()) // lower-priority loadpoint (prio 0) in PV mode low := newPVLoadpoint(0, api.ModePV, api.StatusB, false, time.Time{}) @@ -77,20 +66,10 @@ func TestReservedPVPower(t *testing.T) { loadpoints: []*Loadpoint{high, low}, } - // low reserves the power high needs to start, not its max power (#32778) - if got, want := site.reservedPVPower(low), high.EffectiveMinPower(); got != want { + // low reserves high's anticipated max power while high is starting up + if got, want := site.reservedPVPower(low), high.EffectiveMaxPower(); got != want { t.Errorf("low: want %.0f, got %.0f", want, got) } - if high.EffectiveMinPower() == high.EffectiveMaxPower() { - t.Fatal("test requires min and max power to differ") - } - - // a timer restarting on every surplus dip must not reserve at all (#32778) - high.pvTimer = clock.NewMock().Now() - if got := site.reservedPVPower(low); got != 0 { - t.Errorf("low while high timer restarts: want 0, got %.0f", got) - } - high.pvTimer = pvTimerStarted(testEnableDelay / 2) // high (top priority) reserves nothing if got := site.reservedPVPower(high); got != 0 { diff --git a/core/site_vehicles.go b/core/site_vehicles.go index aa457808c..cedccb264 100644 --- a/core/site_vehicles.go +++ b/core/site_vehicles.go @@ -104,14 +104,7 @@ type vehicles struct { } func (vv *vehicles) Instances() []api.Vehicle { - devs := config.Vehicles().Devices() - - res := make([]api.Vehicle, 0, len(devs)) - for _, dev := range devs { - res = append(res, dev.Instance()) - } - - return res + return config.Instances(config.Vehicles().Devices()) } func (vv *vehicles) Settings() []vehicle.API { @@ -119,6 +112,10 @@ func (vv *vehicles) Settings() []vehicle.API { res := make([]vehicle.API, 0, len(devs)) for _, dev := range devs { + // skip disabled vehicles + if dev.Instance() == nil { + continue + } res = append(res, vehicle.Adapter(vv.log, dev)) } diff --git a/go.mod b/go.mod index bf53915a1..044665c48 100644 --- a/go.mod +++ b/go.mod @@ -269,3 +269,5 @@ replace github.com/lorenzodonini/ocpp-go => github.com/evcc-io/ocpp-go v0.0.0-20 replace github.com/enbility/spine-go => github.com/andig/spine-go v0.7.1-0.20260804073026-f9a8d8e8fd18 replace github.com/enbility/eebus-go => github.com/andig/eebus-go v0.0.0-20260725155950-e735091ff165 + +replace github.com/enbility/ship-go => github.com/andig/ship-go v0.6.1-0.20260818072438-bade5ec9fbd7 diff --git a/go.sum b/go.sum index 642b08740..8ee8c4fc8 100644 --- a/go.sum +++ b/go.sum @@ -30,6 +30,8 @@ github.com/andig/gosunspec v0.0.0-20260705113727-6d585e133512 h1:1y8dS4GaBB9WUu/ github.com/andig/gosunspec v0.0.0-20260705113727-6d585e133512/go.mod h1:c6P6szcR+ROkqZruOR4f6qbDKFjZX6OitPpj+yJ/r8k= github.com/andig/mbserver v0.0.0-20230310211055-1d29cbb5820e h1:m/NTP3JWpR7M0ljLxiQU4fzR25jjhe1LDtxLMNcoNJQ= github.com/andig/mbserver v0.0.0-20230310211055-1d29cbb5820e/go.mod h1:4VtYzTm//oUipwvO3yh0g/udTE7pYJM+U/kyAuFDsgM= +github.com/andig/ship-go v0.6.1-0.20260818072438-bade5ec9fbd7 h1:YXXxRvtVSTLATXSvMYBtf8PBNeVRBu7WzxfL4PCqN+8= +github.com/andig/ship-go v0.6.1-0.20260818072438-bade5ec9fbd7/go.mod h1:EvRFx83phCwPQrOuWw2CX5t0c6mcFVzIk6gQEz4Lijg= github.com/andig/spine-go v0.7.1-0.20260804073026-f9a8d8e8fd18 h1:FVKqjwJJLo8vV47jXt1lNN4EVJXCyV3LSTNFt9eeimU= github.com/andig/spine-go v0.7.1-0.20260804073026-f9a8d8e8fd18/go.mod h1:ddWZU5BQGyzRGF20Z7rUKFFzbCp+cfu9mZgYtOMW/fM= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= @@ -140,8 +142,6 @@ github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2I github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= github.com/enbility/go-avahi v0.0.0-20240909195612-d5de6b280d7a h1:foChWb8lhzqa6lWDRs6COYMdp649YlUirFP8GqoT0JQ= github.com/enbility/go-avahi v0.0.0-20240909195612-d5de6b280d7a/go.mod h1:H64mhYcAQUGUUnVqMdZQf93kPecH4M79xwH95Lddt3U= -github.com/enbility/ship-go v0.6.1-0.20260720110450-0aa90f64ac76 h1:gDrV6vBG3luAgsyX4sXYShITT41CDKXrtyluLeRmglE= -github.com/enbility/ship-go v0.6.1-0.20260720110450-0aa90f64ac76/go.mod h1:EvRFx83phCwPQrOuWw2CX5t0c6mcFVzIk6gQEz4Lijg= github.com/enbility/zeroconf/v2 v2.0.0-20240920094356-be1cae74fda6 h1:XOYvxKtT1oxT37w/5oEiRLuPbm9FuJPt3fiYhX0h8Po= github.com/enbility/zeroconf/v2 v2.0.0-20240920094356-be1cae74fda6/go.mod h1:BszP9qFV14mPXgyIREbgIdQtWxbAj3OKqvK02HihMoM= github.com/evcc-io/modbus v0.0.0-20250501165638-8b6f1fbdb7ea h1:F6eyC8V8wvc3ranlsR4coAls+OPBkVvxyX0a2VqdlPc= diff --git a/meter/shelly/gen1.go b/meter/shelly/gen1.go index a8e007ef5..d57784cf4 100644 --- a/meter/shelly/gen1.go +++ b/meter/shelly/gen1.go @@ -149,9 +149,11 @@ func (c *gen1) IsReversed() bool { return false } -// HasReturnEnergy reports whether the device measures energy in the return direction +// HasReturnEnergy reports whether the device measures energy in the return direction. +// Only the EM variants have a total_returned register, relay/plug meters don't. func (c *gen1) HasReturnEnergy() bool { - return true + res, err := c.status.Get() + return err == nil && c.channel >= len(res.Meters) && c.channel < len(res.EMeters) } // IsThreePhase reports whether the device is a three-phase energy meter. diff --git a/meter/shelly/gen1_test.go b/meter/shelly/gen1_test.go index 7f9dc6b21..28129d992 100644 --- a/meter/shelly/gen1_test.go +++ b/meter/shelly/gen1_test.go @@ -3,7 +3,9 @@ package shelly import ( "encoding/json" "testing" + "time" + "github.com/evcc-io/evcc/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -46,3 +48,21 @@ func TestUnmarshalGen1Status(t *testing.T) { assert.Equal(t, -620.34, res.EMeters[0].Power) } } + +// TestGen1HasReturnEnergy asserts that only the EM variants report a return +// register - a 1PM's production must not be booked in return direction (#33062). +func TestGen1HasReturnEnergy(t *testing.T) { + status := func(s string) util.Cacheable[Gen1Status] { + var res Gen1Status + require.NoError(t, json.Unmarshal([]byte(s), &res)) + return util.ResettableCached(func() (Gen1Status, error) { return res, nil }, time.Minute) + } + + // Shelly 1PM: meters without total_returned + g := &gen1{status: status(`{"meters":[{"power":198.0,"total":31510486}]}`)} + assert.False(t, g.HasReturnEnergy(), "1PM has no return register") + + // Shelly EM: emeters with total_returned + g = &gen1{status: status(`{"emeters":[{"power":-620.34,"total":401472.9,"total_returned":653673.7}]}`)} + assert.True(t, g.HasReturnEnergy()) +} diff --git a/server/http_config_loadpoint_handler.go b/server/http_config_loadpoint_handler.go index 23f875c48..c30359536 100644 --- a/server/http_config_loadpoint_handler.go +++ b/server/http_config_loadpoint_handler.go @@ -13,6 +13,7 @@ import ( "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/config" "github.com/evcc-io/evcc/util/templates" + "github.com/go-viper/mapstructure/v2" "github.com/gorilla/mux" ) @@ -119,8 +120,9 @@ func loadpointConfig(dev config.Device[loadpoint.API]) (loadpointFullConfig, err return loadpointFullConfig{}, err } + // non-strict: runtime settings (mode, ...) are persisted alongside the config var static loadpoint.StaticConfig - if err := util.DecodeOther(staticMap, &static); err != nil { + if err := mapstructure.Decode(staticMap, &static); err != nil { return loadpointFullConfig{}, err } diff --git a/server/http_config_loadpoint_handler_test.go b/server/http_config_loadpoint_handler_test.go index e9ddf4a49..a8050bd62 100644 --- a/server/http_config_loadpoint_handler_test.go +++ b/server/http_config_loadpoint_handler_test.go @@ -25,6 +25,7 @@ func TestLoadpointConfigDisabledNilInstance(t *testing.T) { "charger": "wallbox", "meter": "lp-meter", "title": "Garage", + "mode": "pv", // runtime setting persisted by the settings adapter }, } diff --git a/templates/definition/meter/tesla-powerwall-fleet.yaml b/templates/definition/meter/tesla-powerwall-fleet.yaml index 92f806fe5..84c577edd 100644 --- a/templates/definition/meter/tesla-powerwall-fleet.yaml +++ b/templates/definition/meter/tesla-powerwall-fleet.yaml @@ -19,6 +19,8 @@ requirements: The [myteslamate.com](https://www.myteslamate.com/tesla-api-application-registration/) guide explains the process and generates a free Access and Refresh Token. With this token pair and your Client ID created in the Tesla Developer Account, evcc can directly communicate with the Tesla Fleet API. params: + - name: usage + choice: ["battery"] - name: host - name: password required: true diff --git a/tests/config-disable.spec.ts b/tests/config-disable.spec.ts index 4d94087ed..5f5b66e19 100644 --- a/tests/config-disable.spec.ts +++ b/tests/config-disable.spec.ts @@ -59,6 +59,21 @@ test.describe("disable / enable", async () => { await page.reload(); await createLoadpoint(page, "Garage"); + // restart to bring both loadpoints live + await restart(); + await page.reload(); + + // switch charge mode on dashboard; runtime setting is persisted next to the loadpoint config + await page.goto("/"); + const modeButton = page + .getByTestId("loadpoint") + .nth(0) + .getByTestId("mode") + .getByRole("button", { name: "Fast" }); + await modeButton.click(); + await expect(modeButton).toHaveClass(/active/); + await page.goto("/#/config"); + const target = page.getByTestId("loadpoint").nth(0); await toggleLoadpointDisable(page, 0, "Disable"); @@ -66,10 +81,11 @@ test.describe("disable / enable", async () => { // card shows disabled state await expect(disabledBadge(target)).toBeVisible(); - // restart, no fatal + // restart, no fatal, both loadpoints still listed await restart(); await page.reload(); await expectNoFatal(page); + await expect(page.getByTestId("loadpoint")).toHaveCount(2); await expect(disabledBadge(target)).toBeVisible(); // re-enable by clicking the disabled card @@ -223,6 +239,10 @@ test.describe("disable / enable", async () => { await start(); await page.goto("/#/config"); + // loadpoint so the vehicle shows up on the dashboard + await createLoadpoint(page, "Carport"); + await page.reload(); + // add user-defined vehicle await page.getByTestId("add-vehicle").click(); const modal = page.getByTestId("vehicle-modal"); @@ -261,6 +281,23 @@ soc: await expectNoFatal(page); await expect(disabledBadge(vehicleCard)).toBeVisible(); + // disabled vehicle is hidden from main ui + await page.goto("/"); + await expect(page.getByTestId("loadpoint")).toBeVisible(); + await expect( + page.getByTestId("change-vehicle").getByRole("option", { name: "blue Honda" }) + ).toHaveCount(0); + + // no vehicles entry in more menu + const moreTab = page.getByTestId("tab-more"); + await moreTab.click(); + await expect(moreTab.getByRole("button", { name: "User Interface" })).toBeVisible(); + await expect(moreTab.getByRole("button", { name: "Vehicles" })).not.toBeVisible(); + // close more menu + await moreTab.click(); + + await page.goto("/#/config"); + // re-enable by clicking the disabled card await disabledBadge(vehicleCard).click(); await expect(disabledBadge(vehicleCard)).toHaveCount(0); @@ -270,6 +307,19 @@ soc: await page.reload(); await expectNoFatal(page); await expect(vehicleCard).toContainText("blue Honda"); + + // vehicle is selectable on the dashboard again + await page.goto("/"); + await expect( + page.getByTestId("change-vehicle").getByRole("option", { name: "blue Honda" }) + ).toHaveCount(1); + + // vehicle is back in the more menu vehicles modal + await moreTab.click(); + await moreTab.getByRole("button", { name: "Vehicles" }).click(); + const settingsModal = page.getByTestId("vehicle-settings-modal"); + await expectModalVisible(settingsModal); + await expect(settingsModal.getByRole("group", { name: "blue Honda" })).toBeVisible(); }); });