From 9db7be1ededad485622f4e72c5c1f9b8c8d90f88 Mon Sep 17 00:00:00 2001 From: andig Date: Wed, 22 Jul 2026 13:47:50 +0200 Subject: [PATCH] Optimizer: notify on advisory suggestion changes (#31905) --- .../components/Config/Messaging/EventItem.vue | 1 + .../Config/Messaging/MessagingModal.vue | 3 +- assets/js/types/evcc.ts | 1 + core/site.go | 5 +- core/site_optimizer.go | 111 +++++++++++++++++- core/site_optimizer_test.go | 51 ++++++++ i18n/en.json | 5 + messenger/hub.go | 10 +- tests/config-messaging.spec.ts | 9 +- 9 files changed, 185 insertions(+), 11 deletions(-) diff --git a/assets/js/components/Config/Messaging/EventItem.vue b/assets/js/components/Config/Messaging/EventItem.vue index 93f692e2d..4cf45d05e 100644 --- a/assets/js/components/Config/Messaging/EventItem.vue +++ b/assets/js/components/Config/Messaging/EventItem.vue @@ -82,6 +82,7 @@ const EVENT_PARAMS: Record> = { start: { mode: "${mode}" }, stop: { chargedEnergy: "${chargedEnergy:%.1fk}", chargeDuration: "${chargeDuration}" }, planoverrun: { vehicleTitle: "{{ if .vehicleTitle }} {{ .vehicleTitle }} {{end}}" }, + suggestion: { suggestionTitle: "${suggestionTitle}", suggestionAction: "${suggestionAction}" }, guest: {}, }; diff --git a/assets/js/components/Config/Messaging/MessagingModal.vue b/assets/js/components/Config/Messaging/MessagingModal.vue index 9536277d5..15140a894 100644 --- a/assets/js/components/Config/Messaging/MessagingModal.vue +++ b/assets/js/components/Config/Messaging/MessagingModal.vue @@ -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 ?? "", }; diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index bab00a197..ee7ad95b6 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -627,6 +627,7 @@ export enum MESSAGING_EVENTS { GUEST = "guest", ASLEEP = "asleep", PLANOVERRUN = "planoverrun", + SUGGESTION = "suggestion", } export interface MessagingEvent { diff --git a/core/site.go b/core/site.go index 0abbed8b2..bb85c8c5f 100644 --- a/core/site.go +++ b/core/site.go @@ -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 diff --git a/core/site_optimizer.go b/core/site_optimizer.go index 6922221f5..e72398a92 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -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:" / "battery:") 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 } diff --git a/core/site_optimizer_test.go b/core/site_optimizer_test.go index aa0fb9559..595593c3f 100644 --- a/core/site_optimizer_test.go +++ b/core/site_optimizer_test.go @@ -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) +} diff --git a/i18n/en.json b/i18n/en.json index 42363ccbc..d2973a129 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -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", diff --git a/messenger/hub.go b/messenger/hub.go index a81d8e52a..cd50bc9b7 100644 --- a/messenger/hub.go +++ b/messenger/hub.go @@ -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) } diff --git a/tests/config-messaging.spec.ts b/tests/config-messaging.spec.ts index a72861fe8..47b174e21 100644 --- a/tests/config-messaging.spec.ts +++ b/tests/config-messaging.spec.ts @@ -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("")); }); });