Skip to content

Hems/FNN: add curtail and dim for TAB26 Steuerbox relay standard#29886

Merged
andig merged 30 commits into
evcc-io:masterfrom
thecem:hems/fnn-4
May 28, 2026
Merged

Hems/FNN: add curtail and dim for TAB26 Steuerbox relay standard#29886
andig merged 30 commits into
evcc-io:masterfrom
thecem:hems/fnn-4

Conversation

@thecem
Copy link
Copy Markdown
Contributor

@thecem thecem commented May 14, 2026

This pull request introduces support for a new fnn HEMS (Home Energy Management System) that replaces the existing fnn3 implementation (backwards compatible).

Config UI has to be adapted since "Externe Begrenzung" should have two limits for dim (§14a) and curtail (§9). Also "Kommunikaton via FNN-4" is Missing:

The limits are aligned with EEBus/EnWG:

  • consumption limit 0 equals "not defined"
  • production limit 0 means "cut all feed-in"

TODO

grafik

Copy link
Copy Markdown
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 Fnn4.Run, using time.Tick without stopping it can leak a ticker over the process lifetime; consider switching to time.NewTicker with a defer ticker.Stop() and a loop over ticker.C.
  • NewFnn4FromConfig assumes S1 and S2 are non-nil and directly calls BoolGetter on them; add explicit nil checks (and a clear error) to avoid panics when these inputs are not configured.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `Fnn4.Run`, using `time.Tick` without stopping it can leak a ticker over the process lifetime; consider switching to `time.NewTicker` with a `defer ticker.Stop()` and a loop over `ticker.C`.
- `NewFnn4FromConfig` assumes `S1` and `S2` are non-nil and directly calls `BoolGetter` on them; add explicit nil checks (and a clear error) to avoid panics when these inputs are not configured.

## Individual Comments

### Comment 1
<location path="hems/fnn/fnn-4.go" line_range="58-63" />
<code_context>
+	site.SetCircuit(gridcontrol)
+
+	// fnn-3 inputs
+	s1G, err := cc.S1.BoolGetter(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	s2G, err := cc.S2.BoolGetter(ctx)
+	if err != nil {
+		return nil, err
</code_context>
<issue_to_address>
**issue (bug_risk):** Accessing optional S1/S2 configs without nil checks can panic at runtime if they are omitted from configuration.

`S1` and `S2` are `*plugin.Config` but are dereferenced via `cc.S1.BoolGetter(ctx)` and `cc.S2.BoolGetter(ctx)` without checking for nil. If either is optional, this will panic on startup when omitted. Even if they are required, consider validating and returning a clear config error instead of relying on a nil dereference.
</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.

Comment thread hems/fnn/fnn-4.go Outdated
@andig andig added the devices Specific device support label May 15, 2026
Comment thread hems/fnn/fnn-3.go Outdated
Comment thread hems/fnn/fnn-3.go Outdated
@andig
Copy link
Copy Markdown
Member

andig commented May 15, 2026

I guess this PR should just remove Fnn3 and make either dim or curtail optional.

@andig andig marked this pull request as draft May 15, 2026 05:09
@thecem
Copy link
Copy Markdown
Contributor Author

thecem commented May 15, 2026

Ok, I understood to not replace the fnn3 but use it in fnn4.
So I will copy fnn3 and add dim. Fnn4 could be used for dim or curtail and for dim and curtail? Right?

@andig
Copy link
Copy Markdown
Member

andig commented May 15, 2026

I think so, yes. Noticed the duplicate setup so this seems better.

@thecem
Copy link
Copy Markdown
Contributor Author

thecem commented May 15, 2026

I changed (added) the dim function into fnn-3 this is now testet an working. Either dim or curtail or both can be configured.

@thecem
Copy link
Copy Markdown
Contributor Author

thecem commented May 15, 2026

Next step rename it to fnn-4.

@andig
Copy link
Copy Markdown
Member

andig commented May 15, 2026

Lets name it fnn (or even tab26?) and keep fnn-3 an alias- it should initialize with an fnn-3 config afaikt?

@thecem
Copy link
Copy Markdown
Contributor Author

thecem commented May 15, 2026

ok i will rename it to fnn and alias it so fnn-3 config could be used

@thecem
Copy link
Copy Markdown
Contributor Author

thecem commented May 15, 2026

grafik The CSV Dokumentation system is currently partially working: grafik

The UI behavior needs to be adapted as described above.

Please note that all existing fnn-3 configurations will run without any changes or interruptions, and can be extended with the new dim configuration.

@thecem
Copy link
Copy Markdown
Contributor Author

thecem commented May 15, 2026

config:

type: fnn # or fnn-3

# §9 EZA
maxPower: 10000   # maximale Erzeugerleistung in Watt

#interval: 10s

## 0% Abregelung
w3: # Eingang für 100% Abregelung → setzt auf 0% Einspeisung/Leistung
  source: http

## 30% Abregelung
s2: # optionaler Eingang für 30%
  source: http

## 60% Abregelung
s1: # optionaler Eingang für 60%
  source: http

# §14a / Verbrauch / SteuVE / Dim
maxPowerdim: 4200

## Dim
w4:
  source: http

@thecem thecem changed the title Hems/fnn 4 add curtail and dim for TAB26 Steuerbox relay standard Hems/fnn add curtail and dim for TAB26 Steuerbox relay standard May 15, 2026
@andig andig requested a review from premultiply May 15, 2026 15:41
@andig andig marked this pull request as ready for review May 15, 2026 15:41
Comment thread hems/fnn/fnn.go Outdated
Copy link
Copy Markdown
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 3 issues, and left some high level feedback:

  • In curtail, c.limit = new(c.maxPower * frac) will not compile since new expects a type, not an expression; allocate a float64 and assign (e.g. v := c.maxPower * frac; c.limit = &v) or use new(float64) then set the value.
  • Run uses time.Tick in a for range loop with no way to stop, which can leak a goroutine; consider accepting a context.Context and using time.NewTicker with proper Stop handling.
  • The limit field is only written in curtail and never read elsewhere; if it’s not needed for external consumers, consider removing it to avoid confusion or wire it into the control logic so the stored value is actually used.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `curtail`, `c.limit = new(c.maxPower * frac)` will not compile since `new` expects a type, not an expression; allocate a `float64` and assign (e.g. `v := c.maxPower * frac; c.limit = &v`) or use `new(float64)` then set the value.
- `Run` uses `time.Tick` in a `for range` loop with no way to stop, which can leak a goroutine; consider accepting a `context.Context` and using `time.NewTicker` with proper `Stop` handling.
- The `limit` field is only written in `curtail` and never read elsewhere; if it’s not needed for external consumers, consider removing it to avoid confusion or wire it into the control logic so the stored value is actually used.

## Individual Comments

### Comment 1
<location path="hems/fnn/fnn.go" line_range="25-34" />
<code_context>
+	limit          *float64
</code_context>
<issue_to_address>
**issue (bug_risk):** Fix creation of pointer to computed limit value in `curtail`

In `curtail`, `c.limit = new(c.maxPower * frac)` won’t compile because `new` expects a type, not an expression. To store a pointer to the computed value while keeping `nil` when inactive, use:

```go
c.limit = nil
if active {
    v := c.maxPower * frac
    c.limit = &v
}
```
</issue_to_address>

### Comment 2
<location path="hems/fnn/fnn.go" line_range="31" />
<code_context>
+	interval       time.Duration
+}
+
+// NewFromConfig creates an nn4 HEMS from generic config
+func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*Fnn, error) {
+	cc := struct {
</code_context>
<issue_to_address>
**nitpick (typo):** Correct the comment to match the FNN implementation

The comment still mentions "nn4" while the type and package are `Fnn`. Please update it to reference FNN (or the correct variant) to keep the documentation accurate.

```suggestion
 // NewFromConfig creates an FNN HEMS from generic config
```
</issue_to_address>

### Comment 3
<location path="hems/fnn/fnn.go" line_range="97" />
<code_context>
+	return c, nil
+}
+
+func boolGetter(ctx context.Context, cfg *plugin.Config) (func() (bool, error), error) {
+	if cfg == nil {
+		return nil, nil
</code_context>
<issue_to_address>
**issue (complexity):** Consider refactoring the Fnn control flow to use non-nil getters, data-driven curtailment rules, and a shared helper for smartgrid/root updates to reduce duplication and bug surface area.

You can keep the new functionality but reduce complexity (and surface area for bugs) with some targeted refactors.

### 1. Avoid `nil` getters and scattered nil checks

`boolGetter` returning `nil` forces every caller to branch on it. You can return a no-op getter instead and remove all `nil` checks in `Update` and `runDim`:

```go
func boolGetter(ctx context.Context, cfg *plugin.Config) (func() (bool, error), error) {
	if cfg == nil {
		// neutral default: “feature disabled”
		return func() (bool, error) { return false, nil }, nil
	}
	return cfg.BoolGetter(ctx)
}
```

Then `Update` and `runDim` can assume non-nil functions:

```go
func (c *Fnn) runDim() error {
	if c.maxPowerDim <= 0 {
		return nil
	}

	active, err := c.w4()
	if err != nil {
		return err
	}

	limit := 0.0
	if active {
		limit = c.maxPowerDim
	}
	return c.setDim(limit)
}
```

### 2. Make `Update` data-driven to remove duplicated logic

You can replace the three repeated branches with a compact priority table. This keeps behavior identical but reduces duplication and makes changes easier:

```go
type curtailRule struct {
	getter   func() (bool, error)
	fraction float64
}

func (c *Fnn) Update() error {
	rules := []curtailRule{
		{getter: c.w3, fraction: 0.0}, // 0%
		{getter: c.s2, fraction: 0.3}, // 30%
		{getter: c.s1, fraction: 0.6}, // 60%
	}

	for _, r := range rules {
		active, err := r.getter()
		if err != nil {
			return err
		}
		if active {
			return c.curtail(r.fraction)
		}
	}

	// 100%
	return c.curtail(1.0)
}
```

With the non-nil `boolGetter` above, you don’t need nil checks here, and adding new signals becomes a one-line change in `rules`.

### 3. Factor out the common “session + root” update pattern

`curtail` and `setDim` both lock, derive an `active` flag, call methods on `root`, and update a smartgrid session. You can centralize that pattern while preserving semantics:

```go
func (c *Fnn) applyMode(
	id *uint,
	mode smartgrid.Mode,
	active bool,
	limit float64,
	applyRoot func(),
) {
	applyRoot()

	if err := smartgrid.UpdateSession(id, mode, c.root.GetChargePower(), limit, active); err != nil {
		c.log.ERROR.Printf("smartgrid session: %v", err)
	}
}

func (c *Fnn) curtail(frac float64) error {
	c.mu.Lock()
	defer c.mu.Unlock()

	active := frac < 1.0
	var limitPtr *float64
	if active {
		v := c.maxPower * frac
		limitPtr = &v
	}
	c.limit = limitPtr

	c.applyMode(&c.smartgridID, smartgrid.Curtail, active, c.maxPower*frac, func() {
		c.root.Curtail(active)
		// TODO: c.root.SetMaxPower(c.maxPower*frac) if/when enabled
	})

	return nil
}

func (c *Fnn) setDim(limit float64) error {
	c.mu.Lock()
	defer c.mu.Unlock()

	active := limit > 0

	c.applyMode(&c.smartgridDimID, smartgrid.Dim, active, limit, func() {
		c.root.Dim(active)
		c.root.SetMaxPower(limit)
	})

	return nil
}
```

This keeps functionality intact, keeps the locking behavior as-is, and reduces duplication so the two flows stay consistent (including future changes like logging or metrics).
</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.

Comment thread hems/fnn/fnn.go Outdated
Comment thread hems/fnn/fnn.go Outdated
Comment thread hems/fnn/fnn.go Outdated
Comment thread hems/fnn/fnn.go Outdated
thecem and others added 4 commits May 15, 2026 17:51
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
Comment thread hems/fnn/fnn.go Outdated
Comment thread hems/fnn/fnn.go Outdated
Comment thread hems/fnn/fnn.go Outdated
@andig
Copy link
Copy Markdown
Member

andig commented May 20, 2026

We should probably also emit log warning if dim/curtail are executed but no limits configured.

@thecem
Copy link
Copy Markdown
Contributor Author

thecem commented May 20, 2026

But we will stay on bool not a relativ value like 0/30/60/100?

@thecem
Copy link
Copy Markdown
Contributor Author

thecem commented May 22, 2026

[Fnn ] DEBUG 2026/05/22 14:19:36 curtail: source=w3 active=true fraction=0.00 limit=0W
[Fnn ] DEBUG 2026/05/22 14:19:36 dim: source=w4 active=true limit=4200W
[Fnn ] DEBUG 2026/05/22 14:20:06 curtail: source=none active=false fraction=1.00 limit=10000W
[Fnn ] DEBUG 2026/05/22 14:20:06 dim: source=w4 active=false limit=0W
[Fnn ] DEBUG 2026/05/22 14:20:26 curtail: source=s1 active=true fraction=0.60 limit=6000W
[Fnn ] DEBUG 2026/05/22 14:20:36 curtail: source=s2 active=true fraction=0.30 limit=3000W
[Fnn ] DEBUG 2026/05/22 14:20:46 curtail: source=w3 active=true fraction=0.00 limit=0W
[Fnn ] DEBUG 2026/05/22 14:21:16 curtail: source=s1 active=true fraction=0.60 limit=6000W
[Fnn ] DEBUG 2026/05/22 14:21:26 curtail: source=none active=false fraction=1.00 limit=10000W
[Fnn ] DEBUG 2026/05/22 14:21:36 curtail: source=w3 active=true fraction=0.00 limit=0W
[Fnn ] DEBUG 2026/05/22 14:23:56 curtail: source=none active=false fraction=1.00 limit=10000W

Comment thread hems/fnn/fnn.go Outdated
@andig andig changed the title Hems/fnn add curtail and dim for TAB26 Steuerbox relay standard Hems/FNN: add curtail and dim for TAB26 Steuerbox relay standard May 23, 2026
@thecem
Copy link
Copy Markdown
Contributor Author

thecem commented May 23, 2026

Fails if W3 (S1/S2/W3 same) or W4 is configured:

type: fnn-3
maxCurtailPower: 10000
w3:
source: http

panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x210a753]

goroutine 60 [running]:
github.com/evcc-io/evcc/hems/fnn.(*Fnn).runCurtail(0x29a91ada1c0)
github.com/evcc-io/evcc/hems/fnn/fnn.go:135 +0x93
github.com/evcc-io/evcc/hems/fnn.(*Fnn).Run(0x29a91ada1c0)
github.com/evcc-io/evcc/hems/fnn/fnn.go:113 +0x9b
created by github.com/evcc-io/evcc/cmd.configureHEMS in goroutine 1
github.com/evcc-io/evcc/cmd/setup.go:805 +0x37f

type: fnn
maxPower: 10000
maxDimPower: 4200
w4:
source: http

panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x2110b13]

goroutine 123 [running]:
github.com/evcc-io/evcc/hems/fnn.(*Fnn).runCurtail(0x3d5857a7c540)
github.com/evcc-io/evcc/hems/fnn/fnn.go:135 +0x93
github.com/evcc-io/evcc/hems/fnn.(*Fnn).Run(0x3d5857a7c540)
github.com/evcc-io/evcc/hems/fnn/fnn.go:113 +0x9b
created by github.com/evcc-io/evcc/cmd.configureHEMS in goroutine 1
github.com/evcc-io/evcc/cmd/setup.go:805 +0x37f passiert ? Passiert wenn beide s1/s2/w3/w4 konfiguriert sind nicht!

If S1, S2, W3, or W4 are missing in YAML, no error is thrown; instead, nil is returned.

In the FNN code, these getters are later called without a nil check.
In fnn.go:135, rule.get() is executed directly.
In fnn.go:151, c.w4() is executed directly.
The loop first calls Curtail, then Dim.
See fnn.go:113 and fnn.go:117.

What happens in your two examples:

Only W3 configured
In Curtail, the rules are W3, then S2, then S1 (fnn.go:129).
If W3 currently returns false, the code moves to the next rule and tries to call S2. S2 is nil, resulting in a SIGSEGV at fnn.go:135.
If W3 were true, a potential crash would still occur afterward in Dim because W4 is nil and is called directly at fnn.go:151.

Only W4 configured
Curtail runs first and tries to call W3 first (fnn.go:129, called at fnn.go:135).
W3 is nil, causing an immediate panic before Dim even runs.
In short: The current FNN effectively expects at runtime that all getters used in the rules are either present or checked for nil before being called. With only W3 or only W4 configured, at least one required getter is nil, and the direct function call causes a crash.

thecem and others added 3 commits May 23, 2026 15:13
Nil guard in the Curtail rule loopIn fnn.go:129 to fnn.go:136, rule.get is called without validation.Change: Before making the call, check if rule.get is nil and, if so, simply skip to the next rule.

Nil guard for W4 in runDimIn fnn.go:151, c.w4 is called directly.Change: If c.w4 is nil, treat Dim as inactive (i.e., set the consumption limit to 0) instead of calling it.
- runDim/runCurtail are now no-ops when their inputs are not configured;
  previously they would overwrite c.root.Dim/Curtail/SetMaxPower on every
  tick, clobbering state set by other HEMS sharing the root circuit.
- Legacy fnn-3 MaxPower (PV/curtail cap) now falls back to MaxCurtailPower
  instead of MaxDimPower so existing fnn-3 configs keep working.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@thecem
Copy link
Copy Markdown
Contributor Author

thecem commented May 24, 2026

Tested! With all config variants W3/W4/S1/S2/S2W3/S1W3/W4S1/W4S2/S1S2W3W4/S1S2W3.
The function works flawlessly.

@andig andig enabled auto-merge (squash) May 24, 2026 07:16
@andig andig merged commit 4f94365 into evcc-io:master May 28, 2026
6 of 7 checks passed
@thecem thecem deleted the hems/fnn-4 branch May 28, 2026 10:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

devices Specific device support

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants