The filename was the only reason for the importlib shim: a module called ff-mcu-bringup cannot be written as an import, so ff-startup.py loaded it from a spec and hand-rolled the failure handling. Renaming it removes all of that and leaves a plain guarded import beside the one ffscreen already had. start.sh still runs it as a script, by path, on the fallback route -- a rename costs that nothing. What the shim was hiding is worth keeping visible, though: these imports resolve only because python puts a SCRIPT's own directory on sys.path, which is true when the wrapper runs the file by path and false the moment anything loads it another way. So there is now a --selftest that reports what it can actually reach, and the replica gate calls that instead of loading the module itself -- which it was doing before, and which would have passed while the real path was broken. It is worth having on a printer over ssh too. The unit tests were making the same mistake in the other direction: loading ff-startup.py through importlib with payload/bin absent from sys.path, so its siblings were missing and the selftest correctly said so. The loader now puts that directory on the path, which is what the runtime looks like, and a separate test runs the script from another directory entirely to prove the premise rather than assume it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
6.1 KiB
Hardware & MCU topology
MCUs (Klipper multi-MCU)
| Klipper MCU | Serial | Role |
|---|---|---|
mcu |
(main board, see printer.base.cfg on device) | X/Y/Z steppers, bed heater PD2, bed thermistor PC3 |
eboard |
/dev/ttyS5 |
Carriage extruder board — ONE stepper driver shared by whichever head is mounted (PB14/PB15/PB12) |
eheaterboard |
/dev/ttyS4 |
4 hotend heaters PC6..PC9, 4 thermistors PC0/PA4/PA5/PA7, 24V rail PA3 |
levelboard |
/dev/ttyS7 |
The fixed inductive "cylinder" under the bed — all three [e_stop X/Y/Z] sit on levelboard:PD0 and drive the nozzle-offset calibration. The carriage eddy used for G28 Z and bed mesh is [probe] on eboard:PG0 — see 25-app-vs-klipper-ownership.md |
The app pokes ttyS4/S5/S7 (sends a char, waits for "Ready") before starting Klipper.
Other serial: /dev/ttyS3 = drying box (own protocol, not a Klipper MCU).
The bootloader handshake, as decompiled
Not reconstructed from behaviour — this is _Z17bootSerialHeatMcuv,
_Z23bootSerialMainEboardMcuv and _Z23bootSerialLevelBoardMcuv disassembled out
of usr/prog/PROGRAM/software/firmwareExe (1.9.7). The three are the same function
with a different device path and different printf strings; usr/prog/klipper/checkEboard
is a separate, older -O2 build of the eboard one.
open(dev, O_RDWR) # no O_NOCTTY, no O_NONBLOCK
fcntl(fd, F_SETFL, 0)
tcgetattr; cfmakeraw
c_cc[VMIN]=0; c_cc[VTIME]=1 # MIPS: VMIN=4, VTIME=5
cfsetispeed/cfsetospeed(B115200) # 0x1002 on MIPS
c_iflag &= ~(IGNBRK|ICRNL|IXON) # &= ~0x501
c_oflag &= ~OPOST
c_cflag = (c_cflag & ~(CRTSCTS|PARODD|CSTOPB|CSIZE)) | CLOCAL|CREAD|CS8
c_lflag &= ~(ISIG|ICANON|ECHO|ECHOE)
tcsetattr(fd, 0x540E, &tio) # see below
isReady = 0; isAck = 1
for (i = 0; i < 50; i++) { # ~5s ceiling at VTIME=1
n = read(fd, buf, 50)
if (n == 0) continue
if (find(buf, "Ready") != npos) { isReady = 1; break }
else { isReady = 1 } # ttyS5/ttyS7 only
}
send = 'A'
if (isReady)
for (j = 0; j < 50; j++) {
write(fd, &send, 1)
read(fd, &recv, 1)
if (recv == 6) { isAck = 1; break }
}
close(fd)
return (isReady == 1 && isAck == 1) ? 0 : -1
Four things worth keeping in mind, all of which bit us:
'A'goes out fifty times, not once. The board gets fifty chances.- A non-0x06 reply is ignored.
isAckis initialised to 1 and is never assigned 0 anywhere in the function, so the ack byte cannot fail the handshake — it only decides whether the loop stops early. The level board answers0x01every time. Refusing the handshake over that byte is what stranded ttyS7. - The ready phase differs by board. On
ttyS4only a literal"Ready"setsisReady. OnttyS5andttyS7any byte does, banner or not — which is whycheckEboardhappily sends 'A' at an eboard that is already running Klipper and is just returning garbage at the wrong baud.ff_mcu_bringup.pydeliberately does not copy that: only a banner earns a write. Since 2026-08-25checkEboardis no longer called at all —ff_mcu_bringup.pycoversttyS5too. Dropping it loses nothing: the binary is 9KB holding one function (_Z23bootSerialMainEboardMcuv), hard-wired to/dev/ttyS5, importing only the tty calls andprintf. It flashes no firmware and opens no other device. It is still on the firmware partition; nothing runs it. tcsetattr(fd, 0x540E, ...)— that isTCSETS, where POSIX wantsTCSANOW/TCSADRAIN/TCSAFLUSH. Both binaries do it, so it is in FlashForge's source, not a build artefact. It evidently does not fail on the printer (the routines go on to print their read loop), butff_mcu_bringup.pypassesTCSANOW, which is correct either way.
The ready phase is only ~5s and it does not wait for a second banner, so stock only ever worked when it ran immediately after the board powered up. The bootloader re-sends its banner on a period longer than that, which is why a bring-up run later in the boot can see nothing at all and still be looking at a board in its bootloader.
Extruder config (embedded default printer.cfg)
All four [extruderN] sections share the same step/dir/enable pins — only the mounted
head is electrically active; the fork tolerates the duplicate pin claims (vanilla
Klipper would refuse). Gear ratio 6.5:1, rotation_distance 19.15, Generic 3950
sensors, max_temp 350.
Klipper objects the UI expects (all present in the stock config)
- Heaters/sensors:
extruder..extruder3,heater_bed,heater_generic chamber_heater(Pro only — it lives inprinter.chamber.cfg, and the plain Creator 5 gets[temperature_sensor chamber]instead),temperature_sensor ptcTemp,temperature_sensor extruder_servo_value(eboard:PA0),temperature_sensor adc_current_value(eheaterboard:PC1).temperature_sensor motor_value(sensor_type: motor_current_sensor,eboard:PA1) is real and ships, but inprinter.motor.cfg, notprinter.base.cfg— soprinter.base.cfgdoes not include it and the machine's ownprinter.cfgmust.MOTOR_STOP,MOTOR_GRABand the rest of the lock macros live in that same file, which is whyff-toolchange.cfgcan name them. - Fans:
fan_generic fanM106(part fan, via customSET_FAN_M106),chamber_fan,chamber_cool_fan,chamber_heat_fan,chamber_loop_fan. - Buttons (
gcode_button):extruder_pos1..4(head present in dock N),extruder_grab1..4(head on carriage),frontDoor,topDoor. That is the whole family — confirmed against this printer'sprinter.base.cfg(seeff-toolchange.cfg's sensor note): there is no bareextruder_grab, noextruder_check_pos, noservo_min/servo_max, andmotor_pinis commented out inprinter.motor.cfg. - Filament:
filament_switch_sensor fd_ex0..3(presence), custom motion sensorsfm_ex0..3. - Steppers:
manual_stepper gear_stepper(the tool LOCK motor, not a feeder; its endstop doubles as the lock status signal — see30-toolchange.md). - Fork's
virtual_sdcardextra status fields:channel,refuelling,after_channel_g1,doingChangeEx.