Files
stm32-openinverter-teslacha…/src/charger.cpp
T
Bastian de Byl 0514940877 feat: ramp DC current to zero over 3 ticks before RUN → STOP
When vcucmd transitions to COMPLETE while in EVSEACTIVATE the previous
behavior was to clear acpres_out / evseact_out and transition to STOP
in a single 100 ms tick — cutting the OBC AC enable immediately. The
VCU's HV contactor opens at roughly the same moment; under active 9 kW
charging (~23 A) this hard disconnect transient floods the Tesla BMS
state-change CAN path and starves the VCU's task scheduler past the
IWDG window. Bench-confirmed 2026-05-21 as the trigger for the
iteration N limit-lower 0x002A reset loop.

Add a 3-tick (300 ms) ramp inside EVSEACTIVATE before the STOP
transition: stash the original DC-current reference, scale it 2/3 →
1/3 → 0 across the remaining ticks, and only then clear the AC enable
GPIOs and go STOP. The PI controller's natural response walks the AC
current limit down so output current decays smoothly rather than
hard-stopping.

If vcucmd reverts to Charging mid-ramp (user raised limit again),
restore the original reference cleanly before the existing branches
take over.

The VCU also defers its own HV-contactor open by 1500 ms (see VCU
iteration O commit 5781f47). Belt-and-suspenders: either fix alone
substantially reduces the disconnect transient; together they make
RUN → STOP graceful on any controller that interoperates with this
charger.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 13:33:29 -04:00

481 lines
15 KiB
C++

#include "my_math.h"
#include "errormessage.h"
#include "charger.h"
#ifdef TEST_COMMON_H
#include "../test/test_common.h"
#include "../test/digio_mock.h"
#include "../test/timer_mock.h"
#else
#include "digio.h"
#include "anain.h"
#include <libopencm3/stm32/timer.h>
#include <libopencm3/stm32/rtc.h>
#endif
bool IsEvseInput()
{
enum inputs input = (enum inputs)Param::GetInt(Param::inputype);
return input == INP_TYPE1 || input == INP_TYPE2 || input == INP_TYPE2_3P || input == INP_TYPE2_AUTO;
}
bool CheckUnplugged()
{
return IsEvseInput() && !Param::GetBool(Param::proximity);
}
void DisableAll()
{
DigIo::hvena_out.Clear();
DigIo::acpres_out.Clear();
DigIo::evseact_out.Clear();
DigIo::ch1act_out.Clear();
DigIo::ch2act_out.Clear();
DigIo::ch3act_out.Clear();
DigIo::ch1ena_out.Clear();
DigIo::ch2ena_out.Clear();
DigIo::ch3ena_out.Clear();
}
void CalcTotals()
{
s32fp totalCurrent = Param::Get(Param::c1idc) + Param::Get(Param::c2idc) + Param::Get(Param::c3idc);
Param::SetFixed(Param::idc, totalCurrent);
s32fp u1 = Param::Get(Param::c1udc);
s32fp u2 = Param::Get(Param::c2udc);
s32fp u3 = Param::Get(Param::c3udc);
s32fp udcmax = MAX(u1, MAX(u2, u3));
Param::SetFixed(Param::udc, udcmax);
}
bool CheckStartCondition()
{
return (IsEvseInput() && Param::GetBool(Param::proximity) && Param::Get(Param::cablelim) > FP_FROMFLT(1.4) && Param::GetBool(Param::enable)) ||
(!IsEvseInput() && Param::GetBool(Param::enable));
}
bool CheckVoltage()
{
static int timeout = 0;
if (Param::Get(Param::udc) > Param::Get(Param::udclim))
{
timeout++;
}
else
{
timeout = 0;
}
return timeout > 10;
}
bool CheckTimeout()
{
uint32_t now = rtc_get_counter_val();
uint32_t timeout = Param::GetInt(Param::timelim);
timeout *= 60;
return timeout > 0 && (now - startTime) > timeout;
}
bool CheckDelay()
{
uint32_t now = rtc_get_counter_val();
// Upstream uses uint32_t here, which wraps a negative timedly (the param's
// documented -1 "no delay" sentinel) into a ~4 billion-second timeout that
// never expires — leaving the state machine wedged in WaitStart forever.
// Use signed math for the short-circuit so timedly <= 0 still means
// "start immediately." The cast on the elapsed-comparison side is only
// reached when start > 0, so it can't reinterpret a negative as huge.
int start = Param::GetInt(Param::timedly) * 60;
return start <= 0 || (now - startTime) > (uint32_t)start;
}
void EvseRead()
{
const int threshProxType1 = 2200;
const int threshProx = 3700;
const int thresh13A = 3200;
const int thresh20A = 2800;
const int thresh32A = 1800;
const int thresh63A = 1000;
int val = AnaIn::cablelim.Get();
if (timer_get_flag(TIM3, TIM_SR_CC2IF))
{
//The relationship between duty cycle and maximum current is linear
//until 85% = 51A. Above that it becomes non-linear but that is not
//relevant for our 10kW charger.
float evselim = timer_get_ic_value(TIM3, TIM_IC2) / 10;
evselim *= 0.666666f;
Param::SetFloat(Param::evselim, evselim);
}
else
{
//If no PWM detected, set limit to 0
Param::SetInt(Param::evselim, 0);
}
if (Param::GetInt(Param::inputype) == INP_TYPE2 ||
Param::GetInt(Param::inputype) == INP_TYPE2_3P ||
Param::GetInt(Param::inputype) == INP_TYPE2_AUTO)
{
if (val > threshProx)
{
Param::SetInt(Param::proximity, 0);
Param::SetInt(Param::cablelim, 0);
}
else
{
Param::SetInt(Param::proximity, 1);
if (val > thresh13A)
{
Param::SetInt(Param::cablelim, 13);
}
else if (val > thresh20A)
{
Param::SetInt(Param::cablelim, 20);
}
else if (val > thresh32A)
{
Param::SetInt(Param::cablelim, 32);
}
else if (val > thresh63A)
{
Param::SetInt(Param::cablelim, 63);
}
}
}
else if (Param::GetInt(Param::inputype) == INP_TYPE1)
{
if (val > threshProxType1)
{
Param::SetInt(Param::proximity, 0);
Param::SetInt(Param::cablelim, 0);
}
else
{
Param::SetInt(Param::proximity, 1);
Param::SetInt(Param::cablelim, 40);
}
}
else
{
Param::SetInt(Param::proximity, 0);
Param::SetInt(Param::cablelim, 32);
}
}
void ResetValuesInOffMode()
{
if (Param::GetInt(Param::state) == OFF)
{
for (int i = Param::c1stt; i <= Param::c3idc; i++)
{
Param::SetInt((Param::PARAM_NUM)i, 0);
}
}
}
void CalcEnable()
{
static int recheckCan = 10;
bool enablePol = Param::GetBool(Param::enablepol);
bool enable = DigIo::enable_in.Get() ^ enablePol;
enable &= !Param::GetBool(Param::cancontrol) || Param::GetBool(Param::canenable);
if (Param::GetBool(Param::cancontrol))
{
if (recheckCan == 0)
{
if (Param::GetInt(Param::canenable) == 3)
{
Param::SetInt(Param::canenable, 0);
ErrorMessage::Post(ERR_EXTCAN);
}
else
{
Param::SetInt(Param::canenable, 3); //Must be overwritten by CAN message within the next second
}
recheckCan = 10;
}
recheckCan--;
}
Param::SetInt(Param::enable, enable);
}
// TODO: no unit tests below here (see test_logic.h and implement there)
bool CheckChargerFaults()
{
const int acPresentThresh = 70;
const int timeout = 20;
static int counters[3] = { timeout, timeout, timeout };
int configuredChargers = Param::GetInt(Param::chargerena);
bool timeouts[3];
bool active1 = (configuredChargers & 1) && (Param::GetInt(Param::c1uac) > acPresentThresh);
bool active2 = (configuredChargers & 2) && (Param::GetInt(Param::c2uac) > acPresentThresh);
bool active3 = (configuredChargers & 4) && (Param::GetInt(Param::c3uac) > acPresentThresh);
timeouts[0] = (Param::GetInt(Param::c1flag) & FLAG_CHECK) != 0;
timeouts[1] = (Param::GetInt(Param::c2flag) & FLAG_CHECK) != 0;
timeouts[2] = (Param::GetInt(Param::c3flag) & FLAG_CHECK) != 0;
for (int i = 0; i < 3; i++)
{
if (timeouts[i])
{
if (counters[i] > 0)
{
counters[i]--;
timeouts[i] = false;
}
else
{
ErrorMessage::Post(ERR_CHARGERCAN);
}
}
else
{
counters[i] = timeout;
}
}
//Set check flag. By the next call this should be deleted by the CAN module
Param::SetInt(Param::c1flag, Param::GetInt(Param::c1flag) | FLAG_CHECK);
Param::SetInt(Param::c2flag, Param::GetInt(Param::c2flag) | FLAG_CHECK);
Param::SetInt(Param::c3flag, Param::GetInt(Param::c3flag) | FLAG_CHECK);
return (active1 && ((Param::GetInt(Param::c1flag) & FLAG_FAULT) || timeouts[0])) ||
(active2 && ((Param::GetInt(Param::c2flag) & FLAG_FAULT) || timeouts[1])) ||
(active3 && ((Param::GetInt(Param::c3flag) & FLAG_FAULT) || timeouts[2]));
}
void CalcAcCurrentLimit()
{
int configuredChargers = Param::GetInt(Param::chargerena);
float iacLim = Param::GetFloat(Param::iaclim);
float hwaclim = Param::GetFloat(Param::hwaclim);
float evseLim = Param::GetFloat(Param::evselim);
float cableLim = Param::GetFloat(Param::cablelim);
int activeModules = ((configuredChargers & 1) > 0) + ((configuredChargers & 2) > 0) + ((configuredChargers & 4) > 0);
if (IsEvseInput())
{
iacLim = MIN(iacLim, MIN(evseLim, cableLim));
}
if (Param::GetInt(Param::opmode) == 0)
{
dcCurController.ResetIntegrator();
iacLim = 0;
}
else
{
dcCurController.SetMinMaxY(0, iacLim);
iacLim = dcCurController.Run(Param::Get(Param::idc));
}
if (Param::GetInt(Param::inputype) == INP_MANUAL ||
Param::GetInt(Param::inputype) == INP_TYPE1 ||
Param::GetInt(Param::inputype) == INP_TYPE2 ||
(Param::GetInt(Param::inputype) == INP_TYPE2_AUTO && !DigIo::threep_in.Get()))
{
iacLim /= (float)activeModules;
}
iacLim = MIN(iacLim, hwaclim);
Param::SetFloat(Param::aclim, iacLim);
}
// SKUDAK-S12: VCU is authoritative for charge-cycle lifecycle. The charger
// keeps hardware-level wisdom (EVSE pilot detection, plug-out safety,
// fault detection, current ramping) but obeys vcucmd for "should I be
// charging right now?" — exactly mirroring the Dilong OBC_ControlCMD
// pattern so the VCU's two charger paths can share code/state.
//
// VCUCMDS vocabulary (see include/param_prj.h):
// 0 Charging — VCU wants current; walk to / hold at EVSEACTIVATE
// 1 Stopped — VCU has no active cycle; sit idle (no current)
// 2 Complete — VCU has terminated the cycle; drop to STOP
//
// Self-decided exits from EVSEACTIVATE (CheckVoltage, CheckTimeout) are
// gone — those were band-aids for the absence of an authoritative VCU
// stop signal. CheckUnplugged() still owns hardware safety from every
// state; CheckChargerFaults() still bails on real module faults.
void ChargerStateMachine()
{
static states state = OFF;
int configuredChargers = Param::GetInt(Param::chargerena);
int vcucmd = Param::GetInt(Param::vcucmd);
if (!Param::GetBool(Param::enable))
{
state = OFF;
}
switch (state)
{
default:
case OFF:
Param::SetInt(Param::opmode, 0);
DisableAll();
// OFF→WAITSTART requires hardware-level start condition (EVSE pilot
// or non-EVSE input). vcucmd is checked downstream — we still walk
// up to ENABLE on plug-in so the modules are ready when VCU says go.
if (CheckStartCondition())
{
startTime = rtc_get_counter_val();
state = WAITSTART;
}
break;
case WAITSTART:
if (CheckDelay())
state = ENABLE;
break;
case ENABLE:
DigIo::hvena_out.Set();
if (configuredChargers & 1)
DigIo::ch1ena_out.Set();
if (configuredChargers & 2)
DigIo::ch2ena_out.Set();
if (configuredChargers & 4)
DigIo::ch3ena_out.Set();
// Hold here until VCU says go (vcucmd=Charging) or terminates
// (vcucmd=Complete). Stopped keeps us armed but idle.
if (vcucmd == VCUCMD_COMPLETE)
state = STOP;
else if (vcucmd == VCUCMD_CHARGING)
state = ACTIVATE;
else if (CheckUnplugged())
state = OFF;
break;
case ACTIVATE:
Param::SetInt(Param::opmode, 1);
if (configuredChargers & 1)
DigIo::ch1act_out.Set();
if (configuredChargers & 2)
DigIo::ch2act_out.Set();
if (configuredChargers & 4)
DigIo::ch3act_out.Set();
startTime = rtc_get_counter_val();
state = EVSEACTIVATE;
break;
case EVSEACTIVATE:
DigIo::evseact_out.Set();
DigIo::acpres_out.Set();
// VCU command is the primary exit. CheckUnplugged + CheckChargerFaults
// remain for hardware safety; the self-decided CheckVoltage/CheckTimeout
// exits are gone (S12 — VCU is authoritative).
//
// SKUDAK: ramp the DC-current reference toward zero across 3 ticks
// (300 ms) before transitioning to STOP. Without this, the VCU
// opening its HV contactor at the moment we go STOP triggers a hard
// 23 A → 0 disconnect transient that floods the Tesla BMS state-
// change CAN path and starves the VCU's task scheduler past its
// IWDG window (bench-confirmed 2026-05-21 limit-lower 0x002A loop).
// The VCU also defers its contactor open by ~1500 ms; together
// they make the disconnect graceful even on a hard limit-lower
// mid-RUN. completeRampTicks counts DOWN from 3 → 0; the original
// reference is stashed so if vcucmd reverts to Charging mid-ramp
// we restore cleanly.
static uint8_t completeRampTicks = 0;
static s32fp completeRampOriginalRef = 0;
if (vcucmd == VCUCMD_COMPLETE)
{
if (completeRampTicks == 0)
{
completeRampOriginalRef = MIN(Param::Get(Param::idcspnt), Param::Get(Param::idclim));
completeRampTicks = 3;
}
if (completeRampTicks > 1)
{
// Scale 2/3 → 1/3 → 0 across remaining ticks.
s32fp scaled = (completeRampOriginalRef * (completeRampTicks - 1)) / 3;
dcCurController.SetRef(scaled);
completeRampTicks--;
// Stay in EVSEACTIVATE this tick; current is decaying.
}
else
{
dcCurController.SetRef(0);
DigIo::acpres_out.Clear();
DigIo::evseact_out.Clear();
completeRampTicks = 0;
completeRampOriginalRef = 0;
state = STOP;
}
break;
}
// vcucmd not Complete — if we were mid-ramp the user reversed
// (raised limit again); restore reference before the existing
// branches take over.
if (completeRampTicks != 0)
{
dcCurController.SetRef(completeRampOriginalRef);
completeRampTicks = 0;
completeRampOriginalRef = 0;
}
if (vcucmd == VCUCMD_STOPPED)
{
// VCU revoked the charge command (e.g. user lowered limit below
// current SOC but BMS isn't full yet). Park HV-armed in ENABLE
// ready to resume on the next Charging command.
DigIo::ch1act_out.Clear();
DigIo::ch2act_out.Clear();
DigIo::ch3act_out.Clear();
DigIo::acpres_out.Clear();
DigIo::evseact_out.Clear();
Param::SetInt(Param::opmode, 0);
state = ENABLE;
}
else if (CheckUnplugged())
{
DigIo::acpres_out.Clear();
DigIo::evseact_out.Clear();
state = OFF;
}
else if (CheckChargerFaults())
{
DigIo::acpres_out.Clear();
state = OFF;
}
break;
case STOP:
DisableAll();
Param::SetInt(Param::opmode, 0);
if (CheckUnplugged())
state = OFF;
// S12 fix for the "raise ChargeLimit mid-COMPLETE wedges in STOP"
// bug: when VCU clears Complete (latch released because user raised
// limit above current SOC), drop to OFF so the natural OFF→WAITSTART
// →ENABLE→ACTIVATE→EVSEACTIVATE flow restarts charging next tick.
// Previously STOP only exited on physical unplug — wedge on every
// limit-raise.
else if (vcucmd != VCUCMD_COMPLETE)
state = OFF;
break;
}
Param::SetInt(Param::state, state);
}