Optimizer: notify on advisory suggestion changes (#31905)

This commit is contained in:
andig 2026-07-22 13:47:50 +02:00 • committed by GitHub
parent de4c63d182
commit 9db7be1ede
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 185 additions and 11 deletions

View file

@ -82,6 +82,7 @@ const EVENT_PARAMS: Record<MESSAGING_EVENTS, Record<string, string>> = {
start: { mode: "${mode}" },
stop: { chargedEnergy: "${chargedEnergy:%.1fk}", chargeDuration: "${chargeDuration}" },
planoverrun: { vehicleTitle: "{{ if .vehicleTitle }} {{ .vehicleTitle }} {{end}}" },
suggestion: { suggestionTitle: "${suggestionTitle}", suggestionAction: "${suggestionAction}" },
guest: {},
};

View file

@ -128,7 +128,8 @@ export default {
this.events.forEach((event) => {
const e = v[event];
v[event] = {
disabled: e?.disabled ?? true,
// suggestion is advisory and opt-out to gather feedback, all others opt-in
disabled: e?.disabled ?? event !== MESSAGING_EVENTS.SUGGESTION,
title: e?.title ?? "",
msg: e?.msg ?? "",
};

View file

@ -627,6 +627,7 @@ export enum MESSAGING_EVENTS {
GUEST = "guest",
ASLEEP = "asleep",
PLANOVERRUN = "planoverrun",
SUGGESTION = "suggestion",
}
export interface MessagingEvent {

View file

@ -53,7 +53,8 @@ var _ site.API = (*Site)(nil)
// Site is the main configuration container. A site can host multiple loadpoints.
type Site struct {
valueChan chan<- util.Param // client push messages
valueChan chan<- util.Param // client push messages
pushChan chan<- messenger.Event // notification events
lpUpdateChan chan *Loadpoint
sync.RWMutex
@ -112,6 +113,7 @@ type Site struct {
batteryModeExternalTimer time.Time // Battery mode timer for external control
batterySuggestions map[string]types.Suggestion // Optimizer suggestions by battery meter name
loadpointSuggestions map[int]types.Suggestion // Optimizer suggestions by loadpoint id
suggestionActions map[string]string // last notified actionable optimizer action by device key
}
// MetersConfig contains the site's meter configuration
@ -1228,6 +1230,7 @@ func (site *Site) prepare() {
// Prepare attaches communication channels to site and loadpoints
func (site *Site) Prepare(valueChan chan<- util.Param, pushChan chan<- messenger.Event) {
site.pushChan = pushChan
// https://github.com/evcc-io/evcc/issues/11191 prevent deadlock
// https://github.com/evcc-io/evcc/pull/11675 maintain message order

View file

@ -19,6 +19,7 @@ import (
"github.com/evcc-io/evcc/core/metrics"
"github.com/evcc-io/evcc/core/types"
"github.com/evcc-io/evcc/hems/hems"
"github.com/evcc-io/evcc/messenger"
"github.com/evcc-io/evcc/tariff"
"github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/util/request"
@ -119,6 +120,38 @@ const (
actionCharge = "charge"
)
// evSuggestion notifies when the optimizer's advisory action for a device changes
const evSuggestion = "suggestion"
// pendingSuggestion pairs a device's current-run suggestion with the
// notification event to emit if it represents an actionable change.
type pendingSuggestion struct {
suggestion types.Suggestion
event messenger.Event
}
// suggestionEvent builds the notification key and event for a device suggestion.
// The key ("loadpoint:<id>" / "battery:<name>") identifies the device across
// runs; an empty key means the device can't act on a suggestion.
func suggestionEvent(detail batteryDetail, s types.Suggestion) (string, messenger.Event) {
ev := messenger.Event{Event: evSuggestion, Attributes: map[string]any{
"suggestionAction": s.Action,
"suggestionTitle": detail.Title,
}}
switch {
case detail.Type == batteryTypeBattery:
ev.Attributes["suggestionName"] = detail.Name
return "battery:" + detail.Name, ev
case detail.loadpoint != nil:
id := *detail.loadpoint
ev.Loadpoint = &id
return fmt.Sprintf("loadpoint:%d", id), ev
default:
return "", ev
}
}
// currentSlotSuggestion maps the optimizer's first-slot corner result onto an advisory action.
// Because the optimization is linear, the first slot is at an operating-range extreme, so it
// maps cleanly onto the discrete battery mode / loadpoint intent that control would later apply.
@ -235,6 +268,66 @@ func (site *Site) clearSuggestions() {
site.publishBattery()
site.publishSuggestions()
site.Lock()
site.suggestionActions = nil
site.Unlock()
}
// pendingSuggestions collects the stored suggestions with their actionable flag
// evaluated against the devices' current operating mode
func (site *Site) pendingSuggestions(details []batteryDetail) map[string]pendingSuggestion {
pending := make(map[string]pendingSuggestion, len(details))
for _, detail := range details {
var s *types.Suggestion
switch {
case detail.Type == batteryTypeBattery:
s = site.batterySuggestion(detail.Name)
case detail.loadpoint != nil:
s = site.loadpointSuggestion(*detail.loadpoint)
}
if s == nil {
continue
}
key, ev := suggestionEvent(detail, *s)
pending[key] = pendingSuggestion{suggestion: *s, event: ev}
}
return pending
}
// diffSuggestions updates the tracked actionable optimizer suggestions and
// returns the events to send for devices whose actionable action changed since
// the last run. Non-actionable or vanished devices are pruned so a later
// actionable change re-notifies.
func (site *Site) diffSuggestions(pending map[string]pendingSuggestion) []messenger.Event {
site.Lock()
defer site.Unlock()
if site.suggestionActions == nil {
site.suggestionActions = make(map[string]string)
}
// prune devices that are gone or no longer actionable
for key := range site.suggestionActions {
if p, ok := pending[key]; !ok || !p.suggestion.Actionable {
delete(site.suggestionActions, key)
}
}
var events []messenger.Event
for key, p := range pending {
if !p.suggestion.Actionable || site.suggestionActions[key] == p.suggestion.Action {
continue
}
site.suggestionActions[key] = p.suggestion.Action
events = append(events, p.event)
}
return events
}
type requestDetails struct {
@ -497,12 +590,15 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error {
if suggestion.Action == "" {
continue
}
if detail.Type == batteryTypeBattery {
switch {
case detail.Type == batteryTypeBattery:
// uncontrollable batteries can't act on a suggestion
if detail.controllable {
suggestions[detail.Name] = suggestion
if !detail.controllable {
continue
}
} else if detail.loadpoint != nil {
suggestions[detail.Name] = suggestion
case detail.loadpoint != nil:
lpSuggestions[*detail.loadpoint] = suggestion
}
}
@ -517,6 +613,13 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error {
// publish for all loadpoints so suggestions of dropped-out loadpoints clear
site.publishSuggestions()
// notify on actionable suggestion changes (advisory only, see #31903)
if site.pushChan != nil {
for _, ev := range site.diffSuggestions(site.pendingSuggestions(details.BatteryDetails)) {
site.pushChan <- ev
}
}
return nil
}

View file

@ -353,3 +353,54 @@ func TestSuggestionActionable(t *testing.T) {
assert.Nil(t, site.loadpointSuggestion(1))
}
func TestSuggestionEvent(t *testing.T) {
id := 2
// battery: no loadpoint id, carries name
key, ev := suggestionEvent(batteryDetail{Type: batteryTypeBattery, Name: "home", Title: "Home"}, types.Suggestion{Action: api.BatteryCharge.String()})
assert.Equal(t, "battery:home", key)
assert.Nil(t, ev.Loadpoint)
assert.Equal(t, evSuggestion, ev.Event)
assert.Equal(t, api.BatteryCharge.String(), ev.Attributes["suggestionAction"])
assert.Equal(t, "home", ev.Attributes["suggestionName"])
assert.Equal(t, "Home", ev.Attributes["suggestionTitle"])
// loadpoint: carries id, no name
key, ev = suggestionEvent(batteryDetail{Type: batteryTypeVehicle, loadpoint: &id, Title: "Garage"}, types.Suggestion{Action: actionCharge})
assert.Equal(t, "loadpoint:2", key)
require.NotNil(t, ev.Loadpoint)
assert.Equal(t, id, *ev.Loadpoint)
assert.NotContains(t, ev.Attributes, "suggestionName")
}
func TestDiffSuggestions(t *testing.T) {
site := &Site{}
pending := func(s types.Suggestion) map[string]pendingSuggestion {
_, ev := suggestionEvent(batteryDetail{loadpoint: new(int)}, s)
return map[string]pendingSuggestion{"loadpoint:0": {suggestion: s, event: ev}}
}
charge := types.Suggestion{Action: actionCharge, Actionable: true}
stop := types.Suggestion{Action: actionStop, Actionable: true}
notActionable := types.Suggestion{Action: actionCharge, Actionable: false}
// first actionable suggestion fires
assert.Len(t, site.diffSuggestions(pending(charge)), 1)
// unchanged action does not fire again
assert.Empty(t, site.diffSuggestions(pending(charge)))
// changed action fires
assert.Len(t, site.diffSuggestions(pending(stop)), 1)
// non-actionable suggestion does not fire and clears tracking so the same
// action re-notifies when it becomes actionable again
assert.Empty(t, site.diffSuggestions(pending(notActionable)))
assert.Len(t, site.diffSuggestions(pending(stop)), 1)
// vanished device is pruned and re-notifies on return
assert.Empty(t, site.diffSuggestions(map[string]pendingSuggestion{}))
assert.Len(t, site.diffSuggestions(pending(stop)), 1)
}

View file

@ -585,6 +585,11 @@
"messageDefault": "Finished charging {chargedEnergy}kWh in {chargeDuration}.",
"title": "When charging stops",
"titleDefault": "Charge finished"
},
"suggestion": {
"messageDefault": "Optimizer suggestion for {suggestionTitle}: if optimizer control were active it would apply '{suggestionAction}' now. Advisory only, nothing was changed. Experimental — feedback welcome at https://github.com/evcc-io/evcc/issues/31903",
"title": "When the optimizer's advisory action changes",
"titleDefault": "Optimizer suggestion"
}
},
"eventMessage": "Message",

View file

@ -15,8 +15,9 @@ import (
// Event is a notification event
type Event struct {
Loadpoint *int // optional loadpoint id
Event string
Loadpoint *int // optional loadpoint id
Event string
Attributes map[string]any // optional event-specific template attributes
}
type Vehicles interface {
@ -104,6 +105,11 @@ func (h *Hub) apply(ev Event, tmpl string) (string, error) {
}
}
// event-specific attributes override cache values
for k, v := range ev.Attributes {
attr[k] = v
}
return util.ReplaceFormatted(tmpl, attr)
}

View file

@ -114,7 +114,8 @@ test.describe("messaging", async () => {
// validate connection
await modal.getByRole("button", { name: "Save", exact: true }).click();
await expectModalHidden(modal);
await expect(card).toContainText(["Events", "1", "Services", "0"].join(""));
// start plus the opt-out suggestion event
await expect(card).toContainText(["Events", "2", "Services", "0"].join(""));
// restart button appears
const restartButton = page
@ -165,7 +166,8 @@ test.describe("messaging", async () => {
await expect(messengerBox).toHaveText(["#1", "User-defined service"].join(""));
await modal.getByRole("button", { name: "Close" }).click();
await expectModalHidden(modal);
await expect(card).toContainText(["Events", "0", "Services", "1"].join(""));
// suggestion event is enabled by default
await expect(card).toContainText(["Events", "1", "Services", "1"].join(""));
await card.getByRole("button", { name: "edit" }).click();
await expectModalVisible(modal);
@ -179,6 +181,7 @@ test.describe("messaging", async () => {
await page.keyboard.press("Escape");
await expectModalHidden(modal);
await expect(card).toContainText(["Configured", "no"].join(""));
// service gone, saved suggestion event remains
await expect(card).toContainText(["Events", "1", "Services", "0"].join(""));
});
});