rippleon: GFCI fault policy (fault/warn) + contactor force-open latch fix#30
Merged
Conversation
Adds a persisted, TLV-settable GFCI fault-handling policy to the
OpenEVCharger MCU firmware and the ESPHome component, exposed in HA
as a select entity ("GFCI Fault Policy").
- fault (default): unchanged UL2231 behaviour — latch FAULT_GFCI,
force EVSE_FAULT, contactor force-open, power-cycle to clear.
- warn: log + EVT_FAULT_RAISED (visible in HA fault log) but keep the
contactor closed — charging continues.
- ignore: detector still runs but takes no action.
MCU: new gfci_fault_policy byte in boot_config, carved from reserved[]
so a 0 byte in any pre-existing record reads as the safe FAULT default
(struct stays 32 B, backward compatible). New CMD_SET/GET_GFCI_POLICY +
EVT_GFCI_POLICY TLV, plumbed comms -> persist -> safety. check_gfci()
branches on the policy; WARN/IGNORE are edge-latched to emit once per
PE2-LOW episode. Loud boot log when policy != fault.
ESPHome: OpenevchargerTlvSelect + new select.py platform; "GFCI Fault
Policy" config entity in openevcharger.yaml.
warn/ignore suppress a real UL2231 ground-fault interlock and are
intended for bench/diagnostic use. Policy persists across power cycles.
Verified: native host core build + 119/119 tests pass. ARM firmware
and ESPHome image not yet compile-checked (toolchain swap pending).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ignore (detector runs, takes no action) only removed logging / HA visibility versus warn — warn already keeps charging and at minimum records the trip to the event log + EVT_FAULT_RAISED. There is no case where silent charge-through beats logged charge-through, so the option is dropped per review. Wire enum is now FAULT=0, WARN=1 — contiguous, no renumbering. check_gfci() drops the IGNORE branch; any policy byte other than WARN now falls through to the FAULT path, so an unknown or stale value fails safe to the UL2231 interlock. boot_config validator rejects values above WARN. GFCI_POLICY_MAX, select.py options, the ESPHome enum, the boot-log announce, and all comments updated to match. Host build + 119/119 tests still pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
check_gfci was a static in safety_task.c, which the host test harness deliberately cannot build (FreeRTOS / SPL / HW headers). Mirror the core/over_temp.c pattern: the debounce + WARN-edge-latch + fail-safe-to-FAULT decision moves to a pure static-inline gfci_policy_step() in core/gfci_policy.h; check_gfci keeps only the I/O (fault_raise / post_fault_event / evse_transition / printk). Behaviour is unchanged — a _Static_assert locks GFCI_POL_* against the proto/commands.h GFCI_POLICY_* wire values. tests/test_gfci_policy.c — 15 cases: FAULT raises after debounce; WARN emits once per PE2-LOW episode, re-arms on release, never latches a fault; unknown/stale policy bytes fail safe to FAULT; sub-debounce no-ops; the WARN latch never blocks a live policy flip to FAULT. test_boot_config.c — 5 cases for boot_config_set_gfci_fault_policy (round-trips, validator rejects values above WARN). Host suite 119 -> 139 cases, all pass. rippleon ARM firmware builds clean; safety_task.c + gfci_policy.c also compile clean for nexcyber. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…er a fault evse_transition() assigns *cur = next and then, in the else-if for non-FAULT transitions, tested `*cur != next` — always false after that assignment. relay_force_open_release() was therefore unreachable: once the PB12 hardware force-open latch is armed on an EVSE_FAULT entry, the firmware never releases it. Every subsequent relay_main_close() drives PE12 HIGH into a latched-open contactor — "commanded closed" reads true but the contactor never pulls in and the EV draws 0 A, then regresses C->B. The bug (commit 7fcba09, 2026-05-03) was masked for weeks: GFCI faults are power-cycle-only-clear, so the mandatory power-cycle reset PB12 every session. It surfaced once the GFCI WARN policy removed those power-cycles and an ordinary clearable fault armed the latch. Fix: drop the dead `*cur != next` guard. The early-return at the top of evse_transition() already guarantees a real state change, so every non-FAULT transition must release an active force-open latch. Recovery for an already-bricked unit: power-cycle (PB12 boots LOW). rippleon ARM firmware builds clean; host suite 139/139. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Four commits, all bench-validated on the Rippleon ROC001 (2026-05-22):
fault(default; UL2231 latch, power-cycle clear) orwarn(logs the trip + raisesEVT_FAULT_RAISEDfor HA, but keeps the contactor closed). Persisted inboot_config; exposed as a HAselectentity.warnis a bench/diagnostic escape for a known external leakage fault (here: common-mode leakage from an off-grid inverter).ignoredropped after review — silent charge-through has no advantage over logged charge-through; policy isfault/warnonly, and any non-WARN byte fails safe to FAULT.core/gfci_policymodule (mirrorscore/over_temp), making the debounce / WARN-edge-latch / fail-safe rule host-testable — 20 new unit tests (test_gfci_policy+boot_configvalidator cases).evse_transition()armed the PB12 hardware force-open latch on everyEVSE_FAULTbut its release was dead code (*cur != nexttested after*cur = next). Once armed the latch was never released, hardware-latching the main contactor open — "commanded closed" but 0 A drawn. Latent since 2026-05-03; masked by GFCI faults being power-cycle-only-clear, surfaced once WARN removed those power-cycles.Test plan
ctest)safety_task.c+gfci_policy.calso compile clean for the nexcyber target🤖 Generated with Claude Code