Files
firmware/docs/notes/10-hardware.md
Oleksandr ShyshatskyiandClaude Opus 5 c7a821bb9d ff_mcu_bringup.py is importable by name
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>
2026-08-25 21:23:45 +03:00

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. isAck is 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 answers 0x01 every time. Refusing the handshake over that byte is what stranded ttyS7.
  • The ready phase differs by board. On ttyS4 only a literal "Ready" sets isReady. On ttyS5 and ttyS7 any byte does, banner or not — which is why checkEboard happily sends 'A' at an eboard that is already running Klipper and is just returning garbage at the wrong baud. ff_mcu_bringup.py deliberately does not copy that: only a banner earns a write. Since 2026-08-25 checkEboard is no longer called at all — ff_mcu_bringup.py covers ttyS5 too. Dropping it loses nothing: the binary is 9KB holding one function (_Z23bootSerialMainEboardMcuv), hard-wired to /dev/ttyS5, importing only the tty calls and printf. It flashes no firmware and opens no other device. It is still on the firmware partition; nothing runs it.
  • tcsetattr(fd, 0x540E, ...) — that is TCSETS, where POSIX wants TCSANOW/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), but ff_mcu_bringup.py passes TCSANOW, 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 in printer.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 in printer.motor.cfg, not printer.base.cfg — so printer.base.cfg does not include it and the machine's own printer.cfg must. MOTOR_STOP, MOTOR_GRAB and the rest of the lock macros live in that same file, which is why ff-toolchange.cfg can name them.
  • Fans: fan_generic fanM106 (part fan, via custom SET_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's printer.base.cfg (see ff-toolchange.cfg's sensor note): there is no bare extruder_grab, no extruder_check_pos, no servo_min/servo_max, and motor_pin is commented out in printer.motor.cfg.
  • Filament: filament_switch_sensor fd_ex0..3 (presence), custom motion sensors fm_ex0..3.
  • Steppers: manual_stepper gear_stepper (the tool LOCK motor, not a feeder; its endstop doubles as the lock status signal — see 30-toolchange.md).
  • Fork's virtual_sdcard extra status fields: channel, refuelling, after_channel_g1, doingChangeEx.