Skip to content

Huawei: add HoldCharge#30598

Merged
andig merged 29 commits into
evcc-io:masterfrom
CiNcH83:huawei_nocharge
Jul 19, 2026
Merged

Huawei: add HoldCharge#30598
andig merged 29 commits into
evcc-io:masterfrom
CiNcH83:huawei_nocharge

Conversation

@CiNcH83

@CiNcH83 CiNcH83 commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

SUN2000/SDongle Modbus Primer

Testing

States

  • normal: battery charged with surplus only / discharged to home ... $${\color{lightgreen}OK}$$
  • hold: only discharging locked / charging with surplus still possible ... $${\color{lightgreen}OK}$$
  • charge: force-charge at maxchargepower from surplus/grid combined ... $${\color{lightgreen}OK}$$
  • holdcharge: only charging locked / discharging still possible ... $${\color{lightgreen}OK}$$

State Transitions

while true; do curl -X 'POST' 'http://localhost:7070/api/batterymode/hold' -H 'accept: application/json' -d ''; sleep 30; done
while true; do curl -X 'POST' 'http://localhost:7070/api/batterymode/charge' -H 'accept: application/json' -d ''; sleep 30; done
while true; do curl -X 'POST' 'http://localhost:7070/api/batterymode/holdcharge' -H 'accept: application/json' -d ''; sleep 30; done
curl -X 'POST' 'http://localhost:7070/api/batterymode/normal' -H 'accept: application/json' -d ''
  • normalhold
  • normalcharge
  • normalholdcharge
  • holdnormal
  • holdcharge
  • holdholdcharge
  • holdchargenormal
  • holdchargehold
  • holdchargecharge
  • chargenormal
  • chargehold
  • chargeholdcharge

Register states are being checked with modbus-cli via evcc Modbus Proxy:

modbus 192.168.1.8:5021 -s 1 h@47075/I # max. charge power
modbus 192.168.1.8:5021 -s 1 h@47077/I # max. discharge power
modbus 192.168.1.8:5021 -s 1 h@47087/h # charge from grid enable
modbus 192.168.1.8:5021 -s 1 h@47100/h # force charge/discharge function
modbus 192.168.1.8:5021 -s 1 h@47246/h # force charge/discharge setting mode
modbus 192.168.1.8:5021 -s 1 h@47083/h # force charge/discharge period
modbus 192.168.1.8:5021 -s 1 h@47247/I # force charge power

Please beware that when checking states with FusionSolar portal, displayed settings might not reflect register settings immediately! Fetching the whole configuration via the portal also puts quite a lot of load on the SUN2000 which may cause Modbus errors for evcc.

Remarks

Old configs may still have the old default of 10000 for maxchargepower set which is higher than what most installed battery setups support (only the LUNA2000-S1 with 3 battery packs supports a higher charge power). While the Forcible charge power register (47247) allows out-of-bounds values to be set, trying to set it for the Maximum charging power register (47075) introduced in this PR fails. The introduced clamping mechanism takes care of that. So this is not a breaking change. Broken configs will remain broken though.

This PR also fixes a race-condition in the watchdog plugin. After leaving charge, there could have been another watchdog tick, prolonging charge for another minute in the Huawei case.

It would be great if Huawei had something similar to the SunSpec StorCtl_Mod register. With Huawei, every mode requires quite some registers to be written for cleaning up every potential previous mode/state as the number of modes increases. Implementing all modes (force-charge, force-discharge, lock, lock-charge, lock-discharge) via register 47100, also being guarded by a HW watchdog, would indeed be superb. It unfortunately only supports force-charge and force-discharge today.

@CiNcH83
CiNcH83 marked this pull request as draft June 7, 2026 14:28

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

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 new holdcharge case duplicates the maxchargepower write logic that was added to the other cases; consider extracting a shared sequence or helper template to avoid repeating the same 47075/47077 write blocks in multiple branches.
  • In the holdcharge sequence you explicitly zero 47075 while keeping 47077 at maxdischargepower; if this asymmetry is intentional, a brief inline comment explaining why discharge is left at max while charge is zeroed would make the behavior clearer to future maintainers.
  • The watchdog handler’s new case: 4 uses a sleep with duration: 0s; if this case does not need any special handling, you can omit the branch entirely rather than adding a no-op step.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The new `holdcharge` case duplicates the `maxchargepower` write logic that was added to the other cases; consider extracting a shared sequence or helper template to avoid repeating the same `47075`/`47077` write blocks in multiple branches.
- In the `holdcharge` sequence you explicitly zero `47075` while keeping `47077` at `maxdischargepower`; if this asymmetry is intentional, a brief inline comment explaining why discharge is left at max while charge is zeroed would make the behavior clearer to future maintainers.
- The `watchdog` handler’s new `case: 4` uses a `sleep` with `duration: 0s`; if this case does not need any special handling, you can omit the branch entirely rather than adding a no-op step.

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.

@CiNcH83

CiNcH83 commented Jun 7, 2026

Copy link
Copy Markdown
Contributor Author

Looks to be working. But I want to do some more stability testing, also see #30467.

@CiNcH83 CiNcH83 changed the title Huawei: add HoldCharge Huawei: add HoldCharge [BC] Jun 10, 2026
@CiNcH83

CiNcH83 commented Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

@andig

I am again experiencing a (reproducible) race-condition when switching from charge to hold. (or in fact when leaving charge). hold sets 47100 to 0. Then there seems to be another charge watchdog cycle which sets it to 1 again.

Splitting modes in switch and watchdog does not seem to be a good idea?

@andig

andig commented Jun 10, 2026

Copy link
Copy Markdown
Member

Sounds as if the watchdog reset happens too late? It would be helpful if we could have a demo config that allows to reproduce this without having a Huawei inverter.

@andig

andig commented Jun 10, 2026

Copy link
Copy Markdown
Member

Fix options, roughly in order of preference:

  1. Harden the watchdog plugin (needed regardless): re-check ctx.Err() inside the mutex-protected tick callback, and ideally make stopWdt synchronous (wait for goroutine exit). This kills the stale-tick-after-cancel race, but cannot fix the sequence-level race since the switch writes never pass through the watchdog's mutex.
  2. Give the watchdog two actions — e.g. set: (executed once on value change, under the mutex, after the old timer is stopped) plus a separate refresh:/keep-alive config (executed per tick). The template then becomes a single watchdog { set: , refresh: <47100-only switch> }, the sequence disappears, and both write paths are serialized correctly. This is the principled fix matching what the template is trying to express.
  3. Template-only workaround: move the whole mode switch inside the watchdog's set. Correctly serialized and self-healing, but rewrites all charge registers every 15s — probably tolerable on modbus TCP, ugly nonetheless, and it still needs fix 1.

Imho 3 would be easier to understand unless this kills any eeprom register?

@CiNcH83

CiNcH83 commented Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

Imho 3 would be easier to understand unless this kills any eeprom register?

Hope not. I am just trying to only do as much as necessary to keep the burden on those devices as low as possible...

@CiNcH83

CiNcH83 commented Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

BTW... So far I haven't realized that error because as soon as the 1 minute watchdog expires, the forcible charging is disabled anyway. I just realized now because I am actively checking the register states after a mode switch...

@CiNcH83 CiNcH83 changed the title Huawei: add HoldCharge [BC] Huawei: add HoldCharge (BC) Jun 11, 2026
@CiNcH83

CiNcH83 commented Jun 12, 2026

Copy link
Copy Markdown
Contributor Author

I measured response times when writing the respective registers and they indeed seem costly, between 500ms and multiple seconds(!) per register.

@CiNcH83

CiNcH83 commented Jun 18, 2026

Copy link
Copy Markdown
Contributor Author

So how do you want to proceed here? I think it makes a ton of sense to have a setup and a periodic phase for a state. Plus I want to keep the numbers of registers written low as writes are indeed expensive for Huawei.

I will finish testing holdcharge over the weekend. The race-condition when leaving charge is also in the master/release. But after the 1m watchdog expires, the forced charge is stopped anyway.

@andig

andig commented Jun 18, 2026

Copy link
Copy Markdown
Member

Up to you. Accept the broken config or do this.

@CiNcH83 CiNcH83 changed the title Huawei: add HoldCharge (BC) Huawei: add HoldCharge Jun 20, 2026
@CiNcH83
CiNcH83 marked this pull request as ready for review June 20, 2026 08:04
@andig

andig commented Jul 6, 2026

Copy link
Copy Markdown
Member

Good call — simplified in 1872726 using the switch plugin's default: fallback instead of enumerating sleep 0s for cases 1/2/4 individually:

```yaml
set:
source: switch
switch:
- case: 3 # charge
set:
source: const
value: 1 # charge
set:
source: modbus
...
default:
source: sleep
duration: 0s
```

Verified directly against plugin/switch.go with a throwaway unit test: without default:, calling the setter with an unmatched value errors (switch: value not found: 1); with default: as a sibling of switch:, it falls through cleanly. So this is correct and still gets invoked on every mode change (needed for the watchdog to see transitions away from charge and cancel its ticker).

🤖 Generated with Claude Code

@andig

andig commented Jul 6, 2026

Copy link
Copy Markdown
Member

@CiNcH83 that's good news, actually — the bug is fixed. Your new trace shows 47100 staying at 0 after the stop; it never flips back to 1 the way it did before. That was the actual problem being fixed here.

The double 00 write isn't a new issue and isn't specific to this template: core/site_battery.go's updateBatteryMode re-evaluates requiredBatteryMode on every site update cycle, and calls applyBatteryMode/SetBatteryMode again whenever it computes a mode (even the same one) rather than BatteryUnknown. So it's plausible for normal to get reapplied a minute later depending on rate/condition changes, sending the same 47100=0 write twice. Since the value is unchanged, this is a harmless idempotent write, unlike before where the second write flipped the register to the wrong value.

🤖 Generated with Claude Code

@CiNcH83

CiNcH83 commented Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

Oooops

[db:3-LUNA2000] TRACE 2026/07/06 15:14:40 modbus: send 00 32 00 00 00 06 01 06 b7 fc 00 01
[db:3-LUNA2000] TRACE 2026/07/06 15:14:42 modbus: recv 00 32 00 00 00 06 01 06 b7 fc 00 01
[db:3-LUNA2000] TRACE 2026/07/06 15:14:57 modbus: send 00 3d 00 00 00 06 01 06 b7 fc 00 01
[db:3-LUNA2000] TRACE 2026/07/06 15:14:58 modbus: recv 00 3d 00 00 00 06 01 06 b7 fc 00 01
[db:3-LUNA2000] TRACE 2026/07/06 15:15:08 modbus: send 00 47 00 00 00 06 01 06 b7 fc 00 00
[db:3-LUNA2000] TRACE 2026/07/06 15:15:10 modbus: recv 00 47 00 00 00 06 01 06 b7 fc 00 00
[db:3-LUNA2000] TRACE 2026/07/06 15:15:14 modbus: send 00 4a 00 00 00 06 01 06 b7 fc 00 01
[db:3-LUNA2000] TRACE 2026/07/06 15:15:17 modbus: recv 00 4a 00 00 00 06 01 06 b7 fc 00 01
[db:3-LUNA2000] TRACE 2026/07/06 15:16:21 modbus: send 00 75 00 00 00 06 01 06 b7 fc 00 00
[db:3-LUNA2000] TRACE 2026/07/06 15:16:22 modbus: recv 00 75 00 00 00 06 01 06 b7 fc 00 00

Still racy. This time with latest change.

evcc.log

@github-actions github-actions Bot removed the stale Outdated and ready to close label Jul 9, 2026
@CiNcH83
CiNcH83 marked this pull request as draft July 10, 2026 11:42
@CiNcH83

CiNcH83 commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

I now made stopWdt synchronous and handle the racy 47100 register solely in the watchdog.

Pi@raspberrypi:~/repos/evcc-CiNcH83 $ ./evcc -l trace | grep "b7 fc"
[db:3-LUNA2000] TRACE 2026/07/10 14:26:11 modbus: send 00 18 00 00 00 06 01 06 b7 fc 00 01
[db:3-LUNA2000] TRACE 2026/07/10 14:26:13 modbus: recv 00 18 00 00 00 06 01 06 b7 fc 00 01
[db:3-LUNA2000] TRACE 2026/07/10 14:26:28 modbus: send 00 23 00 00 00 06 01 06 b7 fc 00 01
[db:3-LUNA2000] TRACE 2026/07/10 14:26:29 modbus: recv 00 23 00 00 00 06 01 06 b7 fc 00 01
[db:3-LUNA2000] TRACE 2026/07/10 14:26:38 modbus: send 00 30 00 00 00 06 01 06 b7 fc 00 00
[db:3-LUNA2000] TRACE 2026/07/10 14:26:38 modbus: recv 00 30 00 00 00 06 01 06 b7 fc 00 00

No more races so far.

@CiNcH83
CiNcH83 marked this pull request as ready for review July 10, 2026 12:31

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

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:

  • The max charge power clamping logic for register 47075 is duplicated in three different cases in the Huawei template; consider extracting this into a shared include/partial to keep behavior consistent and easier to maintain if the clamping rules change.
  • In the watchdog plugin, stopWdt now temporarily releases o.mu while waiting on wdtDone; it would help future maintainers if this lock/unlock behavior and its implications for callers (e.g. any assumptions about atomicity around set) were clearly documented at the type or method level.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The max charge power clamping logic for register 47075 is duplicated in three different cases in the Huawei template; consider extracting this into a shared include/partial to keep behavior consistent and easier to maintain if the clamping rules change.
- In the watchdog plugin, `stopWdt` now temporarily releases `o.mu` while waiting on `wdtDone`; it would help future maintainers if this lock/unlock behavior and its implications for callers (e.g. any assumptions about atomicity around `set`) were clearly documented at the type or method level.

## Individual Comments

### Comment 1
<location path="plugin/watchdog.go" line_range="25" />
<code_context>
 	timeout  time.Duration
 	deferred bool
 	cancel   func()
+	wdtDone  chan struct{} // closed by the running wdt goroutine once it has exited
 	clock    clock.Clock
 }
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting the watchdog start/stop lifecycle into a dedicated helper type so `setter` only coordinates via simple start/stop calls instead of managing locks and channels directly.

You can encapsulate the watchdog lifecycle into a small helper type and remove most of the lock/chan juggling from `setter`, while still ensuring “no tick in-flight after reset”.

### 1. Encapsulate the lifecycle in a `watchdogRunner`

Instead of exposing `cancel` and `wdtDone` as separate fields on `watchdogPlugin`, move them into a small helper that owns its own context and completion signalling:

```go
type watchdogRunner struct {
	mu     sync.Mutex
	cancel context.CancelFunc
	wg     sync.WaitGroup
}

func (r *watchdogRunner) Start(run func(ctx context.Context)) {
	r.mu.Lock()
	defer r.mu.Unlock()

	// stop any existing runner first
	if r.cancel != nil {
		r.cancel()
	}
	if r.cancel != nil {
		r.wg.Wait()
	}

	ctx, cancel := context.WithCancel(context.Background())
	r.cancel = cancel

	r.wg.Add(1)
	go func() {
		defer r.wg.Done()
		run(ctx)
	}()
}

func (r *watchdogRunner) Stop() {
	r.mu.Lock()
	cancel := r.cancel
	r.cancel = nil
	r.mu.Unlock()

	if cancel != nil {
		cancel()
		r.wg.Wait()
	}
}
```

Then change the plugin to use this, instead of `cancel` + `wdtDone`:

```go
type watchdogPlugin struct {
	mu   sync.Mutex
	// ...
	// cancel   func()
	// wdtDone  chan struct{}
	runner *watchdogRunner
}
```

Initialize `runner` in the constructor:

```go
o := &watchdogPlugin{
	// ...
	runner: &watchdogRunner{},
}
```

### 2. Simplify `setter` by delegating to the runner

Inside `setter`, you only coordinate with `runner.Start`/`Stop`; `setter` no longer needs to manipulate channels or split lock/unlock inside `stopWdt`:

```go
// stop running wdt and block until it has exited
stopWdt := func() {
	// drop the lock before blocking to avoid splitting critical sections implicitly
	o.mu.Unlock()
	defer o.mu.Lock()

	o.runner.Stop()
}
```

Starting the watchdog becomes:

```go
setAndStartWdt := func(val T) error {
	if err := set(val); err != nil {
		return err
	}
	lastUpdated = o.clock.Now()
	last = &val

	if !slices.Contains(reset, val) {
		o.runner.Start(func(ctx context.Context) {
			o.wdt(ctx, func() error {
				o.mu.Lock()
				defer o.mu.Unlock()

				if ctx.Err() != nil {
					return nil
				}

				if err := set(val); err != nil {
					return err
				}
				lastUpdated = o.clock.Now()
				return nil
			})
		})
	}
	return nil
}
```

Key benefits without changing behavior:

- The “no in-flight tick after reset” invariant is enforced by `runner.Stop` using a `WaitGroup`, not an exposed `wdtDone` channel.
- `watchdogPlugin` no longer has to manage two coordination primitives (`cancel` + `wdtDone`) or drop/reacquire its own lock inside `stopWdt`.
- The lifecycle logic (ctx, cancel, wait for completion) is localized inside `watchdogRunner`, reducing mental overhead for future changes.
</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 plugin/watchdog.go Outdated
@CiNcH83
CiNcH83 marked this pull request as draft July 10, 2026 13:52
@CiNcH83
CiNcH83 marked this pull request as ready for review July 11, 2026 05:48

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

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:

  • The clamping logic for registers 47075/37046 and 47077/37048 is duplicated across several cases; consider extracting this into a reusable template or helper to reduce repetition and the risk of inconsistencies in future changes.
  • In the watchdog plugin, wdtDone is set and cleared around stopWdt; it may be safer to document or enforce that setter is not called concurrently for the same plugin instance, or add guards to avoid races where multiple goroutines manipulate cancel/wdtDone at the same time.
  • The new default branch in the 47100 switch unconditionally sends a "stop" command; verify that this behavior is intended for all non-[1,2,3,4] modes and consider explicitly listing supported modes to make the mapping easier to reason about.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The clamping logic for registers 47075/37046 and 47077/37048 is duplicated across several cases; consider extracting this into a reusable template or helper to reduce repetition and the risk of inconsistencies in future changes.
- In the watchdog plugin, `wdtDone` is set and cleared around `stopWdt`; it may be safer to document or enforce that `setter` is not called concurrently for the same plugin instance, or add guards to avoid races where multiple goroutines manipulate `cancel`/`wdtDone` at the same time.
- The new default branch in the 47100 switch unconditionally sends a "stop" command; verify that this behavior is intended for all non-[1,2,3,4] modes and consider explicitly listing supported modes to make the mapping easier to reason about.

## Individual Comments

