Compare commits

...

8 commits

Author SHA1 Message Date
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
19 changed files with 114 additions and 52 deletions

View file

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

View file

@ -1,9 +1,9 @@
<template> <template>
<div v-if="chartData.labels.length > 1" class="row"> <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" /> <PolarArea :data="chartData" :options="options" />
</div> </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 /> <LegendList :legends="legends" :device-colors="deviceColors" grid />
</div> </div>
</div> </div>
@ -130,7 +130,7 @@ export default defineComponent({
beginAtZero: false, beginAtZero: false,
ticks: { ticks: {
color: colors.muted || "", color: colors.muted || "",
backdropColor: colors.background || "", backdropColor: colors.box || "",
font: { size: 10 }, font: { size: 10 },
callback: this.formatValue, callback: this.formatValue,
maxTicksLimit: 6, maxTicksLimit: 6,

View file

@ -1,9 +1,9 @@
<template> <template>
<div v-if="chartData.labels.length > 1" class="row"> <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" /> <Doughnut :data="chartData" :options="options" />
</div> </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 /> <LegendList :legends="legends" :device-colors="deviceColors" grid />
</div> </div>
</div> </div>
@ -94,7 +94,7 @@ export default defineComponent({
borderRadius: 10, borderRadius: 10,
color: colors.text || "", color: colors.text || "",
borderWidth: 3, borderWidth: 3,
borderColor: colors.background || "", borderColor: colors.box || "",
cutout: "70%", cutout: "70%",
radius: "95%", radius: "95%",
animation: { duration: 250 }, animation: { duration: 250 },

View file

@ -1,9 +1,9 @@
<template> <template>
<div v-if="chartData.labels.length > 1" class="row"> <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" /> <Doughnut :data="chartData" :options="options" />
</div> </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 /> <LegendList :legends="legends" :device-colors="deviceColors" grid />
</div> </div>
</div> </div>
@ -107,7 +107,7 @@ export default defineComponent({
borderRadius: 10, borderRadius: 10,
color: colors.text, color: colors.text,
borderWidth: 3, borderWidth: 3,
borderColor: colors.background, borderColor: colors.box,
cutout: "70%", cutout: "70%",
radius: "95%", radius: "95%",
animation: { duration: 250 }, animation: { duration: 250 },

View file

@ -1,6 +1,6 @@
<template> <template>
<ul <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="{ :class="{
'root--small-equal-widths': smallEqualWidths, 'root--small-equal-widths': smallEqualWidths,
'root--grid': grid, 'root--grid': grid,
@ -50,7 +50,7 @@
<div <div
v-for="value in valueList(legend.value)" v-for="value in valueList(legend.value)"
:key="value" :key="value"
class="text-muted text-nowrap legend-value text-end" class="text-muted text-nowrap legend-value text-end tabular"
> >
{{ value }} {{ value }}
</div> </div>

View file

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

View file

@ -1,9 +1,9 @@
<template> <template>
<div v-if="chartData.labels.length > 1" class="row"> <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" /> <PolarArea :data="chartData" :options="options" />
</div> </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 /> <LegendList :legends="legends" :device-colors="deviceColors" grid />
</div> </div>
</div> </div>
@ -123,7 +123,7 @@ export default defineComponent({
ticks: { ticks: {
stepSize: 25, stepSize: 25,
color: colors.muted, color: colors.muted,
backdropColor: colors.background, backdropColor: colors.box,
}, },
grid: { color: colors.border }, grid: { color: colors.border },
}, },

View file

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

View file

@ -86,9 +86,9 @@
/> />
</Card> </Card>
<div v-if="showExtraCharts"> <div v-if="showExtraCharts">
<div class="row align-items-start"> <div class="row">
<div class="col-12 col-lg-6 mb-4"> <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"> <div v-if="activeType === types.SOLAR">
<SolarYearChart <SolarYearChart
v-if="showSolarYearChart" v-if="showSolarYearChart"
@ -116,7 +116,7 @@
</Card> </Card>
</div> </div>
<div class="col-12 col-lg-6 mb-4"> <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 <EnergyGroupedChart
v-if="activeType === types.SOLAR" v-if="activeType === types.SOLAR"
:sessions="currentSessions" :sessions="currentSessions"

View file

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

View file

@ -226,22 +226,25 @@ func (p *HTTP) request(url string, body string) ([]byte, error) {
return nil, err 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) val, err := request.ReadBody(resp)
if err != nil { if err != nil {
if err2 := knownErrors(val); err2 != nil { if err2 := knownErrors(val); err2 != nil {
err = err2 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 { type httpAccess struct {
@ -254,15 +257,6 @@ var (
httpSeen = make(map[string]httpAccess) 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 // 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. // the response should be cached. It fires once per url to avoid log spam.
func repeatedGet(url string, now time.Time) bool { 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()) 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) { func TestRepeatedGet(t *testing.T) {
url := "http://repeated.test/uncached" url := "http://repeated.test/uncached"
t0 := time.Now() t0 := time.Now()
@ -176,8 +197,7 @@ func TestRepeatedGet(t *testing.T) {
require.False(t, repeatedGet(spaced, t0)) require.False(t, repeatedGet(spaced, t0))
require.False(t, repeatedGet(spaced, t0.Add(2*time.Second))) // >1s apart: no warn 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 // query params are part of the key, so cache-busting urls are distinct requests
require.Equal(t, "http://q.test/path", stripQuery("http://q.test/path?ts=1&x=2#frag")) require.False(t, repeatedGet("http://q.test/path?ts=1", t0))
require.False(t, repeatedGet(stripQuery("http://q.test/path?ts=1"), t0)) require.False(t, repeatedGet("http://q.test/path?ts=2", t0.Add(300*time.Millisecond)))
require.True(t, repeatedGet(stripQuery("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) address: 60324 # Grid Maximum Export Power (kW * 1000)
type: writemultiple type: writemultiple
encoding: uint32 encoding: uint32
curtailed: curtailed: # the power limit is expressed as percent of nominal discharge power
source: go source: go
in: in:
- name: limit - name: limit
@ -437,7 +437,18 @@ render: |
address: 60324 # Grid Maximum Export Power (kW * 1000) address: 60324 # Grid Maximum Export Power (kW * 1000)
type: holding type: holding
decode: uint32 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: dim:
source: ifelse source: ifelse
if: if:

View file

@ -33,6 +33,10 @@ function chartSection(page: Page, heading: string): Locator {
} }
test("device colors: autoassign, override, persistence", async ({ page }) => { 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 ---------- // ---------- Step 1 — Sessions, by-vehicle view ----------
await page.goto("/#/sessions?year=2026&month=5"); await page.goto("/#/sessions?year=2026&month=5");
await expect(page.getByRole("heading", { name: "Charging Sessions" })).toBeVisible(); 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/cloud"
"github.com/evcc-io/evcc/util/machine" "github.com/evcc-io/evcc/util/machine"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"google.golang.org/grpc"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
) )
@ -46,6 +47,9 @@ func machineID() string {
const unavailable = "sponsorship unavailable" 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 { func IsAuthorized() bool {
mu.RLock() mu.RLock()
defer mu.RUnlock() defer mu.RUnlock()
@ -98,10 +102,10 @@ func ConfigureSponsorship(token string) error {
client := pb.NewAuthClient(conn) client := pb.NewAuthClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), startupTimeout)
defer cancel() 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 { if err == nil && res.Authorized {
Subject = res.Subject Subject = res.Subject
ExpiresAt = res.ExpiresAt.AsTime() ExpiresAt = res.ExpiresAt.AsTime()

View file

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

View file

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

View file

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

View file

@ -71,14 +71,17 @@ func (v *Provider) findOrCreateContainer() (string, error) {
return "", err return "", err
} }
// obsolete containers keep streaming, resulting in duplicate messages
defer v.deleteObsoleteContainers(containers)
if i := slices.IndexFunc(containers, func(c Container) bool { 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 { }); i >= 0 {
return containers[i].ContainerId, nil return containers[i].ContainerId, nil
} }
res, err := v.api.CreateContainer(CreateContainer{ res, err := v.api.CreateContainer(CreateContainer{
Name: "evcc.io", Name: containerName,
Purpose: requiredVersion, Purpose: requiredVersion,
TechnicalDescriptors: requiredKeys, TechnicalDescriptors: requiredKeys,
}) })
@ -86,6 +89,20 @@ func (v *Provider) findOrCreateContainer() (string, error) {
return res.ContainerId, err 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 { func (v *Provider) setupContainer() error {
container, err := v.findOrCreateContainer() container, err := v.findOrCreateContainer()
if err != nil { if err != nil {