Bondtech INDX ⇄ Prusa Core One (xBuddy) — Interface Reference

The INDX toolhead is a Prusa "puppy": a satellite STM32C092 (Cortex-M0+) that acts as a
MODBUS-RTU server over half-duplex RS485. The xBuddy mainboard (STM32F427) is the client/host.
This doc is the pinout, the link layer, the register protocol, and the firmware-flashing (bootloader)
protocol needed to re-implement the host.

Source of truth for registers: src/module/indx_head/include/indx_head/modbus.hpp (Prusa-Firmware-Buddy
v6.6.0); wire encoding verified against the MODBUS core src/module/modbus/modbus/src/modbus/modbus.cpp.
See Byte & word order below before decoding.

Target host/flasher MCU: STM32G0B1KEU6 (Cortex-M0+, 512 KB flash / 144 KB RAM, UFQFPN-32;
~20 RON @ TME). 512 KB comfortably holds the ≤248 KB INDX app image plus the host/flasher firmware,
and being Cortex-M0+/little-endian like the head, the SHA-256 salt byte order matches natively.


1. Connector pinout

12-pin connector, 2 rows × 6, tab parallel to the 6-pin rows. Graphic is the as-probed layout
(README numbering, tab-down):

_             col1   col2   col3   col4    col5     col6
  top row │    1      3      5      7       9       11    │
          │   MOT    MOT   +24V   +24V    485 A    485 B  │
  bot row │    2      4      6      8      10       12    │
          │   MOT    MOT    GND    GND     GND     5V/RST │
          └───────────────────  tab  ─────────────────────┘
Pin(s) Signal Notes
1–4 Extruder stepper motor coils 2 phases; driven by the mainboard E-axis TMC2130
5, 7 +24 V tied together; powers induction heater + fans
6, 8, 10 GND
9 RS485 A (non-inv / "+") → head THVD1420
11 RS485 B (inv / "−") → head THVD1420
12 Switched 5 V + head reset = J5 FAN-1-OUT, a P-MOS 5 V switch gated by mainboard PE9

RS485 polarity is by TI convention (A = non-inverting); keep 9→9 / 11→11 straight through. If matching
the mainboard's RS485+/− labels, ring 9/11 to the mainboard transceiver rather than trusting labels.


RS485 physical layer

Mainboard (host, STM32F427) INDX head (STM32C092)
USART USART6 (PC6 TX / PC7 RX) USART2 (PA2 TX / PA3 RX)
DE/RE PB7 (RS485FlowControlPuppies) PB9 (USART2_RE_DE)
Transceiver on-board (+ DE PB7) TI THVD1420 (SOIC-8), bus on connector pins 9/11
Baud / framing 230400 8N1 230400 8N1
Duplex half-duplex half-duplex

Half-duplex turnaround (host must do this): assert DE (PB7 high) before transmitting, hold
through the frame, drop it (low = receive) on transmit-complete. Respect a ≥3.5-character idle gap
between frames (standard MODBUS-RTU inter-frame). The head adds ~2.5-char guard before it drives the
bus back. The bus is shared with the MMU port — only one device population at a time.

Head power / reset (PE9)

Connector pin 12 is a mainboard-switched 5 V rail that both powers and resets the head's logic
(the 24 V on 5/7 for heater/fans is independent). On INDX builds mainboard PE9 (indx_head_reset,
OutputPin_Inverted) gates the P-MOS for that rail:

There is no separate NRST pin and no always-on 5 V pin — pin 12 is the power+reset for the MCU.


3. MODBUS-RTU protocol (host re-implementation)

Transport

Item Value
Slave address (app mode) 0x22 (34)
Slave address (bootloader) 0x12 (only relevant if flashing/bringing up from reset)
Frame [addr][func][data…][CRC_lo][CRC_hi] (CRC low byte first)
CRC CRC-16/MODBUS: poly 0x8005, init 0xFFFF, reflected in/out
Validation CRC computed over the whole frame must equal 0; reject frames < 4 bytes
Function codes 0x03/0x04 read registers · 0x10 write multiple registers · 0x18 (24) read FIFO

