Compare commits
12 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec8c5ec8ea | ||
|
|
e0192a0e42 | ||
|
|
12958e1b04 | ||
|
|
fbcea37f61 | ||
|
|
5b4b4a333c | ||
|
|
4ca64eaf49 | ||
|
|
58938bd0a2 | ||
|
|
07f3982d1e | ||
|
|
e4ca549ea6 | ||
|
|
0990bd9784 | ||
|
|
fff6479115 | ||
|
|
58c4b446d6 |
30 changed files with 626 additions and 88 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -174,7 +174,7 @@ const COLUMNS_PER_BREAKPOINT = {
|
|||
sm: 3,
|
||||
md: 4,
|
||||
lg: 7,
|
||||
xl: 8,
|
||||
xl: 7,
|
||||
xxl: 9,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
161
core/site_delete_test.go
Normal 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")
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ type Generation interface {
|
|||
api.MeterEnergy
|
||||
api.MeterReturnEnergy
|
||||
IsThreePhase() bool
|
||||
IsReversed() bool
|
||||
HasReturnEnergy() bool
|
||||
}
|
||||
|
||||
type Phases interface {
|
||||
|
|
|
|||
140
meter/shelly/connection_test.go
Normal file
140
meter/shelly/connection_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -33,7 +33,10 @@ var requiredKeys = []string{
|
|||
"vehicle.vehicle.travelledDistance",
|
||||
}
|
||||
|
||||
const requiredVersion = "v6"
|
||||
const (
|
||||
containerName = "evcc.io"
|
||||
requiredVersion = "v6"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
*request.Helper
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue