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 coresrc/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.
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.
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.
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:
PuppyBootstrap.cpp:363-365).reset_counter).There is no separate NRST pin and no always-on 5 V pin — pin 12 is the power+reset for the MCU.
| 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 |
hi then lo (core modbus.cpp:127-131) and parses hi<<8 | lo (:91-94).uint32 values use little-endian word order: the _lo field (low 16 bits) is at the lower_hi at the next (head modbus.cpp:52-53, 56-57, 82-83). Decode:value = (reg_hi << 16) | reg_lo, each register read big-endian.uint16/int16 fields carry their numeric value directly (big-endian on the wire).memcpys the raw (little-endian) struct over the register spanmodbus.cpp:250), so the first-declared byte lands in the register's LOW byte. For leds,(g<<8)|r and 0xE003 = (mode<<8)|b; for print_fan_pwm, the duty is the low0x04, 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 |
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).
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.
0x18 (Read FIFO Queue), address 0x0000Enable 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.
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.
spi_task.cpp:48-57): each packed 32-bit accel sampleConsuming 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).
# 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()
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).
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.
Request : [addr] [cmd] [data…] [crc_lo] [crc_hi]
Response: [addr] [status] [len] [data…] [crc_lo] [crc_hi]
addr echoes the request; status per table; len = response data length (0–250).0x00 OK · 0x01 FAILED · 0x02 NOT_SUPPORTED · 0x03 INVALID_TRANSFER ·0x04 INVALID_CRC · 0x05 INVALID_ARGUMENTS.| 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 = 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.
PE9) so it enters the bootloader; address it at 0x12.GET_PROTOCOL_VERSION → require 0x0302; GET_HARDWARE_INFO → require bl_version ≥ 294 andhw_type == 45 (INDX head). Optionally READ_OTP for serial / hw revision.salt. COMPUTE_FINGERPRINT(salt) → wait → GET_FINGERPRINT. Locally computeSHA-256(salt_LE ‖ app).WRITE_FLASH the app in ≤247-B blocks at increasing offsets (0…len), thenFINALIZE_FLASH; re-verify (repeat 3 with a fresh salt).START_APPLICATION(salt, expected_fingerprint) → the head re-checks salt+fingerprint and jumps toBus 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).
# 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
PE9) and let the bootloader hand off to the app.temps_valid.PE9, re-init, discard cached state.
TODOEnsure the implementation does not use PE9 but the config pin chosen.