core: fix nil pointer panic when deleting active grid meter (#32607)

This commit is contained in:
github-actions[bot] 2026-08-07 16:52:58 +00:00 • committed by GitHub
parent 950ca489e5
commit 6f8a6b8c36
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 165 additions and 2 deletions

View file

@ -881,7 +881,7 @@ func (site *Site) updateGridMeter() error {
return nil return nil
} }
mm := types.Measurement{Name: site.Meters.GridMeterRef} mm := types.Measurement{Name: site.gridMeter.Config().Name}
meter := site.gridMeter.Instance() meter := site.gridMeter.Instance()
@ -935,7 +935,9 @@ func (site *Site) updateGridMeter() error {
} }
} }
site.collectors[site.Meters.GridMeterRef].AddEnergy(mm.Energy, mm.ReturnEnergy, mm.Power) if c, ok := site.collectors[site.gridMeter.Config().Name]; ok {
c.AddEnergy(mm.Energy, mm.ReturnEnergy, mm.Power)
}
site.publish(keys.Grid, mm) site.publish(keys.Grid, mm)

161
core/site_delete_test.go Normal file
View file

@ -0,0 +1,161 @@
package core
import (
"testing"
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/core/metrics"
"github.com/evcc-io/evcc/core/types"
"github.com/evcc-io/evcc/server/db"
"github.com/evcc-io/evcc/tariff"
"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"
)
// deleteTestSite mirrors what Prepare wires up: cached devices plus one energy
// collector per meter, keyed by the meter's config name.
type deleteTestSite struct {
*Site
params chan util.Param
}
func newDeleteTestSite(t *testing.T, ctrl *gomock.Controller) *deleteTestSite {
t.Helper()
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
require.NoError(t, metrics.SetupSchema())
meter := func(power float64) api.Meter {
m := api.NewMockMeter(ctrl)
m.EXPECT().CurrentPower().Return(power, nil).AnyTimes()
return m
}
battery := func(power, soc float64) api.Meter {
m := api.NewMockMeter(ctrl)
m.EXPECT().CurrentPower().Return(power, nil).AnyTimes()
b := api.NewMockBattery(ctrl)
b.EXPECT().Soc().Return(soc, nil).AnyTimes()
return &struct {
api.Meter
api.Battery
}{Meter: m, Battery: b}
}
dev := func(name string, m api.Meter) config.Device[api.Meter] {
return config.NewStaticDevice(config.Named{Name: name}, m)
}
params := make(chan util.Param, 1024)
site := NewSite()
site.log = util.NewLogger("foo")
site.valueChan = params
site.gridMeter = dev("grid", meter(1e3))
site.pvMeters = []config.Device[api.Meter]{dev("pv", meter(2e3))}
site.batteryMeters = []config.Device[api.Meter]{dev("battery", battery(-500, 42))}
// collectors as Prepare registers them: keyed by the meter's config name
for _, c := range []struct{ group, name string }{
{metrics.Grid, "grid"},
{metrics.PV, "pv"},
{metrics.Battery, "battery"},
{metrics.Forecast, metrics.Forecast},
{metrics.Temperature, metrics.Temperature},
} {
col, err := metrics.NewCollector(c.group, c.name, c.name)
require.NoError(t, err)
site.collectors[c.name] = col
}
// site settings pointing at those meters
site.Meters.GridMeterRef = "grid"
site.Meters.PVMetersRef = []string{"pv"}
site.Meters.BatteryMetersRef = []string{"battery"}
return &deleteTestSite{Site: site, params: params}
}
// published drains the value channel and returns the last value per key
func (s *deleteTestSite) published() map[string]any {
res := make(map[string]any)
for {
select {
case p := <-s.params:
res[p.Key] = p.Val
default:
return res
}
}
}
// TestSiteUpdateAfterMeterDeletion deletes the active grid, pv and battery meters while
// running. Only the refs are cleared, the cached devices keep being polled (#32605).
func TestSiteUpdateAfterMeterDeletion(t *testing.T) {
ctrl := gomock.NewController(t)
s := newDeleteTestSite(t, ctrl)
// baseline: everything still referenced
require.NoError(t, s.updateGridMeter())
s.updatePvMeters()
s.updateBatteryMeters()
grid, ok := s.published()[keys.Grid].(types.Measurement)
require.True(t, ok, "grid measurement published")
assert.Equal(t, "grid", grid.Name, "grid measurement carries the meter name")
// delete all three meters- exactly what deleteDeviceHandler does to the site
s.SetGridMeterRef("")
s.SetPVMeterRefs(nil)
s.SetBatteryMeterRefs(nil)
require.NotPanics(t, func() {
require.NoError(t, s.updateGridMeter())
s.updatePvMeters()
s.updateBatteryMeters()
}, "update loop must survive the refs being cleared")
// the devices are still cached, so the loop keeps producing measurements
pub := s.published()
assert.Equal(t, 2e3, s.pvPower, "pv still polled")
assert.Equal(t, -500.0, s.battery.Power, "battery still polled")
grid, ok = pub[keys.Grid].(types.Measurement)
require.True(t, ok, "grid measurement still published")
assert.Equal(t, "grid", grid.Name, "grid measurement must keep the meter name, not the cleared ref")
}
// TestSiteUpdateAfterTariffDeletion deletes a tariff device. cleanupTariffRef only
// rewrites the persisted refs, so the publish path must cope either way.
func TestSiteUpdateAfterTariffDeletion(t *testing.T) {
ctrl := gomock.NewController(t)
s := newDeleteTestSite(t, ctrl)
solar := api.NewMockTariff(ctrl)
solar.EXPECT().Type().Return(api.TariffTypeSolar).AnyTimes()
solar.EXPECT().Rates().Return(api.Rates{
{Start: time.Now().Add(-time.Hour), End: time.Now(), Value: 1e3},
{Start: time.Now(), End: time.Now().Add(time.Hour), Value: 2e3},
}, nil).AnyTimes()
s.tariffs = &tariff.Tariffs{Solar: solar}
require.NotPanics(t, func() {
s.publishTariffs(0, 0)
}, "tariff publish with a solar tariff")
require.NotNil(t, s.published()[keys.Forecast], "forecast published while the tariff exists")
// delete the tariff
s.tariffs = &tariff.Tariffs{}
require.NotPanics(t, func() {
s.publishTariffs(0, 0)
}, "tariff publish must survive the tariff being deleted")
}