Skip to content

Repository files navigation

cabin_guard_2_monitor_testprogrammer

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.

Modules

image_verify

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_version word like the firmware's, set bootloader_version_addr in VerifyConfig and 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-bootloader

Release history

image_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.

hex_merge

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_verify passes — 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.

Running

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 nothing

Exit 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.

Output

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.

Using it from other code

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 off

Verifying without generating anything:

from image_verify import VerifyConfig, verify

report = verify(VerifyConfig())
if not report.ok:
    print(report.summary())                   # report.errors, .warnings, .firmware_version

Configuration

merge_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".

device_program

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 too

The production flow

Programming 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.

FTDI wiring for BOOT0 and NRST

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.

What it does

  1. Enters the system bootloader (auto via DTR/RTS, or waits in --manual).
  2. Identifies the chip and refuses anything that is not 0x480 (STM32H7A3xx/B3xx) unless --any-chip is given.
  3. Erases all of bank 1, 0x08000000-0x0807FFFF, as 64 sectors of 8 KB. Bank 2 — FLASH2 and the settings at 0x0817E000 — is left intact, so calibration survives reprogramming. --erase-touched-only erases just the sectors the image covers; --mass-erase wipes everything.
  4. Writes the image as 128-bit-aligned blocks, filling small gaps with 0xFF and skipping large ones.
  5. Reads flash back and compares (--no-verify to skip), reporting the first differing address on failure.
  6. Drives BOOT0 low, asks for the power cycle, and reads the boot banner to confirm the image runs (--no-run to stay in the bootloader).

Why the reset needs an operator

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.

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.

Requirements

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.txt

The 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.txt

Deliberately 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.

About

test and programming tool for the cabin guard 2 project

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages