Optimizer: notify on advisory suggestion changes (#31905)
This commit is contained in:
parent
de4c63d182
commit
9db7be1ede
9 changed files with 185 additions and 11 deletions
|
|
@ -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: {},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ?? "",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -627,6 +627,7 @@ export enum MESSAGING_EVENTS {
|
|||
GUEST = "guest",
|
||||
ASLEEP = "asleep",
|
||||
PLANOVERRUN = "planoverrun",
|
||||
SUGGESTION = "suggestion",
|
||||
}
|
||||
|
||||
export interface MessagingEvent {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(""));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue