Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
92fd1ba
Huawei: add nocharge/holdcharge
CiNcH83 Jun 7, 2026
027e72e
naming
CiNcH83 Jun 7, 2026
6f7f639
Merge branch 'master' into huawei_nocharge
CiNcH83 Jun 7, 2026
7ff6897
also set max. charge power in force-charge mode
CiNcH83 Jun 7, 2026
869bdd9
Merge branch 'master' into huawei_nocharge
CiNcH83 Jun 18, 2026
3ca64e0
remove forceaccharging block in holdcharge
CiNcH83 Jun 18, 2026
6d99426
clamp all charge/discharge register writes
CiNcH83 Jun 18, 2026
a9d8a91
Merge branch 'master' into huawei_nocharge
CiNcH83 Jun 18, 2026
1d5a14e
Merge branch 'master' into huawei_nocharge
CiNcH83 Jun 18, 2026
2005d1c
Merge branch 'master' into huawei_nocharge
CiNcH83 Jun 20, 2026
e94672f
Merge branch 'master' into huawei_nocharge
CiNcH83 Jun 22, 2026
2de61ae
Merge branch 'master' into huawei_nocharge
CiNcH83 Jun 22, 2026
b90437c
Merge branch 'master' into huawei_nocharge
CiNcH83 Jun 29, 2026
af7728d
Update templates/definition/meter/huawei-sun2000-hybrid.yaml
CiNcH83 Jul 4, 2026
49210b8
implement review
CiNcH83 Jul 4, 2026
0a388b5
Huawei Sun2000 hybrid: explain why only charge needs periodic watchdo…
andig Jul 4, 2026
d32e9e8
add comment regarding charge watchdog
CiNcH83 Jul 4, 2026
535ef22
Merge branch 'master' into huawei_nocharge
CiNcH83 Jul 6, 2026
756a14d
Huawei: fix watchdog re-asserting 47100 after mode switch clears it
andig Jul 6, 2026
1872726
Huawei: simplify watchdog switch using default fallback
andig Jul 6, 2026
a8ed20a
Merge branch 'master' into huawei_nocharge
CiNcH83 Jul 7, 2026
2acff85
Merge branch 'master' into huawei_nocharge
CiNcH83 Jul 10, 2026
856158d
Merge branch 'huawei_nocharge' of github.com:CiNcH83/evcc into huawei…
CiNcH83 Jul 10, 2026
36567e4
make stopWdt synchronous
CiNcH83 Jul 10, 2026
87fd6d0
fix race-condition - 47100 is now entirely handled by the watchdog
CiNcH83 Jul 10, 2026
df18297
fix watchdog switch/case
CiNcH83 Jul 10, 2026
b26caf0
Merge remote-tracking branch 'remotes/upstream/master' into huawei_no…
CiNcH83 Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 43 additions & 17 deletions plugin/watchdog.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type watchdogPlugin struct {
timeout time.Duration
deferred bool
cancel func()
wdtDone chan struct{} // closed by the running wdt goroutine once it has exited
clock clock.Clock
}

Expand Down Expand Up @@ -84,11 +85,29 @@ func setter[T comparable](o *watchdogPlugin, set func(T) error, reset []T) func(
lastUpdated := o.clock.Now()
var last *T

// stop running wdt
// stop running wdt and block until it has actually exited, so that any
// tick already in flight has resolved (either wrote, or observed
// 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() {

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.

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:

// 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:

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.

if o.cancel != nil {
o.cancel()
o.cancel = nil
if o.cancel == nil {
return
}

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

if done != nil {
// must release the lock here: the goroutine's tick callback
// needs o.mu to observe ctx.Err() and return, or to finish a
// write that was already in flight. Holding the lock while
// waiting on done would deadlock against that callback.
o.mu.Unlock()
<-done
o.mu.Lock()
}
}

Expand All @@ -105,22 +124,29 @@ func setter[T comparable](o *watchdogPlugin, set func(T) error, reset []T) func(
var ctx context.Context
ctx, o.cancel = context.WithCancel(context.Background())

go o.wdt(ctx, func() error {
o.mu.Lock()
defer o.mu.Unlock()
done := make(chan struct{})
o.wdtDone = done

// a reset may have cancelled us while we waited for the lock
if ctx.Err() != nil {
return nil
}
go func() {
defer close(done)

if err := set(val); err != nil {
return err
}
lastUpdated = o.clock.Now()
o.wdt(ctx, func() error {
o.mu.Lock()
defer o.mu.Unlock()

return nil
})
// a reset may have cancelled us while we waited for the lock
if ctx.Err() != nil {
return nil
}

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

return nil
})
}()
}

return nil
Expand Down
178 changes: 149 additions & 29 deletions templates/definition/meter/huawei-sun2000-hybrid.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -378,15 +378,37 @@ render: |
set:
source: sequence
set:
- source: const
value: 0 # stop
set:
source: modbus
{{- include "modbus" . | indent 16 }}
register:
address: 47100 # Forcible charge/discharge
type: writesingle
encoding: uint16
# register 47075 is range-checked: a maxchargepower above the
Comment thread
andig marked this conversation as resolved.
# inverter rated max (37046) is rejected, so clamp before writing
- source: go
in:
- name: rated
type: int
config:
source: cached
cache: 1h # rated max is static
value:
source: modbus
{{- include "modbus" . | indent 22 }}
register:
address: 37046 # [ESS] Maximum charge power (RO)
type: holding
decode: uint32
out:
- name: power
type: int
config:
source: modbus
{{- include "modbus" . | indent 20 }}
register:
address: 47075 # Max. Charge Power
type: writemultiple
encoding: uint32
script: |
limit := {{ .maxchargepower }}
out := rated
if limit < rated { out = limit }
Comment thread
andig marked this conversation as resolved.
out
# register 47077 is range-checked: a maxdischargepower above the
# inverter rated max (37048) is rejected, so clamp before writing
- source: go
Expand Down Expand Up @@ -422,15 +444,37 @@ render: |
set:
source: sequence
set:
- source: const
value: 0 # stop
set:
source: modbus
{{- include "modbus" . | indent 16 }}
register:
address: 47100 # Forcible charge/discharge
type: writesingle
encoding: uint16
# register 47075 is range-checked: a maxchargepower above the
# inverter rated max (37046) is rejected, so clamp before writing
- source: go
in:
- name: rated
type: int
config:
source: cached
cache: 1h # rated max is static
value:
source: modbus
{{- include "modbus" . | indent 22 }}
register:
address: 37046 # [ESS] Maximum charge power (RO)
type: holding
decode: uint32
out:
- name: power
type: int
config:
source: modbus
{{- include "modbus" . | indent 20 }}
register:
address: 47075 # Max. Charge Power
type: writemultiple
encoding: uint32
script: |
limit := {{ .maxchargepower }}
out := rated
if limit < rated { out = limit }
out
- source: const
value: 0 # W Discharge Power
set:
Expand All @@ -444,6 +488,37 @@ render: |
set:
source: sequence
set:
# register 47075 is range-checked: a maxchargepower above the
# inverter rated max (37046) is rejected, so clamp before writing
- source: go
in:
- name: rated
type: int
config:
source: cached
cache: 1h # rated max is static
value:
source: modbus
{{- include "modbus" . | indent 22 }}
register:
address: 37046 # [ESS] Maximum charge power (RO)
type: holding
decode: uint32
out:
- name: power
type: int
config:
source: modbus
{{- include "modbus" . | indent 20 }}
register:
address: 47075 # Max. Charge Power
type: writemultiple
encoding: uint32
script: |
limit := {{ .maxchargepower }}
out := rated
if limit < rated { out = limit }
out
# clamp maxchargepower to the inverter rated max (37046) for parity
# with discharge, avoiding a stale out-of-range value
- source: go
Expand Down Expand Up @@ -493,21 +568,57 @@ render: |
address: 47246 # Forcible charge/discharge setting mode
type: writesingle
encoding: uint16
- case: 4 # holdcharge
set:
source: sequence
set:
- source: const
value: 0 # W
set:
source: modbus
{{- include "modbus" . | indent 16 }}
register:
address: 47075 # Max. Charge Power
type: writemultiple
encoding: uint32
# register 47077 is range-checked: a maxdischargepower above the
# inverter rated max (37048) is rejected, so clamp before writing
- source: go
in:
- name: rated
type: int
config:
source: cached
cache: 1h # rated max is static
value:
source: modbus
{{- include "modbus" . | indent 22 }}
register:
address: 37048 # [ESS] Maximum discharge power (RO)
type: holding
decode: uint32
out:
- name: power
type: int
config:
source: modbus
{{- include "modbus" . | indent 20 }}
register:
address: 47077 # Max. Discharge Power
type: writemultiple
encoding: uint32
script: |
limit := {{ .maxdischargepower }}
out := rated
if limit < rated { out = limit }
out
# single watchdog wraps the ENTIRE 47100 lifecycle, not just case 3
- source: watchdog
timeout: 30s
reset: 1
reset: [1, 2, 4]
set:
source: switch
switch:
- case: 1 # normal
set:
source: sleep
duration: 0s
- case: 2 # hold
set:
source: sleep
duration: 0s
# only charging requires repeated requests
- case: 3 # charge
set:
source: const
Expand All @@ -519,6 +630,15 @@ render: |
address: 47100 # Forcible charge/discharge
type: writesingle
encoding: uint16

default:
source: const
value: 0 # stop
set:
source: modbus
{{- include "modbus" . | indent 12 }}
register:
address: 47100 # Forcible charge/discharge
type: writesingle
encoding: uint16
{{- include "battery-params" . }}
{{- end }}
Loading