9 Commits

Author SHA1 Message Date
Bastian de Byl 1803f9b41d fix: rebuild stale CAN map on firmware upgrade (new tmpobcmax canary)
Build Firmware / Build stm32-teslacharger (pull_request) Successful in 58s
The openinverter single-shot MapMessages() guard was using Param::hwaclim
as a "is the map valid" canary. hwaclim has been in every firmware version
for years, so on every upgrade the guard finds it in the saved flash map
and short-circuits — new AddRecv/AddSend entries in chargercan.cpp never
make it into the live canMap.

Symptom on the -S5 bench (verified via openinverter web UI spot values):
tmpobcmax, c1*/c2*/c3* per-module receives, and canenable/idcspnt/udclim
CHAdeMO RX entries were all unmapped despite being in current source.
The Polarity app saw zero charger temperature, zero per-module
current/voltage, and tmpobcmax=0 on 0x211 byte 7.

Fix: switch the canary from hwaclim -> tmpobcmax (added in -S5, never
present in older saved maps) and uncomment canMap->Clear() so the
rebuild starts clean. The early-return guard still kicks in on every
subsequent boot once tmpobcmax is in the map, preserving the original
intent of letting user customizations persist across reboots.

Also bumps VERSTR S5 -> S6 so the openinverter web UI labels this
firmware distinctly from the prior -S5 flash.

#minor

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:06:52 -04:00
bastian 2c9e7a8bbe Merge pull request 'feat: broadcast worst-case OBC temperature on 0x211 byte 7' (#7) from feat/skudak-broadcast-obc-temp into main
Build Firmware / Build stm32-teslacharger (push) Successful in 58s
2026-05-14 15:53:52 -04:00
Bastian de Byl b7fcd2b301 feat: broadcast worst-case OBC temperature on 0x211 byte 7
Build Firmware / Build stm32-teslacharger (pull_request) Successful in 59s
The Tesla OBC hardware already publishes 9 temperature probes (3
modules × {tmp1, tmp2, tmpin}) on CAN 0x237/0x239/0x23B which the
teslacharger receives as c[123]tmp[12,in]. Until now none of these
were forwarded downstream — the Polarity VCU's STATUS_SCREEN OBC-temp
and DCDC-temp fields rendered 0 °C on OI-OBC cars.

This commit:

- Adds Param::tmpobcmax — derived value computed each Ms100Task tick
  as the max across the 9 probes, skipping any that read -40 °C
  (sensor absent / module not present).
- Maps it to 0x211 byte 7 (previously reserved, the last unused byte
  of the 7-byte frame). Wire encoding uses the Tesla OBC convention
  of offset 40 (wire = °C + 40), matching c[123]tmp* upstream so
  receivers can decode with the same formula.
- Bumps VERSTR to -S5 so the right firmware is visually confirmable
  in the OpenInverter web UI.

The new byte is purely additive — existing 0x211 consumers that read
only bytes 0-6 continue to work unchanged.

The Polarity VCU's 0x211 RX handler grows a corresponding decode +
display path in a paired stm32-hal-vcu commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:48:40 -04:00
bastian e36ba2ccc5 Merge pull request 'fix: drop extern "C" on main to clear -Wpedantic warning (-S4)' (#6) from fix/main-extern-c-pedantic-warning into main
Build Firmware / Build stm32-teslacharger (push) Successful in 1m0s
2026-05-12 15:41:43 -04:00
Bastian de Byl 02e3f2643f fix: drop extern "C" on main to clear -Wpedantic warning
Build Firmware / Build stm32-teslacharger (pull_request) Successful in 1m1s
src/main.cpp:161:16: warning: cannot declare '::main' with a linkage
specification [-Wpedantic]

Long-standing upstream OpenInverter code (predates SKUDAK). C++ forbids
linkage specifications on main; with -pedantic in CPPFLAGS this fires
every build.

Confirmed safe to drop:
- libopencm3 vector.c:35 forward-declares int main(void) with default
  linkage and invokes it at boot.
- Linker resolves main by symbol name, not by linkage attribute.
- stm32-sine has the same upstream issue; same fix applies if we ever
  push upstream.

Bump VERSTR -S3 -> -S4 so the OI web UI version field shows the new
build. Verified in the built binary as "4=1.20.R-S4".

Build: clean, no compiler warnings (only the long-standing linker RWX
notice remains, unrelated).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:41:06 -04:00
bastian ee59f69890 Merge pull request 'feat: receive ChargeLimit_pct from VCU on CAN 0x212 (SKUDAK-516)' (#4) from feat/skudak-516-receive-vcu-chargelimit into main
Build Firmware / Build stm32-teslacharger (push) Successful in 1m19s
2026-05-10 10:50:20 -04:00
Bastian de Byl 7acbd66907 feat: receive ChargeLimit_pct from VCU on CAN 0x212 (SKUDAK-516)
Build Firmware / Build stm32-teslacharger (pull_request) Successful in 58s
Pairs with stm32-hal-vcu PR #42. Adds a runtime "vcuchglim" param that
the VCU broadcasts every 200 ms; on receipt, the param's Change()
callback translates 20-100% to the charger's udcspnt (DC voltage
setpoint) using a linear cell-voltage ramp.

Without this, the teslacharger had no path for the user's app-set
charge limit to actually limit charging — udcspnt was effectively
a compile-time setpoint.

What's added
------------

- Param::vcuchglim in include/param_prj.h:
    PARAM_ENTRY(CAT_CHARGER, vcuchglim, "%", 20, 100, 80, 23)
  - Range 20-100 (matches VCU validation)
  - Default 80% if VCU never connects
  - ID 23 (next free per the comment, bumped to 24)
  - Saveable so a flashed charger paired with a silent VCU still
    behaves predictably across reboots

- can->AddRecv(Param::vcuchglim, 0x212, 0, 8, 1) registration in
  ChargerCAN::MapMessages() — libopeninv's CanMap dispatches to the
  Change() callback on each received frame.

- Param::Change(vcuchglim) handler in src/main.cpp:
    cell_target = 3.30 + (4.15 - 3.30) * (pct - 20) / 80
    udcspnt     = cell_target * 96
  Endpoints chosen to be safe across Tesla LDU / VW MEB / Volt2:
    20%  → 3.30 V/cell × 96 = 316.8 V (deep-cycle storage floor)
    80%  → 3.94 V/cell × 96 = 378.0 V (recommended daily ceiling)
    100% → 4.15 V/cell × 96 = 398.4 V (matches existing udclim default)
  Tuneable here as constants if a particular pack needs a tighter range.

- VER bump 1.19.R → 1.20.R. Combined with the pre-existing -S1 suffix
  the OI web UI shows "4=1.20.R-S1", visually distinct from upstream
  and from the previous Skudak build.

Build
-----

text=25288, links clean. The "RWX LOAD segment" linker warning is
pre-existing on this template, unrelated. No new warnings from this
change.

Version string in the binary verified as "4=1.20.R-S1".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:45:24 -04:00
bastian fb721f7392 Merge pull request 'fix: CheckDelay returns false forever when timedly is negative (-S2)' (#5) from fix/checkdelay-negative-timedly into main
Build Firmware / Build stm32-teslacharger (push) Successful in 1m0s
2026-05-10 09:34:44 -04:00
Bastian de Byl 5dc715558f fix: CheckDelay returns false forever when timedly is negative
Build Firmware / Build stm32-teslacharger (pull_request) Successful in 1m1s
Upstream openinverter bug surfaced after recent SKUDAK PRs.

Symptom (reported by Kyle on the bench): teslacharger detects EVSE,
shows the AC limit, but stays stuck in WaitStart and never proceeds
to Enable / Activate / Run. State machine looks like it's the OFF
state but it's actually one step further on.

Root cause in src/charger.cpp:85-91:

    bool CheckDelay()
    {
       uint32_t now = rtc_get_counter_val();
       uint32_t start = Param::GetInt(Param::timedly) * 60;
       return start <= 0 || (now - startTime) > start;
    }

With timedly = -1 (the param's documented "no delay" sentinel and its
default value), the math is:

    Param::GetInt(timedly) -> -1 (signed int)
    -1 * 60 -> -60 (signed)
    assigned to uint32_t -> 0xFFFFFFC4 (~4.29 billion)
    start <= 0 -> false (uint32_t can't be negative)
    (now - startTime) > 4294967236 -> false for ~136 years

So CheckDelay() returns false forever; charger wedges in WaitStart.

Why "it was working before": every SKUDAK PR that bumped the param
table (uaux broadcast, expanded 0x210, 0x210->0x211, version suffix)
invalidates the saved-params CRC and resets timedly to its -1 default.
Kyle had a non-default value (probably 0) saved before.

Fix: signed math for the short-circuit so timedly <= 0 still means
"start immediately." Cast to uint32_t only on the right operand, which
is only evaluated when start > 0 — can't reinterpret negative as huge.

The companion CheckTimeout() at line 75 has the same uint32_t pattern
but works "correctly by accident" because timelim = -1 makes timeout
huge and (now - startTime) > timeout is false, which matches the
intended "no timeout" semantics. Leaving that alone — fixing it would
change behaviour, not bugs.

VER suffix bumped -S1 -> -S2 so the OI web UI clearly shows the
deployed firmware (post-fix). Confirmed in built binary as
"4=1.19.R-S2".

Build: text=25192, links clean, pre-existing pedantic warning unchanged.

This bug is also present upstream (johanneshuebner/stm32-charger);
worth a PR upstream once we've confirmed the fix on the bench.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 09:34:17 -04:00
5 changed files with 73 additions and 20 deletions
+1
View File
@@ -4,3 +4,4 @@ stm32_charger*
linker.map linker.map
*.layout *.layout
*.out *.out
.DS_Store
+4 -3
View File
@@ -47,7 +47,7 @@
3. Display values 3. Display values
*/ */
//Next param id (increase when adding new parameter!): 24 //Next param id (increase when adding new parameter!): 24
//Next value Id: 2051 //Next value Id: 2052
/* category name unit min max default id */ /* category name unit min max default id */
#define PARAM_LIST \ #define PARAM_LIST \
PARAM_ENTRY(CAT_CHARGER, idclim, "A", 0, 45, 45, 3 ) \ PARAM_ENTRY(CAT_CHARGER, idclim, "A", 0, 45, 45, 3 ) \
@@ -108,6 +108,7 @@
VALUE_ENTRY(c3iac, "A", 2034 ) \ VALUE_ENTRY(c3iac, "A", 2034 ) \
VALUE_ENTRY(c3udc, "V", 2035 ) \ VALUE_ENTRY(c3udc, "V", 2035 ) \
VALUE_ENTRY(c3idc, "A", 2036 ) \ VALUE_ENTRY(c3idc, "A", 2036 ) \
VALUE_ENTRY(tmpobcmax, "°C", 2051 ) \
VALUE_ENTRY(test_time, "s", 3000 ) \ VALUE_ENTRY(test_time, "s", 3000 ) \
VALUE_ENTRY(test_timer_flag, "X", 3001 ) \ VALUE_ENTRY(test_timer_flag, "X", 3001 ) \
VALUE_ENTRY(test_timer_icvalue, "X", 3002 ) \ VALUE_ENTRY(test_timer_icvalue, "X", 3002 ) \
@@ -126,9 +127,9 @@
#define CAT_COMM "Communication" #define CAT_COMM "Communication"
// SKUDAK customization suffix: bump on every Skudak-side change so the OpenInverter // SKUDAK customization suffix: bump on every Skudak-side change so the OpenInverter
// web UI shows a distinct version (e.g. "4=1.19.R-S1") and we can visually confirm // web UI shows a distinct version (e.g. "4=1.20.R-S4") and we can visually confirm
// the right firmware is flashed. Match the stm32-sine -S<N> convention. // the right firmware is flashed. Match the stm32-sine -S<N> convention.
#define VERSTR STRINGIFY(4=VER-S1) #define VERSTR STRINGIFY(4=VER-S6)
/***** enums ******/ /***** enums ******/
+8 -2
View File
@@ -85,9 +85,15 @@ bool CheckTimeout()
bool CheckDelay() bool CheckDelay()
{ {
uint32_t now = rtc_get_counter_val(); uint32_t now = rtc_get_counter_val();
uint32_t start = Param::GetInt(Param::timedly) * 60; // 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) > start; return start <= 0 || (now - startTime) > (uint32_t)start;
} }
+14 -11
View File
@@ -108,16 +108,19 @@ void ChargerCAN::MapMessages(CanMap* can)
can->AddRecv(Param::vcuchglim, 0x212, 0, 8, 1); can->AddRecv(Param::vcuchglim, 0x212, 0, 8, 1);
/***** SKUDAK VCU telemetry (0x211) — SKUDAK-448 expanded charger telemetry *****/ /***** SKUDAK VCU telemetry (0x211) — SKUDAK-448 expanded charger telemetry *****/
// Byte 0: state (0=Off, 1=WaitStart, 2=Enable, 3=Activate, 4=Run, 5=Stop) // Byte 0: state (0=Off, 1=WaitStart, 2=Enable, 3=Activate, 4=Run, 5=Stop)
// Bytes 1-2: udc (V, gain=1) // Bytes 1-2: udc (V, gain=1)
// Bytes 3-4: idc (0.1A, gain=10) // Bytes 3-4: idc (0.1A, gain=10)
// Byte 5: uaux (decivolts, gain=10) — 12V aux battery rail // Byte 5: uaux (decivolts, gain=10) — 12V aux battery rail
// Byte 6: soc (%, gain=1) // Byte 6: soc (%, gain=1)
// Byte 7: reserved // Byte 7: tmpobcmax (°C, gain=1, offset=-40) — worst-case across all
// 9 OBC probes; matches Tesla OBC's own CAN offset
// so receivers can decode with the same formula.
// Consumed by stm32-hal-vcu Core/Src/main.c CHARGER_CAN_ID_OI_OBC_AUX handler. // Consumed by stm32-hal-vcu Core/Src/main.c CHARGER_CAN_ID_OI_OBC_AUX handler.
can->AddSend(Param::state, 0x211, 0, 8, 1); can->AddSend(Param::state, 0x211, 0, 8, 1);
can->AddSend(Param::udc, 0x211, 8, 16, 1); can->AddSend(Param::udc, 0x211, 8, 16, 1);
can->AddSend(Param::idc, 0x211, 24, 16, 10); can->AddSend(Param::idc, 0x211, 24, 16, 10);
can->AddSend(Param::uaux, 0x211, 40, 8, 10); can->AddSend(Param::uaux, 0x211, 40, 8, 10);
can->AddSend(Param::soc, 0x211, 48, 8, 1); can->AddSend(Param::soc, 0x211, 48, 8, 1);
can->AddSend(Param::tmpobcmax, 0x211, 56, 8, 1, 40); // offset 40 → wire byte = temp + 40
} }
+46 -4
View File
@@ -77,6 +77,32 @@ static void Ms100Task(void)
EvseRead(); EvseRead();
// SKUDAK: derive a single "worst case" OBC temperature for downstream
// consumers (Polarity VCU broadcasts it on STATUS_SCREEN bytes 23/26).
// Aggregates the 9 per-module probes the Tesla OBC publishes on CAN
// 0x237/0x239/0x23B — max wins. Modules without a sensor read -40 °C
// (the DBC offset; raw byte 0); we ignore those so an absent module
// doesn't drag the worst-case down. If no probes are valid we leave
// tmpobcmax at -40 °C, which the VCU will display as "—".
{
const Param::PARAM_NUM probes[] = {
Param::c1tmp1, Param::c1tmp2, Param::c1tmpin,
Param::c2tmp1, Param::c2tmp2, Param::c2tmpin,
Param::c3tmp1, Param::c3tmp2, Param::c3tmpin,
};
int worst = -40;
bool any_ok = false;
for (unsigned i = 0; i < sizeof(probes) / sizeof(probes[0]); i++) {
int t = Param::GetInt(probes[i]);
if (t <= -40) continue; // No sensor / module not present
if (!any_ok || t > worst) {
worst = t;
any_ok = true;
}
}
Param::SetInt(Param::tmpobcmax, any_ok ? worst : -40);
}
canMap->SendAll(); canMap->SendAll();
} }
@@ -88,10 +114,22 @@ static void MapChargerMessages()
float dummyGain; float dummyGain;
bool dummyrx; bool dummyrx;
//check sample value, if it is mapped assume valid CAN map // SKUDAK: tmpobcmax is the canary for the -S5+ CAN-map schema. If it's
if (canMap->FindMap(Param::hwaclim, dummyId, dummyOfs, dummyLen, dummyGain, dummyAdd, dummyrx)) return; // already mapped, the persistent flash map was built by a firmware that
// included the full current set of mappings (including the OBC temp
// aggregate and all c1*/c2*/c3* receives) — return early so user
// customizations are preserved.
//
// If tmpobcmax is NOT mapped, the saved map is from an older firmware
// that didn't know about it. We must wipe and rebuild from current
// chargercan.cpp — without this the OpenInverter single-shot MapMessages()
// pattern silently leaves newer params un-mapped on every subsequent
// boot, even after flashing fresh firmware (the original guard keyed off
// hwaclim, which has existed since the very first version — so it always
// short-circuited on upgrade and the new entries were dead code).
if (canMap->FindMap(Param::tmpobcmax, dummyId, dummyOfs, dummyLen, dummyGain, dummyAdd, dummyrx)) return;
//canMap->Clear(); canMap->Clear();
ChargerCAN::MapMessages(canMap); ChargerCAN::MapMessages(canMap);
@@ -158,7 +196,11 @@ extern "C" void tim2_isr(void)
scheduler->Run(); scheduler->Run();
} }
extern "C" int main(void) // C++ forbids a linkage specification on `main` (-Wpedantic). The reset_handler
// in libopencm3's vector.c forward-declares `int main(void)` with default
// linkage and resolves it at link time by symbol name, so dropping `extern "C"`
// here doesn't affect how main is invoked at boot.
int main(void)
{ {
extern const TERM_CMD termCmds[]; extern const TERM_CMD termCmds[];