Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion boards/nexcyber-zbu011k/board.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@

set(PROD_APP_SRCS
src/main.c
src/core/fault.c src/core/j1772.c src/core/over_temp.c
src/core/fault.c src/core/gfci_policy.c src/core/j1772.c src/core/over_temp.c
src/core/rfid.c src/core/system_state.c src/core/system_time.c
src/persist/crc.c src/persist/boot_count.c src/persist/pingpong.c
src/persist/boot_config.c src/persist/calibration.c src/persist/crc16.c
Expand Down
1 change: 1 addition & 0 deletions boards/rippleon-roc001/board.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
set(APP_SRCS
src/main.c
src/core/fault.c
src/core/gfci_policy.c
src/core/j1772.c
src/core/over_temp.c
src/core/rfid.c
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ void OpenevchargerTlv::setup() {
send_get_lifetime_kwh();
send_rfid_get_list();
send_get_rfid_config();
send_get_gfci_policy();
}

void OpenevchargerTlv::loop() {
Expand Down Expand Up @@ -487,6 +488,25 @@ void OpenevchargerTlv::dispatch_frame_(uint8_t cmd, uint8_t seq,
break;
}

case EVT_GFCI_POLICY: {
// Payload: u8 policy (GFCI_POLICY_*).
if (plen < 1) break;
gfci_policy_ = p[0];
const char *name = (p[0] == GFCI_POLICY_FAULT) ? "fault"
: (p[0] == GFCI_POLICY_WARN) ? "warn"
: "?";
if (p[0] == GFCI_POLICY_FAULT) {
ESP_LOGI(TAG, "gfci_policy: %s", name);
} else {
ESP_LOGW(TAG, "gfci_policy: %s — ground-fault interlock suppressed",
name);
}
#ifdef USE_SELECT
if (gfci_policy_select_) gfci_policy_select_->publish_from_mcu(p[0]);
#endif
break;
}

case EVT_RFID_LIST_END: {
if (plen >= 1) {
rfid_authlist_count_ = p[0];
Expand Down Expand Up @@ -977,6 +997,19 @@ uint8_t OpenevchargerTlv::send_get_rfid_config() {
return s;
}

uint8_t OpenevchargerTlv::send_set_gfci_policy(uint8_t policy) {
uint8_t s = next_seq_();
send_frame_(CMD_SET_GFCI_POLICY, s, &policy, 1);
// MCU will publish a fresh EVT_GFCI_POLICY once persisted.
return s;
}

uint8_t OpenevchargerTlv::send_get_gfci_policy() {
uint8_t s = next_seq_();
send_frame_(CMD_GET_GFCI_POLICY, s, nullptr, 0);
return s;
}

uint8_t OpenevchargerTlv::send_set_time(uint32_t unix_seconds) {
/* Rate-limit at 2 s gap: HA fires on_client_connected and time
* on_time_sync within ms of each other on every reconnect, which
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
#ifdef USE_SWITCH
#include "esphome/components/switch/switch.h"
#endif
#ifdef USE_SELECT
#include "esphome/components/select/select.h"
#endif
#ifdef USE_SENSOR
#include "esphome/components/sensor/sensor.h"
#endif
Expand Down Expand Up @@ -71,6 +74,8 @@ static constexpr uint8_t CMD_GET_TIME = 0x1A;
static constexpr uint8_t CMD_RESTART = 0x1B;
static constexpr uint8_t CMD_SIMULATE_REPLUG = 0x1C;
static constexpr uint8_t CMD_RUN_GFCI_CAL_TEST = 0x1E; // 0x1D reserved (was relay actuate, now N/A)
static constexpr uint8_t CMD_SET_GFCI_POLICY = 0x1F;
static constexpr uint8_t CMD_GET_GFCI_POLICY = 0x20;

// MCU → FC41D events / responses (bit 7 set)
static constexpr uint8_t EVT_STATE_CHANGED = 0x80;
Expand All @@ -97,6 +102,13 @@ static constexpr uint8_t EVT_OTA_COMMITTED = 0x95;
static constexpr uint8_t EVT_OTA_ABORTED = 0x96;
static constexpr uint8_t EVT_TIME = 0x97;
static constexpr uint8_t EVT_DIAG_ADC = 0x98;
static constexpr uint8_t EVT_GFCI_POLICY = 0x99;

// GFCI fault-handling policy values (CMD_SET_GFCI_POLICY / EVT_GFCI_POLICY,
// mirror src/proto/commands.h). Index order matches the HA select options
// in select.py, so the select index is the wire value.
static constexpr uint8_t GFCI_POLICY_FAULT = 0;
static constexpr uint8_t GFCI_POLICY_WARN = 1;

// OTA status codes (mirror src/proto/commands.h).
static constexpr uint8_t OTA_STATUS_OK = 0;
Expand Down Expand Up @@ -210,6 +222,8 @@ class OpenevchargerTlv : public Component, public uart::UARTDevice {
uint8_t send_rfid_remove_uid(uint32_t uid);
uint8_t send_set_require_rfid_auth(bool enable);
uint8_t send_get_rfid_config();
uint8_t send_set_gfci_policy(uint8_t policy);
uint8_t send_get_gfci_policy();

// Push the current HA wall-clock to the MCU. Caller must check the
// time component's is_valid() before calling — sending 0 is allowed
Expand Down Expand Up @@ -362,6 +376,11 @@ class OpenevchargerTlv : public Component, public uart::UARTDevice {
#ifdef USE_SWITCH
void set_require_rfid_auth_switch(class OpenevchargerTlvSwitch *s) { require_rfid_auth_switch_ = s; }
#endif
#ifdef USE_SELECT
void set_gfci_policy_select(class OpenevchargerTlvSelect *s) { gfci_policy_select_ = s; }
#endif
// Last GFCI fault-handling policy reported by the MCU (GFCI_POLICY_*).
uint8_t gfci_policy() const { return gfci_policy_; }

static const char *evse_state_name(uint8_t s);
static const char *j1772_state_name(uint8_t s);
Expand Down Expand Up @@ -469,6 +488,10 @@ class OpenevchargerTlv : public Component, public uart::UARTDevice {
#ifdef USE_SWITCH
class OpenevchargerTlvSwitch *require_rfid_auth_switch_{nullptr};
#endif
#ifdef USE_SELECT
class OpenevchargerTlvSelect *gfci_policy_select_{nullptr};
#endif
uint8_t gfci_policy_{GFCI_POLICY_FAULT};

// --- OTA push state machine ----------------------------------------
// Driven by loop() — fires next CMD_OTA_CHUNK on every CHUNK_ACK with
Expand Down Expand Up @@ -598,6 +621,37 @@ class OpenevchargerTlvSwitch : public switch_::Switch, public Component {
};
#endif

#ifdef USE_SELECT
// HA dropdown for the MCU's GFCI fault-handling policy. Option order
// ("fault","warn" — see select.py) matches GFCI_POLICY_*, so the
// select index is the wire value sent to the MCU.
class OpenevchargerTlvSelect : public select::Select, public Component {
public:
void set_parent(OpenevchargerTlv *p) { parent_ = p; }
void setup() override {}
float get_setup_priority() const override { return setup_priority::DATA; }
// Pushed by EVT_GFCI_POLICY dispatch — confirmed-from-MCU value,
// applied without echoing back over UART via control().
void publish_from_mcu(uint8_t policy) {
auto opt = this->at(policy);
if (opt.has_value() && this->state != opt.value())
this->publish_state(opt.value());
}

protected:
void control(const std::string &value) override {
auto idx = this->index_of(value);
if (!idx.has_value())
return;
if (parent_)
parent_->send_set_gfci_policy(static_cast<uint8_t>(idx.value()));
// Optimistic — confirmed by next EVT_GFCI_POLICY.
this->publish_state(value);
}
OpenevchargerTlv *parent_{nullptr};
};
#endif

#ifdef USE_BUTTON
class OpenevchargerTlvButton : public button::Button {
public:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.components import select

from . import openevcharger_tlv_ns, OpenevchargerTlv

CONF_OPENEVCHARGER_TLV_ID = "openevcharger_tlv_id"
CONF_GFCI_FAULT_POLICY = "gfci_fault_policy"

# Option order IS the wire value: index 0 = FAULT, 1 = WARN.
# Must stay in sync with GFCI_POLICY_* in openevcharger_tlv.h and
# proto/commands.h.
GFCI_POLICY_OPTIONS = ["fault", "warn"]

OpenevchargerTlvSelect = openevcharger_tlv_ns.class_(
"OpenevchargerTlvSelect", select.Select, cg.Component
)

CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_OPENEVCHARGER_TLV_ID): cv.use_id(OpenevchargerTlv),
cv.Optional(CONF_GFCI_FAULT_POLICY): select.select_schema(
OpenevchargerTlvSelect
),
}
)


async def to_code(config):
parent = await cg.get_variable(config[CONF_OPENEVCHARGER_TLV_ID])
if conf := config.get(CONF_GFCI_FAULT_POLICY):
sel = await select.new_select(conf, options=GFCI_POLICY_OPTIONS)
await cg.register_component(sel, conf)
cg.add(sel.set_parent(parent))
cg.add(parent.set_gfci_policy_select(sel))
12 changes: 12 additions & 0 deletions boards/rippleon-roc001/fc41d/openevcharger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,18 @@ switch:
name: "Require RFID for Charging"
entity_category: config

# GFCI fault-handling policy. "fault" (default) latches a UL2231
# ground-fault trip — power-cycle to clear. "warn" logs the trip and
# raises it in HA but lets charging continue. "warn" is a
# bench/diagnostic setting — it suppresses a real safety interlock.
# Persisted in W25Q boot_config; default "fault".
select:
- platform: openevcharger_tlv
gfci_fault_policy:
id: gfci_fault_policy_select
name: "GFCI Fault Policy"
entity_category: config

button:
- platform: openevcharger_tlv
request_stop:
Expand Down
11 changes: 11 additions & 0 deletions src/core/gfci_policy.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/* gfci_policy_step() is defined as static inline in gfci_policy.h so
* the firmware inlines it straight into safety_task::check_gfci. This
* translation unit exists only to give the build system a stable file
* to compile (and room for any future stateful helpers). Currently a
* no-op, mirroring core/over_temp.c. */

#include "gfci_policy.h"

/* Force one external reference to the header so editors / static
* analysers tracking includes don't flag this file as orphaned. */
const unsigned gfci_policy_persist_ticks_c = GFCI_PERSIST_TICKS;
96 changes: 96 additions & 0 deletions src/core/gfci_policy.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
#ifndef OPENEVCHARGER_CORE_GFCI_POLICY_H
#define OPENEVCHARGER_CORE_GFCI_POLICY_H

#include <stdint.h>

/* Pure GFCI fault-handling policy decision. safety_task::check_gfci
* samples PE2 (the GFCI module's active-low fault line) and the
* persisted policy byte, feeds them to gfci_policy_step(), and carries
* out the returned action. The caller owns ALL I/O — fault_raise /
* post_fault_event / evse_transition / printk. The decision state
* machine (debounce, the WARN edge-latch, the fail-safe rule) lives
* here so it is host-testable in isolation — see
* tests/test_gfci_policy.c. This mirrors the core/over_temp.h pattern.
*
* Policy values mirror GFCI_POLICY_* in proto/commands.h; a
* _Static_assert in safety_task.c (which sees both headers) locks the
* two against drift:
*
* FAULT (0, default) — raise FAULT_GFCI, force EVSE_FAULT, contactor
* latched open. Power-cycle-only clear per
* UL2231 (fault.c::fault_clear() refuses GFCI).
* WARN (1) — emit one fault event so HA records the trip,
* but do NOT raise into fault_state and do NOT
* open the contactor — charging continues. A
* bench/diagnostic escape for a known external
* leakage fault; it suppresses a real safety
* interlock.
*
* Any other policy value is treated as FAULT — an unknown or stale
* byte fails safe to the UL2231 interlock.
*
* Debounce: PE2 must read LOW for GFCI_PERSIST_TICKS consecutive ticks
* before any action. At the 20 ms safety tick that is 60 ms — well
* under UL2231's 25 ms + upstream contactor-open budget for the trip
* path, but long enough to ride out coupling glitches (cf. the PD6
* bench-wiggle).
*
* WARN edge-latch: WARN emits exactly once per PE2-LOW episode; the
* latch re-arms when PE2 returns HIGH, so a fault that clears and
* re-asserts is logged again.
*
* gfci_policy_step() is static inline so the firmware inlines it
* straight into check_gfci; core/gfci_policy.c is a stable compile
* target carrying no logic. */

#define GFCI_PERSIST_TICKS 3U

/* Policy values — mirror GFCI_POLICY_* in proto/commands.h. */
#define GFCI_POL_FAULT 0u
#define GFCI_POL_WARN 1u

typedef struct {
unsigned trip_streak; /* consecutive ticks PE2 read LOW (capped) */
int warned; /* WARN edge-latch: 1 once emitted this episode */
int fault_active; /* 1 once GFCI_ACT_RAISE_FAULT has been returned */
} gfci_policy_ctx_t;

typedef enum {
GFCI_ACT_NONE = 0, /* nothing for the caller to do this tick */
GFCI_ACT_WARN_EMIT, /* WARN: emit one fault event, keep charging */
GFCI_ACT_RAISE_FAULT, /* FAULT: raise FAULT_GFCI + force EVSE_FAULT */
} gfci_policy_action_t;

/* Advance the detector one safety tick. pe2_low is 1 when the GFCI
* module asserts its (active-low) fault line, 0 when idle. policy is
* the persisted GFCI_POL_* byte. Updates *ctx in place and returns the
* action the caller must carry out. */
static inline gfci_policy_action_t
gfci_policy_step(gfci_policy_ctx_t *ctx, int pe2_low, uint8_t policy)
{
if (pe2_low) {
if (ctx->trip_streak < GFCI_PERSIST_TICKS) ++ctx->trip_streak;
} else {
ctx->trip_streak = 0;
ctx->warned = 0; /* PE2 released — re-arm the WARN edge */
}

if (ctx->trip_streak < GFCI_PERSIST_TICKS)
return GFCI_ACT_NONE;

if (policy == GFCI_POL_WARN) {
if (ctx->warned)
return GFCI_ACT_NONE; /* already emitted this episode */
ctx->warned = 1;
return GFCI_ACT_WARN_EMIT;
}

/* FAULT (default) — and any non-WARN value: fail-safe. Raise once;
* the streak keeps accumulating harmlessly while latched. */
if (ctx->fault_active)
return GFCI_ACT_NONE;
ctx->fault_active = 1;
return GFCI_ACT_RAISE_FAULT;
}

#endif /* OPENEVCHARGER_CORE_GFCI_POLICY_H */
21 changes: 19 additions & 2 deletions src/persist/boot_config.c
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,11 @@ static int store(const char *what)
return rc;
}
printk("boot_config: stored -> slot %c (counter=%u, "
"advertised_amps=%u, require_rfid_auth=%u) [%s]\n",
"advertised_amps=%u, require_rfid_auth=%u, gfci_policy=%u) [%s]\n",
'A' + slot, (unsigned)counter,
(unsigned)s_cfg.fc41d_advertised_amps,
(unsigned)s_cfg.require_rfid_auth, what);
(unsigned)s_cfg.require_rfid_auth,
(unsigned)s_cfg.gfci_fault_policy, what);
return 0;
}

Expand All @@ -86,6 +87,22 @@ int boot_config_set_require_rfid_auth(uint8_t enable)
return store("require_rfid_auth");
}

uint8_t boot_config_gfci_fault_policy(void)
{
return s_cfg.gfci_fault_policy;
}

int boot_config_set_gfci_fault_policy(uint8_t policy)
{
/* 1u == GFCI_POLICY_WARN (proto/commands.h) — the highest valid
* policy. Reject anything above it rather than clamping; a bad
* value must not quietly land as a weaker safety posture. */
if (policy > 1u) return -1;
if (s_cfg.gfci_fault_policy == policy) return 0;
s_cfg.gfci_fault_policy = policy;
return store("gfci_fault_policy");
}

uint8_t boot_config_pending_ota_flag(void)
{
return s_cfg.pending_ota_flag;
Expand Down
Loading
Loading