### Comment 1
<location path="plugin/watchdog.go" line_range="93" />
<code_context>
+	// cancellation and no-opped) before the caller proceeds. Without this,
+	// a tick that fired just before cancel() can still race a subsequent
+	// write issued right after stopWdt() returns.
 	stopWdt := func() {
-		if o.cancel != nil {
-			o.cancel()
</code_context>
<issue_to_address>
**issue (complexity):** Consider refactoring `stopWdt` to return the `wdtDone` channel and keep all lock manipulation at the call site so the watchdog shutdown flow is easier to reason about.

The main complexity comes from the hidden `o.mu` unlock/lock inside `stopWdt`. You can keep the race‑fixing behavior (wait for the last tick to finish) while making the locking and `wdtDone` lifecycle easier to reason about by:

1. Making `stopWdt` *pure* w.r.t. locking (no `o.mu.Unlock()/Lock()`).
2. Returning the `done` channel from `stopWdt`, and having the caller handle lock release/ reacquisition around the blocking wait.

This keeps all semantics (including “no tick races with subsequent writes”) but avoids subtle lock juggling hidden inside `stopWdt`.

### Suggested refactor

Change `stopWdt` to only manage cancellation and the `wdtDone` channel:

```go
// stop running wdt and return a channel that is closed once the wdt goroutine has exited.
stopWdt := func() (done <-chan struct{}) {
    if o.cancel == nil {
        return nil
    }

    o.cancel()
    done = o.wdtDone
    o.cancel = nil
    o.wdtDone = nil

    return done
}
```

Then, at the call site (inside the returned setter closure where `o.mu` is held), explicitly handle the unlock/wait/lock pattern:

```go
return func(val T) error {
    o.mu.Lock()
    defer o.mu.Unlock()

    // cancel deferred update
    // ...

    // stop watchdog and wait for completion without holding o.mu
    done := stopWdt()
    if done != nil {
        o.mu.Unlock()
        <-done
        o.mu.Lock()
    }

    // proceed with writes / state updates knowing no tick is in flight
    // ...

    return nil
}
```

This retains:

- Waiting for the watchdog goroutine to fully exit before proceeding (no races with subsequent writes).
- The `wdtDone` lifecycle tied to each watchdog instance.

But it:

- Removes implicit assumptions about lock state inside `stopWdt`.
- Makes the lock and channel coordination explicit at the call site, which is much easier to audit and reason about.
</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 plugin/watchdog.go
@github-actions github-actions Bot added the stale Outdated and ready to close label Jul 18, 2026
The wdtDone barrier guarded a stale-tick write that only existed while
47100=0 (reset) and 47100=1 (charge) lived in separate plugin instances
with separate mutexes. Now that all 47100 writes go through the single
watchdog plugin, the existing ctx.Err() guard under o.mu already prevents
a cancelled tick from writing. Restore the simpler non-blocking stopWdt.
@andig

andig commented Jul 19, 2026

Copy link
Copy Markdown
Member

@CiNcH83 this should be resolved now. Your diagnosis was right: the leftover 47100=1 came from the mode-clear (47100=0) and the charge re-assertion (47100=1) living in two different plugin instances (the outer switch step and the watchdog step), so they had separate mutexes and no ordering guarantee — a lingering charge tick could land after the switch had already cleared the register.

The fix is to stop splitting: 47100 is now handled entirely by the single watchdog plugin (reset: [1, 2, 4], case 3 writes 1, default writes 0). Because every 47100 write now goes through the same instance under the same o.mu, the existing ctx.Err() guard in the tick callback already prevents a cancelled charge tick from writing after a mode change — the tick either finishes before the reset takes the lock, or observes the cancelled context and no-ops.

Given that, the earlier "synchronous stopWdt" (wdtDone) barrier is no longer needed — it was guarding the cross-mutex race that the consolidation removed — so I've reverted it (c3720a1) to keep the watchdog simple. go test ./plugin/... is green.

Could you re-run your charge → hold / charge → holdcharge transition loops against the current branch and confirm 47100 settles at 0?

🤖 Generated with Claude Code

@github-actions github-actions Bot removed the stale Outdated and ready to close label Jul 19, 2026
@andig

andig commented Jul 19, 2026

Copy link
Copy Markdown
Member

@CiNcH83 is this one good to merge with latest changes?

@CiNcH83

CiNcH83 commented Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

Could you re-run your charge → hold / charge → holdcharge transition loops against the current branch and confirm 47100 settles at 0?

This matches my custom LUNA device implementation. No races.

@andig

andig commented Jul 19, 2026

Copy link
Copy Markdown
Member

Thank you, much appreciated.

@andig
andig merged commit bba63f5 into evcc-io:master Jul 19, 2026
9 checks passed
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