SPARK · firmware architecture
Soneil-EV · Grizzle · v3.3.1:45 · arch v2

Master Architecture · v2 · product + production line

How a Spark charger talks to the car, the phone, and the cloud.

A ground-up map of the firmware — from the STM32/AVR power board whispering serial packets to the ESP32, out through its Wi-Fi hotspot & local API, across the on-site router to an OCPP cloud console, and finally into the Spark app, Home Assistant and voice assistants. Every number below is drawn from the actual source.

New in v2: a second act follows the product tour — the full production line that turns a bare board into a warehouse-ready unit: dual-processor flashing, factory configuration, and the final load-test rig, each backed to a Google Sheet.

230V AC · CP UART0 · L:CC:VVVV UART1 · GPIO17/16 HTTP :80 · 192.168.4.1 Wi-Fi STA (DHCP) Ethernet RMII — ready wss · OCPP 1.6J ftp:// .bin MQTT EV J1772 STM32 / AVR power board CP · relay · sense ESP32 main MCU OCPP · HTTP Wi-Fi · MQTT AP + STA Gateway / Tester factory + integration Spark App iOS / Android On-site router InHand + TP-Link extender OCPP CSMS cloud console api.soneilspark.io FTP host ESP / AVR .bin Home Assistant MQTT discovery Google · Alexa · Siri

Tip: click a channel chip above to isolate one signal path. Everything is reconstructed from charger/main/*_grizzle.* and modules/chargelab-open-firmware/.

01

The power board & the car

Everything starts on the STM32/AVR "power board." It is the only part that touches mains and the vehicle: it drives the Control Pilot (CP) signal on the J1772 connector, closes the contactor/relay, and continuously senses current, voltage and faults. The ESP32 never switches power directly — it asks the AVR to, over serial.

Drives

Control Pilot

Sets the CP duty/level that tells the car how many amps it may draw, and reads the CP voltage state (12V idle → 9V connected → 6V charging).

Switches

Contactor

Physically closes the AC relay only on an ENABLE_CHARGING command and an intact pilot handshake.

Protects

Faults

GFCI / ground-fault, over-voltage, over-current, plug over-temperature and missing-ground are all detected here and reported up as state codes.

Fault & state codes reported by the AVR

valuemeaningmaps to
0Standby / car disconnectedAvailable
1Connected (plugged, not charging)Preparing
2ChargingCharging
3Paused (CP = 9V)SuspendedEV
5Charge completeFinishing
8Plug over-temperatureFaulted
9GFCI / ground faultFaulted
10Over-voltageFaulted
11Over-currentFaulted
12High temperatureFaulted
13Ground missingFaulted
255Unknown errorFaulted

connector_grizzle.cc · state parsing ~3811–3930 · command 4 = STATE

02

ESP32 boot sequence

On power-up app_main() brings the system up in a fixed order, then hands control to a 10 ms OCPP super-loop that runs forever. Two things worth noting: the STM version check happens before networking (so the AVR can be re-flashed at boot), and the gateway/tester UART task is spawned early so a factory line can talk to the unit even before Wi-Fi exists.

LED strip + boot mode main.cc 457–459

RGB status ring initialised; handle_boot_up_mode() decides normal vs. recovery.

NVS flash init system_grizzle.cc 190

nvs_flash_init(); legacy "v5 settings" migrated and cached if present.

UART1 for gateway box main.cc 463–475

UART_NUM_1 @ 115200 8N1, TX=GPIO17 / RX=GPIO16 — the tester/integration channel.

Core factory init main.cc 479–499

Metadata → Settings → System → Firmware → Platform (mounts SPIFFS) → Portal modules created.

STM firmware auto-update check main.cc 503–506

updateSTMFirmware() compares embedded stm_version.txt against the AVR — re-flashes on mismatch.

Load Wi-Fi + backend URL main.cc 508–513

Stored SSID/password and the OCPP central_system_url are pulled from settings.

WebSocket thread starts main.cc 515–516

LibwebsocketsThread begins; OCPP transport comes alive once STA has an IP.

UART command task main.cc 519–524

xTaskCreate(uart_command_task…) — listens for text commands on UART1.

Module stack main.cc 543–580

Connectors, Transaction, BootNotification, Heartbeat, ConnectorStatus, PowerManagement, FirmwareUpdate, Diagnostics.

OCPP handler + super-loop main.cc 582–593, 721

ChargingStation::runStep() pumps every 10 ms — the heartbeat of the whole firmware.

Runtime mode resolve main.cc 646–714

Reads NVS ha_mode / MQTT broker; auto-enables telemetry for non-Soneil backends.

03

AVR ↔ ESP serial protocol

The two MCUs speak a tiny ASCII line protocol over UART0 @ 115200 8N1 (default pins GPIO1 TX / GPIO3 RX). Every line is L:<CMD>:<VALUE>\r\n. The ESP reads a byte at a time with a 25 ms idle timeout to frame each line. This is the cyan lane in the map above.

STM32 / AVRUART0 ESP32receiveMessage() telemetry ↑ L:09:2522 (25.22 A) command ↓ L:05:1600 (set 16.00 A)

AVR → ESP · telemetry

cmdfieldencoding
4Statecode 0–255
9Actual current×100 (A)
8Line voltage/100 (V)
10Max available current×100 (A)
5Set-current echo×100 (A)
14AVR versionASCII e.g. 7620:B18B

ESP → AVR · commands

cmdnamewire
5UPDATE_CURRENTL:05:1600
3ENABLE_CHARGINGL:03:01
4DISABLE_CHARGINGL:03:00
6SET_MAX_CURRENTL:06:<n>
0GET_STATEL:00:00
9RESETL:09:00
# decoded SerialMessage struct (connector_grizzle.cc ~271)
stm32_state            2      // CHARGING
set_current            1600   // 16.00 A commanded
actual_current         2522   // 25.22 A measured
max_current            3200   // 32.00 A ceiling
voltage                23050  // 230.50 V
chrgr_en_and_pilot_level 0x0F // charging enabled + CP level

connector_grizzle.cc parse 3701–4125 · send sendMessage() 3674–3699 · enums in stm_messages_grizzle.h

04

Flashing the AVR from the ESP

The ESP carries the AVR's firmware embedded in its own binary (grizzle.bin, grizzle2.bin, grizzle3.bin) plus version stamps. At boot it interrogates the running AVR; on a version/checksum mismatch it walks the STM32 into its ROM bootloader and re-flashes it over the same wires — a different UART profile for each phase.

Normal mode 38400 8N1 read version Jump to boot cmd 8 / 20 wait 2 s Bootloader 19200 EVEN 0x7F · erase · write GO 0x21 run app
  • Interrogate — open UART_NUM_2 @ 38400, read version1+version2; a .3 suffix means STM v2 → use grizzle2.bin, else v1 → grizzle.bin.
  • Enter bootloader — send JUMP_TO_BOOT (8) or JUMP_TO_BOOT2 (20), wait 2 s, drain serial.
  • Re-open @ 19200, EVEN parity, 8N1 — the STM32 system bootloader profile; handshake with init byte 0x7F.
  • Erase (0x43/0x44) → write (0x31) in ~256-byte chunks from 0x08000000GO (0x21).
  • On success, store the new version + checksum in NVS so the next boot is a no-op.

stm_firmware_updater_grizzle.h 69–223 · trigger stm_firmware_grizzle.h 127 · embedded bins via CMake

Why it matters: because the AVR image ships inside the ESP image, a single OCPP UpdateFirmware (Stage 8) updates both processors — the ESP flashes itself, then re-flashes the AVR on the next boot if the stamp changed.

05

Local access — the hotspot & the API

The ESP runs Wi-Fi in APSTA mode: it is simultaneously a SoftAP (its own hotspot) and a station on your router. The SoftAP is Spark_<last-4-of-serial>, password SparkPower123, WPA2, channel 1, up to 4 clients, gateway 192.168.4.1 with its own DHCP. A libwebsockets HTTP server on :80 binds all interfaces, so the same 50+ endpoints answer on both the hotspot and the router-side IP.

Hotspot lifecycle

The 5-minute rule

SSID starts visible. 5 min after first connect it is hidden (not disabled — still reachable if you know it). If Wi-Fi drops it un-hides for recovery; on reconnect it re-hides.

Two control channels

HTTP + UART1

The phone drives HTTP on :80. A factory/integration gateway box drives the same actions over UART1 text commands — independent of Wi-Fi.

HTTP portal API — by function

Status / read

  • /chargerInfo serial, fw, url, max A, V
  • /GetMeterValue energy_wh, V, A, W
  • /avr_logs live AVR telemetry
  • /wifi_status · /read_voltage
  • /get_config_data full config

Pairing / Wi-Fi

  • /readAN scan networks
  • /req.html?uname&psw&udsn
  • /Wifi_cred?ssid&password
  • captive-portal probes (iOS/Android/Win)

Charging control

  • /Start_charging · /Stop_charging
  • /set_Current=<A>
  • /max_current_limit=<A>
  • /enable_start_stop_charge

Config / calibration

  • /voltageoffset= · /currentoffset=
  • /overcurrenttolerance= (100–125)
  • /SetSerialNumber=
  • /back_end_ocpp_url=

OTA / firmware

  • /esp_OTA multipart upload
  • /AvrUpdate upload AVR bin
  • /update_avr_with_40|48|80_amps_file
  • /auto_update_avr_based_on_max_current

Modes / admin

  • /dashboard · /admin_SparkPower123
  • /logs (SSE stream)
  • /OfflineMode · /factoryReset
  • /setmqttbroker= · /clearmqttbroker

Gateway / tester box — UART1 text commands

commandaction
wifi_config:<ssid>;<pw>Apply Wi-Fi creds, reboot
set_serial:<n>Set device serial number
set_ocpp_url:<url>Backend URL (validates ws/wss), persists
max_current_limit:<n>Set max current, writes ocpp_settings.json
avr_update / _40 / _48 / _80_ampsRe-flash AVR from embedded bin
enable/disable_plug_and_playToggle offline "dumb" charging
voltage_offset: / current_offset:Calibration
reboot_AVR / reboot_charger / resetReset controls
charger_infoEmit serial + combined fw version

portal_grizzle.cc endpoints · main.cc uart_command_task 207–455 · AP config platform_esp.cc 779–786

06

Getting online — router, extender & the Ethernet option

Once provisioned, the charger's station interface joins the site network by DHCP. In a typical Soneil deployment that network is an InHand industrial/cellular router, often paired with a TP-Link Wi-Fi extender so the charger has solid signal in a parking structure. From the firmware's view it is just "a router with DHCP" — there is no InHand- or TP-Link-specific code; it is standard, vendor-agnostic Wi-Fi.

ESP32STA + AP TP-Link extenderWi-Fi repeater InHand routerDHCP · WAN Internetcloud / FTP Wi-Fi Ethernet (RMII) — ready, not yet active
Path A · today

Wi-Fi STA

Charger → (optional TP-Link extender) → InHand router → internet. DHCP-assigned IP, no static config, no mDNS.

Path B · roadmap

Ethernet module

CONFIG_ETH_* is compiled in (ESP32 EMAC/RMII + SPI/W5500), so an add-on Ethernet module is hardware-ready — but no Ethernet init runs in app code yet. Wi-Fi is the only live transport today.

Always available

AP stays reachable

Because the API binds all interfaces, disabling the hotspot would not cut a router-side gateway off — that path is the STA interface, a separate netif.

platform_esp.cc APSTA 568, AP 779–786, IP 459 · Ethernet build/config/sdkconfig.h 89–97

07

The cloud — OCPP 1.6J to the CSMS

With an IP in hand, the ESP opens a secure WebSocket (wss) to the Charging-Station-Management-System using the ocpp1.6 subprotocol. The URL comes from one of four sources (priority NONE → UART → PORTAL → OCPP); Soneil's defaults are wss://api.soneilspark.io:8887 and wss://ocpp.io. The link self-heals with a back-off ladder and a 15 s ping / 60 s heartbeat.

ChargerESP32 client CSMScloud console BootNotification · Heartbeat · StatusNotification Authorize · Start/StopTransaction · MeterValues RemoteStart/Stop · Change/GetConfiguration Reset · UpdateFirmware · TriggerMessage

Connector state machine

CP states from the AVR (Stage 1) map straight to OCPP statuses:

Available Preparing Charging SuspendedEV SuspendedEVSE Finishing Faulted Unavailable

Transaction lifecycle

  • Authorizing → RFID / remote / plug-and-charge id
  • Starting → StartTransaction, meterStart
  • Running → MeterValues every 60 s
  • Stopping → StopTransaction + reason
  • Finished → cleanup

Offline resilience: if the CSMS is unreachable the charger can still run dumb charging or offline plug-and-charge with a pre-set id tag, and the gateway/app can force a session via the local_charging_active path — no cloud required.

transport websocket_lws.cc 175 · charging_station.h 66–135 · transaction_module.h 300–668 · configuration_module.h 141–146

08

Updates — OTA & the FTP bin host

Firmware arrives two ways. Locally, the app POSTs a .bin to /esp_OTA. Remotely, the CSMS sends an OCPP UpdateFirmware carrying a location URL — typically ftp:// — and the charger pulls the image itself. Either way it writes to the inactive OTA partition, validates, flips the boot pointer, and reboots; on the next boot the AVR is re-flashed if its stamp changed (Stage 4).

CSMSUpdateFirmware FTP hostesp / avr .bin ESP32ota_0 / ota_1 AVRre-flash ftp url 1KB chunks next boot
Download

FTP client

ftplib parses ftp[s]://user:pw@host/file.bin, streams in 1 KB chunks, optional TLS.

Write

Dual partition

esp_ota_begin/write/end to the next partition; checksum-validated; set_boot_partition then reboot; rollback on failure.

Report

Status

FirmwareStatusNotification: Downloading → Downloaded → Installing → Installed (or *Failed).

portal_grizzle.cc /esp_OTA 977 · firmware_grizzle.cc esp_ota_* · fetch_ftplibpp.cc 26–119 · firmware_update_module.h 108–249

09

Control surfaces — app, MQTT & voice

The Spark app wears two hats: on the hotspot it drives the local HTTP API (pairing, calibration, manual start/stop); once the charger is on a CSMS it monitors and controls through the cloud. In parallel, a Home Assistant path publishes the charger over MQTT — which is what brings Google, Alexa, Siri and SmartThings into the picture. HA-MQTT and OCPP-telemetry are mutually managed so they don't both stream at once.

MQTT / Home Assistant

  • Broker: default broker.hivemq.com or custom via /setmqttbroker=
  • Discovery: homeassistant/<type>/<id>_<x>/config
  • State: sparkcharger/<id>/status/state
  • Commands: sparkcharger/<id>/cmd/#

Auto-published entities

  • 7 sensors — status, set/live/max current, voltage, power, energy
  • 3 buttons — start, stop, reboot
  • 1 number — set-current slider (6 → max A)
Spark chargerESP32 Spark App (local)HTTP :80 Spark App (cloud)via CSMS Home AssistantMQTT Voice assistantsGoogle·Alexa·Siri

connector_grizzle.cc MQTT init 599–629, discovery 498–580, telemetry 642–680 · toggle portal_grizzle.cc 1446/1491

10

Security posture — read this before field deployment

The local surface is built for easy servicing, which is also its risk. Worth stating plainly so it's a deliberate choice, not a surprise:

⚠ Local API is open by design

• The HTTP portal has no authentication and no TLS — anyone on the hotspot or the site LAN can call /Start_charging, read /get_config_data, or re-point /back_end_ocpp_url=.
• The admin page is guarded only by a path secret, /admin_SparkPower123.
• Hiding the SSID after 5 min is obscurity, not access control — a client that knows the name still connects.
• Exposed UART = full control. The cloud link (OCPP) is TLS (wss); the gap is the local side.

Mitigations to consider: gate the hotspot behind an on-demand enable, add a token to control endpoints, and rotate the admin path — see the earlier discussion on an /ap_timeout-style state read for the app.

Part II · from bare board to warehouse

The production line — three stations, one Google Sheet

A charger is born as two blank processors and ends as an ETL-listed unit on a pallet. On the floor (currently at contract manufacturer MicroArts) it passes three stations: flash both MCUs, configure & functionally test over Wi-Fi, then load-test the assembled unit against a real electronic load. Every station appends a pass/fail row to a shared Google Sheet, so a serial number's whole history is one lookup away.

P1 · flash AVR (ELF) + ESP (esptool) P2 · PCB test + configure (RPi jig) P3 · load test (RPi + ESP tester) P4 · certify & ship
Bare board2 blank MCUs P1 · FlashAVR ELF + ESP binsprogrammer + esptool P2 · Configurevariant + calibrateWi-Fi API · verify P3 · Load testreal current pullRPi + ESP tester P4 · CertifyETL · serial · sealverdict gate Warehouseship every station logs its verdict (GitHub / Google Sheet)
P1

Flashing both processors

The two MCUs are programmed by different tools. The STM32/AVR bottom board is flashed first on a bench programmer (Microchip/Atmel-style, SWD/ISP) with the compiled ELF — that image lives with the manufacturer, not in this repo. The ESP32 is then flashed over USB-UART with esptool.py and four binaries at fixed offsets. From then on the ESP carries the AVR image embedded (Part I · stage 4), so it can re-flash the bottom board itself.

A · STM32 / AVR — programmer + ELF

  • Bench programmer clocks the ELF into the bottom-board MCU over SWD/ISP.
  • One image per hardware revision — the doc's board version column (e.g. 1448, 13, 1480).
  • Not in this repo — the repo only carries the compiled grizzle*.bin the ESP uses for field re-flash.

B · ESP32 — esptool.py

# chip esp32 · DIO · 80 MHz · 4 MB
esptool.py --chip esp32 --before default_reset \
  --after hard_reset write_flash \
  --flash_mode dio --flash_freq 80m \
  0x1000    bootloader.bin \
  0x8000    partition-table.bin \
  0xe000    ota_data_initial.bin \
  0x1f0000  SparkChargerEsp.bin

Flash map

offsetfile
0x1000bootloader.bin
0x8000partition-table.bin
0xe000ota_data_initial.bin
0x1f0000SparkChargerEsp.bin (app, 1.88 MB)

Partitions partitions.csv

namesize
nvs0x5000
otadata0x2000
app0 / app1 (ota_0/1)0x1E0000 ea
spiffs0x2F000
sernr (nvs_keys)0x1000

The bridge to Part I: on first boot the freshly-flashed ESP interrogates the AVR and, if the version stamp differs, walks it into its ROM bootloader and re-flashes it (stage 4). After P1 the board is a blank-but-alive charger — no variant identity yet. That's P2's job.

artifacts charger/build/flasher_args.json · charger/partitions.csv · sdkconfig 4MB/DIO/80m · AVR ELF = manufacturer bench step

P2

Factory configuration & functional test

On the floor this is an automated test fixture built by MicroArts. A motorized actuator lowers a bed of pogo pins onto both boards clamped in a jig — the top (ESP) board and bottom (AVR) board — and a Raspberry Pi 4 runs the whole sequence off a touchscreen. It first does bare PCB checks (resistance, rail voltages, connectivity), then configures the unit through two channels at once: an Arduino Mega for serial commands and an ESP32-WROOM bridged onto the charger's Wi-Fi for the local API. A scanned barcode prefix picks the variant app, and the log uploads to GitHub.

The MicroArts test fixture — data flow

Raspberry Pi 4 B+touchscreen · barcode app Arduino Mega 2560serial path PZEM-004T240V sensor ESP32-WROOM-32EWi-Fi API path GitHubSPARK_PILOT_BUILD TEST JIG · UUT  ↓ pogo pins Top board · ESP layer Bottom board · AVR layer 240V feed · TP104–110 USB USB Serial1 p18/19 · isolator (UART1) Wi-Fi API 192.168.4.1 240V sense log upload

Fixture test sequence

1 · Resistanceactuator down 2 · Voltagepower auto-ON 3 · LEDoperator Y/N 4 · Commserial + API 5 · Wi-FiLEDs white Y/N 6 · Upload→ GitHub

Serial path · Arduino Mega

  • USB from the Pi; reaches the top board's UART1 via Serial1 (pin18 TX1 / pin19 RX1) through a digital isolator to connector P19.
  • Commands: avr_update, set_serial:AR 000000, wifi_config:SSID;PW, reset.
  • PZEM-004T AC sensor on the Arduino reads the live 240 V for calibration.
  • This is the same UART1 "gateway" channel the firmware exposes (Part I · stage 5).

API path · ESP32-WROOM

  • USB from the Pi; the tester ESP joins the charger's Wi-Fi AP and issues the local HTTP API.
  • /read_voltage, /voltageoffset=±x, /max_current_limit=, /back_end_ocpp_url=, /chargerInfo.
  • Editable in Soneil_ESP.ino (API) & Soneil_arduino.ino (serial).

PCB test points pogo pins

TPnetboard
TP104CP_OUTbottom
TP10512Vbottom
TP1063.3Vbottom
TP107-12Vbottom
TP10812Vtop
TP1093.3Vtop
TP110L1 (240V)power

Fixture equipment

  • Raspberry Pi 4 B+ + Waveshare 4.3″ DSI touchscreen
  • Arduino Mega 2560 Pro — serial to board
  • ESP32-WROOM-32E — Wi-Fi API to board
  • PZEM-004T AC voltage sensor
  • Motorized actuator + pogo-pin bed; per-variant apps Soneil_AR/BR/BC/CR/CC.py

Variant matrix — driven by the serial prefix

variantprefixmax Abackend URLboard verAVR fileplug&play
40A NEMA residentialAR40api.soneilspark.io:8887144840True
40A NEMA res · NACSNAR40api.soneilspark.io:8887144840True
48A HW residentialBR48api.soneilspark.io:88871348True
48A HW res · NACSNBR48api.soneilspark.io:8887144848True
48A HW commercialBC48ocpp.io1348False
80A HW residentialCR80api.soneilspark.io:8887148080True
80A HW commercialCC80ocpp.io148080False

Configuration operations applied

Flash + identity serial phase

  • clear_SPIFFS_avr_firmware_file
  • update_avr_with_{40|48|80}_amps_file → wait ~15s
  • avr_passcode (sync L:10 max current)
  • SetSerialNumber= → SSID becomes Spark_<last4>

Apply variant settings

  • max_current_limit=40|48|80
  • back_end_ocpp_url= (res vs. commercial)
  • Enable|Disable_Offline_Dumb_Charging
  • avr_hardware_board_version=

Voltage calibration 240V ±5V

  • voltageoffset=1 → reset to raw
  • read_voltage → uncalibrated
  • voltageoffset=x → apply ± offset
  • read_voltage → confirm within ±5V

Verify & reboot-proof

  • Wifi_cred?ssid&password → reboot, expect wifi_status=1
  • OfflineMode → reboot, expect wifi_status=0
  • chargerInfo / get_config_data / avr_logs
  • re-check serial, max A, URL, STMVersion, flags after reboot

→ Results log — two sinks

The MicroArts fixture pushes its log to GitHub (repo SPARK_PILOT_BUILD) via the DATA button. Santhosh's companion Windows app (spark_charger_tester, PyQt6) runs the same config over Wi-Fi and appends a row to a Google Sheet (Apps Script webhook). Same fields either way:

Timestamp · Hostname · Serial · Max Current · Backend URL · Board Version
AVR Flash · Offline Dumb · Voltage Cal · Wifi SSID · Verdict · Details
# e.g. 2026-02-27 15:27:21 ; Santhosh ; 40252203539011 ; 48 ; wss://ocpp.io ; 12 ; 48 ; Enabled ; Yes ; Soneil ; PASS ; "…12 line-item checks…"

spark_charger_AP_app/spark_charger_tester_03-03-2026.py · AutomationWorker 810–1454 · variants 5034–5045 · webhook 630–645

P3

Final load test — the ETL rig

Now assembled, the charger meets a real load. A Raspberry-Pi Python orchestrator conducts the test but touches no mains itself: it commands an ESP32 tester board (Arduino, serial @ 921600). Real power comes from a transformer whose 204 / 240 / 264 V taps are switched by the relay bank, which routes AC into the charger and emulates the car's connector states. The charger's EVSE output then feeds a real electronic load driven over MODBUS-RTU, while the RPi captures the charger's waveform on an oscilloscope.

Raspberry Piorchestrator Google Sheetlive results webhook ESP32 testerrelays · MODBUS Transformer240V · taps 204/240/264 Relay bankV-select · car emulate Charger under testassembled unit Electronic loadMODBUS RTU · 38400 Oscilloscopewaveform capture AC AC in EVSE output → load 921600 relay ctrl Wi-Fi/Telnet MODBUS RS485 → load probe waveform → RPi

Power path — follow the amber

A real transformer supplies mains, tapped to 204 / 240 / 264 V. The relay bank picks the tap and line and emulates the car's connector states, then feeds that AC into the charger under test. The charger's EVSE output is what the electronic load pulls current from (set/measured over MODBUS). So the relay bank sits on both sides — source-select in, car-emulate out — while the ESP tester only ever sends control signals (the thin lines), never power.

Electronic load · MODBUS-RTU

RS485 · 38400 8N1 · slave 0x01 · CRC-16

opfn · regrange
Read voltage03 · 0x113AV
Read current03 · 0x1028×0.0001 A
Read power03 · 0x1046W
Load ON/OFF05 · 0x0802coil
Set voltage06 · 0x11F6200–300 V
Set current06 · 0x11F40–81 A ×10

ESP tester — relay vocabulary

  • Voltage v1/v2/v3 → 204 / 240 / 264 V, voff
  • Connector state a/b/c → Available / Connected / Charging
  • Resistor bank r1…r12 — 3 banks × 4 steps
  • Line select line1/line2; faults groundfault/highcurrent
  • Load loadon/loadoff · setv · setc · readv/readc/readp
  • Drive charger avrstart=L:03:01 · avrstop=L:03:00 · avrcurrent==L:05:<A×100>

Oscilloscope: not in the ESP firmware — the RPi owns it (USBTMC/SCPI) so it can grab the charger's live output waveform at each load step and file it alongside the pass/fail. Each run posts to a Google Sheet via webhook — the same monitoring pattern as P2 — so load-test verdicts and waveforms are tracked live per serial number.

ESP32_relay_board_updated_resistor_bank_12-02-2026.ino · MODBUS 171/221–401 · relays 213–732 · serial 921600 (855) · MODBUS 38400 (874)

P4

Certify & ship

The last gate is human + paperwork. A unit only advances if every station's verdict is PASS and its unique serial resolves cleanly in the sheet. Then it's sealed, ETL-listed for safety, boxed, and moved to the warehouse — ready for a customer whose very first act (Part I · stage 5) is to pair it with the Spark app.

Gate

Verdict = PASS

P2 config + P3 load test both green in the Google Sheet, keyed by the board's unique serial.

Listing

ETL / safety

Assembled unit carries its safety listing; the load-test waveforms are the evidence trail.

Out the door

Warehouse → customer

Sealed and shelved. The loop closes when the customer powers it on and the product tour begins.

Full circle: a shipped charger re-enters Part I — hotspot pairing, OCPP onboarding to the CSMS, and OTA updates that can re-flash both processors in the field without ever returning to this line.