Compare commits

...

12 commits

Author SHA1 Message Date
Michael Geers
ec8c5ec8ea
Meter (SolarEdge Hybrid): fix stale battery power register in pv formula [0.313 backport] (#32724)
Some checks failed
Release / Hassio Addon (push) Has been cancelled
Release / call-build-workflow (push) Has been cancelled
Release / Publish Docker :release (push) Has been cancelled
Release / Github & APT (push) Has been cancelled
Release / Demo (push) Has been cancelled
2026-08-11 12:16:35 +02:00
Michael Geers
e0192a0e42
Shelly: fix PV energy for devices with reverse power measurement enabled [0.313 backport] (#32643) 2026-08-08 15:05:09 +02:00
Michael Geers
12958e1b04
core: fix nil pointer panic when deleting active grid meter [0.313 backport] (#32621) 2026-08-07 23:37:13 +02:00
Michael Geers
fbcea37f61
Bender: skip Mennekes 4You/4Business unsupported registers 730/740 [0.313 backport] (#32593) 2026-08-07 11:56:38 +02:00
Michael Geers
5b4b4a333c
Sponsor: wait for network to settle during startup check [0.313 backport] (#32557)
Some checks failed
Release / call-build-workflow (push) Has been cancelled
Release / Publish Docker :release (push) Has been cancelled
Release / Github & APT (push) Has been cancelled
Release / Demo (push) Has been cancelled
Release / Hassio Addon (push) Has been cancelled
2026-08-06 08:38:05 +02:00
Michael Geers
4ca64eaf49
Warn on repeated requests only when they succeed [0.313 backport] (#32472)
Co-authored-by: andig <andi@evcc.io>
2026-08-03 12:12:13 +02:00
Michael Geers
58938bd0a2
Atmoce: fix curtailed for battery [0.313 backport] (#32461)
Co-authored-by: FrankvdAa <FrankvdAa@users.noreply.github.com>
2026-08-03 08:57:12 +02:00
andig
07f3982d1e
chore: pin devEngines package manager (#32386)
Some checks failed
Validate Schema / build (push) Has been cancelled
Release / call-build-workflow (push) Has been cancelled
Release / Publish Docker :release (push) Has been cancelled
Release / Github & APT (push) Has been cancelled
Release / Demo (push) Has been cancelled
Release / Hassio Addon (push) Has been cancelled
2026-08-01 13:16:56 +02:00
andig
e4ca549ea6
0.313 backport: chore: pin clock in device colors test (#32325) (#32387) 2026-08-01 13:16:37 +02:00
Michael Geers
0990bd9784
0.313 backport: Sessions UI: fix grouped chart layout and legend styling (#32383) 2026-08-01 13:00:13 +02:00
Michael Geers
fff6479115
0.313 backport: chore: fix request cache warning (#32382) 2026-08-01 12:56:12 +02:00
Michael Geers
58c4b446d6
Cardata: delete obsolete streaming containers (#32376) 2026-08-01 12:44:11 +02:00
30 changed files with 626 additions and 88 deletions

View file

@ -27,6 +27,7 @@ const colors: {
temperature: string | null;
export: string | null;
background: string | null;
box: string | null;
light: string | null;
selfPalette: string[];
palette: string[];
@ -44,6 +45,7 @@ const colors: {
temperature: null,
export: null,
background: null,
box: null,
light: null,
selfPalette: ["#0FDE41", "#FFBD2F", "#FD6158", "#03C1EF", "#0F662D", "#FF922E"],
palette: [
@ -152,6 +154,7 @@ export function updateCssColors() {
colors.temperature = style.getPropertyValue("--evcc-temperature");
colors.export = style.getPropertyValue("--evcc-export-contrast");
colors.background = style.getPropertyValue("--evcc-background");
colors.box = style.getPropertyValue("--evcc-box");
colors.pricePerKWh = style.getPropertyValue("--bs-gray-medium");
colors.co2PerKWh = style.getPropertyValue("--bs-gray-medium");
colors.light = style.getPropertyValue("--bs-gray-light");

View file

@ -1,9 +1,9 @@
<template>
<div v-if="chartData.labels.length > 1" class="row">
<div class="col-12 col-md-6 mb-3">
<div class="col-12 col-md-6 col-lg-12 col-xxl-6 mb-3">
<PolarArea :data="chartData" :options="options" />
</div>
<div class="col-12 col-md-6 d-flex align-items-center py-0 py-md-3">
<div class="col-12 col-md-6 col-lg-12 col-xxl-6 d-flex align-items-center">
<LegendList :legends="legends" :device-colors="deviceColors" grid />
</div>
</div>
@ -130,7 +130,7 @@ export default defineComponent({
beginAtZero: false,
ticks: {
color: colors.muted || "",
backdropColor: colors.background || "",
backdropColor: colors.box || "",
font: { size: 10 },
callback: this.formatValue,
maxTicksLimit: 6,

View file

@ -1,9 +1,9 @@
<template>
<div v-if="chartData.labels.length > 1" class="row">
<div class="col-12 col-md-6 mb-3">
<div class="col-12 col-md-6 col-lg-12 col-xxl-6 mb-3">
<Doughnut :data="chartData" :options="options" />
</div>
<div class="col-12 col-md-6 d-flex align-items-center">
<div class="col-12 col-md-6 col-lg-12 col-xxl-6 d-flex align-items-center">
<LegendList :legends="legends" :device-colors="deviceColors" grid />
</div>
</div>
@ -94,7 +94,7 @@ export default defineComponent({
borderRadius: 10,
color: colors.text || "",
borderWidth: 3,
borderColor: colors.background || "",
borderColor: colors.box || "",
cutout: "70%",
radius: "95%",
animation: { duration: 250 },

View file

@ -1,9 +1,9 @@
<template>
<div v-if="chartData.labels.length > 1" class="row">
<div class="col-12 col-md-6 mb-3">
<div class="col-12 col-md-6 col-lg-12 col-xxl-6 mb-3">
<Doughnut :data="chartData" :options="options" />
</div>
<div class="col-12 col-md-6 d-flex align-items-center">
<div class="col-12 col-md-6 col-lg-12 col-xxl-6 d-flex align-items-center">
<LegendList :legends="legends" :device-colors="deviceColors" grid />
</div>
</div>
@ -107,7 +107,7 @@ export default defineComponent({
borderRadius: 10,
color: colors.text,
borderWidth: 3,
borderColor: colors.background,
borderColor: colors.box,
cutout: "70%",
radius: "95%",
animation: { duration: 250 },

View file

@ -1,6 +1,6 @@
<template>
<ul
class="root p-0 d-flex flex-wrap column-gap-4 row-gap-2"
class="root p-0 m-0 d-flex flex-wrap column-gap-4 row-gap-2"
:class="{
'root--small-equal-widths': smallEqualWidths,
'root--grid': grid,
@ -50,7 +50,7 @@
<div
v-for="value in valueList(legend.value)"
:key="value"
class="text-muted text-nowrap legend-value text-end"
class="text-muted text-nowrap legend-value text-end tabular"
>
{{ value }}
</div>

View file

@ -174,7 +174,7 @@ const COLUMNS_PER_BREAKPOINT = {
sm: 3,
md: 4,
lg: 7,
xl: 8,
xl: 7,
xxl: 9,
};

View file

@ -1,9 +1,9 @@
<template>
<div v-if="chartData.labels.length > 1" class="row">
<div class="col-12 col-md-6 mb-3">
<div class="col-12 col-md-6 col-lg-12 col-xxl-6 mb-3">
<PolarArea :data="chartData" :options="options" />
</div>
<div class="col-12 col-md-6 d-flex align-items-center py-0 py-md-3">
<div class="col-12 col-md-6 col-lg-12 col-xxl-6 d-flex align-items-center">
<LegendList :legends="legends" :device-colors="deviceColors" grid />
</div>
</div>
@ -123,7 +123,7 @@ export default defineComponent({
ticks: {
stepSize: 25,
color: colors.muted,
backdropColor: colors.background,
backdropColor: colors.box,
},
grid: { color: colors.border },
},

View file

@ -180,7 +180,7 @@ export default defineComponent({
ticks: {
stepSize: 20,
color: colors.muted,
backdropColor: colors.background,
backdropColor: colors.box,
font: { size: 10 },
callback: (value: number) => this.fmtPercentage(value, 0),
},

View file

@ -86,9 +86,9 @@
/>
</Card>
<div v-if="showExtraCharts">
<div class="row align-items-start">
<div class="row">
<div class="col-12 col-lg-6 mb-4">
<Card :title="firstExtraTitle" edge-to-edge>
<Card :title="firstExtraTitle" edge-to-edge class="h-100">
<div v-if="activeType === types.SOLAR">
<SolarYearChart
v-if="showSolarYearChart"
@ -116,7 +116,7 @@
</Card>
</div>
<div class="col-12 col-lg-6 mb-4">
<Card :title="secondExtraTitle" edge-to-edge>
<Card :title="secondExtraTitle" edge-to-edge class="h-100">
<EnergyGroupedChart
v-if="activeType === types.SOLAR"
:sessions="currentSessions"

View file

@ -49,12 +49,13 @@ type sempHandler struct {
// BenderCC charger implementation
type BenderCC struct {
implement.Caps
conn *modbus.Connection
current uint16
regCurr uint16
legacy bool
log *util.Logger
semp sempHandler
conn *modbus.Connection
current uint16
regCurr uint16
legacy bool
mennekes4 bool
log *util.Logger
semp sempHandler
}
const (
@ -135,10 +136,16 @@ func NewBenderCC(ctx context.Context, settings modbus.TcpSettings, cache time.Du
}
// check legacy register set
if _, err := wb.conn.ReadHoldingRegisters(bendRegChargePointModel, 10); err != nil {
var model string
if b, err := wb.conn.ReadHoldingRegisters(bendRegChargePointModel, 10); err != nil {
wb.legacy = true
} else {
model = bytesAsString(b)
}
// Mennekes 4You/4Business firmware closes the modbus connection on access to unsupported registers 730/740
wb.mennekes4 = strings.Contains(model, "4You") || strings.Contains(model, "4Business")
// check presence of metering
reg := uint16(bendRegActivePower)
if wb.legacy {
@ -155,7 +162,7 @@ func NewBenderCC(ctx context.Context, settings modbus.TcpSettings, cache time.Du
implement.Has(wb, implement.PhaseVoltages(wb.voltages))
}
if !wb.legacy {
if !wb.legacy && !wb.mennekes4 {
if _, err := wb.conn.ReadHoldingRegisters(bendRegEVBatteryState, 1); err == nil {
implement.Has(wb, implement.Battery(wb.soc))
}
@ -498,7 +505,7 @@ func (wb *BenderCC) getPhases() (int, error) {
// identify implements the api.Identifier interface
func (wb *BenderCC) identify() (string, error) {
if !wb.legacy {
if !wb.legacy && !wb.mennekes4 {
b, err := wb.conn.ReadHoldingRegisters(bendRegSmartVehicleDetected, 1)
if err == nil && binary.BigEndian.Uint16(b) != 0 {
b, err = wb.conn.ReadHoldingRegisters(bendRegEVCCID, 6)
@ -556,13 +563,13 @@ func (wb *BenderCC) Diagnose() {
if b, err := wb.conn.ReadHoldingRegisters(bendRegOcppCpStatus, 1); err == nil {
fmt.Printf("\tOCPP Status:\t%d\n", binary.BigEndian.Uint16(b))
}
if !wb.legacy {
if !wb.legacy && !wb.mennekes4 {
if b, err := wb.conn.ReadHoldingRegisters(bendRegSmartVehicleDetected, 1); err == nil {
fmt.Printf("\tSmart Vehicle:\t%t\n", binary.BigEndian.Uint16(b) != 0)
}
}
if b, err := wb.conn.ReadHoldingRegisters(bendRegEVCCID, 6); err == nil {
fmt.Printf("\tEVCCID:\t%s\n", b)
if b, err := wb.conn.ReadHoldingRegisters(bendRegEVCCID, 6); err == nil {
fmt.Printf("\tEVCCID:\t%s\n", b)
}
}
if b, err := wb.conn.ReadHoldingRegisters(bendRegUserID, 10); err == nil {
fmt.Printf("\tUserID:\t%s\n", b)

View file

@ -859,7 +859,7 @@ func (site *Site) updateGridMeter() error {
return nil
}
mm := types.Measurement{Name: site.Meters.GridMeterRef}
mm := types.Measurement{Name: site.gridMeter.Config().Name}
meter := site.gridMeter.Instance()
@ -913,7 +913,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)

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

View file

@ -49,12 +49,17 @@ func NewShellyFromConfig(other map[string]any) (api.Meter, error) {
// making their totals unsuitable for bidirectional grid metering.
if !(c.usage == "grid" && c.conn.IsThreePhase()) {
total, ret := c.conn.TotalEnergy, c.conn.ReturnEnergy
if c.usage == "pv" {
// reverse direction
// production is measured in return direction, unless the device has no return
// register at all or already reverses the direction itself
if c.usage == "pv" && c.conn.HasReturnEnergy() && !c.conn.IsReversed() {
total, ret = ret, total
}
implement.Has(c, implement.MeterEnergy(total))
implement.Has(c, implement.MeterReturnEnergy(ret))
// without a return register the second reading is a constant zero
if c.conn.HasReturnEnergy() {
implement.Has(c, implement.MeterReturnEnergy(ret))
}
}
if phases, ok := c.conn.Generation.(shelly.Phases); ok {
@ -88,16 +93,20 @@ func (c *Shelly) CurrentPower() (float64, error) {
if err != nil {
return 0, err
}
return c.currentPowerForUsage(power, c.conn.SignedPower()), nil
return c.currentPowerForUsage(power, c.conn.SignedPower(), c.conn.IsReversed()), nil
}
// PV usage inverts directional power, otherwise the magnitude is used.
func (c *Shelly) currentPowerForUsage(power float64, signed bool) float64 {
// PV usage inverts directional power unless the device already reverses it, otherwise the magnitude is used.
func (c *Shelly) currentPowerForUsage(power float64, signed, reversed bool) float64 {
if c.usage != "pv" {
return power
}
if signed {
switch {
case !signed:
return math.Abs(power)
case reversed:
return power
default:
return -power
}
return math.Abs(power)
}

View file

@ -19,6 +19,8 @@ type Generation interface {
api.MeterEnergy
api.MeterReturnEnergy
IsThreePhase() bool
IsReversed() bool
HasReturnEnergy() bool
}
type Phases interface {

View file

@ -0,0 +1,140 @@
package shelly
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// shellyServer emulates a gen2+ device serving the given rpc responses. Methods
// not listed are reported as unavailable by Shelly.ListMethods and answered 404.
func shellyServer(t *testing.T, rpc map[string]string) *httptest.Server {
t.Helper()
methods := make([]string, 0, len(rpc))
for m := range rpc {
methods = append(methods, m)
}
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/shelly":
json.NewEncoder(w).Encode(map[string]any{"gen": 2, "model": "SNSW-001X16EU"})
case "/rpc/Shelly.ListMethods":
json.NewEncoder(w).Encode(Gen2Methods{Methods: methods})
default:
method := strings.TrimPrefix(r.URL.Path, "/rpc/")
res, ok := rpc[method]
if !ok {
http.Error(w, `{"code":-105,"message":"no handler"}`, http.StatusNotFound)
return
}
w.Write([]byte(res))
}
}))
}
// TestNakedSwitchConnection asserts that a switch without power measurement
// (Shelly Plus 1) connects - its status has neither aenergy nor ret_aenergy.
func TestNakedSwitchConnection(t *testing.T) {
srv := shellyServer(t, map[string]string{
"Switch.GetStatus": `{"id":0,"source":"init","output":true,"temperature":{"tC":45.2,"tF":113.4}}`,
"Switch.GetConfig": `{"id":0,"name":null,"in_mode":"follow","initial_state":"match_input","auto_on":false}`,
})
defer srv.Close()
conn, err := NewConnection(srv.URL, "", "", 0, time.Second)
require.NoError(t, err, "naked switch must connect")
assert.False(t, conn.IsReversed())
assert.False(t, conn.HasReturnEnergy())
enabled, err := conn.Enabled()
require.NoError(t, err)
assert.True(t, enabled)
total, err := conn.TotalEnergy()
require.NoError(t, err)
assert.Zero(t, total)
ret, err := conn.ReturnEnergy()
require.NoError(t, err)
assert.Zero(t, ret)
}
// TestSwitchConnectionStatusError asserts that a temporarily unavailable switch
// status does not break connecting- the error surfaces on read instead.
func TestSwitchConnectionStatusError(t *testing.T) {
// Switch.GetStatus advertised but erroring, e.g. component busy right after boot
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/shelly":
json.NewEncoder(w).Encode(map[string]any{"gen": 2, "model": "SNSW-001X16EU"})
case "/rpc/Shelly.ListMethods":
json.NewEncoder(w).Encode(Gen2Methods{Methods: []string{"Switch.GetStatus", "Switch.GetConfig"}})
case "/rpc/Switch.GetConfig":
w.Write([]byte(`{"id":0}`))
default:
http.Error(w, `{"code":-114,"message":"component not found"}`, http.StatusNotFound)
}
}))
defer srv.Close()
conn, err := NewConnection(srv.URL, "", "", 0, time.Second)
require.NoError(t, err, "status error must not break connecting")
_, err = conn.CurrentPower()
require.Error(t, err, "error surfaces on read")
}
// TestPlugConnection covers the metered plug from #32213: aenergy without ret_aenergy.
func TestPlugConnection(t *testing.T) {
srv := shellyServer(t, map[string]string{
"Switch.GetStatus": `{"id":0,"output":true,"apower":399.2,"voltage":240.8,"current":1.886,"aenergy":{"total":2574466.629}}`,
"Switch.GetConfig": `{"id":0,"power_limit":2800,"voltage_limit":280}`,
})
defer srv.Close()
conn, err := NewConnection(srv.URL, "", "", 0, time.Second)
require.NoError(t, err)
assert.False(t, conn.IsReversed())
assert.False(t, conn.HasReturnEnergy(), "plug has no return register")
total, err := conn.TotalEnergy()
require.NoError(t, err)
assert.Equal(t, 2574.466629, total)
}
// TestReversedSwitchConnection covers a switch with device-side reverse measurement.
func TestReversedSwitchConnection(t *testing.T) {
srv := shellyServer(t, map[string]string{
"Switch.GetStatus": `{"id":0,"output":true,"apower":-350,"aenergy":{"total":10000},"ret_aenergy":{"total":4000}}`,
"Switch.GetConfig": `{"id":0,"reverse":true}`,
})
defer srv.Close()
conn, err := NewConnection(srv.URL, "", "", 0, time.Second)
require.NoError(t, err)
assert.True(t, conn.IsReversed())
assert.True(t, conn.HasReturnEnergy())
total, err := conn.TotalEnergy()
require.NoError(t, err)
assert.Equal(t, 6.0, total)
ret, err := conn.ReturnEnergy()
require.NoError(t, err)
assert.Equal(t, 4.0, ret)
}

View file

@ -144,6 +144,16 @@ func (c *gen1) ReturnEnergy() (float64, error) {
return c.energy(energy) / 1000, nil
}
// IsReversed reports whether the device reverses its measurement direction. Gen1 devices cannot.
func (c *gen1) IsReversed() bool {
return false
}
// HasReturnEnergy reports whether the device measures energy in the return direction
func (c *gen1) HasReturnEnergy() bool {
return true
}
// IsThreePhase reports whether the device is a three-phase energy meter.
func (c *gen1) IsThreePhase() bool {
res, err := c.status.Get()

View file

@ -28,6 +28,10 @@ type Gen2Methods struct {
Methods []string
}
type Gen2Config struct {
Reverse bool
}
type Gen2SwitchStatus struct {
Output bool
Apower float64
@ -36,11 +40,23 @@ type Gen2SwitchStatus struct {
Aenergy struct {
Total float64
}
Ret_Aenergy struct {
// nil on devices without reverse energy metering- they omit the register entirely
Ret_Aenergy *struct {
Total float64
}
}
// switchEnergy splits the switch registers into import and return energy (kWh).
// https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Switch#status
// NOTE: energy added to ret_aenergy is also added to aenergy, so aenergy holds
// both directions. Without the register aenergy is import only.
func switchEnergy(res Gen2SwitchStatus) (total, ret float64) {
if res.Ret_Aenergy == nil {
return res.Aenergy.Total / 1000, 0
}
return max(0, res.Aenergy.Total-res.Ret_Aenergy.Total) / 1000, res.Ret_Aenergy.Total / 1000
}
type Gen2EMStatus struct {
TotalActPower float64 `json:"total_act_power"`
ACurrent float64 `json:"a_current"`
@ -84,6 +100,7 @@ type gen2 struct {
switchchannel int
model string
methods []string
reversed bool
switchstatus util.Cacheable[Gen2SwitchStatus]
em1status func() (Gen2EM1Status, error)
em1data func() (Gen2EM1Data, error)
@ -139,6 +156,27 @@ func newGen2(helper *request.Helper, uri, model string, channel int, user, passw
} else {
c.switchstatus = util.ResettableCached(apiCall[Gen2SwitchStatus](c, c.switchchannel, "Switch.GetStatus"), cache)
}
// device-side "Reverse power measurement" setting (requires restart, hence static)
var cfgMethod string
cfgChannel := channel
switch {
case c.hasEM1Endpoint():
cfgMethod = "EM1.GetConfig"
case c.hasMethod("PM1.GetStatus"):
cfgMethod = "PM1.GetConfig"
case c.hasMethod("Switch.GetStatus"):
cfgMethod = "Switch.GetConfig"
cfgChannel = c.switchchannel
}
if c.hasMethod(cfgMethod) {
var cfg Gen2Config
if err := c.execCmd(cfgChannel, cfgMethod, &cfg); err != nil {
return nil, err
}
c.reversed = cfg.Reverse
}
c.em1status = util.Cached(apiCall[Gen2EM1Status](c, channel, "EM1.GetStatus"), cache)
c.em1data = util.Cached(apiCall[Gen2EM1Data](c, channel, "EM1Data.GetStatus"), cache)
c.emstatus = util.Cached(apiCall[Gen2EMStatus](c, channel, "EM.GetStatus"), cache)
@ -232,10 +270,8 @@ func (c *gen2) TotalEnergy() (float64, error) {
case c.hasSwitchEndpoint():
res, err := c.switchstatus.Get()
// https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Switch#status
// NOTE: ret_aenergy - the active energy added to this container is also added to aenergy container.
// All the consumed energy is collected in aenergy regardless of the direction(consumed or returned) of the active energy.
return max(0, res.Aenergy.Total-res.Ret_Aenergy.Total) / 1000, err
total, _ := switchEnergy(res)
return total, err
default:
return 0, fmt.Errorf("unknown shelly model: %s", c.model)
@ -255,7 +291,8 @@ func (c *gen2) ReturnEnergy() (float64, error) {
case c.hasSwitchEndpoint():
res, err := c.switchstatus.Get()
return res.Ret_Aenergy.Total / 1000, err
_, ret := switchEnergy(res)
return ret, err
default:
return 0, fmt.Errorf("unknown shelly model: %s", c.model)
@ -340,6 +377,25 @@ func (c *gen2) IsThreePhase() bool {
return c.hasEMEndpoint()
}
// IsReversed reports whether the device-side "Reverse power measurement" setting is enabled
func (c *gen2) IsReversed() bool {
return c.reversed
}
// HasReturnEnergy reports whether the device measures energy in the return direction.
// Plain plugs omit ret_aenergy entirely, so the (cached) status decides.
func (c *gen2) HasReturnEnergy() bool {
switch {
case c.hasEM1Endpoint(), c.hasEMEndpoint():
return true
case c.hasSwitchEndpoint():
res, err := c.switchstatus.Get()
return err == nil && res.Ret_Aenergy != nil
default:
return false
}
}
// Gen2+ models using EM1.GetStatus endpoint for power and EM1Data.GetStatus for energy
// https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/EM1#em1getstatus-example
// https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/EM1Data#em1datagetstatus-example

View file

@ -2,8 +2,11 @@ package shelly
import (
"encoding/json"
"errors"
"testing"
"time"
"github.com/evcc-io/evcc/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -80,3 +83,86 @@ func TestUnmarshalGen2StatusResponse(t *testing.T) {
assert.Equal(t, 0, parseAddOnSwitchID(channel, res))
}
}
func TestSwitchEnergy(t *testing.T) {
tests := []struct {
name string
status string
total, ret float64
hasReturnReg bool
}{
{
// Shelly Plug S Gen3 as pv meter, https://github.com/evcc-io/evcc/issues/32213
// no ret_aenergy register: aenergy is production, swapping it would report 0
name: "plug without reverse metering",
status: `{"id":0,"source":"init","output":true,"apower":399.2,"voltage":240.8,"current":1.886,"aenergy":{"total":2574466.629,"by_minute":[7200.069,6121.044,5088.795],"minute_ts":1786033200},"temperature":{"tC":46.3,"tF":115.4}}`,
total: 2574.466629,
},
{
// aenergy holds both directions, so import is the difference
name: "switch with reverse metering",
status: `{"id":0,"output":true,"apower":-350,"aenergy":{"total":10000},"ret_aenergy":{"total":4000}}`,
total: 6,
ret: 4,
hasReturnReg: true,
},
{
// pure production: everything lands in ret_aenergy, import must not go negative
name: "switch measuring return only",
status: `{"id":0,"output":true,"aenergy":{"total":4000},"ret_aenergy":{"total":4000}}`,
ret: 4,
hasReturnReg: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var res Gen2SwitchStatus
require.NoError(t, json.Unmarshal([]byte(tc.status), &res))
assert.Equal(t, tc.hasReturnReg, res.Ret_Aenergy != nil, "ret_aenergy presence")
total, ret := switchEnergy(res)
assert.Equal(t, tc.total, total, "total energy")
assert.Equal(t, tc.ret, ret, "return energy")
// same values through the endpoint dispatch
c := &gen2{
methods: []string{"Switch.GetStatus"},
switchstatus: util.ResettableCached(func() (Gen2SwitchStatus, error) {
return res, nil
}, time.Minute),
}
assert.Equal(t, tc.hasReturnReg, c.HasReturnEnergy(), "HasReturnEnergy")
totalEnergy, err := c.TotalEnergy()
require.NoError(t, err)
assert.Equal(t, tc.total, totalEnergy, "TotalEnergy")
returnEnergy, err := c.ReturnEnergy()
require.NoError(t, err)
assert.Equal(t, tc.ret, returnEnergy, "ReturnEnergy")
})
}
}
// a failed read yields the zero status, whose ret_aenergy register is nil too
func TestSwitchEnergyReadError(t *testing.T) {
c := &gen2{
methods: []string{"Switch.GetStatus"},
switchstatus: util.ResettableCached(func() (Gen2SwitchStatus, error) {
return Gen2SwitchStatus{}, errors.New("offline")
}, time.Minute),
}
require.NotPanics(t, func() {
total, err := c.TotalEnergy()
require.Error(t, err)
assert.Zero(t, total)
ret, err := c.ReturnEnergy()
require.Error(t, err)
assert.Zero(t, ret)
})
}

View file

@ -8,23 +8,26 @@ import (
func TestShellyCurrentPowerForUsage(t *testing.T) {
tests := []struct {
name string
usage string
signed bool
power float64
want float64
name string
usage string
signed bool
reversed bool
power float64
want float64
}{
{name: "grid keeps sign", usage: "grid", power: -350, want: -350},
{name: "unsigned pv uses absolute value", usage: "pv", power: -350, want: 350},
{name: "unsigned pv keeps positive values", usage: "pv", power: 350, want: 350},
{name: "signed pv inverts positive values", usage: "pv", signed: true, power: 350, want: -350},
{name: "signed pv inverts negative values", usage: "pv", signed: true, power: -350, want: 350},
{name: "signed reversed pv keeps sign", usage: "pv", signed: true, reversed: true, power: 350, want: 350},
{name: "signed reversed pv keeps negative sign", usage: "pv", signed: true, reversed: true, power: -350, want: -350},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
m := &Shelly{usage: tc.usage}
assert.Equal(t, tc.want, m.currentPowerForUsage(tc.power, tc.signed))
assert.Equal(t, tc.want, m.currentPowerForUsage(tc.power, tc.signed, tc.reversed))
})
}
}

View file

@ -51,6 +51,12 @@
"npm": ">=10.0.0",
"node": ">=26.0.0"
},
"devEngines": {
"packageManager": {
"name": "npm",
"version": ">=10.0.0"
}
},
"license": "MIT",
"repository": "github:evcc-io/evcc",
"devDependencies": {

View file

@ -226,22 +226,25 @@ func (p *HTTP) request(url string, body string) ([]byte, error) {
return nil, err
}
// warn on uncached GET polling: a repeated roundtrip means neither a configured
// cache nor the device's own response headers spared it. cache hits are exempt.
if p.method == http.MethodGet && p.mu == nil && resp.Header.Get(httpcache.XFromCache) == "" {
if key := stripQuery(url); repeatedGet(key, time.Now()) {
p.log.WARN.Printf("uncached request repeated within 1s, please report at https://github.com/evcc-io/evcc/issues: %s", key)
}
}
val, err := request.ReadBody(resp)
if err != nil {
if err2 := knownErrors(val); err2 != nil {
err = err2
}
return val, err
}
return val, err
// warn on uncached GET polling: a repeated roundtrip means neither a configured
// cache nor the device's own response headers spared it. cache hits are exempt.
// only successful responses count, a failed one is retried by the caller
if p.method == http.MethodGet && p.mu == nil && resp.Header.Get(httpcache.XFromCache) == "" {
if repeatedGet(url, time.Now()) {
p.log.WARN.Printf("uncached request repeated within 1s, please report at https://github.com/evcc-io/evcc/issues: %s", url)
}
}
return val, nil
}
type httpAccess struct {
@ -254,15 +257,6 @@ var (
httpSeen = make(map[string]httpAccess)
)
// stripQuery drops the query and fragment so cache-busting params do not make
// each poll look like a distinct url.
func stripQuery(url string) string {
if i := strings.IndexAny(url, "?#"); i >= 0 {
return url[:i]
}
return url
}
// repeatedGet reports the first time url is fetched again within a second, a sign
// the response should be cached. It fires once per url to avoid log spam.
func repeatedGet(url string, now time.Time) bool {

View file

@ -164,6 +164,27 @@ func (suite *httpTestSuite) TestSetPath() {
suite.Require().Equal("/foo/bar/4711", suite.h.req.URL.String())
}
func TestRepeatedGetFailed(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "nope", http.StatusInternalServerError)
}))
defer srv.Close()
p := NewHTTP(util.NewLogger("foo"), http.MethodGet, srv.URL, false, 0)
g, err := p.StringGetter()
require.NoError(t, err)
// a failing request is retried by the caller, it must not count as a sighting
for range 2 {
_, err := g()
require.Error(t, err)
}
httpSeenMu.Lock()
defer httpSeenMu.Unlock()
require.NotContains(t, httpSeen, srv.URL)
}
func TestRepeatedGet(t *testing.T) {
url := "http://repeated.test/uncached"
t0 := time.Now()
@ -176,8 +197,7 @@ func TestRepeatedGet(t *testing.T) {
require.False(t, repeatedGet(spaced, t0))
require.False(t, repeatedGet(spaced, t0.Add(2*time.Second))) // >1s apart: no warn
// query params are stripped before keying, so cache-busting still counts as a repeat
require.Equal(t, "http://q.test/path", stripQuery("http://q.test/path?ts=1&x=2#frag"))
require.False(t, repeatedGet(stripQuery("http://q.test/path?ts=1"), t0))
require.True(t, repeatedGet(stripQuery("http://q.test/path?ts=2"), t0.Add(300*time.Millisecond)))
// query params are part of the key, so cache-busting urls are distinct requests
require.False(t, repeatedGet("http://q.test/path?ts=1", t0))
require.False(t, repeatedGet("http://q.test/path?ts=2", t0.Add(300*time.Millisecond)))
}

View file

@ -425,7 +425,7 @@ render: |
address: 60324 # Grid Maximum Export Power (kW * 1000)
type: writemultiple
encoding: uint32
curtailed:
curtailed: # the power limit is expressed as percent of nominal discharge power
source: go
in:
- name: limit
@ -437,7 +437,18 @@ render: |
address: 60324 # Grid Maximum Export Power (kW * 1000)
type: holding
decode: uint32
script: limit != 0xFFFFFFFF
- name: maxdischargepower
type: int
config:
source: const
value: {{ .maxdischargepower }}
script: |
percent := 100
if limit != 0xFFFFFFFF && maxdischargepower > 0 {
// round, the watt conversion does not reproduce the written percent exactly
percent = (limit*100 + maxdischargepower/2) / maxdischargepower
}
percent
dim:
source: ifelse
if:

View file

@ -106,7 +106,7 @@ render: |
- source: modbus
{{- include "modbus" . | indent 6 }}
register:
address: 62836 # Battery 1 Instantaneous Power
address: 0xE174 # Battery 1 Instantaneous Power
type: holding
decode: float32nans
maxacpower: {{ .maxacpower }} # W

View file

@ -33,6 +33,10 @@ function chartSection(page: Page, heading: string): Locator {
}
test("device colors: autoassign, override, persistence", async ({ page }) => {
// fixture sessions are dated 2026-05. Sessions.vue ranks loadpoints by energy
// over the last three months relative to now, so pin the clock to keep them in range
await page.clock.setFixedTime(new Date("2026-05-15T12:00:00Z"));
// ---------- Step 1 — Sessions, by-vehicle view ----------
await page.goto("/#/sessions?year=2026&month=5");
await expect(page.getByRole("heading", { name: "Charging Sessions" })).toBeVisible();

View file

@ -30,6 +30,7 @@ import (
"github.com/evcc-io/evcc/util/cloud"
"github.com/evcc-io/evcc/util/machine"
"github.com/golang-jwt/jwt/v5"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
@ -46,6 +47,9 @@ func machineID() string {
const unavailable = "sponsorship unavailable"
// startupTimeout leaves the network time to settle at boot; grpc retries dialing with backoff until deadline
const startupTimeout = 30 * time.Second
func IsAuthorized() bool {
mu.RLock()
defer mu.RUnlock()
@ -98,10 +102,10 @@ func ConfigureSponsorship(token string) error {
client := pb.NewAuthClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), startupTimeout)
defer cancel()
res, err := client.IsAuthorized(ctx, &pb.AuthRequest{Token: token, MachineId: machineID()})
res, err := client.IsAuthorized(ctx, &pb.AuthRequest{Token: token, MachineId: machineID()}, grpc.WaitForReady(true))
if err == nil && res.Authorized {
Subject = res.Subject
ExpiresAt = res.ExpiresAt.AsTime()

View file

@ -22,7 +22,7 @@ import (
"github.com/evcc-io/evcc/api/proto/pb"
"github.com/evcc-io/evcc/util/cloud"
"github.com/evcc-io/evcc/util/request"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
@ -34,7 +34,7 @@ func checkHardware(vendor string, metadata map[string]string) string {
return unavailable
}
ctx, cancel := context.WithTimeout(context.Background(), request.Timeout)
ctx, cancel := context.WithTimeout(context.Background(), startupTimeout)
defer cancel()
client := pb.NewAuthClient(conn)
@ -42,7 +42,7 @@ func checkHardware(vendor string, metadata map[string]string) string {
MachineId: machineID(),
Vendor: vendor,
Metadata: metadata,
})
}, grpc.WaitForReady(true))
if err == nil && res.Authorized {
return res.Subject

View file

@ -33,7 +33,10 @@ var requiredKeys = []string{
"vehicle.vehicle.travelledDistance",
}
const requiredVersion = "v6"
const (
containerName = "evcc.io"
requiredVersion = "v6"
)
type API struct {
*request.Helper

View file

@ -205,7 +205,7 @@ func (v *MqttConnector) handler(_ mqtt.Client, m mqtt.Message) {
return
}
v.log.TRACE.Println("recv: " + string(m.Payload()))
v.log.TRACE.Printf("recv %s: %s", m.Topic(), string(m.Payload()))
v.mu.RLock()
defer v.mu.RUnlock()

View file

@ -71,14 +71,17 @@ func (v *Provider) findOrCreateContainer() (string, error) {
return "", err
}
// obsolete containers keep streaming, resulting in duplicate messages
defer v.deleteObsoleteContainers(containers)
if i := slices.IndexFunc(containers, func(c Container) bool {
return c.Name == "evcc.io" && c.Purpose == requiredVersion
return c.Name == containerName && c.Purpose == requiredVersion
}); i >= 0 {
return containers[i].ContainerId, nil
}
res, err := v.api.CreateContainer(CreateContainer{
Name: "evcc.io",
Name: containerName,
Purpose: requiredVersion,
TechnicalDescriptors: requiredKeys,
})
@ -86,6 +89,20 @@ func (v *Provider) findOrCreateContainer() (string, error) {
return res.ContainerId, err
}
func (v *Provider) deleteObsoleteContainers(containers []Container) {
for _, c := range containers {
if c.Name != containerName || c.Purpose == requiredVersion {
continue
}
v.log.DEBUG.Printf("deleting obsolete container %s (%s)", c.ContainerId, c.Purpose)
if err := v.api.DeleteContainer(c.ContainerId); err != nil {
v.log.WARN.Printf("delete container %s: %v", c.ContainerId, err)
}
}
}
func (v *Provider) setupContainer() error {
container, err := v.findOrCreateContainer()
if err != nil {