Skip to content

Add delta plugin#26897

Merged
andig merged 4 commits intomasterfrom
feat/delta
Jan 28, 2026
Merged

Add delta plugin#26897
andig merged 4 commits intomasterfrom
feat/delta

Conversation

@andig
Copy link
Member

@andig andig commented Jan 22, 2026

Refs #26875 (comment)

Some heatpumps expect current excess power, which is the delta of target power and current power. The delta plugin calculates that difference.

/cc @premultiply @ganzton @docolli

@evcc-bot evcc-bot added enhancement New feature or request heating Heating labels Jan 22, 2026
Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've left some high level feedback:

  • The plugin keeps a single total value for all params, so if multiple parameters use this plugin they will interfere with each other; consider tracking deltas per parameter (e.g., a map keyed by param) instead of a single shared accumulator.
  • The deltaPlugin state (total) is mutated without any synchronization; if setters can be called concurrently, you may need to protect this with a mutex or otherwise enforce single-threaded access.
  • The decoded pipeline.Settings in NewDeltaFromConfig are currently unused; either apply them (e.g., for behavior configuration) or remove them from the config struct to avoid confusion.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The plugin keeps a single `total` value for all `param`s, so if multiple parameters use this plugin they will interfere with each other; consider tracking deltas per parameter (e.g., a map keyed by `param`) instead of a single shared accumulator.
- The `deltaPlugin` state (`total`) is mutated without any synchronization; if setters can be called concurrently, you may need to protect this with a mutex or otherwise enforce single-threaded access.
- The decoded `pipeline.Settings` in `NewDeltaFromConfig` are currently unused; either apply them (e.g., for behavior configuration) or remove them from the config struct to avoid confusion.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@premultiply
Copy link
Member

Heatpump is a slowly self-regulating device.
The expectation is to provide the raw grid power to this type of register like a seperate meter being attached to the heatpump to read the solar surplus or grid import.

It is not designed to provide power offsets etc to it.
Adaption of power will be very slow like 15 min average - if any heat is requested at all.
This is far beyond our timebase and it is not designed to be used this way.

See the discussion about Lambda heatpumps etc.

The right way to interface heatpumps is SG-Ready or a similar simple digital logic.

@andig
Copy link
Member Author

andig commented Jan 22, 2026

@premultiply thanks for the comment, but OT. We already have the ability for setting a power envelope. Let's please not discuss this capability here. Think AC Elwa...

Heatpump is a slowly self-regulating device.

That said, an actual HP can still integrate internally. This PR only ensures that it gets the "measurements" that it expects.

The expectation is to provide the raw grid power to this type of register like a seperate meter being attached to the heatpump to read the solar surplus or grid import.

See linked issue. The expectation is apparently not met by Ochsner and we know that Elwa needs specific configuration.

Ok?

@premultiply
Copy link
Member

Elwa is fast, a heatpump is very slow.
Not comparable.

Yes, might be a bit OT here but I think this plugin is a bit obsolete for that reason?

@andig
Copy link
Member Author

andig commented Jan 23, 2026

Ah, thank you for the hit on the head. For heatpump, delta plugin would set to 0 after first update which would then not trigger the heatpump at all. We could still add a baseline by adding a get parameter that would need to be configured to current power. Let's see what @ganzton's testing does. Can still do that.

@andig andig marked this pull request as draft January 24, 2026 14:06
@ganzton
Copy link
Contributor

ganzton commented Jan 24, 2026

Bis jetzt habe ich eine benutzerdefinierte WP mit nur einem Eintrag zum Testen, mit dem zumindest schon einmal das Schreiben von Überschussmeldungen geht, so dass sie bei der WP ankommen:

setmaxpower:
source: modbus
uri: 192.168.9.88:50284
id: 50
register:
address: 2201 # SUR Überschussleistung Auflösung 1 W
type: writeholding
decode: uint16

Ich habe in der Doku gesucht, aber nicht gefunden, wie ich jetzt das delta-plugin hier integrieren kann.
Gerne teste ich hier weiter, wäre aber dankbar für Hilfe.

@andig
Copy link
Member Author

andig commented Jan 25, 2026

@ganzton du musst deine Posts bitte formatieren. So geht delta:

setmaxpower:
  source: delta
  set:
    source: modbus
    uri: 192.168.9.88:50284
    id: 50
    register:
    address: 2201 # SUR Überschussleistung Auflösung 1 W
    type: writeholding
    decode: uint16

@andig
Copy link
Member Author

andig commented Jan 25, 2026

@ganzton ich habe jetzt noch einen getter mit ergänzt gem. #26875 (comment):

setmaxpower:
  source: delta
  get:
    source: modbus
    uri: 192.168.9.88:50284
    id: 50
    register:
    address: 2012 # Totale Überschussleistung Auflösung 1 W
    type: input
    decode: uint16
  set:
    source: modbus
    uri: 192.168.9.88:50284
    id: 50
    register:
    address: 2201 # SUR Überschussleistung Auflösung 1 W
    type: writeholding
    decode: uint16

@ganzton
Copy link
Contributor

ganzton commented Jan 25, 2026

Danke - muss ich das delta Plugin noch installieren? Wenn ich die benutzerdefinierte WP einrichten will, kommt
cannot create charger type 'heatpump': invalid plugin type: delta

@andig
Copy link
Member Author

andig commented Jan 25, 2026

Ja klar, das ist ja noch nicht enthalten. Du musst compilieren.

@ganzton
Copy link
Contributor

ganzton commented Jan 25, 2026

Oh, oh - komplettes Neuland für mich ...
Kann ich das irgendwo nachlesen für Laien, wie das geht?
Bin ja lernwillig.

@ganzton
Copy link
Contributor

ganzton commented Jan 25, 2026

so, ziemlich steile lernkurve ...
jetzt hab ich zumindest einmal (das erst Mal im Leben ...) kompiliert und evcc scheint "normal" mit Versionsnummer 0000 zu laufen. Was ich noch nicht habe, ist, das delta plugin einzubeziehen. Wie mache ich das?

@andig
Copy link
Member Author

andig commented Jan 25, 2026

Indem du diesen PR hier auscheckst und compilierst und eben nicht "master".

@ganzton
Copy link
Contributor

ganzton commented Jan 25, 2026

Danke für den Hinweis. Hab's hinbekommen.
Und es scheint erfreulicherweise im Prinzip zu funktionieren :-)
Bin noch nicht ganz sicher, ob es ein bißchen schwingt, weil die "totale_berechnete_Überschussleistung" nicht immer ganz genau die Zahl zeigt, die evcc zum Berechnen vorgibt, sondern irgendwie noch ein leichtes "Eigenleben " hat. Aber an sich scheint es schon mal zu gehen. Ich schau mir morgen oder wenn mal wieder die Sonne scheint an, wie alles im "echten Leben" läuft.

Noch 2 Anfragen zum WWWP UI:

  • Ich habe einen externen Eastron Zähler, der natürlich auch den Verbrauch anzeigt, wenn kein Überschuss genutzt wird, also beim regulären Heizen. In diesem Fall zeigt evcc zwar den Verbrauch an, aber standby. Da wäre es schön, wenn evcc ab etwa 100 W Leistung der WWWP auch "heizt" anzeigt.
  • Analog zu den EV Einstellungen fänd' ich es schön, wenn ich unten im Ladebalken sowohl die aktuelle Temperatur sowie die Zieltemperatur bei Überschussladen angezeigt bekäme und letztere natürlich auch ändern. Das sind Register 2000 (Ist Temp lesen) und 2203 (Soll Temp lesen und schreiben).

@andig
Copy link
Member Author

andig commented Jan 25, 2026

Das wären temp und limittemp die ins Template müssten. Können wir machen wenn delta funktioniert und gemerged ist. Glückwunsch zum compilieren 👍🏻

@ganzton
Copy link
Contributor

ganzton commented Jan 26, 2026

Jetzt habe ich eine Rückmeldung vom Ochsner Kundendienst, der den Sinn des Registers 2201 zumindest schlüssig erklärt:

