Skip to content

Releases: OpenEVSE/OpenEVSE_Lib

v0.0.24

Choose a tag to compare

@chris1howell chris1howell released this 12 Sep 19:37

The controller firmware already exposed this as $S0 0|1 (RAPI) /
ECF_MONO_LCD (EEPROM flags bit 0x0100), gated behind the LCD16X2 +
RGBLCD compile flags — only the m328p_LCD_WIFI build defines both;
m328p_core ("WiFi, no text LCD") defines neither, so $S0 NAKs there. No
change was needed on the controller firmware (open_evse) side. OpenEVSE_Lib
could already read the flag but had no method to set it — that was the
actual gap this closes.

Changes by repo

OpenEVSE_Lib (branch OpenEVSE9, commit 8e93b3a)

  • OPENEVSE_LCD_TYPE_MONO / OPENEVSE_LCD_TYPE_RGB constants
    (src/openevse.h).
  • OpenEVSEClass::setLcdType(uint8_t type, callback) (src/openevse.cpp) —
    sends $S0 0|1, following the same validate-then-sendCmd shape as
    setServiceLevel()/feature().
  • Bumped library.json to 0.0.24.

v0.0.23

Choose a tag to compare

@chris1howell chris1howell released this 07 Sep 17:07

Monitors NTC thermistors embedded in the EV (output) cable and the input
(supply) cable via the two proximity-pilot-style analog inputs, and faults the
EVSE into EVSE_STATE_OVER_TEMPERATURE when a cable gets too hot.

Build flag: CABLE_TEMPERATURE_MONITORING (enabled in the common
platformio.ini flags). Runtime-disabled by default — nothing reads the ADC
and nothing can fault until $FF C 1 is sent and at least one source is
assigned a pin.

Source: firmware/open_evse/CableTempMonitor.h,
.cpp.

Hardware

Per channel, the divider is the same shape as the existing PP ampacity
circuit:

  VCC (5V, or 3.3V on SAMD)
   |
  [1k]                       <- CABLE_TEMP_PULLUP_OHMS
   |
   +---- connector pin (PP / PP2) ---- [NTC in cable] ---- GND
   |
  [10k]                      <- series protection only; the ADC input is
   |                            high-impedance, so it drops ~0 V
  PP_READ / PP2_READ
   |
  [diode to GND]             <- negative clamp

so the ADC sees Vadc/Vcc = Rntc / (Rpullup + Rntc), and because the ADC
reference is the supply on both targets (AVR AVCC, SAMD INTVCC1 with
GAIN_DIV2) the measurement is ratiometric — supply variation cancels.

ATmega328P SAMD21
PP_PIN (PP_READ) ADC2 PB09 (A2)
PP2_PIN (PP2_READ) ADC3 PA04 (A3)
ADC resolution 10-bit 12-bit

PP2_PIN was added to both targets' pindefs.h; PP_PIN already existed.

Sources

Four logical sources, each with independent thermistor parameters,
calibration offset and shutdown threshold:

idx name intended use
0 EV1 EV / output cable
1 EV2 EV / output cable
2 IN1 input / supply cable
3 IN2 input / supply cable

Each is assigned to an input pin — 0 unassigned, 1 PP_READ, 2 PP2_READ.
There are only two physical inputs, so at most two sources can be reading at
once; the naming is about which cable the thermistor is in, not about how many
ADCs exist. Nothing stops two sources sharing an input — they simply read the
same voltage through their own calibration.

PP_READ is shared with the proximity pilot

PP_READ cannot serve both the proximity pilot and a temperature sensor. The
interlock is enforced at the point of change, in both directions:

  • $SN idx 1 … (assigning a source to PP_READ) clears ECF_PP_AUTO_AMPACITY,
    exactly as $FF P 0 would.
  • $FF P 1 (enabling PP auto-ampacity) unassigns every source sitting on
    PP_READ, exactly as $SN idx 0 would.

Both are silent — read back with $GE (flags) and $GN idx to see the
result. CableTempMonitor::Init() re-checks at boot as a corruption guard
only; if a saved configuration somehow claims the pin for both, proximity
pilot keeps it.

PP2_READ has no such conflict.

Temperature conversion

No floating point and no logarithm, on either target.

  1. Rntc = Rpullup * adc / (ADC_MAX - adc)
  2. normalize: ratio = Rntc / R25, scaled by 2000
  3. look the ratio up in a 39-entry PROGMEM table of R/R25 tabulated every
    5 °C from −40 °C to 150 °C, linearly interpolating within the step
  4. correct for a thermistor beta differing from the table's, if needed
  5. add the calibration offset

Storing the normalized curve rather than absolute ohms is what makes R25
configurable — any NTC of the same curve shape works by dividing out its own
R25 before the lookup.

The beta correction (step 4) is exact rather than an approximation. For any
beta, ln(R/R25) = B·(1/T − 1/T25). Reading the table (which assumes B0)
yields a Tref satisfying ln(R/R25) = B0·(1/Tref − 1/T25), and equating the
two gives

1/T = 1/T25 + (B0/B) · (1/Tref − 1/T25)

which is plain integer arithmetic on a fixed-point reciprocal. It is skipped
entirely when beta == B0.

Curve reference point

B0 = 3443 is derived from the two Phoenix Contact NACS published points,
10000 Ω @ 25 °C and 1266 Ω @ 90 °C:

B = ln(10000/1266) / (1/298.15 − 1/363.15) = 3442.6

3443 reproduces 1265.7 Ω @ 90 °C. Both datasheet points come back out of the
integer pipeline exactly (25.0 °C and 90.0 °C).

Accuracy

Modelled against exact floating-point ground truth through the whole chain
(true temperature → true resistance → truncating ADC → integer algorithm):

328P (10-bit) SAMD (12-bit)
worst |error|, 20–120 °C, stock 10k B3443 0.5 °C 0.4 °C
worst |error|, 20–120 °C, across 10k B3950 / 10k B3380 / 5k B3450 0.6 °C 0.4 °C
error at 70/90/110 °C +0.0 … +0.3 °C +0.0 … +0.3 °C

Error in the band that matters is small and biased hot, which is the
fail-safe direction. With the default 90.0 °C threshold the fault fires at a
true 89.8 °C (10-bit) / 89.9 °C (12-bit).

Accuracy degrades below about 0 °C — a 1 kΩ pull-up against a 10 kΩ NTC puts
the divider within a few ADC counts of the rail down there (about 1.5 °C
worst case at −20 °C on the 328P). Irrelevant for over-temperature
protection, but don't treat a cold reading as precise.

Sentinel readings

All in 10ths of a degree C, all below any real reading, so a >= panic
comparison can never trip on one:

value meaning
−2560 source unassigned, or the feature is disabled
−2561 open circuit — no cable plugged in, or a disconnected/broken thermistor. Also reported below about −40 °C, where a very cold cable and an open one are not distinguishable.
−2562 shorted thermistor or wiring

Neither open nor short faults the EVSE. An unplugged connector reads open
during entirely normal operation, so open cannot be treated as an error. A
hard short is reported for diagnosis rather than shutdown — see the caveat
below.

Over-temperature behaviour

CableTempMonitor::Read() re-reads every 1000 ms (8 averaged ADC samples per
source) and sets an internal over-temperature flag if any assigned source is
at or above its own panic threshold. J1772EVSEController::Update() then
drops into EVSE_STATE_OVER_TEMPERATURE, the same fault state the enclosure
sensors use.

Gated on the cable feature's own enable ($FF C), not on $FF T — these are
separate sensors with separate thresholds, and one shouldn't silently switch
off the other.

Like the existing ambient panic check, there is no hysteresis: the fault
clears when the reading drops back below the threshold.

Two deliberate design decisions worth knowing

A reading above the top of the table clamps to 150.0 °C. That value means
"at or above the top of the measurable range", not a measurement — for a
thermistor whose beta differs from the curve's, the true temperature at the
clamp point can be meaningfully lower (about 128 °C for a 10k B3950). Clamping
high is the fail-safe direction: a beta-corrected, and therefore lower, clamp
value could sit under a high panic threshold and never trip it.

The short-detection band is deliberately very narrow — R/R25 < 0.001,
under 10 Ω on a 10k NTC. Everything between there and the bottom of the curve
table clamps to 150.0 °C and therefore trips. A wider short band would carve a
hole in the protection: a thermistor hot enough to fall into it would report
"shorted", which does not fault, and would silently clear an over-temperature
fault raised on the way up. Verified over the full ADC range that there is no
reading between 90 °C and 300 °C that fails to trip.

Open/short do not fault. A failed or disconnected sensor on an assigned
source means the cable is unprotected, and the firmware currently reports that
rather than acting on it — visible through $GN, so the ESP32 layer can
decide. Worth revisiting if you want sensor-integrity enforcement in the
controller itself.

RAPI

Full reference is inline in
rapi_proc.h. Summary:

$FF C 0|1 — enable/disable

Saved to EEPROM. Disabling also clears all readings back to −2560 and drops
any over-temperature condition.

$SN — configure a source

$SN idx pin                        reassign the input pin only
$SN idx pin r25 beta offset panic  full configuration
arg range default meaning
idx 0–3 — 0=EV1 1=EV2 2=IN1 3=IN2
pin 0–2 0 0=unassigned 1=PP_READ 2=PP2_READ
r25 100–65535 10000 NTC nominal resistance at 25 °C, Ω
beta 1000–6000 3443 NTC beta coefficient
offset −2000…2000 0 calibration offset, 10ths °C
panic 300–1500 900 shutdown threshold, 10ths °C

Saved to EEPROM. $NK on any out-of-range argument — values are validated as
int32_t before narrowing, so an out-of-range argument is rejected rather
than wrapping into range.

$SN 0 2 10000 3443 0 900   EV1 on PP2_READ, stock Phoenix Contact NACS cable
$SN 0 2                    move EV1 to PP2_READ, keep its calibration
$SN 0 0                    unassign EV1

$GN — read

$GN       -> $OK ev1 ev2 in1 in2      temperatures, 10ths °C or a sentinel
$GN idx   -> $OK pin r25 beta offset panic

EEPROM

37 bytes at offsets 58–94; next free offset is 95.

offset size contents
58 1 CTMF_ENABLED; 0xff = unformatted → feature off
59 + 9·idx 9 per source: r25(2) beta(2) offset(2) panic(2) pin(1)

Serialized field by field rather than block-copied, so struct alignment
padding cannot change the on-EEPROM layout between targets. Anything
unformatted or out of range falls back to the Phoenix Contact NACS defaults.

Cost

build flash before flash after RAM before RAM after
m328p_core 23008 (70.2%) 25254 (77.1%) 940 1015
`m328p_L...
Read more

v0.0.22

Choose a tag to compare

@chris1howell chris1howell released this 30 Aug 14:42

Stuck-relay recovery support

Client-library support for the controller-side stuck-relay auto-recovery
feature (open_evse firmware 9.3.0, dev @ 5b3a755). OpenEVSE9 @
2c7c3f3, library.json 0.0.22.

Background

Firmware 9.3.0 added an automatic recovery attempt for a welded/stuck main
contactor: when EVSE_STATE_STUCK_RELAY is entered with no EV connected,
the controller cycles the relay (3 rounds of 5 on/off toggles, each shorter
than the last) instead of going straight to an unrecoverable hard fault.
It also added $FK to run that same cycle manually, and extended $GL
(relay health) with a cumulative attempt counter. Full firmware-side
derivation: open_evse/docs/stuck_relay_recovery.md.

This release wires both into the client API.

getRelayHealth() - new field

The callback signature gained a 10th parameter,
uint32_t stuck_relay_recovery_count:

OpenEVSE.getRelayHealth([](int ret, uint8_t life_remaining_pct,
                            uint32_t cold_open_count, uint32_t elec_damage_x1e6,
                            uint32_t transit_baseline_ms, bool transit_drift_warning,
                            uint32_t thermal_index_x100, uint32_t thermal_baseline_x100,
                            uint8_t thermal_warning_level,
                            uint32_t stuck_relay_recovery_count)
{
  if (ret == RAPI_RESPONSE_OK) {
    Serial.printf("Recovery attempts: %u\n", stuck_relay_recovery_count);
  }
});

Backward compatible on the wire: against a pre-9.3.0 controller, $GL
still only returns 8 fields, and stuck_relay_recovery_count reads back
0 rather than the call failing.

runStuckRelayRecovery() - new method

void OpenEVSEClass::runStuckRelayRecovery(std::function<void(int ret)> callback);

Sends $FK to run the recovery cycle on demand - the same routine the
controller runs automatically. The controller NAKs it if an EV is
connected (unsafe to cycle the relay under load).

Uses an explicit 35 second RapiSender timeout, not the library
default (RAPI_TIMEOUT_MS, 500ms). The controller call itself is blocking
and can legitimately take up to ~30s (3 rounds x 5 toggles x up to 2s
on+off, plus zero-cross/transit overhead) before it responds - the default
timeout would give up long before that.

Doesn't itself report whether the relay came free; check getStatus()
(state) or getRelayHealth() (the recovery counter, or re-poll relay
status) afterward.

resetRelayHealth() - behavior change, no API change

$FH now also clears the recovery counter server-side, alongside the
existing damage-accumulator and baseline resets. No client-side change
needed - just documented.

Requirements

Same D9-protocol gate (isD9Supported()) as the other linco-work
extensions, and firmware 9.3.0+ / ADVPWR for runStuckRelayRecovery() and
the recovery counter specifically (older/non-ADVPWR controllers NAK $FK
with RAPI_RESPONSE_FEATURE_NOT_SUPPORTED and report the counter as 0).

Verification

Compiled by temporarily pointing openevse_esp32_firmware's lib_deps at
this checkout (symlink://) and building openevse_wifi_v1. The library
itself compiled clean; the only error was the expected one - that repo's
evse_monitor.cpp still has the old 9-argument getRelayHealth() lambda.

v0.0.21

Choose a tag to compare

@chris1howell chris1howell released this 30 Aug 00:06

OpenEVSE_Lib 0.0.21

Branch: OpenEVSE9

New: relay contact-life health API

Adds client support for the controller's new relay-health RAPI commands
($GL / $FH, firmware open_evse dev branch commit 442a98f), following
the same D9-gated pattern as the existing getFrequency() /
getRelayStatus() / resetFaultCounters() helpers.

OpenEVSEClass::getRelayHealth(callback)

Sends $GL and reports the controller's cumulative-damage relay-life
estimate:

OpenEVSE.getRelayHealth([](int ret, uint8_t life_remaining_pct,
                            uint32_t cold_open_count, uint32_t elec_damage_x1e6,
                            uint32_t transit_baseline_ms, bool transit_drift_warning,
                            uint32_t thermal_index_x100, uint32_t thermal_baseline_x100,
                            uint8_t thermal_warning_level)
{
  if (ret == RAPI_RESPONSE_OK) {
    Serial.printf("Relay life remaining: %u%%\n", life_remaining_pct);
  }
});
Field Meaning
life_remaining_pct 0-100: Miner's-rule electrical-damage accumulator (hot opens, weighted by (I/I_rated)^2 × load-character × temperature) combined with a mechanical-damage term (cold opens / rated mechanical life)
cold_open_count cumulative cold (non-arced) relay-open count
elec_damage_x1e6 raw electrical-damage accumulator, millionths of rated electrical life consumed (debug/trend field)
transit_baseline_ms self-learned open (drop-out) transit-time baseline; OPENEVSE_RELAY_HEALTH_NOT_AVAILABLE until established (needs 8 post-reset measurements; requires CGMI hardware)
transit_drift_warning true if the live open-transit time has drifted ≥1.5x the baseline - an early symptom of contact welding, ahead of the hard stuck-relay fault
thermal_index_x100 most recent ΔT/I² sample ×100, proportional to contact resistance; OPENEVSE_RELAY_HEALTH_NOT_AVAILABLE if not available (needs TEMPERATURE_MONITORING on the controller)
thermal_baseline_x100 self-learned baseline H0 ×100; same "not available" sentinel
thermal_warning_level 0 = ok/not available, 1 = watch (≥1.5x baseline), 2 = warn (≥2x baseline)

OpenEVSEClass::resetRelayHealth(callback)

Sends $FH. Clears the cumulative-damage accumulator and the transit-time/
thermal-index self-learned baselines - call this after physically replacing
the contactor, so the estimate doesn't carry over wear from the old relay.

New constant

OPENEVSE_RELAY_HEALTH_NOT_AVAILABLE (0xffff) - sentinel for the
transit-baseline and thermal fields above when that signal isn't available
yet, or at all (thermal fields require TEMPERATURE_MONITORING on the
controller).

Requirements

Both calls are gated by isD9Supported() (RAPI protocol ≥ 6.0.0) like the
other D9 extensions, and additionally require the controller to have been
built with the RELAY_HEALTH firmware feature (auto-enabled wherever the
existing $GW/$GZ relay-life diagnostics are). A controller without it
returns RAPI_RESPONSE_FEATURE_NOT_SUPPORTED / a normal RAPI NAK for $GL/
$FH, not a crash.

Changes

  • src/openevse.h / src/openevse.cpp: added getRelayHealth(),
    resetRelayHealth(), OPENEVSE_RELAY_HEALTH_NOT_AVAILABLE.
  • library.json: version bump 0.0.20 → 0.0.21.

Upgrading

No breaking changes - existing calls are unaffected. New calls are safe to
use against older controllers; they'll simply report "not supported" until
the firmware side (RELAY_HEALTH) is deployed.

v0.0.20

Choose a tag to compare

@RAR RAR released this 20 Aug 14:23

Heap and robustness fixes for the RAPI layer, plus a wider $G0/state surface.

Heap

  • The RAPI command queue no longer fragments the heap (#6). queue.h::pop()
    copied the item out but never cleared the slot, so the ten-element static
    CommandItem array kept ten Strings and ten std::functions alive for the
    life of the process. Each new command allocated fresh and freed nothing, and
    the surviving allocations migrated steadily deeper into the heap.

    On an ESP32 running the OpenEVSE WiFi firmware, the largest contiguous block
    fell from ~51 KB to under 10 KB over 14 hours at roughly 1.3 commands/second.
    With this fix, the same unit held 36,852 bytes flat over 10 hours and 41,898
    commands
    .

  • Inline handler storage is now sized by pointer width (sizeof(void *) * 10)
    rather than a fixed byte count, so 64-bit host and native-test builds no
    longer trip the capacity static_assert.

Robustness

  • The async event handler is guarded against short frames.
  • Assorted audit follow-ups in RapiSender and OpenEVSEClass.

API

  • Additional state and flag accessors on OpenEVSEClass, including the $G0
    path — the reliable source for vehicle-connected state, as opposed to the
    cached EV_CONNECTED vflag.

Maintenance

  • Repository metadata corrected; CI actions bumped.

Full changelog: v0.0.19...v0.0.20

v0.0.19

Choose a tag to compare

@jeremypoulter jeremypoulter released this 18 Jun 21:20

Full Changelog: v0.0.15...v0.0.19

v0.0.17

Choose a tag to compare

@chris1howell chris1howell released this 18 Jun 18:29

setPanicTemperature() $GO

Bump to v0.0.17