From aae23f53a6477ee211a4160a2afed2381a091b31 Mon Sep 17 00:00:00 2001 From: Bastian de Byl Date: Mon, 18 May 2026 18:06:19 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20S12=20=E2=80=94=20vcucmd=203-state?= =?UTF-8?q?=20command=20from=20VCU=20(replaces=20binary=20vcustop)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote 0x212 byte 3 from S11's binary vcustop to the full Dilong-style OBC_ControlCMD vocabulary: 0 = Charging — walk to / hold at EVSEACTIVATE, produce current 1 = Stopped — sit idle in OFF/WAITSTART/ENABLE, no current 2 = Complete — drop to STOP (cycle terminated by VCU) VCU is now authoritative for the charge-cycle lifecycle. The charger keeps hardware-level wisdom (EVSE pilot via CheckStartCondition, plug-out safety via CheckUnplugged, module-fault detection via CheckChargerFaults) but obeys vcucmd for "should I be charging?". FSM changes: - ENABLE→ACTIVATE now gated on vcucmd==Charging (was unconditional). ENABLE on vcucmd==Complete drops to STOP; vcucmd==Stopped holds HV-armed waiting for next Charging command. - EVSEACTIVATE self-decided exits (CheckVoltage, CheckTimeout) are gone. Those were band-aids for the absence of an authoritative VCU stop signal. vcucmd==Stopped now parks back in ENABLE (HV held); vcucmd==Complete drops to STOP. CheckUnplugged + CheckChargerFaults still own hardware-safety exits. - STOP→OFF now also triggers on vcucmd != Complete (was unplug-only). This is the fix for the "raise ChargeLimit mid-COMPLETE doesn't restart charging" wedge — the VCU's vcucmd flip from Complete to Stopped on a limit-raise lets the natural OFF→WAITSTART→ENABLE→ ACTIVATE→EVSEACTIVATE flow resume charging without an unplug cycle. Param rename vcustop→vcucmd. External tooling that reads/writes the S11 vcustop param via the openinverter web UI will see the new vcucmd param with 3-state semantics — single-user bench, acceptable. VERSTR bumped to S12. Co-Authored-By: Claude Opus 4.7 (1M context) --- include/param_prj.h | 33 +++++++++++++++++++++-- src/charger.cpp | 64 +++++++++++++++++++++++++++++++++++++-------- src/chargercan.cpp | 20 +++++++++----- 3 files changed, 97 insertions(+), 20 deletions(-) diff --git a/include/param_prj.h b/include/param_prj.h index bb59a14..597ea1d 100644 --- a/include/param_prj.h +++ b/include/param_prj.h @@ -109,7 +109,7 @@ VALUE_ENTRY(c3udc, "V", 2035 ) \ VALUE_ENTRY(c3idc, "A", 2036 ) \ VALUE_ENTRY(tmpobcmax, "°C", 2051 ) \ - VALUE_ENTRY(vcustop, OFFON, 2052 ) \ + VALUE_ENTRY(vcucmd, VCUCMDS, 2052 ) \ VALUE_ENTRY(test_time, "s", 3000 ) \ VALUE_ENTRY(test_timer_flag, "X", 3001 ) \ VALUE_ENTRY(test_timer_icvalue, "X", 3002 ) \ @@ -119,6 +119,12 @@ #define OPMODES "0=Off, 1=Run" #define CHARGERS "1=Charger1, 2=Charger2, 4=Charger3" #define OFFON "0=Off, 1=On" +// SKUDAK-S12: VCU→charger control command on 0x212 byte 3. Mirrors the Dilong +// path's OBC_ControlCMD vocabulary exactly so VCU code paths can be reused. +// 0 = Charging — VCU wants current to flow; walk to / hold at EVSEACTIVATE +// 1 = Stopped — VCU is not driving a cycle; sit in OFF/WAITSTART/ENABLE +// 2 = Complete — VCU has terminated the cycle; drop to STOP +#define VCUCMDS "0=Charging, 1=Stopped, 2=Complete" #define CHFLAGS "0=None, 1=Enabled, 2=Fault, 4=CheckAlive" #define STATES "0=Off, 1=WaitStart, 2=Enable, 3=Activate, 4=Run, 5=Stop" #define INPUTS "0=Type2, 1=Type2-3P, 2=Type1, 3=Manual, 4=Manual-3P, 5=Type2-Auto" @@ -151,7 +157,18 @@ // it sets vcustop=1; ChargerStateMachine's EVSEACTIVATE case transitions to // STOP immediately, the J1772 CP signal drops, EVSE stops delivering AC, // VCU_IsCharging() returns 0, water pumps power down naturally. -#define VERSTR STRINGIFY(4=VER-S11) +// -S12 promotes byte 3 from a binary vcustop flag to the full 3-state +// VCUCMDS vocabulary (0=Charging, 1=Stopped, 2=Complete), matching the +// Dilong OBC_ControlCMD exactly. The internal FSM now obeys vcucmd for +// lifecycle transitions: ENABLE only advances to ACTIVATE on Charging, STOP +// returns to OFF when vcucmd flips back to Charging/Stopped (fixes the +// "raise ChargeLimit mid-COMPLETE doesn't resume" wedge — teslacharger was +// stuck in STOP because STOP only exited on CheckUnplugged). The self- +// decided EVSEACTIVATE exits (CheckVoltage, CheckTimeout) are stripped; the +// VCU is now authoritative for when charging starts and stops. The charger +// retains hardware-level wisdom: CheckUnplugged() still forces OFF from any +// state, and CheckStartCondition() still gates OFF→WAITSTART on EVSE pilot. +#define VERSTR STRINGIFY(4=VER-S12) /***** enums ******/ @@ -175,6 +192,18 @@ enum states STOP }; +// SKUDAK-S12: VCU→charger command vocabulary on 0x212 byte 3. Must match +// the values exposed via VCUCMDS enum string AND the VCU's +// VCU_OIOBCCommand_t enum (stm32-hal-vcu Core/Inc/applications/charger/ +// charger_types.h). Updating one without the others silently breaks the +// charge state machine, so keep them in lock-step. +enum vcucmds +{ + VCUCMD_CHARGING = 0, + VCUCMD_STOPPED = 1, + VCUCMD_COMPLETE = 2 +}; + enum _canspeeds { CAN_PERIOD_100MS = 0, diff --git a/src/charger.cpp b/src/charger.cpp index bf676c1..2dc9f34 100644 --- a/src/charger.cpp +++ b/src/charger.cpp @@ -299,10 +299,26 @@ void CalcAcCurrentLimit() 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)) { @@ -316,6 +332,9 @@ void ChargerStateMachine() 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(); @@ -334,7 +353,15 @@ void ChargerStateMachine() DigIo::ch2ena_out.Set(); if (configuredChargers & 4) DigIo::ch3ena_out.Set(); - state = ACTIVATE; + + // 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); @@ -353,21 +380,28 @@ void ChargerStateMachine() DigIo::evseact_out.Set(); DigIo::acpres_out.Set(); - // SKUDAK-S11: explicit VCU stop command takes priority over every - // other exit condition. When the VCU's BMS-side gate determines the - // charge session is complete (or the user lowered ChargeLimit below - // current SOC), it sets vcustop=1 on 0x212 byte 3. We obey - // immediately — drop AC pres + EVSE-active GPIOs and transition to - // STOP. Mirrors how the Dilong path's OBC_ControlCMD = Stopped works. - // Replaces the brittle udclim=200V mechanism from -S10. - if (Param::GetInt(Param::vcustop)) + // VCU command is the primary exit. CheckUnplugged + CheckChargerFaults + // remain for hardware safety; the self-decided CheckVoltage/CheckTimeout + // exits are gone (S12 — VCU is authoritative). + if (vcucmd == VCUCMD_COMPLETE) { DigIo::acpres_out.Clear(); DigIo::evseact_out.Clear(); state = STOP; } - else if (CheckVoltage() || CheckTimeout()) - state = STOP; + else 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(); @@ -386,6 +420,14 @@ void ChargerStateMachine() 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; } diff --git a/src/chargercan.cpp b/src/chargercan.cpp index 884765c..5264c1d 100644 --- a/src/chargercan.cpp +++ b/src/chargercan.cpp @@ -100,7 +100,7 @@ void ChargerCAN::MapMessages(CanMap* can) can->AddSend(Param::aclim, 0x44c, 16, 16, 1500); can->AddSend(Param::opmode, 0x44c, 32, 8, 154, 100); - /***** SKUDAK VCU command RX (0x212) — SKUDAK-516 chglim + S10 udclim + S11 stop *****/ + /***** SKUDAK VCU command RX (0x212) — SKUDAK-516 chglim + S10 udclim + S12 vcucmd *****/ // Byte 0: ChargeLimit_pct (0-100). Param::Change(vcuchglim) translates // % to udcspnt setpoint (see src/main.cpp). // Bytes 1-2: udclim_V (uint16 LE). DEPRECATED in -S11 but mapping kept for @@ -111,17 +111,23 @@ void ChargerCAN::MapMessages(CanMap* can) // contactors, never accumulating 10 ticks > udclim. Defensive // clamp in Param::Change(udclim) ignores values < 50 V (would // otherwise turn an old-VCU bench into a never-charges brick). - // Byte 3: vcustop (uint8, NEW in -S11). 0 = no override / normal state - // machine. 1 = VCU commands STOP. Checked at the top of - // EVSEACTIVATE in ChargerStateMachine; transition to STOP fires - // within one 100 ms tick. Mirrors how the Dilong path uses - // OBC_ControlCMD = Stopped to authoritatively halt charging. + // Byte 3: vcucmd (uint8, S12 — was binary vcustop in S11). Three states + // mirror the Dilong OBC_ControlCMD vocabulary 1:1 so VCU code + // paths can be reused across charger families: + // 0 = Charging — walk to / hold at EVSEACTIVATE, produce current + // 1 = Stopped — sit idle in OFF/WAITSTART/ENABLE, no current + // 2 = Complete — VCU declares cycle done; drop to STOP + // VCU is now authoritative for lifecycle. Self-decided + // EVSEACTIVATE exits (CheckVoltage, CheckTimeout) are gone; + // STOP also exits to OFF when vcucmd flips back from Complete + // to Stopped/Charging so the natural restart path runs (fixes + // the "raise limit mid-COMPLETE doesn't resume" wedge in S11). // Bytes 4-7: reserved 0x00. // VCU broadcasts every 200 ms once teslacharger detected; charger holds // last received values if VCU goes silent. can->AddRecv(Param::vcuchglim, 0x212, 0, 8, 1); can->AddRecv(Param::udclim, 0x212, 8, 16, 1); - can->AddRecv(Param::vcustop, 0x212, 24, 8, 1); + can->AddRecv(Param::vcucmd, 0x212, 24, 8, 1); /***** SKUDAK VCU telemetry (0x211) — SKUDAK-448 expanded charger telemetry *****/ // Byte 0: state (0=Off, 1=WaitStart, 2=Enable, 3=Activate, 4=Run, 5=Stop) -- 2.52.0 From 05149408772dff265eaf2a4ed145675667fd7e52 Mon Sep 17 00:00:00 2001 From: Bastian de Byl Date: Thu, 21 May 2026 13:33:29 -0400 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20ramp=20DC=20current=20to=20zero=20o?= =?UTF-8?q?ver=203=20ticks=20before=20RUN=20=E2=86=92=20STOP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/charger.cpp | 53 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/src/charger.cpp b/src/charger.cpp index 2dc9f34..9a3ee3d 100644 --- a/src/charger.cpp +++ b/src/charger.cpp @@ -383,13 +383,58 @@ void ChargerStateMachine() // 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) { - DigIo::acpres_out.Clear(); - DigIo::evseact_out.Clear(); - state = STOP; + 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; } - else if (vcucmd == VCUCMD_STOPPED) + + // 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 -- 2.52.0 From a30cded074cab10eda9390015315370b553761d9 Mon Sep 17 00:00:00 2001 From: Bastian de Byl Date: Thu, 21 May 2026 16:23:47 -0400 Subject: [PATCH 3/3] fix: restore PI reference on final ramp tick (iteration O regression) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The iteration O 3-tick ramp ended with dcCurController.SetRef(0) before transitioning to STOP. Param::Change only fires when idcspnt or idclim parameter values change — a normal STOP → OFF → WAITSTART → ENABLE → ACTIVATE → RUN → EVSEACTIVATE restart cycle never touches those, so nothing restored the PI reference. On the next entry to EVSEACTIVATE the controller targeted 0 A and the charger sipped ~0.1 kW. Bench-confirmed 2026-05-21 on iteration O firmware: after lowering the ChargeLimit_pct to drop into COMPLETE and then raising it back, charging restarted at 0.1 kW instead of ramping to 9 kW. Fix: on the final ramp tick, restore SetRef to MIN(idcspnt, idclim) instead of zeroing it. The GPIO clears (acpres_out, evseact_out) still cut AC enable so the output current decays through the filter caps; the reference value is irrelevant during STOP but matters for the next EVSEACTIVATE entry. The ramp-restore block in the vcucmd != Complete branch (charger.cpp:431 in iteration O) only handles the mid-ramp reversal case (counter ≠ 0); it didn't help when the ramp completed cleanly. This commit closes that gap. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/charger.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/charger.cpp b/src/charger.cpp index 9a3ee3d..1ef60b0 100644 --- a/src/charger.cpp +++ b/src/charger.cpp @@ -414,9 +414,17 @@ void ChargerStateMachine() } else { - dcCurController.SetRef(0); + // Final ramp tick: clear GPIOs (cuts AC enable; output decays + // through filter caps) and restore the PI reference to the + // current idcspnt/idclim so the NEXT EVSEACTIVATE entry + // doesn't sip at 0 A. Param::Change only fires on parameter + // changes; a normal restart cycle never touches idcspnt or + // idclim, so without this restore the PI controller stays + // pinned at 0 across the cycle. Iteration O bug — bench- + // confirmed 0.1 kW sip on limit-raise from COMPLETE. DigIo::acpres_out.Clear(); DigIo::evseact_out.Clear(); + dcCurController.SetRef(MIN(Param::Get(Param::idcspnt), Param::Get(Param::idclim))); completeRampTicks = 0; completeRampOriginalRef = 0; state = STOP; -- 2.52.0