Skip to content

SAMD/NXT fixes: CGMI flag never set, relay-open hang, GMI ADC pin, RAPI buffer overflows#54

Merged
lincomatic merged 8 commits into
OpenEVSE:masterfrom
RAR:fix/samd-cgmi-flag
Jul 11, 2026
Merged

SAMD/NXT fixes: CGMI flag never set, relay-open hang, GMI ADC pin, RAPI buffer overflows#54
lincomatic merged 8 commits into
OpenEVSE:masterfrom
RAR:fix/samd-cgmi-flag

Conversation

@RAR

@RAR RAR commented Jul 10, 2026

Copy link
Copy Markdown

While bringing up an OpenEVSE NXT (SAMD, D9.0.0.SAMD) behind an ESP32 WiFi module I hit some odd relay behavior and ended up auditing the controller firmware. This branch fixes the six concrete problems I could verify, one commit each. Draft for discussion — none of it has been run on NXT hardware yet beyond compiling (I can't flash the SAMD from this bench), so I'd like eyes from someone who can, especially on the ADC change.

Fixes

1. ECF_CGMI was never set on SAMD builds — the g_hasCGMI → flag transfer in Init() was inside #ifdef OEV6, which only Arduino-IDE builds define. Confirmed live on my NXT: $GE reports flags 0121 with bit 1000 clear. Consequences of running a CGMI board with hasCGMI()==false: the relay close is never zero-cross timed (the open is — asymmetric), the continuous ground check and relay-closure-fault check never run, and ground/stuck-relay fall back to the legacy per-half-wave semantics, which are inverted for a continuous GMI line. Fix un-gates the transfer and also clears a stale EEPROM-carried bit on non-CGMI hardware.

2. waitCurrentZero() could hang forever with the relay closed. Exit needs computed current < 100 mA, but the SAMD default calibration (scale 37, offset −135) floors the computed value at 135 mA even for a dead-zero reading — the exit is unreachable, and WDT_RESET() inside the loop hides it from the watchdog. Every normal (non-emergency) end of charge would hang. Bounded the wait at 1 s (~28 ammeter sampling windows / ~50 AC cycles).

3. The SAMD zero-cross detector read the wrong pin. GMI_ADC_PIN is Arduino pin 3 (PA09), but analogRead() remaps pin < A0 by += A0, so it sampled pin 17 = A3 = PA04, which is unconnected — and the arduino_zero variant marks PA09 No_ADC_Channel anyway, so PA09 is unreachable through analogRead() entirely. Floating-pin noise can fake plausible 33–100 Hz "crossings", so $GZ and the ZC switch timing were garbage. Replaced with a direct ADC read of AIN[17] (INPUTCTRL.MUXPOS = 0x11), bracketed around the sampling burst, with the PA09 digital pull-up (it doubles as the ACLINE2/ground-monitor input) disabled during sampling and restored after. This follows the core's enable/discard-first-conversion/read/disable sequence so it coexists with analogRead() on the other channels. This is the commit that most needs hardware verification.

4. ECF_OVERCURRENT_DISABLED collided with ECF_TEMP_CHK_DISABLED — both were 0x0400, so $FF O 0 persistently disabled temperature monitoring and $FF T 0 disabled the overcurrent check. Moved overcurrent to the free bit 0x4000. A unit that had persisted overcurrent-disabled will revert to enabled, which is the safe direction.

5. $GI overflowed two buffers on SAMD. With a 16-byte MCU id, the hex formatting writes 33 bytes into the 32-byte RAPI buffer (the NUL lands on bufCnt), and response() then assembles ~45 bytes into the 34-byte g_sTmp. Sized both per target (SAMD only — zero AVR RAM cost) and added #error guards tying the sizes to MCU_ID_LEN. Also fixed the #ifdef TARGET_M238P typo that left the intended AVR id format dead code.

6. -D NO_GROUND_RECORD_DELAY=2000 never matched the code macro (NO_GND_RECORD_DELAY), so the 2 s suppression of spurious NO-GROUND trip recording has never been active in a PlatformIO build. Renamed the flag.

Verification

  • pio run -e samd -e m328p_core -e m328p_LCD_WIFI -e m328p_noWiFi — all green on every commit.
  • Fix 1's mechanism confirmed against live hardware via RAPI ($GE flag word); fixes 2, 4, 5, 6 are arithmetic/config, traced in the source; fix 3 is compile-verified only and needs a scope or a $GZ sanity check on a powered NXT.

While auditing I also noted (not fixed here, happy to file separately): the GFI retry counter resets on every fresh fault entry so GFI_RETRY_COUNT never limits recurring live faults; AUTOSVCLEVEL and TIME_LIMIT/CHARGE_LIMIT are only defined for Arduino-IDE builds and m328p_noWiFi, so the shipping PlatformIO envs answer $SL A (which the WiFi module's "Auto" service level sends) with NK; $SK is missing its tokenCnt guard; and [env:samd_ice_dev]'s build_src_flags override drops the inherited flags and doesn't build.

RAR added 6 commits July 10, 2026 09:03
The transfer of g_hasCGMI into the runtime flag word was wrapped in
#ifdef OEV6, but OEV6 is only defined for Arduino IDE builds (the
!PLATFORMIO block in open_evse.h). On the SAMD (OpenEVSE NXT) PlatformIO
build, initTarget() hardwires g_hasCGMI = true yet ECF_CGMI was never
set, so hasCGMI() returned false on CGMI hardware. Confirmed live on an
NXT: $GE reports flags 0121 with bit 1000 clear.

Running with hasCGMI()==false on the NXT means:
- chargingOn() skips zcWaitRelayClose(), so the relay close is never
  zero-cross timed despite RELAY_ZC_SWITCH (the open is timed, the
  close is not) - random-phase closes with full inrush and contact
  bounce
- the continuous ground check and RELAY_CLOSURE_FAULT check never run
- ground and stuck-relay use the legacy per-half-wave AC pin semantics,
  which are inverted for a continuous GMI line: a correctly grounded,
  correctly sensing NXT false-trips STUCK_RELAY (including at POST)

Also clear a stale ECF_CGMI bit loaded from EEPROM flags when the
hardware is not CGMI, since the flag reflects detected hardware rather
than a user setting.

Verified: samd and m328p_core envs build clean.
waitCurrentZero() spun until the measured current dropped below
CURRENT_ZERO_THRESHOLD_MA (100 mA), but with the SAMD OpenEVSE NXT
default calibration that exit is unreachable. The computed current is
ma = reading * scale - offset, and the SAMD defaults are scale 37,
offset -135, so ma = 37 * reading + 135. Even at a dead-zero ammeter
reading the floor is 135 mA, which is always > the 100 mA threshold.

Because ZC switching is enabled by default and the relay is closed
when this runs, every non-emergency chargingOff() at the normal end of
a charge session entered an infinite loop with the relay still closed.
The loop calls WDT_RESET() each pass, so the watchdog never fired to
recover it -- the controller simply hung with power still flowing.

Bound the wait with an overall deadline (CURRENT_ZERO_TIMEOUT_MS) so it
degrades to opening the relay anyway instead of hanging. The threshold,
the ma formula, and the calibration defaults are unchanged; the early
return below threshold and WDT_RESET() are preserved, and the deadline
check uses wrap-safe unsigned subtraction.

I sized the timeout at 1000 ms. readAmmeter() samples for up to
CURRENT_SAMPLE_INTERVAL (35 ms) per call, so 1 s allows roughly 28
sampling windows, and at 50 Hz it spans about 50 AC cycles -- ample
time to catch a genuine current zero on hardware where the exit is
reachable, while capping the worst case at one second when it is not.
… pin

The SAMD zero-cross detector sampled the GMI line through an AdcPin built
on GMI_ADC_PIN (Arduino pin 3 = PA09) and calling analogRead().  That never
touched PA09.  The SAMD Arduino core's analogRead() does 'if (pin < A0) pin
+= A0;', so analogRead(3) samples pin 3 + A0(14) = 17 = A3 = PA04 -- an
unconnected, floating pin.  Independently, the arduino_zero variant lists
PA09 with No_ADC_Channel, so analogRead() has no channel for it anyway.
PA09's real ADC input is AIN[17], which the variant table cannot express.

The result was zero-cross timing derived from floating-pin noise: fake
crossings that still passed the 33-100 Hz sanity check produced garbage $GZ
line-frequency output and mistimed relay switching, and when nothing crossed
each relay operation burned the full 105 ms detection busy-wait.

Read PA09 correctly with a direct SAMD21 ADC access (INPUTCTRL.MUXPOS =
0x11 = AIN[17]) in new gmiAdcBegin/gmiAdcRead/gmiAdcEnd helpers in the SAMD
target layer, following the core analogRead sequence (SYNCBUSY waits around
INPUTCTRL/ENABLE/SWTRIG, discard the first conversion after the mux change,
enable/read/disable so it coexists with analogRead on the other pins).

PA09 doubles as the digital ACLINE2 ground-monitor input (pinAC2, INP_PU).
measureAcFreq now brackets its sampling burst with begin/end: begin switches
PA09 to the analog mux with the pull-up disabled (an enabled pull-up would
bias the sample), and end restores it to a digital input with pull-up via
pinMode(INPUT_PULLUP), faithfully reproducing DigitalPin INP_PU.  The dead
adcGmi member is removed so nobody calls its broken read(), and a comment
documents the AIN[17]/No_ADC_Channel limitation and the analogRead remap
trap so this is not simplified back to analogRead().
ECF_OVERCURRENT_DISABLED was defined as 0x0400, the exact bit already
used by ECF_TEMP_CHK_DISABLED. Both features are compiled into every
PlatformIO build (TEMPERATURE_MONITORING and -D OVERCURRENT_THRESHOLD=5),
so the two flags aliased the same bit in m_wFlags. Because these flags
are persisted to EEPROM, sending RAPI $FF O 0 to disable the overcurrent
check also silently and permanently disabled temperature monitoring, and
$FF T 0 disabled the overcurrent hard-fault check. The effect survived
reboots.

I moved ECF_OVERCURRENT_DISABLED to 0x4000, the only free bit in the
ECF_ table (ECVF_ is a separate namespace, so ECVF_BOOT_LOCK 0x4000 does
not collide). All references to the flag are symbolic, so nothing depends
on the old numeric value.

RAPI transmits flag words as hex, so a unit that previously had
overcurrent-disabled persisted in EEPROM will read that bit under the old
0x0400 meaning and revert to overcurrent-enabled after this change. That
is the safe direction.
…28P typo

The $GI (get MCU id) handler had two problems.

First, its AVR-specific branch was guarded by #ifdef TARGET_M238P, a typo:
the real macro is TARGET_M328P. That branch (leading space, 6 raw id
bytes, remaining bytes as hex) was therefore dead on every build, and
m328p fell through to the generic hex #else. I corrected the guard.
m328p has MCU_ID_LEN 10, so its branch emits 1 + 6 + 8 = 15 chars, which
fits comfortably.

Second, the generic #else branch writes 2*MCU_ID_LEN hex chars plus a NUL
into buffer[ESRAPI_BUFLEN]. On SAMD MCU_ID_LEN is 16, so the sixteenth
sprintf placed its NUL at buffer[32], one past the 32-byte buffer, and
clobbered the adjacent bufCnt member. response() then assembled the reply
into g_sTmp[TMP_BUF_SIZE] as '$' + "OK" + ' ' + buffer + " :XX" sequence
id + "^XX" checksum + CR + NUL. For SAMD $GI that is 1 + 2 + 1 + 32 + 4
+ 4 + 1 = 45 bytes, well past the 34-byte g_sTmp, so the write ran off
the end of that buffer too.

I sized both buffers per target so AVR pays nothing. ESRAPI_BUFLEN is now
40 on SAMD (worst case 2*16+1 = 33) and stays 32 elsewhere. TMP_BUF_SIZE
is 48 on SAMD (worst case 45) and stays at the LCD-derived 34 on AVR,
whose largest reply ($GS with a sequence id) is exactly 34 and whose $GI
needs only 33. Two #error guards, keyed off MCU_ID_LEN, now fail the
build if either buffer is ever sized below its worst case.
The root platformio.ini passed -D NO_GROUND_RECORD_DELAY=2000, but the
firmware reads NO_GND_RECORD_DELAY (defaulted to 0 in open_evse.h and used
in J1772EvseController.cpp to gate recording of a NO-GROUND trip). Because
the two spellings never matched, the define landed on nothing and the
macro stayed at its 0 default, so the intended 2 second suppression of
spurious NO-GROUND trip recording has never been active in any PlatformIO
build. I renamed the flag to NO_GND_RECORD_DELAY so it reaches the code.
@chris1howell
chris1howell requested a review from lincomatic July 10, 2026 22:12
@lincomatic
lincomatic marked this pull request as ready for review July 10, 2026 23:18
@lincomatic
lincomatic marked this pull request as draft July 10, 2026 23:25
@lincomatic

Copy link
Copy Markdown
Member

Thanks for the PR. Fixes 4,5,6 can be merged as is.
Fix 1 let's remove the superfluous

  else {
    m_wFlags &= ~ECF_CGMI;
  }

The flag will never be set a priori

Fix 2 looks good.

On a higher level, unrelated to this PR, but concerning waitCurrentZero():

a. Perhaps the CURRENT_ZERO_THRESHOLD_MA should be bumped up or the default scale/offset should be adjusted so the loop doesn't run to the timeout as easily?

b. chargingOff() is called in J1772EvseController::Init() prior to the m_AmmeterCurrentOffset/m_CurrentScaleFactor getting loaded, the the init code either has to be move prior to that call, or it should be changed to chargingOff(1)

Fix 3 looks solid, but I don't know the low level commands well enough to comment on whether or not it works as designed.

Unrelated to the PR, but concerning the concept of waiting for a zero crossing to open the relay, has anyone verified with a spec sheet or scope that this is even feasible? I am wondering if it's even physically possible for the relay to open the relay fast enough for this code to have the desired effect

@lincomatic

Copy link
Copy Markdown
Member

Good catch on m_GfiRetryCnt getting reset on every GFI fault; yes, that line needs to be deleted.

9eccf4e

AUTOSVCLEVEL/TIME_LIMIT/CHARGE_LIMIT are enabled only for legacy non-WiFi builds. @chris1howell - need to tell the WiFi guys to get rid of Auto as a service level option

thanks for the heads up on the samd_dev_ice build. I'm the only one who uses that. It must have gotten messed up when someone else changed the platformio.ini. I'll fix it next time I do some debugging with the ICE

RAR added 2 commits July 10, 2026 21:03
Review feedback: the flag is never set a priori, so the else branch clearing
it is dead. Also switch the boot-time chargingOff() to an emergency open -
Init() calls it before the ammeter scale/offset are loaded from EEPROM, so
the graceful path could wait on garbage readings, and there is no current to
break at boot anyway.
…n floor

With the SAMD default ammeter calibration (scale 37, offset -135) an idle
ammeter computes 135 mA, so the 100 mA threshold could never be reached and
waitCurrentZero() always ran to its timeout. 200 mA clears the floor with
margin and is still far below any current level the relay contacts care
about.
@RAR

RAR commented Jul 11, 2026

Copy link
Copy Markdown
Author

Thanks for the review — all three items are in:

  • Dropped the superfluous else from the CGMI fix (5b0ed72).
  • Init() now calls chargingOff(1) — you're right that the graceful path could run against unloaded scale/offset, and a boot-time force-off has no current to break anyway (same commit).
  • Bumped CURRENT_ZERO_THRESHOLD_MA to 200 (74bcd95): with the SAMD defaults (scale 37, offset -135) an idle ammeter computes a 135 mA floor, so 100 was unreachable by construction. 200 clears the floor with margin and is still far below anything the contacts care about. I left the defaults themselves alone — they look like calibration values someone will want to set properly against real hardware, and I didn't want to guess.

Some hardware feedback in the meantime: my NXT is now running this branch. The stuck-at-UNKNOWN pilot I reported earlier turned out to be a missing ground screw on my unit — with that landed and this firmware flashed, it POSTs clean, rests at state A, and $GE shows 0x1121 (ECF_CGMI set). No STUCK_RELAY false-trip with a working GMI line, which was the failure mode I predicted for the old gating. $GZ still reads 0 because it's the cached measurement and the relay hasn't cycled since boot — I'll report the frequency it sees after the first real charge, which is also the hardware check fix 3 still needs.

On your zero-crossing question — I think healthy skepticism is warranted, for two reasons. First, the code times coil de-energization RELAY_OPEN_ADVANCE_MS (2 ms) before a voltage zero cross, but mechanical drop-out on a contactor is typically 5–20 ms and varies unit-to-unit and with temperature, so a fixed 2 ms advance can only hit a target phase if the drop-out delay is characterized and compensated (the close side has the same question with its 20 ms advance vs. pull-in time). Second, what the contacts care about for arcing is the current zero, which the GMI line can't see — with an EV's near-unity power factor they're close, but not the same thing. In practice a mistimed open isn't dangerous (the arc extinguishes at the next natural current zero regardless); the question is whether the timing buys any contact-life benefit over random phase. The honest answer needs a scope on coil drive vs. contact voltage/current. If the numbers say the advance constants need per-relay tuning, $Z0-style tunables might be the pattern.

And noted on the Auto service level — I can take care of removing it from the WiFi firmware's UI side.

RAR pushed a commit to OpenEVSE/openevse-gui-nightshift that referenced this pull request Jul 11, 2026
AUTOSVCLEVEL (auto service-level detection) is compiled only into legacy
non-WiFi firmware builds, so Auto is a non-functional option on the WiFi
firmware this GUI serves. Per the firmware maintainer, the WiFi UI should
drop it, leaving L1 and L2:
OpenEVSE/open_evse#54 (comment)

A device still reporting service: 0 now coerces to L2 (the common
default) via `|| 2` so the select stays valid instead of binding to the
removed option and rendering blank. Regenerated settings-evse shot.

Claude-Session: https://claude.ai/code/session_01XMvqVxVAkjyjvFMmHwtFdQ
@RAR
RAR marked this pull request as ready for review July 11, 2026 15:17
@lincomatic
lincomatic merged commit 9b8c78c into OpenEVSE:master Jul 11, 2026
6 checks passed
@lincomatic

Copy link
Copy Markdown
Member

Thanks for the clarification on the zero crossing algorithm. Yes, besides unit to unit variance, there's also temperature variation. As you stated, the only cost added by this code is a time delay. However, a commercial OE-based vendor had to do a lot of hardware gymnastics to get his big contactors to open fast enough to pass UL last year, so it might be an issue? Any idea how long the code takes to execute?

Anyway, since it's a feature that can be disabled, it doesn't hurt to just keep it.

Going forwards, let's keep the master branch for fully vetted release code only, and submit all PRs against a development branch. I just archived the old obsolete development branch and created a new dev branch. Let's do all changes in this branch

https://github.com/OpenEVSE/open_evse/tree/dev

@chris1howell

Copy link
Copy Markdown
Member

The zero crossing only adds a cycle for non priority stops, GFCI still reacts immediately. I did some basic tuning on a scope but Zero Cross will very likely need additional tuning for temperature.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants