Tooling for the production / test programming station of the cabin guard 2 monitor. Every capability lives in its own self-contained Python module so the modules can later be composed into one programmer application.
Reads the bootloader and main firmware versions and decides whether a new
combined image may be generated at all. hex_merge calls it as its
precondition; it also runs on its own.
| version | read from | role |
|---|---|---|
| firmware | the .fw_version word at 0x0807FFFC in the hex, cross-checked against version.h |
hard gate |
| bootloader | SOFT_MAYOR / SOFT_MINOR in the bootloader's main.c |
advisory |
The rule: a new image is only generated when the main firmware version is
higher than the highest one ever recorded in image_history.json. Equal or
lower fails and nothing is written.
Further firmware checks, all fatal: version 0.0 (never bumped), a missing or
magic-mismatched version word, and a binary that disagrees with version.h
(a stale build — the gate would otherwise judge a version that is not in the
image).
The bootloader version is reported and recorded but never blocks: it changes
rarely and the release is identified by the firmware version. Its findings —
unreadable, 0.0, source newer than the hex, or lower than the last release —
are warnings. --strict-bootloader promotes them to errors.
The bootloader has no version section: its macros end up as immediate values in a trace call, so they cannot be read back from the binary and the source is trusted (guarded only by a timestamp comparison). If the bootloader ever gains a
.bl_versionword like the firmware's, setbootloader_version_addrinVerifyConfigand it is read from the image instead — no other change needed.
python -m image_verify # report + exit code (0 pass, 1 fail)
python -m image_verify --history # list every recorded release
python -m image_verify --allow-same-version
python -m image_verify --allow-stale --strict-bootloaderimage_history.json in this directory records every generated image: both
versions, timestamp, hex name and the CRC32s. It is not in output/ and
is meant to be committed — it is what "higher than last time" is measured
against. The bar is the highest version ever recorded, so a forced
re-release of an older version cannot lower it.
Merges the bootloader and the firmware hex into one combined programming image.
The two images occupy disjoint flash regions, so merging is a plain address-space union — no relocation and no gap filling:
| image | region | source (linker script) |
|---|---|---|
| bootloader | 0x08000000, 32K |
cabin_guard_2_monitor_bootloader |
| firmware | 0x08008000, 480K |
cabin_guard_2_monitor_fw (incl. version word at 0x0807FFFC) |
Before writing anything it checks that
- both hex files exist, parse and contain data;
- each image stays entirely inside its own region;
- the firmware starts exactly at
0x08008000(wrong linker script otherwise); - the two images do not overlap anywhere;
image_verifypasses — see the version gate above.
Non-fatal observations become warnings: a hex older than its elf (stale build) and a missing or magic-mismatched firmware version word.
The combined image keeps the bootloader's start-address record, since that is what the CPU boots.
python -m hex_merge # defaults, VisualGDB Debug builds
python -m hex_merge -c merge_config.json # explicit config
python -m hex_merge --firmware path/to/other.hex # ad-hoc override
python -m hex_merge --bin # also emit a raw .bin
python -m hex_merge --strict # warnings become exit code 2
python -m hex_merge --force # generate despite a failed gate
python -m hex_merge --no-verify # skip the gate, record nothingExit codes: 0 ok, 1 validation error or failed version gate (nothing
written), 2 warnings with --strict.
A successful run appends the release to image_history.json, which is what the
next run's version gate compares against. --force still records the release,
marked "forced": true; --no-verify records nothing at all.
Written to output/, named after the firmware version read from
0x0807FFFC:
cabin_guard_2_monitor_v0.3_full.hex combined image, sparse (gaps not padded)
cabin_guard_2_monitor_v0.3_full.txt manifest: sources, timestamps, sizes, CRC32 per region, verification report
cabin_guard_2_monitor_v0.3_full.bin only with --bin; gaps padded with 0xFF
A raw .bin cannot express gaps, so that one is padded — the .hex is not.
from hex_merge import MergeConfig, MergeError, merge
config = MergeConfig()
config.firmware_hex = release_build_path # any field can be overridden
try:
result = merge(config)
except MergeError as exc:
... # gate failed or invalid images; nothing written
else:
program_device(result.output_hex) # result.version, .warnings, .combined.crc32
print(result.verification.summary()) # None when verify_versions is offVerifying without generating anything:
from image_verify import VerifyConfig, verify
report = verify(VerifyConfig())
if not report.ok:
print(report.summary()) # report.errors, .warnings, .firmware_versionmerge_config.json documents every overridable key; anything omitted falls
back to the defaults in hex_merge/config.py. Paths in the file are resolved
relative to the file itself, and addresses/lengths accept 1024, "0x08008000"
or "480K".
Flashes a combined image into a monitor over the trace UART (USART3 on
PD8/PD9), driving the STM32 system bootloader via
stm32loader.
python -m device_program -p COM5 # production flow: newest image in output/
python -m device_program -n # dry run: full plan, no hardware touched
python -m device_program -p COM5 --soft-reset # board already runs firmware: no power cycles
python -m device_program -p COM5 --console 10 # watch the device's trace output
python -m device_program --mass-erase # factory-fresh, wipes bank 2 settings tooProgramming a blank MCU rules out every clever shortcut: there is no firmware yet to command a reset with, and this fixture's adapter has only one usable output. So power is the reset, and the run is a dialogue:
==============================================================
ACTION REQUIRED — power on the board now
BOOT0 is held high via RTS, so it starts in the ST bootloader
==============================================================
... erase, write, verify ...
==============================================================
ACTION REQUIRED — power-cycle the device now
BOOT0 is held low via RTS, so it will boot the new image
==============================================================
device booted:
| Linden Cabin Monitor bootloader v0.1 built Jun 4 2026
| Linden Cabin Monitor v0.3 built Aug 4 2026
The programmer holds BOOT0 at the right level across both power cycles and
confirms the result by reading the boot banner, so booted : yes in the
summary means the image actually ran — not just that it was written.
Wire NRST as well (to DTR) and --nrst-wired removes both prompts.
The FTDI chip's two modem-control outputs replace the jumper and the reset button:
| FTDI pin | board signal | net |
|---|---|---|
| TXD | trace RX (PD9) | uart_trace_rx |
| RXD | trace TX (PD8) | uart_trace_tx |
| RTS | BOOT0 | TP19 |
| GND | GND |
Only RTS and DTR can drive anything. They are outputs on the adapter; CTS and DSR are inputs, so BOOT0 wired to CTS can never be pulled high, whatever the software does. The adapter in this fixture breaks out CTS and RTS but no DTR, which leaves exactly one controllable line — hence BOOT0 on RTS and power as the reset.
Two consequences worth knowing:
- RTS idles high. With no program holding the port, BOOT0 sits high and the board starts in the ST bootloader — blank screen, silent console. A pull-down on BOOT0 makes low the resting state and lets the adapter pull it high only when it wants to. Recommended for the bench.
- Changing baud or parity on an open port releases the modem lines. The
driver rebuilds its settings and BOOT0 drifts high, so a reset right after a
protocol switch lands in the ST bootloader instead of the application. The
level is re-applied after every switch (
_restore_boot0); this cost a long debugging session and is not obvious from anywhere else.
The polarity is the part to get right. DTR and RTS are RS-232 modem
signals, and they are active low at the pin: pyserial's setDTR(1) asserts
DTR, which drives the FTDI output low. stm32loader compensates for that
inversion internally, so with the direct wiring above and its three flags left
at their defaults the levels come out correct:
| call | pyserial | FTDI pin | effect on the board |
|---|---|---|---|
enable_reset(True) |
setDTR(1) |
low | NRST low → MCU held in reset |
enable_reset(False) |
setDTR(0) |
high | NRST released → MCU runs |
enable_boot0(True) |
setRTS(0) |
high | BOOT0 high → boots system memory |
enable_boot0(False) |
setRTS(1) |
low | BOOT0 low → boots flash |
reset_from_system_memory() then does: BOOT0 high → NRST low → NRST high →
send 0x7F to sync. After programming, reset_from_flash() drops BOOT0 and
resets again, so the device starts the image it just received.
Three flags cover fixtures that differ: --swap-rts-dtr (RTS on NRST, DTR on
BOOT0), --reset-active-high and --boot0-active-low (a line goes through an
inverting transistor buffer). --manual leaves both lines alone entirely.
Points to watch when building the harness:
- Use a 3.3 V FTDI adapter (VCCIO at 3.3 V). BOOT0 and NRST on the H7A3 are not 5 V tolerant.
- Drive NRST through a series resistor (~100 Ω) or an open-drain buffer so the adapter does not fight the on-board reset supervisor.
- Opening the serial port asserts DTR and RTS on Windows, so the board resets the moment the programmer connects — expected here, but worth knowing when the same adapter is used as a trace console.
- Enters the system bootloader (auto via DTR/RTS, or waits in
--manual). - Identifies the chip and refuses anything that is not
0x480(STM32H7A3xx/B3xx) unless--any-chipis given. - Erases all of bank 1,
0x08000000-0x0807FFFF, as 64 sectors of 8 KB. Bank 2 — FLASH2 and the settings at0x0817E000— is left intact, so calibration survives reprogramming.--erase-touched-onlyerases just the sectors the image covers;--mass-erasewipes everything. - Writes the image as 128-bit-aligned blocks, filling small gaps with
0xFFand skipping large ones. - Reads flash back and compares (
--no-verifyto skip), reporting the first differing address on failure. - Drives BOOT0 low, asks for the power cycle, and reads the boot banner to
confirm the image runs (
--no-runto stay in the bootloader).
There is no way to start the image over UART on this chip. Everything was tried on the actual hardware:
| attempt | result |
|---|---|
Go to 0x08000000 |
acknowledged, then the device hangs — also with BOOT0 verified low |
Write Memory → AIRCR (SYSRESETREQ) |
NACK on the address; the bootloader only accepts flash, RAM and OTP |
Write Memory → IWDG registers |
same refusal |
Write Unprotect (documented to end in a system reset) |
two ACKs, no restart, and it costs an option-byte write |
| power cycle | always works |
The Go failure is a known STM32H7 bootloader limitation, not a fault in this
tooling: it depends on the ROM bootloader revision, and ST's own write-up
explains that entering the bootloader by hardware (BOOT0 + reset) leaves part
of the system unavailable to the code that Go jumps into. Their advice is a
hardware reset — or entering the bootloader by jumping from application code,
which is no help when programming a blank MCU.
- STM32H7 is stuck in system bootloader when trying to jump to the application
- STM32H723 embedded bootloader command GO fail
- Understanding the bootloader issue with the GO command
Two stm32loader details this module deliberately works around: its
pages_from_range()divides the absolute address by the page size (0x08000000 // 0x20000 = 1024), which is not a valid sector number, and its H7 page size is the H743's 128 KB while the H7A3 uses 8 KB sectors. Sector numbers are therefore computed here, from the offset into flash.
Python 3.10+ is the only thing a bench station needs installed by hand. The
packages — intelhex,
pyserial and
stm32loader — are pinned in
requirements.txt, and run.bat checks them on every start by importing
them. When one is missing it installs the set and carries on, so the first
run on a new station is also its setup.
run setup reinstalls the whole pinned set; that is the repair for a station
where a package is present but broken. By hand it is the usual:
pip install -r requirements.txtThe versions are pinned rather than left open so a reinstalled station ends up with the versions it was qualified with. On a bench without a network, carry them in on a stick:
pip download -r requirements.txt -d packages # on a machine with network
pip install --no-index --find-links packages -r requirements.txtDeliberately no virtualenv: this directory lives on a share, and a venv records absolute paths, so one created on a station would not work from another.