Simplify HEMS api (#31428)

This commit is contained in:
andig 2026-07-18 10:58:06 +02:00 • committed by GitHub
parent 199365288a
commit e239af5d25
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 255 additions and 121 deletions

View file

@ -285,10 +285,9 @@ type Circuit interface {
// HEMS exposes the runtime state of the home energy management system.
type HEMS interface {
SetUpdated(func())
Dimmed() *bool // nil = no statement
MaxConsumptionPower() float64 // 0 = no limit
CurtailedPercent() *int // allowed feed-in percent of nominal production power (0..100), nil = no statement
MaxProductionPower() *float64 // nil = no limit
CurtailedPercent() *int // allowed feed-in percent of nominal production power (0..100), nil = no statement
MaxProductionPower() *float64 // nil = limiting undefined, else active limit
MaxConsumptionPower() *float64 // nil = limiting undefined, else active limit (0 = none)
}
// Redactor is an interface to redact sensitive data

View file

@ -1226,25 +1226,11 @@ func (mr *MockHEMSMockRecorder) CurtailedPercent() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CurtailedPercent", reflect.TypeOf((*MockHEMS)(nil).CurtailedPercent))
}
// Dimmed mocks base method.
func (m *MockHEMS) Dimmed() *bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Dimmed")
ret0, _ := ret[0].(*bool)
return ret0
}
// Dimmed indicates an expected call of Dimmed.
func (mr *MockHEMSMockRecorder) Dimmed() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Dimmed", reflect.TypeOf((*MockHEMS)(nil).Dimmed))
}
// MaxConsumptionPower mocks base method.
func (m *MockHEMS) MaxConsumptionPower() float64 {
func (m *MockHEMS) MaxConsumptionPower() *float64 {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "MaxConsumptionPower")
ret0, _ := ret[0].(float64)
ret0, _ := ret[0].(*float64)
return ret0
}

View file

@ -370,10 +370,10 @@ func runRoot(cmd *cobra.Command, args []string) {
Status: struct {
Dimmed *bool `json:"dimmed,omitempty"`
Curtailed *int `json:"curtailed,omitempty"`
MaxConsumptionPower float64 `json:"maxConsumptionPower,omitempty"`
MaxConsumptionPower *float64 `json:"maxConsumptionPower,omitempty"`
MaxProductionPower *float64 `json:"maxProductionPower,omitempty"`
}{
Dimmed: hemsInstance.Dimmed(),
Dimmed: hems.Dimmed(hemsInstance),
Curtailed: hemsInstance.CurtailedPercent(),
MaxConsumptionPower: hemsInstance.MaxConsumptionPower(),
MaxProductionPower: hemsInstance.MaxProductionPower(),

View file

@ -214,16 +214,16 @@ func (c *Circuit) effectiveMaxPower() float64 {
}
hemsLimit := c.hems.MaxConsumptionPower()
if hemsLimit <= 0 {
if hemsLimit == nil || *hemsLimit <= 0 {
return maxPower
}
// unconfigured maxPower means unlimited, so the HEMS limit applies alone
if maxPower <= 0 {
return hemsLimit
return *hemsLimit
}
return min(hemsLimit, maxPower)
return min(*hemsLimit, maxPower)
}
// SetMaxPower sets the max power

View file

@ -165,7 +165,7 @@ func TestHEMSConsumptionClamp(t *testing.T) {
if tc.hemsLimit > 0 {
ctrl := gomock.NewController(t)
hems := api.NewMockHEMS(ctrl)
hems.EXPECT().MaxConsumptionPower().Return(tc.hemsLimit).AnyTimes()
hems.EXPECT().MaxConsumptionPower().Return(&tc.hemsLimit).AnyTimes()
c.hems = hems
}

View file

@ -6,6 +6,7 @@ import (
"slices"
"github.com/evcc-io/evcc/api"
hemsapi "github.com/evcc-io/evcc/hems/hems"
"github.com/evcc-io/evcc/util/config"
)
@ -83,16 +84,16 @@ func hemsDimmed(hems api.HEMS) *bool {
return nil
}
return hems.Dimmed()
return hemsapi.Dimmed(hems)
}
// hemsCurtailed returns the HEMS curtail percent, nil-safe
func hemsCurtailed(hems api.HEMS) *int {
// hemsCurtailed returns whether HEMS curtailment is active, nil-safe
func hemsCurtailed(hems api.HEMS) *bool {
if hems == nil {
return nil
}
return hems.CurtailedPercent()
return hemsapi.Curtailed(hems)
}
// nonZeroEnergy reports a zero lifetime energy reading as api.ErrNotAvailable.

View file

@ -1081,14 +1081,18 @@ func (site *Site) update(lp updater) {
var wg sync.WaitGroup
wg.Go(func() {
if err := site.dimMeters(hemsDimmed(site.hems)); err != nil {
site.log.ERROR.Println(err)
if dim := hemsDimmed(site.hems); dim != nil {
if err := site.dimMeters(*dim); err != nil {
site.log.ERROR.Println(err)
}
}
})
wg.Go(func() {
if err := site.curtailPV(hemsCurtailed(site.hems)); err != nil {
site.log.ERROR.Println(err)
if hemsCurtailed(site.hems) != nil {
if err := site.curtailPV(site.hems.CurtailedPercent()); err != nil {
site.log.ERROR.Println(err)
}
}
})

View file

@ -48,9 +48,9 @@ func TestDimming(t *testing.T) {
if tc.has != *tc.want {
dimmer.EXPECT().Dim(*tc.want).Return(nil)
}
}
require.NoError(t, s.dimMeters(tc.want))
require.NoError(t, s.dimMeters(*tc.want))
}
if !ctrl.Satisfied() {
ctrl.Finish()

View file

@ -56,11 +56,7 @@ func (site *Site) publishCircuits() {
site.publish(keys.Circuits, res)
}
func (site *Site) dimMeters(dim *bool) error {
if dim == nil {
return nil
}
func (site *Site) dimMeters(dim bool) error {
var errs error
for _, dev := range slices.Concat(site.auxMeters, site.extMeters) {
m, ok := api.Cap[api.Dimmer](dev.Instance())
@ -69,7 +65,7 @@ func (site *Site) dimMeters(dim *bool) error {
}
if dimmed, err := backoff.RetryWithData(m.Dimmed, modbus.Backoff()); err == nil {
if *dim == dimmed {
if dim == dimmed {
continue
}
} else {
@ -79,8 +75,8 @@ func (site *Site) dimMeters(dim *bool) error {
continue
}
if err := m.Dim(*dim); err == nil {
site.log.DEBUG.Printf("%s dim: %t", deviceTitleOrName(dev), *dim)
if err := m.Dim(dim); err == nil {
site.log.DEBUG.Printf("%s dim: %t", deviceTitleOrName(dev), dim)
} else if !errors.Is(err, api.ErrNotAvailable) {
errs = errors.Join(errs, fmt.Errorf("%s dim: %w", deviceTitleOrName(dev), err))
}

View file

@ -38,13 +38,13 @@ type EEBus struct {
smartgridConsumptionId uint
consumptionLimit ucapi.LoadLimit // LPC-041
consumptionLimitActivated time.Time
consumptionLimitActivated *time.Time // nil until first connected, then always set
failsafeConsumptionLimit float64
smartgridProductionId uint
productionLimit ucapi.LoadLimit // feed-in limit (NOT production despite its name)
productionLimitActivated time.Time
failsafeProductionLimit *float64 // feed-in limit (NOT production despite its name)
productionLimitActivated *time.Time // nil until first connected, then always set
failsafeProductionLimit *float64 // feed-in limit (NOT production despite its name)
productionNominalMax float64
heartbeat *util.Value[struct{}]
@ -172,6 +172,26 @@ func (c *EEBus) SetUpdated(f func()) {
c.publishFunc = f
}
// Connect overrides the embedded Connector: on first connect, limit state
// becomes valid (nil -> known). A later disconnect/reconnect is a no-op here.
func (c *EEBus) Connect(connected bool) {
c.Connector.Connect(connected)
if !connected {
return
}
c.mux.Lock()
defer c.mux.Unlock()
if c.consumptionLimitActivated == nil {
c.consumptionLimitActivated = new(time.Time)
}
if c.productionLimitActivated == nil {
c.productionLimitActivated = new(time.Time)
}
}
func (c *EEBus) Run() {
for range time.Tick(c.interval) {
if err := c.run(); err != nil {
@ -227,7 +247,7 @@ func (c *EEBus) run() error {
}
// LPC-914/1
if c.consumptionLimitActivated.IsZero() {
if !limitActive(c.consumptionLimitActivated) {
if c.consumptionLimit.IsActive {
c.log.WARN.Println("activating consumption limit")
c.setConsumptionLimit(c.consumptionLimit.Value)
@ -237,7 +257,7 @@ func (c *EEBus) run() error {
case !c.consumptionLimit.IsActive:
c.log.DEBUG.Println("consumption limit released")
c.setConsumptionLimit(0)
case time.Since(c.consumptionLimitActivated) > c.consumptionLimit.Duration:
case time.Since(*c.consumptionLimitActivated) > c.consumptionLimit.Duration:
c.log.DEBUG.Println("consumption limit duration exceeded")
c.setConsumptionLimit(0)
c.consumptionLimit.IsActive = false
@ -245,7 +265,7 @@ func (c *EEBus) run() error {
}
// LPP
if c.productionLimitActivated.IsZero() {
if !limitActive(c.productionLimitActivated) {
if c.productionLimit.IsActive {
if c.productionNominalMax <= 0 {
return errors.New("production limit received but productionNominalMax is not configured")
@ -259,7 +279,7 @@ func (c *EEBus) run() error {
case !c.productionLimit.IsActive:
c.log.DEBUG.Println("production limit released")
c.setProductionLimit(0, false)
case time.Since(c.productionLimitActivated) > c.productionLimit.Duration:
case time.Since(*c.productionLimitActivated) > c.productionLimit.Duration:
c.log.DEBUG.Println("production limit duration exceeded")
c.setProductionLimit(0, false)
c.productionLimit.IsActive = false
@ -269,6 +289,20 @@ func (c *EEBus) run() error {
return nil
}
// limitActive reports whether t denotes a currently active limit: known (non-nil) and non-zero.
func limitActive(t *time.Time) bool {
return t != nil && !t.IsZero()
}
// activatedAt returns now if active, else a known-but-zero timestamp.
func activatedAt(active bool) *time.Time {
if active {
t := time.Now()
return &t
}
return new(time.Time)
}
func (c *EEBus) setStatus(status status) {
c.status = status
c.statusUpdated = time.Now()
@ -276,12 +310,7 @@ func (c *EEBus) setStatus(status status) {
func (c *EEBus) setConsumptionLimit(limit float64) {
active := limit > 0
if active {
c.consumptionLimitActivated = time.Now()
} else {
c.consumptionLimitActivated = time.Time{}
}
c.consumptionLimitActivated = activatedAt(active)
if err := smartgrid.UpdateSession(&c.smartgridConsumptionId, smartgrid.Dim, c.site.GetGridPower(), limit, active); err != nil {
c.log.ERROR.Printf("smartgrid session: %v", err)
@ -295,11 +324,7 @@ func (c *EEBus) setConsumptionLimit(limit float64) {
}
func (c *EEBus) setProductionLimit(limit float64, active bool) {
if active {
c.productionLimitActivated = time.Now()
} else {
c.productionLimitActivated = time.Time{}
}
c.productionLimitActivated = activatedAt(active)
if err := smartgrid.UpdateSession(&c.smartgridProductionId, smartgrid.Curtail, c.site.GetGridPower(), limit, active); err != nil {
c.log.ERROR.Printf("smartgrid session: %v", err)
@ -308,13 +333,6 @@ func (c *EEBus) setProductionLimit(limit float64, active bool) {
var _ api.HEMS = (*EEBus)(nil)
// Dimmed implements api.HEMS, derived from consumptionLimitActivated.
func (c *EEBus) Dimmed() *bool {
c.mux.RLock()
defer c.mux.RUnlock()
return new(!c.consumptionLimitActivated.IsZero())
}
// CurtailedPercent implements api.HEMS, converting the active LPP production
// limit to an allowed production percent via the configured nominal production power.
func (c *EEBus) CurtailedPercent() *int {
@ -327,7 +345,7 @@ func (c *EEBus) CurtailedPercent() *int {
}
percent := 100
if !c.productionLimitActivated.IsZero() {
if limitActive(c.productionLimitActivated) {
// production limits are negative watts
percent = int(-c.productionLimit.Value / c.productionNominalMax * 100)
}
@ -335,19 +353,21 @@ func (c *EEBus) CurtailedPercent() *int {
return &percent
}
// MaxConsumptionPower implements api.HEMS, returning the consumption cap
// currently in effect: failsafe limit while in failsafe, otherwise the
// EG-supplied LPC limit when active, or 0 when no limit applies.
func (c *EEBus) MaxConsumptionPower() float64 {
// MaxConsumptionPower implements api.HEMS: nil until first connected,
// else failsafe limit in failsafe, else the active EG-supplied LPC limit, else 0.
func (c *EEBus) MaxConsumptionPower() *float64 {
c.mux.RLock()
defer c.mux.RUnlock()
if c.consumptionLimitActivated.IsZero() {
return 0
if c.consumptionLimitActivated == nil {
return nil
}
if !limitActive(c.consumptionLimitActivated) {
return new(0.0)
}
if c.status == StatusFailsafe {
return c.failsafeConsumptionLimit
return new(c.failsafeConsumptionLimit)
}
return c.consumptionLimit.Value
return new(c.consumptionLimit.Value)
}
// MaxProductionPower implements api.HEMS. Scaffolding only — EEBus does not
@ -355,9 +375,12 @@ func (c *EEBus) MaxConsumptionPower() float64 {
func (c *EEBus) MaxProductionPower() *float64 {
c.mux.RLock()
defer c.mux.RUnlock()
if c.productionLimitActivated.IsZero() {
if c.productionLimitActivated == nil {
return nil
}
if !limitActive(c.productionLimitActivated) {
return new(0.0)
}
if c.status == StatusFailsafe {
return c.failsafeProductionLimit
}

View file

@ -7,6 +7,7 @@ import (
ucapi "github.com/enbility/eebus-go/usecases/api"
"github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/server/db"
"github.com/evcc-io/evcc/server/eebus"
"github.com/evcc-io/evcc/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -40,6 +41,7 @@ func newTestEEBus(t *testing.T) *EEBus {
return &EEBus{
log: util.NewLogger("test"),
site: &stubSite{},
Connector: eebus.NewConnector(),
heartbeat: util.NewValue[struct{}](time.Hour),
failsafeConsumptionLimit: testFailsafeConsumption,
failsafeProductionLimit: &failsafeProduction,
@ -48,11 +50,13 @@ func newTestEEBus(t *testing.T) *EEBus {
}
}
// assertConsumptionLimit checks the HEMS consumption state through the api.HEMS surface.
// assertConsumptionLimit checks the HEMS consumption state through the api.HEMS
// surface. Once run() has executed, MaxConsumptionPower is always known (non-nil).
func assertConsumptionLimit(t *testing.T, c *EEBus, limit float64) {
t.Helper()
assert.Equal(t, new(limit > 0), c.Dimmed())
assert.Equal(t, limit, c.MaxConsumptionPower())
power := c.MaxConsumptionPower()
require.NotNil(t, power)
assert.Equal(t, limit, *power)
}
// assertProductionLimit checks the HEMS production state through the api.HEMS surface.
@ -63,6 +67,20 @@ func assertProductionLimit(t *testing.T, c *EEBus, active bool) {
assert.Equal(t, active, *percent < 100)
}
// TestEEBusNoLimitContract verifies api.HEMS's "nil = limiting undefined" contract:
// nil until the controlbox/EnergyGuard first connects, then 0 = no limit.
func TestEEBusNoLimitContract(t *testing.T) {
c := newTestEEBus(t)
require.Nil(t, c.MaxConsumptionPower())
require.Nil(t, c.MaxProductionPower())
c.Connect(true)
assertConsumptionLimit(t, c, 0)
assertProductionLimit(t, c, false)
}
// TestRun_HeartbeatLost_EntersFailsafe verifies the LPC-911/LPP-911 transition:
// a missing heartbeat in the normal state must apply the configured failsafe
// consumption and production limits.
@ -178,3 +196,21 @@ func TestRun_ConsumptionLimitReleasedEarly(t *testing.T) {
require.NoError(t, c.run())
assertConsumptionLimit(t, c, 0)
}
// TestEEBusEdgeTriggered verifies that applying a limit (passthrough) only
// happens on a genuine transition, not on every steady-state run().
func TestEEBusEdgeTriggered(t *testing.T) {
c := newTestEEBus(t)
c.heartbeat.Set(struct{}{})
calls := 0
c.passthrough = func(bool) error { calls++; return nil }
c.consumptionLimit = ucapi.LoadLimit{Value: 3000, IsActive: true, Duration: time.Hour}
require.NoError(t, c.run())
require.NoError(t, c.run())
require.NoError(t, c.run())
require.Equal(t, 1, calls, "passthrough must fire once on the edge, not every tick")
assertConsumptionLimit(t, c, 3000)
}

View file

@ -77,7 +77,7 @@ func NewFnn(site site.API, maxDimPower, maxCurtailPower float64, w3G, s1G, s2G,
return nil, errors.New("cannot have w4 without power limit")
}
return &Fnn{
c := &Fnn{
log: util.NewLogger("fnn"),
site: site,
maxDimPower: maxDimPower,
@ -88,7 +88,17 @@ func NewFnn(site site.API, maxDimPower, maxCurtailPower float64, w3G, s1G, s2G,
w4: w4G,
productionPercent: 100,
interval: interval,
}, nil
}
// read the relays once synchronously so limits are valid as soon as NewFnn returns
if err := c.runCurtail(); err != nil {
return nil, err
}
if err := c.runDim(); err != nil {
return nil, err
}
return c, nil
}
// Fnn implements the FNN HEMS logic for curtailment and dimming.
@ -107,7 +117,7 @@ type Fnn struct {
smartgridConsumptionID uint
smartgridProductionID uint
consumptionLimit float64
consumptionLimit *float64
productionPercent int // allowed feed-in percent (0..100), 100 = uncurtailed
interval time.Duration
@ -119,10 +129,9 @@ func (c *Fnn) SetUpdated(f func()) {
c.publishFunc = f
}
// Run starts the FNN control loop.
// Run starts the FNN control loop. NewFnn already ran the first pass.
func (c *Fnn) Run() {
// run immediately, then on every tick
for tick := time.Tick(c.interval); ; <-tick {
for range time.Tick(c.interval) {
if err := c.runCurtail(); err != nil {
c.log.ERROR.Println(err)
}
@ -218,7 +227,10 @@ func (c *Fnn) setConsumptionLimit(limit float64) error {
defer c.mu.Unlock()
active := limit > 0
c.consumptionLimit = limit
c.consumptionLimit = nil
if active {
c.consumptionLimit = &limit
}
if err := smartgrid.UpdateSession(&c.smartgridConsumptionID, smartgrid.Dim, c.site.GetGridPower(), limit, active); err != nil {
c.log.ERROR.Printf("smartgrid session: %v", err)
@ -229,17 +241,6 @@ func (c *Fnn) setConsumptionLimit(limit float64) error {
var _ api.HEMS = (*Fnn)(nil)
// Dimmed implements api.HEMS.
func (c *Fnn) Dimmed() *bool {
if c.w4 == nil {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
return new(c.consumptionLimit > 0)
}
// CurtailedPercent implements api.HEMS, returning the allowed production percent.
func (c *Fnn) CurtailedPercent() *int {
if c.w3 == nil {
@ -249,23 +250,33 @@ func (c *Fnn) CurtailedPercent() *int {
c.mu.Lock()
defer c.mu.Unlock()
percent := c.productionPercent
return &percent
return new(c.productionPercent)
}
// MaxConsumptionPower implements api.HEMS.
func (c *Fnn) MaxConsumptionPower() float64 {
func (c *Fnn) MaxConsumptionPower() *float64 {
if c.w4 == nil {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
return c.consumptionLimit
if c.consumptionLimit == nil {
return new(0.0)
}
return new(*c.consumptionLimit)
}
// MaxProductionPower implements api.HEMS.
func (c *Fnn) MaxProductionPower() *float64 {
if c.w3 == nil {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
if c.productionPercent >= 100 {
return nil
return new(0.0)
}
return new(float64(c.productionPercent) / 100 * c.maxCurtailPower)

View file

@ -3,16 +3,44 @@ package fnn
import (
"testing"
"github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/server/db"
"github.com/stretchr/testify/require"
)
// stubSite implements site.API for testing — only GetGridPower is exercised.
type stubSite struct {
site.API
}
func (s *stubSite) GetGridPower() float64 { return 0 }
func TestFnn(t *testing.T) {
// TODO add site
fnn, err := NewFnn(nil, 1e3, 1e3, nil, nil, nil, func() (bool, error) {
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
fnn, err := NewFnn(&stubSite{}, 1e3, 1e3, nil, nil, nil, func() (bool, error) {
return true, nil
}, 0)
require.NoError(t, err)
require.Nil(t, fnn.CurtailedPercent())
// require.NoError(t, fnn.runDim())
// require.Equal(t, new(true), fnn.Dimmed())
}
// TestFnnNilWhenNotConfigured verifies "nil = limiting undefined": nil unless
// the relay input is configured, else valid right after NewFnn returns.
func TestFnnNilWhenNotConfigured(t *testing.T) {
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
unconfigured, err := NewFnn(&stubSite{}, 1e3, 1e3, nil, nil, nil, nil, 0)
require.NoError(t, err)
require.Nil(t, unconfigured.MaxConsumptionPower())
require.Nil(t, unconfigured.MaxProductionPower())
off := func() (bool, error) { return false, nil }
configured, err := NewFnn(&stubSite{}, 1e3, 1e3, off, nil, nil, off, 0)
require.NoError(t, err)
require.NotNil(t, configured.MaxConsumptionPower())
require.Equal(t, 0.0, *configured.MaxConsumptionPower())
require.NotNil(t, configured.MaxProductionPower())
require.Equal(t, 0.0, *configured.MaxProductionPower())
}

22
hems/hems/globals.go Normal file
View file

@ -0,0 +1,22 @@
package hems
import "github.com/evcc-io/evcc/api"
// Dimmed reports nil until MaxConsumptionPower is known (see api.HEMS).
func Dimmed(hems api.HEMS) *bool {
dimmed := hems.MaxConsumptionPower()
if dimmed == nil {
return nil
}
return new(*dimmed > 0)
}
func Curtailed(hems api.HEMS) *bool {
percent := hems.CurtailedPercent()
if percent == nil {
return nil
}
return new(*percent < 100)
}

View file

@ -78,6 +78,11 @@ func NewRelay(site site.API, w1 func() (bool, error), passthrough func(bool) err
return nil, errors.New("missing power limit")
}
// read the relay once synchronously so the limit is valid as soon as NewRelay returns
if err := c.run(); err != nil {
return nil, err
}
return c, nil
}
@ -87,9 +92,9 @@ func (c *Relay) SetUpdated(f func()) {
c.publishFunc = f
}
// Run starts the relay control loop. NewRelay already ran the first pass.
func (c *Relay) Run() {
// run immediately, then on every tick
for tick := time.Tick(c.interval); ; <-tick {
for range time.Tick(c.interval) {
if err := c.run(); err != nil {
c.log.ERROR.Println(err)
}
@ -142,13 +147,6 @@ func (c *Relay) setConsumptionLimit(limit float64) error {
var _ api.HEMS = (*Relay)(nil)
// Dimmed implements api.HEMS, derived from the active consumption limit.
func (c *Relay) Dimmed() *bool {
c.mu.Lock()
defer c.mu.Unlock()
return new(c.limit != nil)
}
// CurtailedPercent implements api.HEMS. Relay does not curtail production and
// hence makes no statement.
func (c *Relay) CurtailedPercent() *int {
@ -156,13 +154,13 @@ func (c *Relay) CurtailedPercent() *int {
}
// MaxConsumptionPower implements api.HEMS, returning the active wattage cap.
func (c *Relay) MaxConsumptionPower() float64 {
func (c *Relay) MaxConsumptionPower() *float64 {
c.mu.Lock()
defer c.mu.Unlock()
if c.limit == nil {
return 0
return new(0.0)
}
return *c.limit
return new(*c.limit)
}
// MaxProductionPower implements api.HEMS. Scaffolding only.

30
hems/relay/relay_test.go Normal file
View file

@ -0,0 +1,30 @@
package relay
import (
"testing"
"github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/server/db"
"github.com/stretchr/testify/require"
)
// stubSite implements site.API for testing — only GetGridPower is exercised.
type stubSite struct {
site.API
}
func (s *stubSite) GetGridPower() float64 { return 0 }
// TestRelayNoNilState verifies MaxConsumptionPower is always determinable (w1
// is mandatory) — NewRelay reads it once so the state is valid immediately.
func TestRelayNoNilState(t *testing.T) {
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
off := func() (bool, error) { return false, nil }
c, err := NewRelay(&stubSite{}, off, nil, 1000, 0)
require.NoError(t, err)
require.NotNil(t, c.MaxConsumptionPower())
require.Equal(t, 0.0, *c.MaxConsumptionPower())
require.Nil(t, c.MaxProductionPower()) // scaffolding only, always nil
}