Byte & word order (verified in source)

Status block — read, FC 0x04, base 0x8060 (25 registers)

Read the whole block in one transaction. Word offsets are relative to 0x8060; int16/uint16 are
one register each, uint32 values are split lo/hi across two registers. _c100 = 1/100 °C.

Off Addr Field Type Meaning
0 0x8060 fault_status uint16 bitmask fault flags (clear via Config)
1 0x8061 hotend_temp_uncompensated_c100 int16 raw nozzle temp
2 0x8062 hotend_temp_compensated_c100 int16 nozzle temp for control/display
3 0x8063 hotend_temp_raw_c100_dt_s int16 dT/dt 1/100 °C/s
4–5 0x8064 hotend_duty_cycle_sq_integral_us uint32 (lo,hi) ∫duty² dt µs
6–7 0x8066 hotend_energy_consumed_uJ uint32 (lo,hi) ∫V·I dt µJ
8 0x8068 tpis_ambient_temperature_c100 int16 thermopile cold-junction temp
9 0x8069 board_temperature int16 °C
10 0x806A mcu_temperature int16 °C
11–14 0x806B print_fan rpm / pwm / state / is_rpm_ok uint16 ×4
15–18 0x806F heatbreak_fan rpm / pwm / state / is_rpm_ok uint16 ×4
19 0x8073 system_24V_mV uint16 24 V rail mV
20–21 0x8074 time_sync uint32 (lo,hi)
22 0x8076 nozzle_present uint16 nozzle-presence enum
23 0x8077 nozzle_invalidation_ack uint16 echoes Config invalidate
24 0x8078 temps_valid uint16 0 until temps valid

Reading temperature: use hotend_temp_compensated_c100 (0x8062) ÷ 100 → °C. Gate on
temps_valid (0x8078)
first — until it is nonzero the head reports the default 2500 (25.00 °C),
picked to not trip mintemp. Once valid it only goes invalid on a head reset.

fault_status bits (0x8060) — uint16 bitmask (indx_head::errors::FaultStatusMask), higher bit
= higher priority; 0 = no fault. Clear a fault by writing its mask to clear_fault_status (0xE008).
The reset-cause bits tell the host why the head last restarted.

Mask Fault Mask Fault
0x0001 undefined_error 0x0100 watchdog_reset
0x0002 nozzle_min_temp 0x0200 pin_reset
0x0004 tpis_invalid_timeout 0x0400 power_reset
0x0008 nozzle_max_temp 0x0800 board_min_temp
0x0010 hard_fault 0x1000 board_max_temp
0x0020 stack_overflow 0x2000 tpis_ambient_min_temp
0x0040 assert_failed 0x4000 tpis_ambient_max_temp
0x0080 loadcell_crc_mismatch

Config block — write, FC 0x10, base 0xE000 (10 registers)

Write the whole block. nozzle_target_temperature is whole °C (note the asymmetry vs. the 1/100 °C
readings).

Off Addr Field Type Meaning
0 0xE000 nozzle_target_temperature uint16 heater setpoint °C, 0 = off
1 0xE001 print_fan_pwm uint8 + pad fan duty
2–3 0xE002 leds LedConfig (2 words) head LED config
4 0xE004 hotend_temperature_compensation_c100 int16 comp. offset 1/100 °C, host-computed
5 0xE005 invalidate_nozzle_presence uint16 force presence re-eval
6 0xE006 loadcell_enabled uint16 enable loadcell FIFO stream
7 0xE007 accelerometer_enabled uint16 enable accel FIFO stream
8 0xE008 clear_fault_status uint16 fault bits to clear from Status
9 0xE009 selftest_mode uint16 nonzero forces heatbreak fan full

Commanding the heater: the host sends only the setpoint (nozzle_target_temperature, °C) — no
duty cycle. The head runs the closed-loop induction control locally (turbo → limited → PID) and gates
its own coil with a nozzle-presence interlock. The host optionally streams a compensation offset
(hotend_temperature_compensation_c100, computed from a filament/flow model) which the head fades in
(τ ≈ 6 s) and subtracts to produce the compensated reading it reports back
(compensated = uncompensated − faded_offset). See Host-side temperature compensation below.

leds (LedConfig), 0xE002–0xE003 — packed 4-byte struct { uint8 r; uint8 g; uint8 b; uint8 mode },
mode ∈ {0 = off, 1 = solid, 2 = pulse}. Copied as raw little-endian struct bytes, so the register
values are 0xE002 = (g<<8)|r and 0xE003 = (mode<<8)|b (see Byte & word order).

Host-side temperature compensation (must be re-implemented)

The head measures and applies; the host computes the offset written to 0xE004. Recompute and
write every 100 ms, and always write a value — write 0 on any failure (safety scope-guard).

Offset in °C (ΔT = uncompensated_temp − chamber_temp):

offset = 0.15 × [  0.1445·√(fan_pwm/255)·ΔT            (fan)
                 + 0.9945·dTemp_dt                      (heating rate)
                 − 0.1169·ΔT                            (heatbreak)
                 + min( ΔT·(−0.12 + slope·0.0025·Cλ), 0 )   (filament) ]
         clamped to [−80, +80] °C,   slope = max( EMA₁ₛ(E_feedrate_mm_s) − 28/Cλ , 0 )
Input Source
uncompensated nozzle temp head Status 0x8061 (raw — not 0x8062, avoids feedback)
temp derivative °C/s head Status 0x8063
print-fan PWM (0–255) head Status
chamber temperature host sensor (default 25 °C)
extruder feedrate host E-stepper, retraction removed, EMA τ = 1 s
Cλ linear heat capacity J/(°C·m) host table: PLA 6.1 · PETG 4.3 · ASA 4.0 · PC 4.9 · PVB 4.5 · ABS 3.9 · HIPS 3.9 · PP 5.1 · FLEX 5.9 · PA 5.2 · unknown 5.8 · none 0

Write offset × 100 → int16 → reg 0xE004. Reset state on tool/filament change, puppy reset, or
!temps_valid; skip the first step (needs dt). The head handles the rest: raw thermopile → °C
(per-unit emissivity/calibration), the derivative, the τ ≈ 6 s fade-in, and applying it. Source:
src/feature/indx_hotend_temp_model/indx_hotend_temp_compensation.cpp.

Sensor FIFO — read, FC 0x18 (Read FIFO Queue), address 0x0000

Enable the stream via Config (loadcell_enabled 0xE006 / accelerometer_enabled 0xE007), then poll
the FIFO. Response: big-endian byte-count (= 2 + fifo_bytes), big-endian register-count, then up to
31 big-endian 16-bit words.

The payload is a self-describing message stream: each message = [1-byte type][fixed payload];
type 0 = padding (rest of the transfer is empty); a message never crosses a transfer. Types &
payloads: log=1 (8 B ASCII), loadcell=2 (u32 µs timestamp + u32 raw = 8 B, ~366 Hz),
accelerometer_fast=4 (2 × u32 packed XYZ), accel_sampling_rate=5 (float32 Hz). Reassemble the
byte stream from the FIFO registers low byte first (same LE convention as Byte & word order),
then parse; multi-byte payload fields are little-endian.

Sensor streaming (loadcell & accelerometer)

Two independent streams, enabled by writing loadcell_enabled (0xE006) and/or accelerometer_enabled
(0xE007) in Config. On the enable edge the head drains its stale local queue so your first samples
are fresh (indx_head/src/modbus.cpp:126-152); both may stream at once.

Producers — interrupt-driven on the sensor DRDY line, buffered in a head-side ring:

Stream Sensor Rate Head ring Headroom before loss
Loadcell ADS131M02 (OSR 8192 @ 6 MHz) ~366 Hz 32 records ~87 ms
Accelerometer AIS2IH (high-performance) 1600 Hz 128 samples ~80 ms

Packing (per FC 0x18 transfer, ≤31 regs = 62 payload bytes; indx_head/src/modbus.cpp:165-198):
each round the head encodes in priority order — (1) accelerometer (accelerometer_fast = 2 samples
per 9-byte message), (2) loadcell (one 9-byte record), (3) every 100 accel samples an
accelerometer_sampling_rate message (the head's measured ODR, for input-shaper calibration) — packing
greedily until the 62 bytes fill or the queues drain. Rough per-transfer capacity: ~6 loadcell, or
~12 accel, or ~6 accel + ~3 loadcell when both stream.

Polling — there is no "data-ready" flag. Poll FC 0x18 continuously; the head returns whatever is
queued. The stock host issues up to 5 back-to-back FC24 reads per loop while "more" is likely
(heuristic: response came back ≥ 27/31 registers full ⇒ probably more queued; fifo_decoder.cpp,
puppy_task.cpp:264-289) — effectively polling as fast as the RS485 bus allows.

Required poll rate to stay ahead of the head rings: loadcell-only ≈ 60 transfers/s (< 87 ms);
accel-only ≈ 135/s (< 80 ms); both ≈ 270 transfers/s (~3.7 ms) — accel is the binding
constraint. Poll faster than the ring headroom or you drop samples.

Overflow / loss (no flow control): if you under-poll, the head ring fills and drops samples.

Consuming loadcell: convert the head µs timestamp to your time base and track the reset counter
(bumped on every head reset) — if the head reset since you tared/zeroed, samples are ungrounded (stock
firmware aborts probing on a reset-straddling sample).

Runtime host pseudocode

# app addr 0x22, 230400 8N1; crc16 = CRC-16/MODBUS (appended low byte first). be16(x) = [x>>8, x&0xFF].
APP = 0x22

fn mb_txn(pdu):                                   # pdu = [func, …]; returns response data past addr,func
    f = [APP] + pdu
    c = crc16(f); f += [c & 0xFF, c >> 8]
    de_tx(); uart_write(f); uart_flush(); de_rx()
    r = uart_read_frame()                         # read until a >=3.5-char idle gap
    require crc16(r[:-2]) == (r[-2] | r[-1] << 8)
    require r[0] == APP
    require r[1] == pdu[0]                         # else r[1] == 0x80|func  -> exception, code in r[2]
    return r[2 : -2]

fn read_regs(addr, count):                        # FC 0x04 (read input registers)
    d = mb_txn([0x04] + be16(addr) + be16(count))  # d = [byte_count][data…]
    return [ d[1 + 2i] << 8 | d[2 + 2i]  for i in 0 .. count-1 ]   # each register big-endian

fn write_regs(addr, regs):                        # FC 0x10 (write multiple registers)
    p = be16(addr) + be16(len(regs)) + [2 * len(regs)]
    for v in regs: p += be16(v)
    mb_txn([0x10] + p)                            # response just echoes addr+count

fn read_fifo():                                   # FC 0x18 (read FIFO queue) @ 0x0000
    d = mb_txn([0x18, 0x00, 0x00])                # d = [bytecount(2 BE)][fifocount(2 BE)][regs…]
    n = d[2] << 8 | d[3]                           # register count (<=31)
    b = []
    for i in 0 .. n-1:
        reg = d[4 + 2i] << 8 | d[5 + 2i]          # big-endian register …
        b += [reg & 0xFF, reg >> 8]               # … -> little-endian message byte stream
    p = 0
    while p < len(b):
        t = b[p]; p += 1
        if   t == 0: break                        # padding -> end of transfer
        elif t == 2: on_loadcell(le32(b,p), le32(b,p+4)); p += 8   # ts_us, raw
        elif t == 4: on_accel(le32(b,p), le32(b,p+4));    p += 8
        elif t == 5: on_accel_rate(f32le(b,p));           p += 4
        elif t == 1: on_log(b[p : p+8]);                  p += 8
        else: break                               # unknown -> stop

fn host_step():                                   # call every ~100 ms
    st = read_regs(0x8060, 25)                    # Status block
    fault = st[0]                                 # fault_status bitmask
    if st[24] != 0:                               # temps_valid (0x8078)
        nozzle_C = s16(st[2]) / 100.0             # compensated temp (0x8062)
    comp_c100 = compute_compensation_c100()       # see Host-side temperature compensation; 0 on failure

    cfg = [0] * 10                                # Config block @ 0xE000
    cfg[0] = target_temp_C                        # nozzle_target_temperature (whole °C, 0 = off)
    cfg[1] = print_fan_duty                       # print_fan_pwm (duty in low byte)
    # cfg[2..3] = leds (LedConfig);  cfg[8] = clear_fault;  cfg[9] = selftest
    cfg[4] = comp_c100 & 0xFFFF                   # hotend_temperature_compensation_c100 (int16)
    cfg[6] = loadcell_enable                      # 1 = stream loadcell
    cfg[7] = accel_enable                         # 1 = stream accelerometer
    write_regs(0xE000, cfg)

    if loadcell_enable or accel_enable: read_fifo()

4. Peripheral map

R = readable in Status (FC 04 @ 0x8060) · W = writable in Config (FC 10 @ 0xE000) ·
Stream = FIFO (FC 0x18 @ 0x0000).

Peripheral Access Registers / mechanism
Induction heater W · R setpoint nozzle_target_temperature (0xE000); telemetry duty² ∫ (0x8064/65), energy µJ (0x8066/67)
Nozzle temperature (IR thermopile) R · W compensated (0x8062) / uncompensated (0x8061) / dT/dt (0x8063) / temps_valid (0x8078); comp. offset (0xE004)
Thermopile ambient R tpis_ambient_temperature_c100 (0x8068)
Nozzle-presence detector R · W present (0x8076), ack (0x8077); invalidate (0xE005)
Print fan R · W rpm/pwm/state/ok (0x806B–0x806E); duty print_fan_pwm (0xE001)
Heatbreak fan R · (W) rpm/pwm/state/ok (0x806F–0x8072); no direct duty (head-controlled); selftest_mode (0xE009) forces full
24 V input sense R system_24V_mV (0x8073)
Board temperature (NTC) R board_temperature (0x8069)
MCU temperature R mcu_temperature (0x806A)
Loadcell (ADS131M02) W · Stream enable loadcell_enabled (0xE006); LoadcellRecord via FIFO
Accelerometer W · Stream enable accelerometer_enabled (0xE007); samples + accel_sampling_rate via FIFO
LEDs W leds (0xE002–0xE003)
Fault status R · W read fault_status (0x8060); clear clear_fault_status (0xE008)
Time sync R time_sync lo/hi (0x8074/75)
Selftest mode W selftest_mode (0xE009)
Log (head→host text) Stream log records in the FIFO

Notes: sensors are read-only (Status); actuators/enables are write-only (Config); only the heater
(setpoint W / telemetry R) and nozzle-presence (state R / invalidate W) are bidirectional. The
loadcell and accelerometer have no Status registers — they exist purely as FIFO streams once
enabled. The heater is the only actuator with no direct duty register (you set a temperature; the head
regulates).


5. Firmware provisioning & flashing (bootloader protocol)

For an interface board that stores the INDX firmware and flashes it over RS485. The head's flash is
split: a factory 8 KB bootloader (first 8 KB) + the application (rest of 256 KB). The
bootloader speaks a simple command protocol over the same RS485 line (230400 8N1, half-duplex) but is
separate from the app's MODBUS — different framing, and it lives at bus address 0x12 (the app
runs at 0x22). Play the flasher role: verify by fingerprint, reflash on mismatch, then start the app.
Source: include/puppies/BootloaderProtocol.hpp, src/puppies/BootloaderProtocol.cpp.

Frame format

Request : [addr] [cmd]    [data…]        [crc_lo] [crc_hi]
Response: [addr] [status] [len] [data…]  [crc_lo] [crc_hi]

Commands

Op Name Request data Response data
0x00 GET_PROTOCOL_VERSION — 2 B version (BE); must = 0x0302
0x01 SET_ADDRESS [new_addr][0x00] — (unused for INDX — fixed 0x12)
0x03 GET_HARDWARE_INFO — 11 B: hw_type(1) hw_revision(2 BE) bl_version(4 BE) application_size(4 BE)
0x05 START_APPLICATION [salt(4 BE)][fingerprint(32)] 1 B, = 0x01 = accepted → jumps to app
0x06 WRITE_FLASH [offset(4 BE)][data ≤247] —
0x07 FINALIZE_FLASH — 1 B erase-count (allow 500 ms)
0x08 READ_FLASH [offset(4 BE)][len(1)] len B
0x0e GET_FINGERPRINT [offset=0(1)][size=32(1)] 32 B SHA-256
0x0f COMPUTE_FINGERPRINT [salt(4 BE)] — (async; then wait + GET_FINGERPRINT)
0x10 READ_OTP [offset(4 BE)][len(1)] len B (datamatrix / hw id)

WRITE_FLASH/READ_FLASH offsets are relative to the app region base (offset 0 = first app byte
= flash 0x08002000, above the 8 KB bootloader). The image spans the entire app region = 253 952 B
(256 K − 8 K bootloader) — the linker's code sub-region is 253 824 B and the final 128 B are the FW
descriptor
(FW-type tag + a SHA-256 fingerprint). Flash it verbatim, including the descriptor, at
offset 0 → block count = ceil(253 952 / 247) = 1029 WRITE_FLASH commands. Never write below the
app base — the bootloader region is factory-provisioned and self-maintained by the app.

Reference image (v6.6.1): fw-indx_head.bin = 253 952 B, SHA-256
7d8ef4b74696d8cf34f90040117637ba4c504198f4fe65250b0e3655c29456d8. Extracted from release asset
COREONE_COREONE+_INDX_6.6.1.bbf via utils/unpack_bbf.py (bbf resources = POSIX tar in TLV type 9;
binary at /puppies/fw-indx_head.bin). Kept in firmware/fw-indx_head.bin.

Fingerprint (salted SHA-256)

fingerprint = SHA-256( salt ‖ app_image ) — salt is a random uint32 hashed as its 4
little-endian bytes
(host-native), then the entire app image (src/buddy/digest.cpp:18-21, mbedtls
SHA-256, 32 B). On the wire the salt is sent big-endian. COMPUTE_FINGERPRINT(salt) makes the
head hash its installed app (asynchronous — wait, then GET_FINGERPRINT); compare to your locally
computed expected fingerprint.

Flashing sequence

  1. Reset the head (pin 12 / PE9) so it enters the bootloader; address it at 0x12.
  2. GET_PROTOCOL_VERSION → require 0x0302; GET_HARDWARE_INFO → require bl_version ≥ 294 and
    hw_type == 45 (INDX head). Optionally READ_OTP for serial / hw revision.
  3. Pick a random salt. COMPUTE_FINGERPRINT(salt) → wait → GET_FINGERPRINT. Locally compute
    SHA-256(salt_LE ‖ app).
  4. If mismatch: WRITE_FLASH the app in ≤247-B blocks at increasing offsets (0…len), then
    FINALIZE_FLASH; re-verify (repeat 3 with a fresh salt).
  5. START_APPLICATION(salt, expected_fingerprint) → the head re-checks salt+fingerprint and jumps to
    the app, which then answers MODBUS at 0x22. If step 3 already matched, skip straight here — no
    flashing needed.

Bus addresses: boot 0x12, app 0x22 (= 0x1A + dock 8). INDX is not dynamically addressable, so
skip SET_ADDRESS and use the fixed boot address (PuppyBootstrap.hpp:57-64).

Flasher pseudocode

# be32/le32 = 4-byte big/little-endian encodings; crc16 = CRC-16/MODBUS (poly 0x8005, init 0xFFFF).
BOOT = 0x12
FP_WAIT_MS = 500            # head SHA-256 time (poll/retry GET_FINGERPRINT if it answers FAILED)

fn bl_txn(cmd, data, timeout_ms):              # one bootloader request/response
    f = [BOOT, cmd] + data
    c = crc16(f); f += [c & 0xFF, c >> 8]      # CRC appended low byte first
    de_tx(); uart_write(f); uart_flush(); de_rx()
    h = uart_read(3, timeout_ms)               # [addr, status, len]
    require h[0] == BOOT and h[1] == 0x00       # echoed addr, status == COMMAND_OK
    body = uart_read(h[2], 1ms * h[2])          # 'len' data bytes
    crc  = uart_read(2, 2ms)
    require crc16(h + body) == (crc[1] << 8 | crc[0])
    return body

fn verify(app):                                # -> (salt, want, matches?)
    salt = random_u32()
    bl_txn(0x0F, be32(salt), 10)               # COMPUTE_FINGERPRINT (async on head)
    sleep(FP_WAIT_MS)
    cur  = bl_txn(0x0E, [0x00, 0x20], 500)      # GET_FINGERPRINT -> 32 bytes
    want = sha256(le32(salt) + app)            # salt as 4 LE bytes, then the whole image
    return (salt, want, cur == want)

fn flash_indx(app):                            # app = bytes of fw-indx_head.bin (<= 253824)
    reset_assert(); reset_release()            # pin 12 -> head boots into bootloader @ 0x12
    require be16(bl_txn(0x00, [], 10)) == 0x0302              # GET_PROTOCOL_VERSION
    hi = bl_txn(0x03, [], 10)                                 # GET_HARDWARE_INFO
    require hi[0] == 45 and be32(hi[3:7]) >= 294              # hw_type, bl_version

    (salt, want, ok) = verify(app)
    if not ok:                                 # reflash only on mismatch
        off = 0
        while off < len(app):
            n = min(247, len(app) - off)
            bl_txn(0x06, be32(off) + app[off : off+n], 500)  # WRITE_FLASH block
            off += n
        bl_txn(0x07, [], 500)                                # FINALIZE_FLASH (erase + SHA)
        (salt, want, ok) = verify(app)                       # re-verify with a fresh salt
        require ok

    r = bl_txn(0x05, be32(salt) + want, 500)                 # START_APPLICATION
    require r == [0x01]                                       # accepted -> jumps to app
    # head now speaks MODBUS-RTU at 0x22

Minimal host bring-up sequence

  1. Firmware: if your board manages the head firmware, run the flashing sequence (§5); otherwise —
    head already flashed — just release reset (PE9) and let the bootloader hand off to the app.
  2. Talk MODBUS-RTU at 230400 8N1 to slave 0x22, driving DE for each request.
  3. Poll Status (FC 04 @ 0x8060); wait for temps_valid.
  4. Write Config (FC 10 @ 0xE000) to set target temp, fans, LEDs, stream enables.
  5. For loadcell/accel data, poll the FIFO (FC 0x18 @ 0x0000) and decode records.
  6. On comms failure: power-cycle via PE9, re-init, discard cached state.

TODO Ensure the implementation does not use PE9 but the config pin chosen.