Skip to content

ui: fix stepped breathe at low charge ratios + nexcyber feasibility prereq updates#2

Merged
RAR merged 2 commits into
mainfrom
led-breathe-fix
May 8, 2026
Merged

ui: fix stepped breathe at low charge ratios + nexcyber feasibility prereq updates#2
RAR merged 2 commits into
mainfrom
led-breathe-fix

Conversation

@RAR
Copy link
Copy Markdown
Owner

@RAR RAR commented May 8, 2026

Summary

Two unrelated commits, fine to merge together:

1. cf41e7e — fix stepped cyan breathing at low charge ratios

User report: "pulsing cyan while charging is sometimes dim and has an off stepped breathing."

Root cause: EVSE_CHARGING pre-multiplies the linear breathe envelope by ratio_pct (active/advertised current) before calling fill_all, which then runs scaled_gamma. That order re-introduces the precision-loss the scaled_gamma comment at the top of the file specifically warns against: scale-before-gamma at low ratios squashes the input range so hard that gamma can't recover smooth dynamic range.

Numerical proof at ratio_pct=21, brightness_pct=100:

breathe v OLD: scale → gamma → out NEW: gamma → scale → out
125 (min) 26 → 2 → 2 61 → 12 → 12
200 (mid) 42 → 6 → 6 156 → 32 → 32
255 (max) 53 → 11 → 11 255 → 53 → 53

Old gives ~9 distinct output levels across the breathe (stepped). New gives ~41 (smooth). +84 bytes flash.

2. 289f298 — nexcyber feasibility prereq closure

  • SRAM = 80 KB (SWD probe)
  • No external SPI NOR (visual ID)
  • Persistence: internal-flash ping-pong (~8 KB)
  • OTA: single-bank, no self-rollback
  • Phase 4 effort drops 1-2 days

Test plan

  • Local firmware compile passes (53,212 / 524,288 bytes = 10.2%)
  • OTA push to garage unit
  • Visual confirmation that the cyan breathing is smooth (not stepped) during a real charge session

🤖 Generated with Claude Code

RAR and others added 2 commits May 8, 2026 16:31
The EVSE_CHARGING cyan-breathe path was pre-multiplying the linear
breathe envelope by ratio_pct (active/advertised current) before
handing it to fill_all, which then ran scaled_gamma. That order
re-introduces the precision-loss the scaled_gamma comment at the
top of the file specifically warns against: scale-before-gamma at
low ratios squashes the input range so hard that gamma can't
recover smooth dynamic range.

Numerical example, ratio_pct=21, brightness_pct=100 (advertised
32A, actively drawing 7A), across the breathe cycle:

  breathe v |  OLD (scale→gamma)  |  NEW (gamma→scale)
  -------- + ------------------- + ------------------
       125 |  26 → 2 → out=2     |  61  → 12 → out=12
       200 |  42 → 6 → out=6     |  156 → 32 → out=32
       255 |  53 → 11 → out=11   |  255 → 53 → out=53

Old: ~9 distinct output levels across the breathe → visibly stepped.
New: ~41 levels → smooth.

Fix is to compose ratio_pct with brightness_pct into one combined
percent and pass it through fill_all, so gamma sees the full
125-255 breathe range and both percent scales apply after gamma.
This is the same pattern scaled_gamma() was introduced to use; the
EVSE_CHARGING case was the only remaining caller doing it the old
way.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- SRAM = 80 KB confirmed by SWD probe (Nations N32G457RBL/REL family)
- No external SPI NOR on the board (visual ID)
- Persistence: internal-flash ping-pong, ~8 KB carved off top of 128 KB
- OTA: single-bank, no self-rollback (SWD recovery if a write fails)
- Phase 4 effort drops 1-2 days (no W25Q driver port)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@RAR RAR merged commit 5d8f04e into main May 8, 2026
RAR added a commit that referenced this pull request May 14, 2026
)

* cmake: add OPENEVCHARGER_BOARD selector + M4F toolchain

Introduces a board-selection variable so the codebase can host more
than one MCU target. Defaults to `rippleon` (the GD32F205VE bench unit
that's been the only target through M0-M7), so no behavior change for
the current build.

`OPENEVCHARGER_BOARD=nexcyber` is reserved for the in-progress port to
the Tuya-based Nexcyber AC EVSE / Nations N32G45x; the configure step
emits a FATAL_ERROR pointing at boards/nexcyber/README.md until the
vendor SDK is vendored and the HAL ported.

The companion arm-none-eabi-toolchain-cm4f.cmake mirrors the M3
toolchain but with -mcpu=cortex-m4 -mfpu=fpv4-sp-d16 -mfloat-abi=hard
for the Cortex-M4F core in the N32G45x.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* boards/nexcyber: bring-up skeleton (pin map + linker + porter notes)

Scaffolds the Nexcyber / Nations N32G45x port. Skeleton only — no HAL
implementation, no vendor SDK vendored yet. Sets up the structure so
future bench sessions have a place to land driver code.

What's included:
- pin_map.h: 27 confirmed pins from the 2026-05-07 SWD firmware dump
  (USART1 = WBR2 link, USART2 = Nextion HMI, USART3/SPI2 = BL0939
  candidate, TIM1_CH1 = J1772 CP PWM, 5-channel ADC, beeper). The 9
  silent ULN2003-driven outputs and 3 candidate digital inputs are
  marked TODO bench-resolve.
- n32g457.ld: 120 KB FLASH + 8 KB PERSIST + 80 KB RAM. PERSIST
  region carved off the top for internal-flash ping-pong (no
  external SPI NOR on this PCB — feasibility doc Option A).
- README.md: porter's checklist — what's done, what needs the bench,
  and the exact `git submodule add` for the Nations SDK mirror
  (NationsTechCoreLib/N32G455xx, V3.1.0, 3-clause-BSD).
- hal/ + docs/: empty placeholder directories for Phase 2 driver
  code and per-port engineering notes.

BOARDS.md updated to link the skeleton README and move Nexcyber from
"Wishlist" to "In progress".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* boards/nexcyber: pin_map mains-on bench update (2026-05-09)

120 V single-leg bench session resolved one of the nine silent
OUT_PP pins and refined SRAM cache layout:

- PB0 = GFCI CAL pulse relay coil (PIN_GFCI_CAL). Confirmed by
  audible click of the small relay near the NTC/CP connector when
  PB0 was pulsed HIGH for 500 ms via SWD. Matches the
  NOTES.md hypothesis.
- PC11 promoted to PIN_PERIPHERAL_EN — only OUT_PP pin stock fw
  drives HIGH at idle, strongly suggesting a chip-enable for the
  WBR2 / Nextion / BL0939. Toggling it produced no externally
  visible response, consistent with a CE rather than an LED drive.
- The remaining eight OUT_PP pins (PA0, PA1, PA15, PB8, PB9, PC8,
  PC10, plus PC11) were silent on bench. Most plausible reasons:
  bench unit doesn't have all status indicators fitted, contactor
  drives need a redundant-drive pattern (need stock fw to trigger),
  or the L2 contactor refuses to close on 120 V single-leg supply.
  Resolution path documented in pin_map.h — needs J1772 state-walk
  with stock fw to identify the contactor drives by ODR delta.
- SRAM ADC cache layout learnings: 0x20000700-0x20000744 is a 40-
  halfword circular buffer for waveform analysis (currently railed
  → consistent with L2 ZMPT107 floating because L2 is dead),
  0x20000748+ holds the 4-channel scan cache, 0x2000075c is the CP
  voltage cache (corrected to int32 mV, was uint16_t in earlier
  memory; bench shows -300 mV at idle with no plug).

No HAL or build changes; documentation/header only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* third_party: vendor Nations N32G455xx SDK V3.1.0

Pulls in the Nations SPL + CMSIS subset needed for the in-progress
Nexcyber port (Nations N32G45x main MCU). Vendored from upstream
mirror NationsTechCoreLib/N32G455xx, tag V3.1.0, commit
5c43c149bfcde5bc37b90f711071883d3f57b0c5.

Layout mirrors third_party/GD32F20x_Firmware_Library/ (cmsis/{core,
variants/n32g45x,startup_files} + spl/{inc,src}) so the CMake board
branches read symmetrically. ~52 MB upstream subset down to ~2 MB
vendored — examples (37 MB), RT-Thread middleware, USB stack, and
algo blobs all excluded; layout adaptation + drop list documented
in third_party/N32G45x_Firmware_Library/README.md.

License is Nations' BSD 2-clause with a non-endorsement clause —
GPL-3.0 compatible. Same template as the GD32 vendor lib next door.

Companion task #1 of the 4-task M0 bring-up batch; commits #2-#4
follow on this branch (board HAL, CMake wiring, CI matrix).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* boards/nexcyber: M0 HAL (clock + uart) + bring-up main

Minimal bring-up firmware for the Nations N32G45x: enough to confirm
the chip is alive on the bench after a first flash.

- boards/nexcyber/hal/clock.c
  Trusts the Nations SDK's SystemInit() (HSE_PLL → 144 MHz default
  from the 8 MHz HXTAL) and just publishes the rate via printk.
  Matches the src/hal/clock.h interface so a future shared main.c
  can call clock_real_120m_init() / clock_log_status() agnostically.

- boards/nexcyber/hal/uart.c
  USART1 on PA9 (TX) / PA10 (RX) at 115200 8N1 — the same pads the
  Tuya WBR2 Wi-Fi module sits on (the easiest probe point on the
  bench). uart_init / uart_write / printk match the rippleon HAL
  surface but skip the timestamp prefix and FreeRTOS dependency
  since M0 runs pre-scheduler. Format spec is the same %s/%d/%u/
  %x/%c/%% subset.

- boards/nexcyber/main.c
  Drives clock_init → uart_init → printk("M0 bring-up") → heartbeat
  loop. No FreeRTOS, no tasks, no peripherals beyond the log UART;
  the rest gets layered on as M1+ HAL files port.

Empty `_init` / `_fini` stubs included to satisfy newlib's
__libc_init_array — same idiom as src/main.c on the rippleon target.

Companion task #2 of the 4-task M0 bring-up batch (SDK vendoring
landed in the preceding commit). CMake wiring + CI matrix in #3 / #4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* cmake: wire nexcyber board path → buildable M0 image

Replaces the FATAL_ERROR placeholder on the nexcyber branch with the
real Nations-SDK wiring. Now `cmake -S . -DOPENEVCHARGER_BOARD=nexcyber
-DCMAKE_TOOLCHAIN_FILE=cmake/arm-none-eabi-toolchain-cm4f.cmake` produces
a flashable .elf:

- Vendor lib paths point at third_party/N32G45x_Firmware_Library/
  (cmsis/core, cmsis/variants/n32g45x, cmsis/startup_files, spl/{inc,src}).
- APP_SRCS is the M0 trio: boards/nexcyber/main.c + the two HAL files.
  No FreeRTOS, no safety core, no persist layer — those come online
  alongside the matching HAL ports.
- Pulls in only the SPL modules touched by the M0 image (misc, gpio,
  rcc, usart) rather than the full driver set; extend as more HAL
  modules land.
- N32G45X=1, HSE_VALUE=8000000, USE_STDPERIPH_DRIVER=1 mirror the
  define set the Nations example projects use.
- LINKER_SCRIPT switches to boards/nexcyber/n32g457.ld (128 KB flash
  / 80 KB SRAM / 8 KB persist carve-off).

Refactor moves the build-info derive + post-build objcopy + size dump
into a shared block above/below the two board branches. Rippleon
build path is byte-identical to before — verified locally:
  rippleon: 52,956 B FLASH / 35,696 B RAM (matches main)
  nexcyber: 2,792 B FLASH / 2,576 B RAM (M0 only)

Companion task #3 of the 4-task M0 bring-up batch. CI matrix wiring
in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci: matrix firmware build for rippleon + nexcyber (continue-on-error)

Promotes the firmware workflow from a single rippleon job to a
matrix over [rippleon, nexcyber]. Both rows use the same checkout +
arm-none-eabi-gcc setup + configure/build/size/artifact pipeline,
just parameterised on board + toolchain file.

nexcyber gets `continue-on-error: true` for now: it's an in-flight
port (M0 bring-up only — clock + UART + heartbeat printk, no safety
core yet) and a hard PR gate would create more friction than it
catches. Flip to false once the board ports through M0–M3 on the
bench (matching the bench-validated detector + persistence story
the rippleon side already has).

Artifact names get a board suffix (`openevcharger-rippleon` /
`openevcharger-nexcyber`) so both attach to the same workflow run
without collision.

Companion task #4 of the 4-task M0 bring-up batch — closes the
"add nexcyber row with continue-on-error" item from the agreed
preference (see feedback memory openevcharger_ci_new_boards).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* boards/nexcyber: refresh README for M0 reality + WBR2-side companion plan

README was written pre-M0 and still listed SDK / CMake / HAL items as
"not yet here." Sweep:

- Status banner: bring-up skeleton → M0 (clock + log UART + heartbeat
  printk). Buildable; not yet flashable to a real EVSE.
- "What's here" table picks up hal/clock.c, hal/uart.c, main.c with
  their current scopes.
- New "Build" block shows the actual M0 cmake invocation + the size
  ballpark (~2.8 KB FLASH / ~2.6 KB RAM).
- Roadmap split into bench-blocked vs code-only items so the next
  session can see what's actionable on each side.
- New "Companion ESPHome side" section documents the path from
  `esphome/testcharger/testcharger.yaml` (current stock-TuyaMCU
  bench config on the WBR2 module) to a future
  `OpenEVCharger/wbr2/openevcharger.yaml` that speaks our TLV
  protocol — captured as a strip-out / add-in table so the actual
  adaptation work is mechanical when M2+ arrives. Out of scope until
  then so the bench unit stays usable against stock firmware.

No code changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* boards/nexcyber: M1 FreeRTOS scaffold + heartbeat task

Wire ARM_CM4F FreeRTOS port into the nexcyber CMake branch and swap
the M0 busy-loop heartbeat in main() for a vTaskDelay-driven one.
Smallest possible "scheduler is alive" trace — if printk timing
becomes exactly 1 Hz (vs the M0 nop-loop's "tight nop loop"), the
scheduler is running.

The shared src/FreeRTOSConfig.h is core-independent (configPRIO_BITS=4
matches both GD32F2 Cortex-M3 and N32G45x Cortex-M4F NVIC layouts;
SVC/PendSV/SysTick handler aliasing maps to standard Cortex-M vector
names that the Nations startup file already references). FPU support
auto-handles via the toolchain's -mfpu=fpv4-sp-d16 -mfloat-abi=hard
which sets __ARM_FP — no extra config flag needed.

Build sizes — nexcyber:
  Before (M0): 2,792 B FLASH / 2,576 B RAM
  After  (M1): 6,388 B FLASH / 19,232 B RAM
                (~3.6 KB FreeRTOS code + 16 KB heap_4 reservation)

Rippleon target unchanged: still 52,956 B FLASH / 35,696 B RAM.

Also generalise .gitignore: `build_*/` covers per-board build dirs
(build_nexcyber, build_rippleon) the same way `build/` covered the
single-target builds before the board selector landed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* boards/nexcyber: M2 GPIO HAL — confirmed pins only

Idempotent one-shot config for every confirmed pin in pin_map.h. Same
gpio_init_all() / gpio_log_straps() surface as rippleon's src/hal/gpio.c.

Pins configured (16 total):
  Outputs (drive safe-state BEFORE configure, as F1-style mode change
  could otherwise glitch):
    PC6 buzzer       → LOW (no tone at boot)
    PB0 GFCI CAL     → LOW (relay coil de-energised)
    PC11 periph_en   → HIGH (matches stock idle ODR snapshot)
  AF (peripheral takes the pad later):
    PA8 CP_PWM       → AF_PP @ 50 MHz (ready for TIM1_CH1 in M3)
  Inputs with pull-up:
    PA11/PA12 touch  → IPU (TTP223 active-low taps)
    PC13 GFCI sense  → IPU (polarity TBD bench)
  Input floating:
    PC3 mains-det A  → IN_FLOATING (per stock static decode)
  Inputs pull-down:
    PC7/PC9 mains-det B/C → IPD (60 Hz toggle when live)
  Analog inputs:
    PB1/PB2/PC0/PC4/PC5 → AIN (J1772 + ZMPT correlation bench-blocked)

Pins NOT touched, deliberately:
  USART2 (PA2/PA3), USART3 (PB10/PB11), SPI2 (PB12-15) — owned by
    their peripheral drivers, all bench-blocked roles.
  PA0/PA1/PA15/PB8/PB9/PC8/PC10 — TBD OUT_PP. Don't drive unknown
    loads. Reset default (input floating) is safe.
  PC2/PC12 — AF_PP with unidentified remap target. Skip until AFIO_MAPR
    is decoded on bench.
  Ports D/E/F/G — entirely unused in stock firmware.

main.c calls gpio_init_all() + gpio_log_straps() after uart_init().
Boot log on first flash will now print:
  clock: SDK default 144000000 Hz (M0 bring-up)
  openevcharger nexcyber: M2 bring-up (FreeRTOS + GPIO HAL)
  straps: btn=11 gfci=? mains=???
  beat 0
  beat 1
  ...
The strap line gives a single-shot baseline for bench correlation
without an oscilloscope.

Build sizes — nexcyber:
  Before (M1): 6,388 B FLASH / 19,232 B RAM
  After  (M2): 6,896 B FLASH / 19,232 B RAM (+508 B for the HAL +
                                             Nations SPL GPIO calls)

Rippleon target unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* boards/nexcyber: refresh README for M1+M2 reality

What's-here table grows from 4 entries to 5 (adds hal/gpio.c) and the
status box jumps M0 → M2. Roadmap table flips two rows from "not
started" → "✅" (FreeRTOS port, GPIO HAL) and splits the remaining
work into M3 (ADC + timer + Nextion USART2) vs M3+ (BL0939 once the
variant is bench-confirmed) vs safety-core-port (waits on the HAL
preconditions).

Boot-time output now also includes a `straps: btn=11 gfci=? mains=???`
one-liner from `gpio_log_straps()`, which gives a single-shot bench
baseline without an oscilloscope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* boards/nexcyber: lock PC13 = STOP loop, PC11 = safety-loop enable

Three-snapshot SWD sweep on the bench 2026-05-11 (mains 120 V single-leg,
fault_bitmap=8 latched throughout) resolved two long-standing TBDs:

PC13 — E-stop loop sense, NC switch (active-LOW)
  Snap A: STOP jumpered closed   → PC13 HIGH
  Snap B: jumper momentarily off → PC13 LOW (caught in transition)
  Snap C: STOP wired normally    → PC13 HIGH
Original "GFCI fault sense" hypothesis was wrong — GFCI fault path is
the separate ADC trace seen as G1 in the telemetry dump (raw ~2195 =
mid-rail = healthy), no dedicated digital sense pin.

PC11 — safety-loop-driven enable (NOT the contactor permit)
  Snap A: STOP jumpered (clean short bypass)  → PC11 LOW
  Snap C: STOP wired through physical switch  → PC11 HIGH
  fault_bitmap=8 was latched throughout — PC11 toggled INDEPENDENTLY
  of the fault state. So PC11 isn't gated on "everything green", it's
  gated on "the safety loop is hard-wired correctly, not just clean-
  shorted". Original "always HIGH at idle" finding from 2026-05-09 was
  partial — that snapshot was taken with the safety loop fully wired.

Pin macro renames:
  PIN_GFCI_SENSE_*    → PIN_STOP_SENSE_*
  PIN_PERIPHERAL_EN_* → PIN_SAFETY_LOOP_EN_*

hal/gpio.c follows the renames + adjusts:
  - PC11 default-state at boot LOW (don't assert downstream enable on
    potentially-broken hardware; safety task will raise it once
    safety_state == OK in M5+)
  - PC13 stays IPU so a disconnected wire reads LOW (= halt) rather
    than floating "OK" — fail-safe direction
  - gpio_log_straps boot one-liner now reads `stop=%d` (was gfci=%d)

Build sizes — nexcyber:
  Before: 6,896 B FLASH / 19,232 B RAM
  After:  6,892 B FLASH / 19,232 B RAM (-4 B from rename rotation)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* boards/nexcyber: lock PA0/PA1 contactors + PC9 button (bench 2026-05-11)

Three more pin roles resolved via SWD wiggle + snapshot-diff during a
mains-on bench session with the stock fw running:

PA1 — MASTER CONTACTOR PERMIT (both line contactors together)
  Wiggle test: driving PA1 HIGH clicks BOTH large contactors closed
  and they stay closed for the duration of the HIGH phase. Plain DC
  drive. Working interpretation: gates the coil-supply rail (12 V?)
  to both line contactors via a single high-side switch / opto-
  isolator → both close in parallel.
  Macro: PIN_CONTACTOR_MAIN_*

PA0 — CONTACTOR TEST PULSE (one specific contactor, momentary)
  Wiggle test: PA0 HIGH clicks ONE of the two contactors briefly
  (audible click on rising edge), then auto-opens despite the pin
  staying HIGH. Working interpretation: contactor weld-detect /
  self-test pulse — fires one coil so firmware can verify it'll
  close. Same idiom as rippleon's PB0 GFCI CAL pulse. Driver
  appears to require transitions, not steady DC — fast-wiggle test
  pending. Macro: PIN_CONTACTOR_TEST_*

PC9 — front-panel "tiny button" (IN_PD, active-HIGH)
  Snapshot-diff confirmed: idle PC9 IDR=0, held-down PC9 IDR=1.
  Originally hypothesised as a "mains-detect C" photocoupler in the
  static decode — turned out to be a button. Likely pairing /
  WiFi-reset / factory-reset trigger in stock fw.
  Macro: PIN_BUTTON_*  (was PIN_MAINS_DETECT_C_*)

PA15 — button-LED candidate (hypothesised, needs wiggle to confirm)
  Same snapshot-diff caught PA15 IDR briefly HIGH (ODR=0) during the
  PC9 press. Most likely the button-status LED — firmware flashes it
  on press as user feedback. PA15 is JTAG-TDI in default AFIO_MAPR;
  stock fw remaps SWJ_CFG to "SWD only, JTAG off" to free it.
  Macro: PIN_BUTTON_LED_* (candidate; PA15 wiggle test pending)

hal/gpio.c updates:
- init_outputs_safe_state() now drives PA0+PA1 LOW *before* configuring
  as OUT_PP. Critical — leaving these in reset default (analog input)
  on bench-flashed firmware would let stray pulls energise contactors
  during chip startup before main() runs. Safety task asserts in M5+.
- init_inputs_pulldown() configures PIN_BUTTON_* (PC9) the same way it
  did the old PIN_MAINS_DETECT_C_* — same mode (IN_PD), just a
  different downstream role. Macro rename is the only semantic change.
- gpio_log_straps() boot one-liner now reads
    `touch=%d%d btn=%d stop=%d mains=%d%d`
  (was `btn=%d%d stop=%d mains=%d%d%d`). `touch` = the two TTP223 cap-
  touch buttons; `btn` = the front-panel tiny button.

Build sizes — nexcyber:
  Before: 6,896 B FLASH / 19,232 B RAM
  After:  6,932 B FLASH / 19,232 B RAM (+36 B for the new pins)

Pin map state — pins fully resolved across all sessions:
  Confirmed via firmware static analysis (2026-05-07):
    USART1/2/3, SPI2, TIM1_CH1, 5 ADC channels, PA11/PA12 touch,
    PC2/PC12 AF (remap target TBD), PC6 buzzer, PB0 GFCI CAL,
    PC3/PC7/PC9 inputs
  Pinned by bench probing (2026-05-09 + 2026-05-11):
    PB0 (GFCI CAL relay click), PC11 (safety-loop enable),
    PC13 (STOP loop sense NC), PA0 (contactor test pulse),
    PA1 (master contactor permit), PC9 (front-panel button)
  TBD bench-blocked:
    PA15 (button-LED candidate), PB8/PB9/PC8/PC10 (still silent),
    PC2/PC12 (AFIO_MAPR decode needed), mains-detect A/B (60 Hz
    scope still pending), CC ladder / NTC raw correlation,
    BL0939 SPI-vs-UART resolution

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* boards/nexcyber: BL0939 SPI confirmed + PC11 heartbeat model + SWJ remap

Three substantive bench-driven updates from the afternoon's snapshot-diff
session on 2026-05-11:

1. BL0939 = SPI variant CONFIRMED. Touching the SPI2 data lines coupled
   into PB14 (MISO) IDR readback, confirming firmware is actively
   running an SPI2 transfer cycle reading from a slave. UART-variant
   hypothesis on USART3 ruled out. M3 BL0939 driver can mirror
   rippleon's src/hal/bl0939.c + src/hal/spi3.c verbatim modulo
   pin remap to SPI2.

2. USART3 (PB10/PB11) role narrowed to "stock-fw debug log". 8 literal-
   pool refs make it heaviest of three USARTs; with BL0939 confirmed
   elsewhere, debug log is the strongest remaining hypothesis.
   Candidate target for relocating our M2+ printk so USART1 frees up
   for the WBR2 TLV link.

3. PC11 = safety-supervisor HEARTBEAT (final revision). Earlier-in-
   session reads showed PC11 in steady states (HIGH/LOW); later bench
   observation confirmed it toggles continuously while firmware is
   happy. Snapshot-diff catches different phases at ~1.5 s sample
   spacing.

   Working model: PC11 is a dead-man heartbeat the safety supervisor
   pulses while it's happy. External watchdog timer keeps a peripheral
   (GFCI module / coil-supply rail / similar) energised only while
   pulses keep arriving. On an immediate hardware fault, firmware
   stops pulsing → watchdog times out → peripheral disabled.

   Bench evidence:
   - STOP wired through + GFCI healthy: PC11 oscillates
   - STOP JUMPERED out: pulsing stops, PC11 latches at last state
   - GFCI driven HIGH (faked fault) OR GFCI connector disconnected:
     same — pulsing stops
   - Latched soft fault (fault_bitmap = 8 L2-missing): heartbeat
     CONTINUES — PC11 is for immediate hardware faults only

   IMPORTANT for M2+: driving PC11 statically HIGH is NOT sufficient.
   Our firmware must MATCH the heartbeat rate. Pulse rate not yet
   measured — likely 50 Hz–1 kHz based on typical external-watchdog-
   chip timeout windows.

hal/gpio.c additions:
- swj_disable_jtag() — AFIO_MAPR SWJ_CFG = SW-DP only via
  GPIO_ConfigPinRemap(GPIO_RMP_SW_JTAG_SW_ENABLE, ENABLE). Frees
  PA15/PB3/PB4 from JTAG TAP while keeping SWD alive. Must run
  before any AF init touches those pads. Matches what stock fw does.
- PA15 added to init_outputs_safe_state() — driven LOW at boot.
  PA15 is hypothesised as the front-panel status LED based on the
  snapshot-diff observations (IDR briefly HIGH during PC9 button
  press AND during GFCI fault — multi-purpose status indicator).

CMakeLists.txt:
- Pre-staged 4 SPL src files for M3 work:
  n32g45x_adc.c    — M3 ADC HAL (PB1/PB2/PC0/PC4/PC5 AIN channels)
  n32g45x_tim.c    — M3 TIM1_CH1 CP PWM on PA8
  n32g45x_spi.c    — M3 BL0939 SPI driver on SPI2
  n32g45x_dma.c    — M3 ADC scan DMA support
  All cold dead-code under --gc-sections until M3 driver code refers
  to them, so zero runtime overhead at M2.

Build sizes — nexcyber:
  Before: 6,932 B FLASH / 19,232 B RAM
  After:  7,380 B FLASH / 19,232 B RAM (+448 B for swj_disable_jtag,
                                        PA15 init, gc-sections-survivor
                                        SPL bring-up code)

Rippleon target unchanged at 52,956 B / 35,696 B.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* boards/nexcyber/pin_map.h: DMA probe + revised contactor model

Live SWD probe of stock firmware's DMA1/DMA2 register blocks plus a
charging-state snapshot of GPIO ODRs forced three pin_map revisions.

USART3 (PB10/PB11): role downgraded from "stock-fw debug log" to
"third active peer, role TBD". DMA1 channel 3 is configured with
RX-DMA (CCR=0x30A1, CNDTR=128, CPAR=USART3_DR) — that rules out a
one-way printk path. The literal-pool weight matches a structured-
protocol parser, not a printf wrapper. Candidates: RFID reader,
internal BLE module, LED-strip controller IC.

UART4 / UART5: new block. DMA2 channels 1 and 6 are active with
CPARs pointing at 0x40015004 and 0x40015404 — Nations N32G45x
relocates UART4 and UART5 from the F1-standard 0x40004C00/0x40005000
APB1 bases. The chip is running 5 active UART RX rings, not 3 as
the schematic block diagram suggested. Pinout-to-pad mapping is
bench-blocked until the Nations N32G45x reference manual is in hand.

PA0 / PA1 contactor model REVERSED: the prior bench interpretation
had PA0 as a one-shot test pulse and PA1 as the master contactor
permit. A charging-state SWD snapshot (mains live, contactors
energised, BL0939 SPI active) showed the opposite: PA0 ODR=1 (held
HIGH for the entire charging session), PA1 ODR=0. The new model is
that PA1 fires a one-shot close pulse at session start, an external
SR latch holds the contactors, and PA0 is the permit/hold line that
keeps the latch armed — MCU fault drops PA0, contactors open. This
matches a textbook EVSE safety pattern. Backward-compatibility
aliases preserved for the old PIN_CONTACTOR_TEST_/MAIN_ names so
in-flight code keeps compiling.

ADC paragraph added to the ADC AIN block: RCC_APB2ENR reads
ADC1/2/3 clock-enable = 0 while the chip is mostly sleeping. ADC1
register block at 0x40012400 reads all-zero in that state. Stock
firmware appears to power-cycle the ADC peripheral around each
sample for standby-current savings. None of the 5 active DMA
channels feeds an ADC. M3 ADC HAL will own clock-enable explicitly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* boards/nexcyber/pin_map.h: SRAM ADC cache decoded via two-snapshot diff

Two SWD peeks of 0x20000700-0x2000077F (steady-state vs mid-charge
with EV-sim resistors) pinned 4 of the 6 scan-cache slots:

  0x20000750 = CP_RAW (1.99 V no-plug, 1.43 V loaded — compressed
               read-back range, matches rippleon PB0 pattern;
               needs 2-point empirical cal for M3 cp_mv())
  0x20000752 = CC raw (flat 1.54 V; no plug = no proximity loading)
  0x20000754 = I_L1 (CT mid-rail 1.75 V idle, swings to 0.82 V under
               load; instantaneous 60 Hz sample, not RMS)
  0x20000756 = GFCI sense (analog mid-rail 1.50 V healthy, 1.20 V
               loaded — confirms GFCI sense routes through ADC, not
               a digital GPIO; matches prior memory note)
  0x2000075A = I_L2 (pairs with I_L1, identical swing pattern)

Plus calibrated cache scale confirmed:
  0x2000075C = CP_filtered (signed int32 mV). Bench two-point lock:
               +11,667 mV idle (J1772 state-A +12 V) vs +5,429 mV
               EV-loaded (state-C +6 V range). M3 should prefer this
               over the raw at 0x750.
  0x20000778 = session-active flag (0 idle, 1 charging — clean edge)

Voltage sense channels (0x20000748-0x2000074E) all railed at 3.30 V
in both states — ZMPT107 op-amps saturated on the US 120 V single-
leg bench supply (L2 leg dead). Need split-phase mains for proper
voltage cal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* boards/nexcyber/pin_map.h: soften 0x756 = GFCI sense claim

Three-snapshot SRAM ADC cache test (idle GFCI-unplugged, idle
GFCI-plugged, charging attempt with EV-sim resistors) showed 0x756
moves 1.50 V → 1.20 V on charge attempt BUT stays flat at ~1.50 V
across GFCI plug/unplug. Rules out the simplest "GFCI subsystem
present" reading.

Two models stand for 0x756:
  (a) IS the GFCI sense — but the analog input reads same baseline
      plugged-vs-unplugged; only real residual current deviates it.
      The S1 drop would then reflect a true residual event causing
      the observed 5-second fault on charge attempt.
  (b) Is an onboard NTC or supply-rail-droop sensor.

Disambiguation now bench-blocked on injecting a known residual
current. GFCI subsystem health on this board appears to communicate
to the MCU through PC11 heartbeat suppression (verified separately
in same session: 5 rapid SWD samples of GPIOC ODR with GFCI plugged
showed bit-11 toggling), NOT through this ADC channel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* boards/nexcyber/pin_map.h: 4-snapshot bench locks 0x756 = GFCI sense

Fourth snapshot (charging WITH GFCI plugged) provided the
disambiguation diff missing in the 3-snapshot version: during the
same kind of charging attempt, 0x756 reads 1.43 V with GFCI plugged
(close to the 1.50 V healthy mid-rail) vs 1.20 V with GFCI unplugged
(floating CT input drifts further). That coupling pattern is the
signature of a CT-fed integrator and matches no other candidate.

Onboard-NTC alternative ruled out because it doesn't predict the
plugged-vs-unplugged differential during the same load condition.

Still bench-blocked: a single residual-current injection test
(e.g. 6 mA L→ground during charging) would calibrate the swing
direction + sensitivity for M5+ GFCI threshold tuning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* boards/nexcyber/pin_map.h: PC3 = mains L1, PC7 = mains L2 (bench-locked)

Single GPIOC IDR read on the bench unit running stock fw under US
120 V single-leg supply nailed both mains-detect pin roles + the
fault mechanism behind the user's "5-second voltage error":

  PC3 (IN_FLOATING) = HIGH → L1 present
  PC7 (IN_PD)       = LOW  → L2 missing

PC7 LOW with mains otherwise alive matches exactly the firmware's
"fault_bitmap = 8" (L2 missing) latched-soft-fault behaviour
documented earlier in NOTES.md. The MCU rejects charging attempts
after a short integration window because it can't see L2 — bench
supply only carries one leg.

Macros renamed PIN_MAINS_DETECT_L1_* / _L2_* to make the leg
explicit. Backward-compat aliases A=L1, B=L2 retained.

M5+ implication: track each leg independently; either LOW for > N
ticks = FAULT_MAINS_MISSING_L1 / _L2 trips contactor-permit drop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* M3: boards/nexcyber/hal/adc_scan — first-cut ADC1 scan + DMA

Ports the rippleon adc_scan surface (init/latest/rank) to the
Nations N32G45x. Conservative first cut: ADC1 only, 4 ranks
(PA6 ch3, PC0 ch6, PC1 ch7, VrefInt ch18). Validates the HAL
surface + DMA→SRAM ring + VrefInt cal path before extending to
ADC2 (where most signal channels live).

Two Nations-specific deviations encountered:

1. ADC1-4 are clocked from AHB (RCC_AHB_PERIPH_ADCx), not APB2.
   The F1-style RCC_APB2_PERIPH_ADC1 token doesn't exist on this
   chip. Documented inline.

2. Channel-to-pin mapping is NOT STM32F1-compatible. Per
   n32g45x_adc.h, PA6 = ADC1 ch3 (not ch6); PC0/PC1 = ADC1 ch6/7
   AND ADC2 ch6/7 (shared); PA4/PA5/PA7/PB1/PC4/PC5 = ADC2-only.
   The header inlines the full Nations map for reference.

Wired into main.c heartbeat task — every 5th beat prints "adc
pa6=%u pc0=%u pc1=%u vref=%u" so a bench operator can validate
ADC1 is alive and VrefInt sits in the expected 1.20 V band
(raw ~1490-1540 at 3.3 V Vref, 12-bit right-aligned).

s_adc_buf lands at 0x20000010 in this build — easy halt-and-peek
target for SWD-based DMA validation before checking the printk
output is healthy.

Build: 8972 B flash (+~1.3 KB SPL/DMA + this HAL), 19256 B RAM.
M3 (a) of 4 — CP PWM, BL0939 SPI, Nextion USART2 remain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* M3: boards/nexcyber/hal/cp_pwm — TIM1_CH1 J1772 pilot driver

Port of rippleon's src/hal/cp_pwm.c to the Nations N32G45x.

Pad: PA8 = TIM1_CH1 default AF (no AFIO remap, unlike rippleon
which routes TIMER0_CH3 → PE13 via GPIO_TIMER0_FULL_REMAP).

Clock math (SDK default chain — HSE 8 MHz × PLL ×18 → SYSCLK
144 MHz; APB2 = /2 = 72 MHz; TIM1 timer clock = APB2 × 2 = 144 MHz):
  Prescaler 143 → 1 µs tick
  Period 999  → 1 ms period = 1 kHz ✓
A reconfig of clock_real_120m_init() to lock 120 MHz will need a
single-line PSC update (build flag NEXCYBER_CP_PWM_TIM1_CLOCK_HZ).

Surface mirrors rippleon: cp_pwm_init / set_idle_high / set_state_f /
set_advertise_amps. The J1772 duty-to-amps formula is unchanged
(amps × 10 / 6 lower branch, amps × 10 / 25 + 64 upper branch);
the earlier amps × 6 / 10 inversion is avoided.

Inverting-vs-non-inverting CP buffer status is bench-blocked on
this PCB. Defaults to PWM2 mode (non-inverting). Build-flag
NEXCYBER_CP_PWM_INVERTING=1 flips to PWM1 mode if a bench scope
of PA8 vs CP shows the buffer inverts (the rippleon pattern).

Wired into main.c: cp_pwm_init() + cp_pwm_set_idle_high() during
boot so the EVSE advertises state-A (+12 V) until M5 safety
arbitrates the duty.

Build: 10276 B flash (+1304 B for CP PWM + SPL/TIM activation).
M3 (b) of 4 — BL0939 SPI and Nextion USART2 remain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* M3: boards/nexcyber/hal/{spi2,bl0939} — hardware SPI BL0939 metering

Port of rippleon's src/hal/{spi3,bl0939} to the Nations N32G45x.

Two transport changes vs rippleon:
  - Hardware SPI2 (PB13 SCK / PB14 MISO / PB15 MOSI) instead of
    bit-banged 3-wire. ~562 kHz SCK (APB1 / 64 at the 144 MHz SDK
    chain) — well under the BL0939's 900 kHz max.
  - Software-driven NSS on PB12 (SPI_NSS_SOFT). M2 gpio_init_all
    safe-low init drives it LOW; spi2_init() raises it HIGH after
    the peripheral is up.

Protocol layer unchanged from rippleon: header byte (0x55 read /
0xA5 write), 8-bit addr, 24-bit data, 8-bit checksum (~sum).
Bench-confirmed SPI variant per the 2026-05-11 PB14 touch-coupling
test recorded in pin_map.h.

Calibration constant temporarily hardcoded
(BL0939_FREQ_CONST_DEFAULT = 271900). Persist/calibration port to
nexcyber lives in M7+; the rippleon-side per-chassis cal model
travels directly once that lands.

Wired into main.c boot as a one-shot smoke_test() that dumps a few
defaulted + runtime registers via printk. Bench operators can verify
the link is alive (IA_FAST_RMS_CTRL default 0x00FFFF, TPS_CTRL
default 0x0007FF, runtime V_RMS / IA_RMS / IB_RMS / A_WATT
non-zero with mains live).

Build: 11192 B flash (+916 B for SPI2 + BL0939 + SPL/SPI activation).
M3 (c) of 4 — Nextion USART2 driver remains as the last M3 step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* M3 (d): boards/nexcyber/hal/nextion — USART2 raw transport

Final M3 milestone — USART2 (PA2/PA3) Nextion HMI link at 9600 8N1
with DMA1 channel 6 RX into a 128-byte circular buffer. Surface is
intentionally minimal — init / send_cmd (text + 3 × 0xFF) / rx_drain
(non-blocking sweep). Protocol parser, frame splitter, page-state
model land in M5+ once we know the UI flow needed for OpenEVCharger.

Layout matches the stock-fw DMA config decoded earlier (DMA1 ch6,
CCR=0x30A1, ~115 B CNDTR; our M3 uses 128 B). Pages observed in
stock firmware strings: "setting", "nogun", "chargeing", "waittime".
The page-state machine implementing those transitions is M5+ work.

Wired into main.c boot to send "page setting" — bench operator can
verify TX baud + wiring + display power by watching for the first
screen to render. RX path is silent until the user touches the
screen, at which point rx_drain() will surface the touch-event
bytes for caller-side framing.

Build: 11628 B flash (+436 B for Nextion HAL).

M3 complete — all four drivers (ADC / CP PWM / SPI2+BL0939 /
Nextion) build clean and wire into the boot path. Bench-flash and
correlate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* tools: flash_nexcyber.sh — manual FPEC programmer for N32G45x

OpenOCD 0.12's stm32f1x flash driver auto_probe rejects the Nations
N32G45x's DEV_ID 0x511 (no STMicro overlap, unlike rippleon's GD32F205
which happens to share STM32F1 DEV_ID 0x418). No override flag exists.

Workaround: drive the FPEC controller manually via direct register
pokes from a TCL script. The N32G45x's flash controller is F1-style
verbatim (FLASH_KEYR at 0x40022004, KEY1/KEY2 sequence, PG/PER/STRT
bits, AR/SR layouts). 2 KB page erase. Word writes from RAM staging
to flash via SWD AHB-AP.

CRITICAL bench-confirmed gotcha 2026-05-11: ST-Link AHB-AP silently
drops `mwh` (16-bit) writes to flash addresses when FLASH_CR.PG=1.
No error reported, byte just doesn't land. `mww` (32-bit) writes go
through cleanly — flash controller accepts 2-halfword bursts. The
TCL uses mww throughout.

Validated end-to-end on the bench: M3 build (openevcharger.bin,
11.6 KB, 2907 words) flashed successfully, verified, reset run.
PC moves from 0xfffffffe (lockup) to 0x08000d4e (running in flash),
s_adc_buf populates with live ADC samples, VrefInt reads 1.19 V
(spec 1.16-1.24) confirming ADC + DMA + calibration are operational.

flash_nexcyber.sh is a thin wrapper that sets NX_FLASH_BIN env var
and invokes openocd with the right interface/target/script.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* M3pm: CP polarity fix + ADC2 diag + PB2 = CP_RAW bench-confirmed

Three connected findings from a 2026-05-11 evening bench session:

1. CP PWM polarity was wrong — our PWM2-mode default produced
   CP -12 V instead of +12 V at idle. The nexcyber CP buffer is
   NON-INVERTING (bench-verified: pin LOW = CP LOW), and PWM2 mode
   inverts the CCR duty semantic (CCR=ARR+1 → OCxREF=0 → pin LOW).
   Flipped default to PWM1 mode (OCxREF=1 with CCR=ARR+1 → pin HIGH
   → CP +12 V idle ✓). Fluke reads +11.72 V at the connector now.
   The NEXCYBER_CP_PWM_INVERTING build flag stays as an escape hatch
   for a future inverting-buffer PCB variant.

2. boards/nexcyber include path was resolving to src/hal/adc_scan.h
   (rippleon's) instead of boards/nexcyber/hal/adc_scan.h (ours)
   because CMake had `src` before `boards/nexcyber`. main.c worked
   because its directory has the relative `hal/` path, but
   adc_scan.c inside boards/nexcyber/hal/ fell through to the -I
   paths. Reordered: boards/nexcyber first.

3. boards/nexcyber/hal/adc_scan.{h,c} extended with `adc2_diag_scan`
   — a single-shot ADC2 sweep over the 7 AIN-configured pads ADC1
   can't reach (PA4, PA5, PB1, PA7, PC4, PC5, PB2). Fills the
   exported `adc2_diag_buf` so the bench operator can SWD-peek
   it during a J1772 state walk to identify CP/CC/NTC etc.

J1772 5-point state walk on the bench unit (multimeter at CP
connector vs ADC raw) PINPOINTED PB2 = CP_RAW:

    A  CP=+11.72 V → PB2=2172
    B  CP=+8.50  V → PB2=1662
    C  CP=+5.71  V → PB2=1081
    D  CP=+2.81  V → PB2= 506
    E  CP=  ~0 V  → PB2=  96

Linear fit: CP_mV ≈ raw * 1000 / 187. 5-point residual < 0.5 V
across the positive CP range — plenty good enough for M5 J1772
state thresholds (A/B/C/D bins are each >2 V wide). Slight curve
approaching saturation below CP ≈ +1 V.

Bonus: PA4 also tracks CP linearly with a SECONDARY slope (~38
counts/V vs PB2's 187). Likely a precision low-current path or
differential pair. Use PB2 as primary, PA4 as cross-check.

pin_map.h CP_RAW pin moved from PC0 (placeholder) to PB2
(bench-confirmed). VSENSE_L1/L2/CC/NTC still TBD — none of the
other ADC2 channels moved meaningfully during the CP walk
(different stimulus needed: real EV plug for CC, mains-on
charging for I_L1/L2, heat for NTC).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* M4: J1772 state machine + relay/GFCI drivers + monitor task

Pulls in shared src/core/j1772.c (board-independent) and adds three
nexcyber-specific HAL files plus a monitor task in main.c:

- hal/relay.{h,c} — PA0 hold + PA1 close-pulse contactor driver.
  relay_close() asserts PA0 HIGH first (so the SR latch can latch),
  delays 1 ms, pulses PA1 HIGH for the configured duration, then
  releases PA1 LOW. relay_open() drops PA0. Model bench-confirmed
  via the charging-state SWD snapshot in pin_map.h.

- hal/gfci.{h,c} — GFCI CAL pulse on PB0. Active-HIGH (HIGH at MCU
  → ULN2003 sinks coil → test-pulse relay closes). Default pulse
  width 500 ms per the 2026-05-09 bench-confirmed click.

- hal/adc_scan.{h,c} — adds adc2_cp_raw() / adc2_cp_mv() helpers
  using the bench-locked PB2 calibration (raw/187 = volts).

monitor_task in main.c runs at 10 Hz:
  - adc2_diag_scan() refresh
  - j1772_step() with 5-tick debounce (500 ms commit)
  - printk on state transition + Nextion page switch
  - Polls STOP (PC13) + mains L1/L2 (PC3/PC7), edges logged
  - PC11 safety-supervisor heartbeat at 5 Hz (rate bench-blocked)
  - SWD-triggered g_bench_cmd mailbox:
      1 = relay_close, 2 = relay_open
      3 = gfci_cal_pulse, 4 = bl0939_smoke_test

Build: 13336 B flash (~11 % of 120 KB), 19400 B RAM.

Bench-flash validated 2026-05-11: M4 firmware running, ADC2 scan
filling adc2_diag_buf, CP raw tracking through J1772 states.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* nexcyber pin_map: PC4 = GFCI sense — locked via real fault injection

Bench-confirmed 2026-05-11 evening. A real residual-current fault
injected through the GFCI subsystem produced a +242 raw deflection
on PC4 (ADC2 ch5), 18× larger than any other ADC channel's swing
(<14 counts). All channels returned to baseline on fault clear with
PC4 showing the slow RC-decay characteristic of a CT integrator.

| state | PC4 raw | Δ from baseline |
|-------|---------|------------------|
| baseline (no fault, contactor open) | 2118 | 0    |
| GFCI fault active                   | 2360 | +242 |
| just-cleared                        | 2300 | +182 (decaying) |

Polarity note: a CAL pulse on PB0 also moves PC4 but in the OPPOSITE
direction (-234 vs +242 for a real fault). The CT is polarity-aware;
the CAL relay injects test current the other way around the core.
M5 safety task should treat any |PC4 - 2100| > N as a GFCI event,
not just upward deflection.

PIN_ADC_CC_PORT/_PIN at PC4 was a placeholder that turned out wrong —
PC4 is GFCI sense, not CC. Old macro kept as a backward-compat alias
to avoid breaking in-flight references; PIN_ADC_GFCI_SENSE_* is the
correct name going forward. CC physical pin is still unknown
(needs a real plug-in stimulus to identify).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* nexcyber pin_map: PE continuity has NO dedicated sense pin

Bench-confirmed 2026-05-11 via real PE-fault injection. The tester's
PE-fault mode reads on this EVSE as "open CP cable" — PB2 jumps to
state-A levels (2172) regardless of CP-dial position (was state C
= 1080 before fault). PA4 followed (CP secondary). GFCI sense (PC4)
showed a small -137 deflection from current-path shifts.

Matches the stock-fw SRAM cache layout: no PE_SENSE slot exists
(only CP / CC / I_L1 / GFCI / I_L2). The EVSE's PE detection is
indirect — likely via the CP behavior change ("CP at state A while
plug was known-engaged" = PE fault candidate) plus the small GFCI-
sense deflection.

M5 safety-task implication: nexcyber's PE fault detection lives in
the system_state layer, not the HAL — there's no PE GPIO/ADC pin to
read directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* nexcyber: lock PC0+PC1 as redundant gun-NTC reads, PC5 = onboard NTC

Bench-confirmed 2026-05-11 pm via ground-short test. Shorting the
gun-connector NTC pin collapsed BOTH ADC1 ch6 (PC0) and ADC1 ch7
(PC1) from rail/mid simultaneously to 0 — they are two parallel
conditioning paths off the same physical thermistor pad:

  PC0: high-impedance read, ~3.33 V open (pegged to rail)
  PC1: divided read, ~2.20 V open (mid-rail, range-compressed)

This is a textbook redundant-sensor pattern: M6 over-temp logic
gets to detect open/short/mismatch faults by comparing the two
reads against each other.

PC5 (ADC2 ch12) did NOT respond to the short, so it's not the gun
NTC despite sitting at the "1.7 V" value the prior memory entry
attributed to NTC1. PC5 reads the onboard PCB thermistor (enclosure
/ contactor area temp).

Adds tools/ntc_peek.py to make halt-peek-resume a one-shot during
ADC pin identification: prints labeled table of both ADC buffers,
supports baseline save and Δraw/Δmv diff to spot deflections.

Renamed PIN_ADC_NTC_PORT/PIN → PIN_ADC_NTC_{GUN_A,GUN_B,BOARD}_*
to reflect the three roles. gpio.c now configures all three pads
as AIN.

Memory entry "OpenEVCharger bench: gun NTCs not populated" was
partially wrong: the gun NTC IS populated (or at least the line is
unshorted at rest), and the two parallel reads explain the prior
"NTC1 floats ~1.7 V, NTC2 = 0 V" observation as a mis-attribution
of PC5 (onboard) to one slot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* nexcyber: LED ring driver (blue=PC10, green=PC12), bench-validated

Bench-confirmed 2026-05-11 pm via tools/led_walk.py with corrected
active-HIGH polarity. Topology: 12 V LED rail, three discrete LEDs
(R/G/B) on the ring daughter board with no driver IC, each cathode
routed back to the main PCB through an NPN/MOSFET buffer. MCU pin
HIGH → buffer ON → LED cathode pulled to GND → LED lit.

Pin attribution:
  PC10 = BLUE  (active-HIGH, OUT_PP)
  PC12 = GREEN (active-HIGH, OUT_PP)
  red   = TBD; driver hardware-damaged on this bench unit. 8 V across
          the LED string at rest, no response to forced PC11 or PC14
          drive, consistent with a buffer FET stuck in linear region.
          Stock fw pulsed red with a PWM breathing pattern, so the
          original drive is a TIM channel; re-identify on a fresh unit.

Tooling trail (kept for future fresh-unit re-ID):
  led_walk.py        — active drive sweep with operator-in-the-loop
                        (drives each candidate HIGH and prompts for
                        color; NPN buffer means LOW won't light)
  led_passive_probe.py — passive probe by grounding LED pad and reading
                        IDR for the dropped bit. Returned nothing —
                        demo that a buffer-isolated topology hides
                        the connection from the MCU side.
  pc11_red_test.py   — verified PC11 (safety heartbeat) doesn't double
                        as the red driver
  pc14_red_test.py   — unlocks PWR/BDCR so PC14 is GPIO-addressable
                        before driving; ruled out PC14 as red driver

State→LED mapping in monitor_task:
  J1772 A/B  → blue ON, green OFF  (standby / waiting)
  J1772 C/D  → blue OFF, green ON  (charging)
  J1772 E/F  → both OFF (fault — red would normally indicate)

Bench-validated walk A→B→C→B→A: all transitions tracked correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* nexcyber: LCD link investigation — bench cmds 5–18 for UART/baud sweeps

WIP bench tooling from the 2026-05-11 evening session digging into
why the DGUS display doesn't react to commands from our M4 firmware
despite being on the right physical UART.

What we know for sure:
- LCD is DWIN DMT48270C043_04WT, T5 chip, DGUS-II protocol (5A A5 …
  frames confirmed in stock-mcu-2026-05-07.bin)
- Stock firmware inits USART2 at 115200 baud for the LCD link
  (disasm at 0x8008dd0–0x8008e2a). USART1=9600, USART3=115200,
  UART7=115200 (Nations base 0x40015400, not STM32F1 0x40004C00).
- PA2 (USART2_TX) physically reaches the LCD board's RX pad. Verified
  with the spam test (cmd 15): voltage drops to ~1.65 V on both PA2
  and the LCD-side RX pad while 0x55 streams.
- The LCD board has only one UART pair wired (RX/TX); RX1/TX1 pads
  exist on the connector but aren't routed.
- Firmware tight-loop sends DGUS frames back-to-back (no
  inter-byte gap from openocd round-trips).
- BRR sweep through 20 baud × PCLK1 combos via the firmware-side
  loop produced NO visible LCD reaction (no dim, no page change).

What we DON'T know:
- Why the LCD ignores our DGUS frames. Top hypotheses:
  1. LCD is in a stuck/wedged state from boot-time garbage we sent
     (our M4 nextion_init sends "page setting" as ASCII at 9600 baud,
     which the DGUS display would see as nonsense bytes; unlikely
     to wedge but possible).
  2. The actual PCLK1 differs from SPL's belief (SystemCoreClock =
     144 MHz, so SPL set BRR=0x0138 for 115200 — but our DWT
     CYCCNT measurement was contaminated by FreeRTOS WFI sleep and
     pointed at ~220–250 MHz HCLK, which doesn't match).
  3. There's a hardware enable / RESETB line on the LCD that we
     haven't asserted. Some DWIN displays hold off UART processing
     until a specific GPIO state.
  4. The LCD board has a buffer / level-shifter chip between the
     connector RX pad and the T5, and that chip isn't powered or
     enabled.

Bench cmds in this commit:
  5  = nextion_send_cmd("dims=10")    [Nextion-style — wrong protocol]
  6  = nextion_send_cmd("dims=100")
  7..10 = nextion "page N"
  11 = USART3/9600 on PB10 probe
  12 = UART4 (wrong base — STM32F1 0x40004C00, not Nations) probe
  13 = USART2/115200 probe via nextion_send_cmd
  14 = USART3 partial-remap on PC10 probe
  15 = spam 0x55 on USART2 for 10 s (used for multimeter trace test)
  16 = DGUS dim=0/dim=100 via SPL USART_Init(115200), tight loop
  17 = DGUS page0/page1
  18 = DGUS dim=0/dim=100 with BRR from g_bench_arg (sweep helper)

Mailbox layout shifted: g_bench_arg at 0x2000000c, g_bench_cmd at
0x20000010 (was 0x2000000c). All older bench scripts need to be
updated to use 0x20000010 for cmd triggering.

Tooling added:
  tools/baud_sweep.py        — old USART2 baud sweep, openocd-only,
                                inter-byte gap issue
  tools/dgus_test.py         — direct openocd byte pokes (had gap issue)
  tools/dgus_brr_sweep.py    — final firmware-side BRR sweep (clean)
  tools/uart_wiggle.py       — 1 Hz wiggle of UART_TX candidates for
                                multimeter-on-LCD-pad ID test

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* nexcyber: stock-fw restore tooling + DGUS cmd 19 (VP=0 init sequence)

- tools/restore_stock_nexcyber.sh: 2-pass chunked flasher for the
  128 KB stock dump (single-pass load_image overflows 80 KB SRAM)
- openocd-n32g45x-flash.tcl: accept NX_FLASH_TARGET_OFFSET +
  NX_FLASH_NO_RESET_RUN env vars so multi-region flashes can share
  one TCL helper; default behaviour unchanged
- main.c bench cmd 19: full DGUS-II init sequence extracted from
  stock V1.0.066 dump — VP=0x0000 system-register zero-writes
  before backlight/page commands; BRR override via g_bench_arg

Bench-tested 2026-05-12: SHA-perfect stock restore, but stock fw
on this bench unit no longer reaches its UI-init phase (hardware
drift from prior M4 work — see project memory). DGUS cmd 19
transmits cleanly but LCD still unresponsive on this unit; saved
for retry on a fresh unit per the docs path (CRC mode +
RESET-pin hypotheses).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* nexcyber: cmd 20 — docs-driven DGUS test (System_Reset + bidirectional CRC blink)

Built from DWIN T5L_DGUSII Application Development Guide v2.9 §4.1/§5.1:

  - System_Reset via VP=0x0004 (write magic 55AA 5AA5) — guaranteed
    LCD CPU hard reset. Frame format works in BOTH CRC modes:
    no-CRC LCDs see the trailing CRC bytes as data beyond the 4
    reset bytes (which still trigger reset); CRC LCDs validate the
    trailer normally. Universal probe.
  - Backlight blink (VP=0x0082) sent first WITHOUT CRC trailer then
    WITH CRC trailer — backlight flicker pinpoints which mode the
    LCD's CFG (byte 0x05 bit 7) is configured for.
  - Page 0 (VP=0x0084) sent in both variants — content-change probe.

CRC algorithm verified bit-exact against doc examples:
  MODBUS CRC-16 (poly 0xA001, init 0xFFFF) over CMD+VP+DATA,
  trailer sent LSB first. Doc example 5AA5 06 83 000F 01 ED90
  reproduced from `83 00 0F 01` → 0x90ED → on-wire ED 90 ✓.

Cmd 19 (stock-fw frame replay) is preserved for reference; cmd 20
supersedes it for LCD bring-up testing.

Total sequence runs ~6 sec; trigger via:
  mww 0x2000000c 0; mww 0x20000010 20

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* nexcyber: host-side DGUS probe tool (USB-UART direct to LCD)

Built per DWIN T5L_DGUSII Application Development Guide v2.9. Sweeps
common DGUS baud rates and CRC modes via a Version Read at VP=0x000F
(cmd 0x83); the LCD's reply is deterministic so a clean RX uniquely
identifies (baud, crc-mode). Also offers --reset (universal hard
reset via VP=0x0004) and --blink (visible backlight test).

Bypasses all MCU/clock uncertainty — talks straight to the LCD over
a USB-UART adapter. Wiring + 5V power notes in the module docstring.

Use for next bench unit's LCD bring-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* nexcyber: dgus_listen.py — passive LCD UART listener for wire validation

Companion to dgus_probe.py. Listens passively for any spontaneous DGUS
traffic from the LCD (e.g. touch auto-upload frames if the LCD has a
touch panel and CFG enables it). Useful as a wire-validation step when
the active sweep returns silence: any received bytes prove the
LCD-TX → USB-UART-RX path works, even if baud is wrong.

Note: confirmed via dev-guide naming table that DMT48270C043_04WN's
"N" suffix means no touch panel — listener will be silent on this
specific model unless something else is sending. Kept for use on
touch-equipped DGUS variants.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: multi-MCU board structure design spec

Design for reconciling the nexcyber-port-skeleton island model into a
real portability boundary: a boards/<board>/ config tree, src/hal/
split into a portable interface + per-chip impls, shared production
main.c with Nexcyber's bring-up harness kept as a separate bench
target. Six-step migration sequenced so both the rippleon and nexcyber
builds stay green throughout and all bench-validated work is preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: multi-MCU board structure implementation plan

13-task, 6-phase plan to reconcile the nexcyber-port-skeleton island
model into the boards/<board>/ + src/hal/<chip>/ structure. Sequenced
pure-moves-first with disassembly-diff regression gates so the GD32
production target and the Nexcyber bench harness stay byte-identical
through the refactor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* cmake: extract bench feature flags into cmake/options.cmake

All bench/debug option blocks moved verbatim from the rippleon branch
of CMakeLists.txt into cmake/options.cmake; CMakeLists.txt now includes
it once via include() in the shared tail, after OPENEVCHARGER_GIT_SHA.
Hardware-fact defines (GD32F20X_CL, HXTAL_VALUE, GFCI_CAL_SELF_TEST,
PE_CONTINUITY_DETECTOR) remain in the rippleon branch for Task 3.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(plan): fix the disassembly regression-gate procedure

The sed filter on macro names never worked — objdump -d output carries
the disassembled SHA bytes, not the macro names. Replace with a
characterize-the-diff GATE: pass iff every differing line is git
build-info noise (ASCII-immediate loads / .word data near build_info).

* cmake: split if/elseif into per-board board.cmake; adopt PCBA slugs

Lifts the rippleon and nexcyber target bodies out of CMakeLists.txt into
boards/rippleon-roc001/board.cmake and boards/nexcyber-zbu011k/board.cmake
respectively, and replaces the if/elseif block with a single include().
Adopts the new slugs rippleon-roc001 / nexcyber-zbu011k throughout; the
FATAL_ERROR guard now checks for the board.cmake file's existence rather
than an explicit allowlist. Both firmware targets and host ctest pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(plan): GATE compares instructions only, not raw objdump

* boards: relocate pin maps + linker scripts under boards/<board>/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* hal: move GD32-coupled implementations into src/hal/gd32f205/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* drivers: promote w25q SPI-NOR driver to src/drivers/ (chip-independent)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* hal: relocate Nexcyber HAL to src/hal/n32g45x/, harness to bench target

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* hal: reconcile Nexcyber shadow headers — share cp_pwm/bl0939, isolate divergent adc_scan/gfci/relay

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* hal/n32g45x: add OEVC_HAL_STUB macro + stubs for unported + divergent HAL

Adds src/hal/oevc_hal_stub.h with OEVC_HAL_STUB() trap macro (cpsid i +
infinite loop), then creates 11 stub .c files in src/hal/n32g45x/:
- 8 unported-HAL stubs (rtc, flash, spi3, uart5, rfid, ws2812, wdg,
  adc_inject) implement every function from the shared src/hal/*.h surface
- 3 divergent-peripheral stubs (adc_scan_shared_stub, gfci_shared_stub,
  relay_shared_stub) implement the SHARED hal interface for peripherals that
  already have real N32 drivers against board-specific *_nx.h APIs

All 11 files pass -fsyntax-only. Existing builds and host tests unchanged
(new files are inert until Task 10 wires them into the production target).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* main: extract board-init hooks; make src/main.c board-agnostic

src/main.c had chip-specific bring-up inlined at three non-contiguous
sites (vendor clock fix-up, debug-pin/AF remap, FC41D power sequence).
Extract each behind a board hook — board_early_init / board_debug_pins_init
/ board_fc41d_release, declared in src/hal/board_init.h, implemented per
chip in src/hal/<chip>/board_init.c (GD32 real, N32 stub). Hooks are 1:1
with the original call sites so main()'s call order is preserved.

This is the board-hook half of the original Task 10. Wiring the Nexcyber
production target is deferred — it is blocked on the shared src/ tree not
being chip-clean (raw GD32 SPL in src/ui, src/tasks, src/persist), which a
follow-up task addresses first.

* docs(plan): split Task 10, insert Task 11 (chip-clean core) + Task 12 (production target)

* hal: chip-clean the shared core — GPIO-bit + reset-cause HAL

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* boards/nexcyber: reconcile pin_map.h types + macros; wire production target

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* repo: tighten .gitignore, drop tracked build artifacts + stale vendored OCPP copy

Widen the ESPHome build-cache ignore from fc41d/.esphome/ to repo-wide
.esphome/ and remove the now-redundant fc41d-scoped line.  Step 1 audit
found no tracked build artifacts or vendored external_components to
un-track (the only regex hit, src/proto/build_info.h, is legitimate
source — the substring "build_" in its name is coincidental).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* ci: update firmware matrix for PCBA slugs; nexcyber production target continue-on-error

Rename rippleon→rippleon-roc001 and nexcyber→nexcyber-zbu011k to match
the board slugs adopted in Task 3. Merge Configure+Build into one step
with per-step continue-on-error so a nexcyber link failure doesn't block
the size/upload steps unnecessarily. Add bringup artifact paths for
nexcyber-zbu011k and switch if-no-files-found to warn (bringup files
absent on rippleon-roc001; the job-level continue-on-error already gates
PR status). host-tests.yml and fc41d-config.yml are unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: describe the as-built multi-MCU board structure

Rewrites the stale structural/build-instruction sections in BOARDS.md,
README.md, and boards/nexcyber-zbu011k/README.md to match the refactored
layout (boards/<board>/ + src/hal/<chip>/ + src/drivers/), updates build
commands to the new slugs and build/<board>/ convention, and creates
boards/rippleon-roc001/README.md. All hardware/FCC/OEM/safety/roadmap
content preserved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* boards/nexcyber: define PE_CONTINUITY_DETECTOR=0 (final-review fixes)

Final holistic review flagged two trivial items:
- the nexcyber production target was missing the OPENEVCHARGER_PE_CONTINUITY_DETECTOR
  board-fact define, producing a -Wundef warning on safety_task.c (spec §2 says
  board-fact defines live in board.cmake) — added =0 (no PE-continuity sense pin
  on this PCB);
- stale pre-rename 'nexcyber' slug in a toolchain-file comment — corrected to
  nexcyber-zbu011k.

Nexcyber build is now warning-free; both ELFs link.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant