feat(native): build and test the safety firmware on the host - #57
Draft
jeremypoulter wants to merge 4 commits into
Draft
feat(native): build and test the safety firmware on the host#57jeremypoulter wants to merge 4 commits into
jeremypoulter wants to merge 4 commits into
Conversation
ESRAPI_BUFLEN and TMP_BUF_SIZE were selected with #ifdef TARGET_SAMD while the #error guards that check them assert on MCU_ID_LEN. Any new target with a 16-byte MCU id therefore failed to build, even though the requirement it violated was expressed purely in terms of the id length. Key the selection on MCU_ID_LEN so a target inherits the right buffer from the property that actually drives it. Both existing targets are unchanged: a 16-byte id still selects 40/48, a 10-byte id still selects 32/34, so AVR RAM cost stays zero (ESRAPI_BUFLEN sizes three buffers there). Verified with temporary compile-time assertions pinning the expected values per target, including a deliberate wrong value to confirm the assertions could fail. Clean builds of m328p_core, m328p_LCD_WIFI and samd are byte-identical to baseline: m328p_core text 22752 data 256 bss 684 m328p_LCD_WIFI text 26834 data 348 bss 698 samd text 46716 data 560 bss 4140 Both #error guards are retained. Include order was checked: target.h lands at open_evse.h:39 and rapi_proc.h is only included from open_evse.h:1442, so MCU_ID_LEN is defined before either block is evaluated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds firmware/targets/native, picked up by the existing firmware/targets/*/platformio.ini glob, so the safety firmware builds and runs as an ordinary Linux/macOS binary. The target models no hardware. DigitalPin and AdcPin read and write a small pin state block, and that block is the whole surface a driver -- the emulator, or a test harness -- needs in order to present the firmware with a world. The vehicle, the pilot response and the electrical model stay on the far side of that boundary. Phase 4 replaces the accessors with reads and writes that cross a control channel; nativeServiceIo() is already threaded through WDT_RESET() and both pin reads, which between them cover every millis() wait loop in the shared firmware. Two board modes, since the differences are constants plus one table: native_oev6 - 10-bit ADC, 10-byte MCU id, thresholds 875/780/690 native_nxt - 12-bit ADC, 16-byte MCU id, thresholds 3932/3517/3225 Gfi.cpp follows the m328p pulse loop rather than SAMD's tone()-and-spin: tone() is a no-op on the host, and the m328p form toggles a real pin with a wait between every edge, so each edge is observable and the loop exits as soon as the trip arrives. EEPROM is backed by a file ($OPENEVSE_EEPROM, default ./openevse_eeprom.bin), initialised to 0xFF so "never configured" detection behaves as it does on hardware. Both modes build and run. RAPI over stdio answers correctly: $GV -> $OK 9.3.0.native 6.0.0 $GI -> $OK E0E1E2E3E4E5E6E7E8E9 (oev6, 10 bytes) $GI -> $OK E0E1E2E3E4E5E6E7E8E9EAEBECEDEEEF (nxt, 16 bytes) $GS -> $OK 09 0 00 4200 The 16-byte $GI reply also exercises the MCU_ID_LEN-derived buffer sizing from the previous change on a target that did not exist when it was made. Boot reports postcode 09 (GFI self-test failed) and settles in state 09. That is correct for a skeleton: the firmware runs its real self-test, pulses the coil, and nothing answers because there is no driver yet. It clears once the control channel can raise the GFI line. RAPI is on stdio rather than a PTY for now; the PTY belongs with the emulator work, and stdio makes the build drivable from a pipe in the meantime. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A line-oriented protocol on a Unix socket carrying pin levels, ADC counts
and pilot state, so a driver -- the emulator, or a test harness -- presents
the firmware with a world. Enabled by $OPENEVSE_HW_SOCKET; unset, the
firmware runs standalone exactly as before.
The protocol carries no semantics. Values and edges only, with symbolic
signal names so a driver never needs this target's pin numbering and a
session stays readable under socat.
Three things the firmware turned out to require, none of them obvious from
the outside:
- Digital pin state had to move into EpoxyDuino's own store. The skeleton
kept a private array, but checkInterrupts() polls digitalRead(), so a
GFI edge raised in that array could never have dispatched gfi_isr().
One store means the interrupt path works as hardware does.
- channelService() is registered as a yield service callback, not only
called from WDT_RESET() and the pin reads. Gfi::SelfTest pulses GFITEST
with nothing but delayMicroseconds() between the edges, so without that
registration the pulse is never published while it is still high and no
driver can answer it.
- ADC channels carry two levels, not one. The firmware samples the pilot
and the ammeter as waveforms: ReadPilot() takes min and max over
PILOT_LOOP_CNT samples and fails the diode check unless the negative
excursion is present (plow < m_ThreshDS), and readAmmeter() derives RMS
from peak-to-peak. A single level cannot represent either. Successive
reads alternate; which levels to present stays with the driver.
Also fixes SetPWM, which scaled duty by 10x -- 24A reported 400% rather
than 40%. Now matches J1772 across both ranges: 6A=10.0%, 24A=40.0%,
32A=53.3%, 51A=85.0%, 52A=84.8%, 80A=96.0%.
With a channel configured the firmware now waits for a driver before
running its power-on self tests (OPENEVSE_HW_WAIT_MS, default 5000, 0 to
skip). Booting ahead of the driver ran the GFI test against nothing and
latched GFI_TEST_FAILED before anything could answer, which looked like a
firmware fault rather than an absent bench.
tools/drive_example.py is a throwaway driver, not the harness: enough of a
fake bench to show the loop closes. On native_oev6, five runs out of five:
boot postcode 00 (GFI self-test answered and passed)
$AT 01 state A
$AT 02 state B, pilot PWM 40.0% at 24A
$AT 03 state C, CHARGING relay closed
$AT 06 GFI tripped mid-charge, relay opened
native_nxt builds, boots, and passes its GFI self-test, but stops at
$AT 07 in doPost: with hasCGMI() true the ground check wants a bench the
example driver does not model. That is a driver-side gap rather than a
channel one, and belongs with the harness work.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A bench that launches the firmware, drives its hardware channel and speaks
RAPI, so tests can put the real firmware in a situation and assert on what
it decides. Ten tests covering the cases that are awkward or destructive to
stage on hardware:
- GFI self-test passes when the detector answers
- GFI self-test fails when the detector is dead (postcode 09, no charging)
- a session reaching state C with the relay closed
- pilot duty matches ampacity at 6/16/24/32A
- GFI trip mid-charge opens the relay
- lost earth mid-charge opens the relay
- a welded contactor is detected as a stuck relay
The bench models the board the firmware is wired to -- the GFI detector
follows the test coil, the load-side AC sense follows the contactor, the
pilot is a square wave -- and nothing beyond it. Deciding what a pilot
voltage means stays with the firmware, or the tests stop proving anything.
Reactions run on their own thread, because the firmware pulses the GFI coil
and waits for the trip inside that loop; a bench that only reacted when a
test happened to poll would miss it.
Two things the firmware taught us while writing this:
- RAPI replies with no parameters put the checksum hard against the code,
as in "$OK^20", so responses have to be matched by prefix rather than as
a whitespace-separated token.
- A non-CGMI board calls bad ground only on acpinstate == ACPINS_OPEN,
both sense pins, never on the ground pin alone. The sense circuits are
ground-referenced, so the bench drops both lines when earth is lost.
Modelling only ACLINE2 left the firmware charging happily.
Bench.rapi() waits for the reply by default. A command that changes what the
firmware advertises takes effect when the firmware reads it, so a test that
fires and moves on samples the old value -- that is exactly how the ampacity
test failed first time round.
EpoxyTest::Script was considered and does not fit: it replays a timeline
("at 100ms pin D1 1") and cannot react to what the firmware does or observe
RAPI. It stays a good candidate for injecting the NXT zero-cross waveform,
which is genuinely timed rather than reactive.
Verified the suite fails when it should, by reintroducing the 10x SetPWM
duty bug: all four ampacity cases fail, and pass again once reverted. Three
consecutive runs, 10 passed each.
CI gains native_oev6 (build plus suite) and native_nxt (compile only -- the
bench does not model a CGMI board yet, so the session tests stop at the POST
ground check there).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds
firmware/targets/native, picked up by the existingfirmware/targets/*/platformio.iniglob, so the safety firmware builds and runs as an ordinary Linux/macOS binary — and adds a test suite that drives it.Note
Stacked on #56 and includes its commit. Merge that one first; this PR shows a clean diff once it lands.
Also needs the EpoxyDuino fixes in jeremypoulter/EpoxyDuino#1, which
platformio.inicurrently pins by branch. That pin should move todevelopbefore merge.The target models no hardware
DigitalPinandAdcPinread and write a small pin state block, and a line-oriented protocol on a Unix socket exposes that block to a driver — the emulator, or a test harness. The vehicle and the electrical model stay on the far side of that boundary. Nothing here decides what a pilot voltage means.Two board modes, since the differences are constants plus one table:
The 16-byte
$GIreply in NXT mode also exercises #56 on a target that did not exist when that change was made.Three things the firmware required
checkInterrupts()pollsdigitalRead(), so a GFI edge raised anywhere else could never dispatchgfi_isr().channelService()is a yield service callback.Gfi::SelfTestpulses GFITEST with nothing butdelayMicroseconds()between edges, so otherwise the pulse is never published while it is still high and no driver can answer it.ReadPilot()fails the diode check unlessplow < m_ThreshDS, andreadAmmeter()derives RMS from peak-to-peak. A single level cannot get past the firmware's own diode check.Gfi.cppfollows the m328p pulse loop rather than SAMD'stone(), which is a no-op on the host.Tests
Ten tests covering what is awkward or destructive to stage on hardware — GFI self-test failure, lost earth mid-charge, a welded contactor, pilot duty at 6/16/24/32A. Verified they fail when they should, by reintroducing a 10x
SetPWMduty bug: all four ampacity cases failed, then passed on revert.CI gains
native_oev6(build plus suite) andnative_nxt(compile only — the bench does not model a CGMI board yet, so the session tests stop at the POST ground check there).The three existing targets build unchanged.
🤖 Generated with Claude Code