Der Regler der Genius 333 erwartet alle 5 Sekunden den aktuellen Überschuss am Register 2201.

Beispiel:
500 Watt Überschuss laut Stromzähler vorhanden. Es wird dieser Wert an den Regler übermittelt. Die Wärmepumpe startet und verbraucht genau diese 500 Watt.
Der Stromzähler stellt fest, dass jetzt der Überschuss 0 ist. Dieser Wert wird an den Regler gesendet. Die Wärmepumpe verbraucht weiterhin 500 Watt.
Ein Verbraucher wird zugeschaltet, 100 Watt werden aus dem Netz verbraucht. Der Stromzähler stellt einen Netzbezug fest. Dieser Wert (-100) wird an den Regler gesendet. Die Wärmepumpe reduziert die aufgenommene Leistung auf 400 Watt.

Ich hoffe, das mit meinem Beispiel die Funktion nun für Sie ersichtlich ist.

Ich habe noch nachgefragt, ob es nicht doch eine Möglichkeit gibt, den "totaler_berechneter_Überschuss" direkt zu schreiben - geht leider nicht. Die leichten Differenzen bei diesem Wert entstehen wohl durch zeitliche Abläufe intern.
Naja. Soweit dazu.

Heute bei Sonne hat die PV schon mal das Warmwasser überschuss-geladen. Ich schau mir bald nochmal genauer an, ob die Regelung soweit sinnvoll funktioniert hat.

Kannst Du beim delta-plugin oder hinterher bei dem Ochsner-Template dafür sorgen, dass diese 5 sekündige Übermittlung stattfindet?

@andig
Copy link
Member Author

andig commented Jan 26, 2026

Sollte gehen, wenn wir das nochmal in einen watchdog verpacken. Schaffst du das selbst?

@andig
Copy link
Member Author

andig commented Jan 26, 2026

Wobei ich nicht verstehe, wieso die WP den Wert alle 5s haben will…

@ganzton
Copy link
Contributor

ganzton commented Jan 26, 2026

Ich probier's mal selbst mit dem Watchdog.
Aber bis jetzt und ohne Watchdog sieht es auch schon mal recht ordentlich aus :-)
Könnte sein, dass um 11:45 die WP ausgegangen ist, weil keine neue Meldung (laut Handbuch innerhalb von 10 min) kam.
Na, schaun mer mal.

image

@ganzton
Copy link
Contributor

ganzton commented Jan 26, 2026

Kann ich beim Watchdog auch als reset Wert "kleiner als" eingeben? Wenn ich keinen reset eingebe, dann pendelt evcc immer so mit 10 W rauf und runter, weil die WP diese komische, möglicherweise zeitverzögerte Berechnung macht. Sinnvoll wäre hier vielleicht etwas wie +/-50 W oder so, um den anzuhalten, damit sich die WP beruhigt und so langsam selbst auf 0 W geht.

Nee, das muss anders gehen. Denn wenn gerade Überschuss gemeldet wird, der sich aber nicht oder wenig ändert, dann sollen ja weiter Werte, also eben auch einige Nullen gesendet werden.

Als Trigger für den Watchdog müsste nicht die Überschussleistung, sondern das Register 2012 "berechnete ..." genommen werden. Wenn die irgendwo zwischen +/- 30 ist, kann der Watchdog aus, bis wieder Ü Energie da ist.

@ganzton
Copy link
Contributor

ganzton commented Jan 26, 2026

Oder wir schreiben einfach, wie es mit dem "dummen" Stromzähler ja auch wäre, ganz unabhängig von der von der WP berechneten Ü-Leistung einfach strack den Überschuss rein, der da ist?

@premultiply
Copy link
Member

...oder man lässt die WP einfach laufen und gibt ihr per SG-Ready ein Signal wenn mehr Energie verbraucht werden soll.
So wie bei jeder WP.

Die gleiche Diskussion hatten wir bei Lambda schon und dann abgebrochen, da es letztendlich unsinnig ist zwei Regelungen gegeneinander arbeiten zu lassen.
Die WPs erwarten auf diesen Registern rohe Zählerwerte und führen dann eine eigene Regelung durch. Man könnte auch einfach einen externen Zähler anbinden den die WP-Steuerung dann selbst abfragt.

@ganzton
Copy link
Contributor

ganzton commented Jan 26, 2026

Einen externen Zähler hatte ich ja schon vorgeschlagen.
Aber dann ist evcc komplett außen vor, und ich finde es schon charmant, alles zentral von evcc mit z.B. Prios, Solltemperaturen und so regeln lassen zu können. Und auch nett finde ich, dass diese WP eben halt wirklich stufenlos regelt und das schnell, wie du oben in Grafana sehen kannst.
Von daher bleibe ich noch etwas dran. Müsste doch zu lösen sein und dann freuen sich alle, die so eine Ochsner haben.
Zumal diese WP als "unterstützt" bei evcc gelistet ist, was tatsächlich ein wesentlicher Kaufgrund für mich war ...

@ganzton
Copy link
Contributor

ganzton commented Jan 26, 2026

Könnte man das delta plugin auch mit einem anderen Bezugspunkt nutzen? Ich denke da an den "echten" Zähler, der den Strom von der WP misst (mit dem Ruheleistungsoffset), falls das sinnvoll ist? Dann wäre das Delta klar und entspräche den Werten von dem "dummen" Zähler, oder habe ich hier einen Denkfehler?
Irgendwie müsste doch der geforderte schlichte Zweiwege-Hauszähler mit der ganzen Parameter-Daten-Fülle von evcc zu simulieren sein, ohne dass zwei Regelungen gegeneinander arbeiten.

@premultiply
Copy link
Member

Die Regelung der WP arbeitet mit externen Zählerdaten im Prinzip so autonom wie ein Batteriespeicher, nur eben sehr viel langsamer.

Auch bei Batteriespeichern ersetzen wir aus gutem Grund nicht die interne Leistungsregelung. Auch hier gibt es bei aktiver Batteriekontrolle nur digitale Modus-Logik: Normal/Hold/Charge. Nirgends wird da kontinuierlich in die Leistungsregelung eingegriffen.

@ganzton
Copy link
Contributor

ganzton commented Jan 26, 2026

Hm. Das habe ich noch nicht ganz verstanden. Die Regelung ist doch sehr schnell, denn sobald ich den Parameter 2201 Überschussleistung ändere, reagiert die WP sofort auf einzelne Watt.
Bei meiner PV Batterie wird die Ladeleistung vom WR bestimmt, richtig? Auch das scheint ja schnell zu gehen. evcc gibt hier also nur das "go"? Wie wäre das dann analog mit der WP? Muss ich dann auf die Goodies von evcc verzichten?

@andig andig marked this pull request as ready for review January 28, 2026 16:55
@andig andig merged commit 27e3180 into master Jan 28, 2026
7 checks passed
@andig andig deleted the feat/delta branch January 28, 2026 16:55
@andig andig added the needs documentation Triggers issue creation in evcc-io/docs label Jan 28, 2026
Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue, and left some high level feedback:

  • In both IntSetter and FloatSetter, p.get is dereferenced unconditionally (e.g. p.get.IntGetter) and only checked for nil inside the returned closure, which will panic if Get is not configured; consider guarding the getter construction or handling a nil Get by skipping the read and just using the existing p.total.
  • The plugin stores mutable state in p.total, which may be accessed and updated from multiple goroutines via the returned setter functions; if these setters can be called concurrently, consider protecting p.total with synchronization or avoiding shared mutable state.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In both IntSetter and FloatSetter, p.get is dereferenced unconditionally (e.g. p.get.IntGetter) and only checked for nil inside the returned closure, which will panic if Get is not configured; consider guarding the getter construction or handling a nil Get by skipping the read and just using the existing p.total.
- The plugin stores mutable state in p.total, which may be accessed and updated from multiple goroutines via the returned setter functions; if these setters can be called concurrently, consider protecting p.total with synchronization or avoiding shared mutable state.

## Individual Comments

### Comment 1
<location> `plugin/delta.go:10` </location>
<code_context>
+	"github.com/evcc-io/evcc/util"
+)
+
+type deltaPlugin struct {
+	ctx   context.Context
+	total float64
</code_context>

<issue_to_address>
**issue (complexity):** Consider making the plugin stateless and reusing a shared delta-calculation helper so each setter keeps its own local running total instead of using shared struct state.

You can remove most of the complexity without changing behavior by (1) making `total` closure-local instead of struct state and (2) factoring the common logic + fixing the `get` pointer handling.

### 1. Make `total` closure-local (struct becomes stateless)

You don’t need `p.total` at all; keep it inside each setter factory. That removes hidden coupling between different setters.

```go
type deltaPlugin struct {
	ctx context.Context
	set Config
	get *Config
}

func (p *deltaPlugin) IntSetter(param string) (func(int64) error, error) {
	set, err := p.set.IntSetter(p.ctx, param)
	if err != nil {
		return nil, err
	}

	var getter func() (int64, error)
	if p.get != nil {
		getter, err = p.get.IntGetter(p.ctx)
		if err != nil {
			return nil, err
		}
	}

	var total float64

	return func(val int64) error {
		if getter != nil {
			current, err := getter()
			if err != nil {
				return err
			}
			total = float64(current)
		}

		delta := float64(val) - total
		if err := set(int64(delta)); err != nil {
			return err
		}

		total = float64(val)
		return nil
	}, nil
}
```

Same idea for `FloatSetter`:

```go
func (p *deltaPlugin) FloatSetter(param string) (func(float64) error, error) {
	set, err := p.set.FloatSetter(p.ctx, param)
	if err != nil {
		return nil, err
	}

	var getter func() (float64, error)
	if p.get != nil {
		getter, err = p.get.FloatGetter(p.ctx)
		if err != nil {
			return nil, err
		}
	}

	var total float64

	return func(val float64) error {
		if getter != nil {
			current, err := getter()
			if err != nil {
				return err
			}
			total = current
		}

		delta := val - total
		if err := set(delta); err != nil {
			return err
		}

		total = val
		return nil
	}, nil
}
```

This keeps the “delta relative to last total” behavior but avoids shared mutable state across closures and avoids concurrent access issues.

### 2. Factor common delta logic (optional but reduces duplication)

If you want to go a step further, you can encapsulate the shared pattern and keep the concrete setters very small:

```go
func makeDeltaFloatSetter(
	getter func() (float64, error),
	setter func(float64) error,
) func(float64) error {
	var total float64

	return func(val float64) error {
		if getter != nil {
			current, err := getter()
			if err != nil {
				return err
			}
			total = current
		}

		delta := val - total
		if err := setter(delta); err != nil {
			return err
		}

		total = val
		return nil
	}
}
```

Use it in both methods:

```go
func (p *deltaPlugin) FloatSetter(param string) (func(float64) error, error) {
	set, err := p.set.FloatSetter(p.ctx, param)
	if err != nil {
		return nil, err
	}

	var getter func() (float64, error)
	if p.get != nil {
		getter, err = p.get.FloatGetter(p.ctx)
		if err != nil {
			return nil, err
		}
	}

	return makeDeltaFloatSetter(getter, set), nil
}

func (p *deltaPlugin) IntSetter(param string) (func(int64) error, error) {
	set, err := p.set.IntSetter(p.ctx, param)
	if err != nil {
		return nil, err
	}

	var getter func() (int64, error)
	if p.get != nil {
		getter, err = p.get.IntGetter(p.ctx)
		if err != nil {
			return nil, err
		}
	}

	floatGetter := func() (float64, error) {
		if getter == nil {
			return 0, nil
		}
		v, err := getter()
		return float64(v), err
	}
	floatSetter := func(delta float64) error {
		return set(int64(delta))
	}

	f := makeDeltaFloatSetter(floatGetter, floatSetter)
	return func(v int64) error { return f(float64(v)) }, nil
}
```

This keeps all existing behavior, but:

- `deltaPlugin` no longer carries mutable state.
- Nil handling for `get` is explicit and confined.
- Delta computation logic lives in one place instead of being duplicated.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

"github.com/evcc-io/evcc/util"
)

type deltaPlugin struct {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider making the plugin stateless and reusing a shared delta-calculation helper so each setter keeps its own local running total instead of using shared struct state.

You can remove most of the complexity without changing behavior by (1) making total closure-local instead of struct state and (2) factoring the common logic + fixing the get pointer handling.

1. Make total closure-local (struct becomes stateless)

You don’t need p.total at all; keep it inside each setter factory. That removes hidden coupling between different setters.

type deltaPlugin struct {
	ctx context.Context
	set Config
	get *Config
}

func (p *deltaPlugin) IntSetter(param string) (func(int64) error, error) {
	set, err := p.set.IntSetter(p.ctx, param)
	if err != nil {
		return nil, err
	}

	var getter func() (int64, error)
	if p.get != nil {
		getter, err = p.get.IntGetter(p.ctx)
		if err != nil {
			return nil, err
		}
	}

	var total float64

	return func(val int64) error {
		if getter != nil {
			current, err := getter()
			if err != nil {
				return err
			}
			total = float64(current)
		}

		delta := float64(val) - total
		if err := set(int64(delta)); err != nil {
			return err
		}

		total = float64(val)
		return nil
	}, nil
}

Same idea for FloatSetter:

func (p *deltaPlugin) FloatSetter(param string) (func(float64) error, error) {
	set, err := p.set.FloatSetter(p.ctx, param)
	if err != nil {
		return nil, err
	}

	var getter func() (float64, error)
	if p.get != nil {
		getter, err = p.get.FloatGetter(p.ctx)
		if err != nil {
			return nil, err
		}
	}

	var total float64

	return func(val float64) error {
		if getter != nil {
			current, err := getter()
			if err != nil {
				return err
			}
			total = current
		}

		delta := val - total
		if err := set(delta); err != nil {
			return err
		}

		total = val
		return nil
	}, nil
}

This keeps the “delta relative to last total” behavior but avoids shared mutable state across closures and avoids concurrent access issues.

2. Factor common delta logic (optional but reduces duplication)

If you want to go a step further, you can encapsulate the shared pattern and keep the concrete setters very small:

func makeDeltaFloatSetter(
	getter func() (float64, error),
	setter func(float64) error,
) func(float64) error {
	var total float64

	return func(val float64) error {
		if getter != nil {
			current, err := getter()
			if err != nil {
				return err
			}
			total = current
		}

		delta := val - total
		if err := setter(delta); err != nil {
			return err
		}

		total = val
		return nil
	}
}

Use it in both methods:

func (p *deltaPlugin) FloatSetter(param string) (func(float64) error, error) {
	set, err := p.set.FloatSetter(p.ctx, param)
	if err != nil {
		return nil, err
	}

	var getter func() (float64, error)
	if p.get != nil {
		getter, err = p.get.FloatGetter(p.ctx)
		if err != nil {
			return nil, err
		}
	}

	return makeDeltaFloatSetter(getter, set), nil
}

func (p *deltaPlugin) IntSetter(param string) (func(int64) error, error) {
	set, err := p.set.IntSetter(p.ctx, param)
	if err != nil {
		return nil, err
	}

	var getter func() (int64, error)
	if p.get != nil {
		getter, err = p.get.IntGetter(p.ctx)
		if err != nil {
			return nil, err
		}
	}

	floatGetter := func() (float64, error) {
		if getter == nil {
			return 0, nil
		}
		v, err := getter()
		return float64(v), err
	}
	floatSetter := func(delta float64) error {
		return set(int64(delta))
	}

	f := makeDeltaFloatSetter(floatGetter, floatSetter)
	return func(v int64) error { return f(float64(v)) }, nil
}

This keeps all existing behavior, but:

  • deltaPlugin no longer carries mutable state.
  • Nil handling for get is explicit and confined.
  • Delta computation logic lives in one place instead of being duplicated.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request heating Heating needs documentation Triggers issue creation in evcc-io/docs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants