Compare commits
11 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e395dcf371 | ||
|
|
db206de2f9 | ||
|
|
5f5b33289c | ||
|
|
c4fbffa55e | ||
|
|
6e121c778f | ||
|
|
bbb7d704f6 | ||
|
|
d284a2f9af | ||
|
|
96fb302147 | ||
|
|
eb836e9ec8 | ||
|
|
52c0cfaa5d | ||
|
|
d1b1ded0e6 |
23 changed files with 351 additions and 90 deletions
9
.github/workflows/command-backport.yml
vendored
9
.github/workflows/command-backport.yml
vendored
|
|
@ -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 "")
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
11
cmd/setup.go
11
cmd/setup.go
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
|
||||||
34
core/site.go
34
core/site.go
|
|
@ -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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
2
go.mod
2
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/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
4
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/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=
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue