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.
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).
Contactor
Physically closes the AC relay only on an ENABLE_CHARGING command and an intact pilot handshake.
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
| value | meaning | maps to |
|---|---|---|
| 0 | Standby / car disconnected | Available |
| 1 | Connected (plugged, not charging) | Preparing |
| 2 | Charging | Charging |
| 3 | Paused (CP = 9V) | SuspendedEV |
| 5 | Charge complete | Finishing |
| 8 | Plug over-temperature | Faulted |
| 9 | GFCI / ground fault | Faulted |
| 10 | Over-voltage | Faulted |
| 11 | Over-current | Faulted |
| 12 | High temperature | Faulted |
| 13 | Ground missing | Faulted |
| 255 | Unknown error | Faulted |
connector_grizzle.cc · state parsing ~3811–3930 · command 4 = STATE
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.
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.
AVR → ESP · telemetry
| cmd | field | encoding |
|---|---|---|
| 4 | State | code 0–255 |
| 9 | Actual current | ×100 (A) |
| 8 | Line voltage | /100 (V) |
| 10 | Max available current | ×100 (A) |
| 5 | Set-current echo | ×100 (A) |
| 14 | AVR version | ASCII e.g. 7620:B18B |
ESP → AVR · commands
| cmd | name | wire |
|---|---|---|
| 5 | UPDATE_CURRENT | L:05:1600 |
| 3 | ENABLE_CHARGING | L:03:01 |
| 4 | DISABLE_CHARGING | L:03:00 |
| 6 | SET_MAX_CURRENT | L:06:<n> |
| 0 | GET_STATE | L:00:00 |
| 9 | RESET | L: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
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.
- Interrogate — open
UART_NUM_2@ 38400, read version1+version2; a.3suffix means STM v2 → usegrizzle2.bin, else v1 →grizzle.bin. - Enter bootloader — send
JUMP_TO_BOOT(8) orJUMP_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 from0x08000000→ GO (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.
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.
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.
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
/chargerInfoserial, fw, url, max A, V/GetMeterValueenergy_wh, V, A, W/avr_logslive AVR telemetry/wifi_status·/read_voltage/get_config_datafull config
Pairing / Wi-Fi
/readANscan 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_OTAmultipart upload/AvrUpdateupload 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
| command | action |
|---|---|
| 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_amps | Re-flash AVR from embedded bin |
| enable/disable_plug_and_play | Toggle offline "dumb" charging |
| voltage_offset: / current_offset: | Calibration |
| reboot_AVR / reboot_charger / reset | Reset controls |
| charger_info | Emit serial + combined fw version |
portal_grizzle.cc endpoints · main.cc uart_command_task 207–455 · AP config platform_esp.cc 779–786
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.
Wi-Fi STA
Charger → (optional TP-Link extender) → InHand router → internet. DHCP-assigned IP, no static config, no mDNS.
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.
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
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.
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
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).
FTP client
ftplib parses ftp[s]://user:pw@host/file.bin, streams in 1 KB chunks, optional TLS.
Dual partition
esp_ota_begin/write/end to the next partition; checksum-validated; set_boot_partition then reboot; rollback on failure.
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
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.comor 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)
connector_grizzle.cc MQTT init 599–629, discovery 498–580, telemetry 642–680 · toggle portal_grizzle.cc 1446/1491
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.
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.
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*.binthe 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
| offset | file |
|---|---|
| 0x1000 | bootloader.bin |
| 0x8000 | partition-table.bin |
| 0xe000 | ota_data_initial.bin |
| 0x1f0000 | SparkChargerEsp.bin (app, 1.88 MB) |
Partitions partitions.csv
| name | size |
|---|---|
| nvs | 0x5000 |
| otadata | 0x2000 |
| app0 / app1 (ota_0/1) | 0x1E0000 ea |
| spiffs | 0x2F000 |
| 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
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
Fixture test sequence
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
| TP | net | board |
|---|---|---|
| TP104 | CP_OUT | bottom |
| TP105 | 12V | bottom |
| TP106 | 3.3V | bottom |
| TP107 | -12V | bottom |
| TP108 | 12V | top |
| TP109 | 3.3V | top |
| TP110 | L1 (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
| variant | prefix | max A | backend URL | board ver | AVR file | plug&play |
|---|---|---|---|---|---|---|
| 40A NEMA residential | AR | 40 | api.soneilspark.io:8887 | 1448 | 40 | True |
| 40A NEMA res · NACS | NAR | 40 | api.soneilspark.io:8887 | 1448 | 40 | True |
| 48A HW residential | BR | 48 | api.soneilspark.io:8887 | 13 | 48 | True |
| 48A HW res · NACS | NBR | 48 | api.soneilspark.io:8887 | 1448 | 48 | True |
| 48A HW commercial | BC | 48 | ocpp.io | 13 | 48 | False |
| 80A HW residential | CR | 80 | api.soneilspark.io:8887 | 1480 | 80 | True |
| 80A HW commercial | CC | 80 | ocpp.io | 1480 | 80 | False |
Configuration operations applied
Flash + identity serial phase
clear_SPIFFS_avr_firmware_fileupdate_avr_with_{40|48|80}_amps_file→ wait ~15savr_passcode(sync L:10 max current)SetSerialNumber=→ SSID becomesSpark_<last4>
Apply variant settings
max_current_limit=40|48|80back_end_ocpp_url=(res vs. commercial)Enable|Disable_Offline_Dumb_Chargingavr_hardware_board_version=
Voltage calibration 240V ±5V
voltageoffset=1→ reset to rawread_voltage→ uncalibratedvoltageoffset=x→ apply ± offsetread_voltage→ confirm within ±5V
Verify & reboot-proof
Wifi_cred?ssid&password→ reboot, expectwifi_status=1OfflineMode→ reboot, expectwifi_status=0chargerInfo/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
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.
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
| op | fn · reg | range |
|---|---|---|
| Read voltage | 03 · 0x113A | V |
| Read current | 03 · 0x1028 | ×0.0001 A |
| Read power | 03 · 0x1046 | W |
| Load ON/OFF | 05 · 0x0802 | coil |
| Set voltage | 06 · 0x11F6 | 200–300 V |
| Set current | 06 · 0x11F4 | 0–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; faultsgroundfault/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)
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.
Verdict = PASS
P2 config + P3 load test both green in the Google Sheet, keyed by the board's unique serial.
ETL / safety
Assembled unit carries its safety listing; the load-test waveforms are the evidence trail.
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.