The full writeup behind Decoding Creality's locked Klipper fork. Internal specifics are scrubbed; this is generic enough to run on your own gear.
The K2 Plus runs a Klipper fork with the interesting parts compiled into Cython
.somodules. This is the full system trace plus the priority-ordered plan to replace each closed module with auditable Python while keeping the printer functional - hardware platform, eMMC layout, boot, IPC, the RS485 protocol, and the four reverse-engineering techniques that did most of the work.
Network examples use RFC 5737 documentation ranges (
192.0.2.0/24). Device serials, MACs, and per-device secrets are redacted. Substitute your own.
Table of Contents#
- Goal and Status
- Hardware Platform
- Firmware and OS
- eMMC Partition Map
- Boot Sequence
- Process Map and IPC
- The RS485 Bus
- Reverse-Engineering Cython Without the Source
- Module Replacement Priority List
- CFS Software Stack and Gcode Surface
- AI Detection and Camera
- Display, Errors, Config Files
- Deployment and Rollback
- Custom OS Feasibility
- Known Quirks
- Improvement Projects Without Replacing Firmware
1. Goal and Status#
Replace Creality's custom Klipper fork modules with open-source Python while keeping the printer fully functional - to enable auditing, modification, upstream Klipper porting, and alternative UIs. Work is ordered by printer safety, most critical first.
The interesting parts of the firmware are compiled into a 1.88 MB Cython binary (box_wrapper.cpython-39.so) and a handful of smaller .so modules. Cython compiles Python down to C and then to a shared object: the names and method tables survive, the bodies are machine code, and none of it is upstream Klipper.
Project repo (MIT license): ~/k2-open-klipper/. The document at the centre of it is KNOWN_MISTAKES.md, currently 21 entries, and that is the part most likely to be useful to someone else.
Current state: the FOSS box_wrapper.py is feature-complete for normal operation and verified against captured stock traces, but not deployed. A MOTOR_PROTECT_ERROR during Z homing on the second G28 cycle blocks deployment (see Priority 4). The belt-tension module is replaced and running in production. Everything else is written, tested, or fully enumerated but waiting on the motor-control rewrite.
Two firmware images are tracked: V1.1.4.11 (the verified analysis target) and V1.1.5.2 (on the CDN, not yet stable; adds two methods to box_wrapper, get_material_type and is_different_type_material).
2. Hardware Platform#
| Component | Detail |
|---|---|
| SoC | Allwinner T113-i (sun8iw20p1), 2x ARM Cortex-A7 @ 1 GHz, NEON SIMD |
| RAM | 512 MB DRAM (488 MB usable, 16 MB CMA), no swap |
| Storage | eMMC (mmcblk0): ~122.5 MB squashfs ROM + 240 MB overlay + 27.5 GB UDISK, A/B rootfs |
| LCD | ST7701, 480x800, 32bpp, SPI 9-bit init + parallel RGB |
| Touch | Goodix GT9xx capacitive (I2C, address 0x14), /dev/input/event0 |
| WiFi | BCM43456 (AP6256), SDIO, dhd driver, 5 GHz capable |
| Camera | STD-9613V2, USB UVC, /dev/video0, 1920x1080@15fps |
| Console | /dev/ttyS0 @ 115200 (askfirst login) |
| Main MCU | GD32F303RET6, /dev/ttyS2 @ 230400 (main board) |
| Nozzle MCU | GD32F303CBT6, /dev/ttyS3 @ 230400 (nozzle) |
| RS485 | /dev/ttyS5 @ 230400 (CFS, motors, belt sensors, RFID) |
| Accelerometer | LIS2DW on nozzle MCU (SPI) |
| Z Probe | prtouch_v3 (pressure sensor, non-linear temp compensation) |
| EEPROM | BL24C16F on i2c.1 @ 400 kHz |
| Motors | Closed-loop (PID + LESO observer) on all 5 axes |
| TEE | OP-TEE (Trusted Execution Environment) |
| DSP | Allwinner HiFi4 DSP (own A/B partitions, unused by print firmware) |
| RISC-V | E906 core (own A/B partitions, unused by print firmware) |
Two extra cores in the SoC - a HiFi4 DSP and a RISC-V E906 - both with their own A/B partitions, both doing nothing for the print firmware.
Stepper config: X/Y 16 microsteps, 40 mm rotation distance, 800 mm/s max, 30000 mm/s^2 max accel. Z/Z1 16 microsteps, 8 mm rotation distance (dual Z with z_tilt), 30 mm/s. Extruder 16 microsteps, 6.9 mm rotation distance, 0.4 mm nozzle, 390 C max. Print volume 350x350x350 mm, CoreXY. Belt target tension 140 N per axis.
3. Firmware and OS#
| Property | Value |
|---|---|
| Official firmware | V1.1.4.11 (board ID CR0CN240110C10) |
| Upcoming | V1.1.5.2 (on CDN, not yet stable) |
| CFS box firmware | v1.1.3 |
| MCU firmware | Klipper 1.1.0.48-293-g493f9a0f-dirty (2024-12-20) |
| OS | Allwinner Tina 5.0 (OpenWrt 21.02-SNAPSHOT base) |
| Kernel | Linux 5.4.61 SMP PREEMPT armv7l |
| libc | glibc 2.29, GCC 8.3.0 |
| Shell | /bin/ash (BusyBox v1.33.2) |
| Init | procd (OpenWrt service manager) |
| Packages | Baked into squashfs ROM, opkg database empty |
| Filesystem | OverlayFS: squashfs /rom (RO) + ext4 /overlay (RW) |
Official firmware images are CPIO archives on Creality's CDN (file2-cdn.creality.com/file/<hash>/CR0CN240110C10_ota_img_V1.1.4.11.img).
The SoC is armhf (ARMv7, 32-bit), not aarch64. Any cross-compiled toolchain or pre-built binaries must target arm-linux-gnueabihf.
SSH Access#
Dropbear on port 22. The K2 family ships with a vendor default login (root / creality_2024), enabled via Settings after scrolling through a warning and ticking a box. No SSH keys are configured out of the box; add one to /etc/dropbear/authorized_keys for passwordless access. The printer has no sftp-server, so use scp -O (legacy SCP protocol) to copy files.
4. eMMC Partition Map#
14 partitions with an A/B rootfs scheme for rollback. Two cuts of the layout were observed across firmware builds; the consistent shape:
| Partition | Label | Type | Purpose |
|---|---|---|---|
| p1 | env | raw | U-Boot environment |
| p2 | boot_a | raw | Kernel + DTB (slot A) |
| p3 | boot_b | raw | Kernel + DTB (slot B) |
| p4 | rootfs_a | squashfs | Root filesystem (slot A, RO, ~122 MB) |
| p5 | rootfs_b | squashfs | Root filesystem (slot B, RO) |
| p6 | private | ext4 | Per-device keys and calibration |
| p7 | appfs_a | ext4 | Creality app data (slot A) |
| p8 | appfs_b | ext4 | Creality app data (slot B) |
| p9 | private2 | ext4 | Secondary persistent / keybox storage |
| p10 | overlay | ext4 | OverlayFS upper layer (RW, survives updates, 240 MB) |
| p11 | factory / rvA | ext4 / raw | Factory calibration or RISC-V firmware A |
| p12 | rsvd / rvB | raw | Reserved or RISC-V firmware B |
| p13 | rsvd / rv_data | raw | Reserved or RISC-V data |
| p14 | UDISK | ext4 | User data, gcodes, configs, logs (27.5 GB) |
OTA uses A/B slot switching. U-Boot env vars: appAB_next, appAB_now, applimit, appcount. swupdate writes uboot (~1.3 MB), kernel (~5.4 MB), rootfs (~128 MB) per slot. /overlay and UDISK are preserved across updates.
OTA Update Process#
Firmware images are CPIO archives processed by SWUpdate:
upgrade-serverreceives the.img(USB stick or network).- SWUpdate extracts and validates the CPIO payload.
- Writes the inactive rootfs slot (A or B) with the new squashfs.
- Updates boot flags in U-Boot env to activate the new slot on next boot.
- Printer reboots; U-Boot activates the new slot.
- On successful boot the new slot is marked stable; the old slot is retained for rollback.
/overlayandUDISKare not touched.
USB stick flash: place the .img at the root of a FAT32 USB drive, insert while the printer is on, then trigger via Settings > Firmware Update.
5. Boot Sequence#
procd init, sorted by START=:
S00 sysfixtime Fix time from filesystem timestamps
S01 play Boot animation (boot-play binary, boot.mp3)
S10 boot Core: mount_root, /dev/by-name/ links, kmodloader, wifi detect
S10 system System config from UCI
S10 hostname Set hostname from UCI
S11 sysctl Kernel sysctl params
S12 log Syslog daemon (logd)
S13 fstab Mount filesystems (UDISK, overlay, boot partitions)
S13 mcu_update MCU firmware check/flash (ttyS2/3/5, RS485 slaves)
S15 device_manager USB/camera/laser device manager
S16 refresh_device Refresh device states via ubus
S19 dropbear SSH server
S20 board_init GPIO init: USB 5V enable, MCU reset
S20 network Network stack
S50 cron Cron daemon
S50 tee-supplicant OP-TEE supplicant
S54 klipper_mcu Host MCU process (klipper_mcu -r)
S55 klipper Klipper (Python, pinned CPU1, OOM score -500)
S56 moonraker Moonraker API server (Python)
S60 dbus D-Bus system bus
S80 adbd Android Debug Bridge
S80 nginx Fluidd/Mainsail on :4408
S94 gpio_switch GPIO switch config
S95 done mount_root done, rc.local (forces loglevel 0)
S96 led LED triggers
S96 wipe_data Factory reset listener (/var/run/wipe.sock)
S97 webrtc WebRTC cloud relay (reads SN from keybox)
S98 sysntpd NTP client
S99 app Creality app stack (8 binaries)
S99 mdns mDNS (_Creality-<SN>._udp.local)
S99 swupdate_auto OTA check (swupdate_cmd.sh)Key Boot Scripts#
mcu_update checks firmware on all MCUs and flashes if newer. mcu_util for UART MCUs (ttyS2/ttyS3), mcu_util_485 for RS485 slaves (motors, belts, RFID, CFS boxes). CFS update is triggered by CFS=1 /etc/init.d/mcu_update start. Writes /tmp/.mcu_version and /tmp/.485_mcu_version JSON.
klipper init manages config: model F008 + board CR0CN240110C10 maps to a config dir under /usr/share/klipper/config/<model>/. User configs live at /mnt/UDISK/printer_data/config/. update_config() replaces the user config header but preserves the SAVE_CONFIG section. klipper_watcher.sh sets the OOM score to -500 to protect Klipper.
app init launches 8 binaries: master-server, audio-server, wifi-server, app-server, display-server, upgrade-server, web-server, Monitor.
6. Process Map and IPC#
The user-facing software stack is eight binaries that talk via POSIX shared memory and Unix sockets:
master-server conductor; AI detection, scheduling, shared-memory writer
display-server LVGL on /dev/fb0 + Goodix touch
web-server HTTP API on :80, :443, :9998, :9999 (libhv)
app-server MQTT to Creality Cloud
audio-server buzzer / aplay
wifi-server wpa_supplicant wrapper
upgrade-server OTA orchestration
Monitor watchdog that supervises the seven aboveUnderneath sits Python Klipper, pinned to CPU1, OOM score -500. Underneath that sits the Cython.
Shared Memory (POSIX, /tmp/shm/)#
| Region | Size | Consumers | Content |
|---|---|---|---|
| device_state_shm | 1 MB | master, web, app, display | Temperatures, positions, print state (protobuf-c) |
| print_object_shm | 1 MB | master, web, display | Print job objects, progress (JSON) |
| materail_box_shm | 1 MB | master, web, app, display | CFS slot state, RFID, filament info (JSON) |
| dev_maintain_shm | 1 MB | master, display | Maintenance counters (protobuf-c) |
| main_ai_image | 1 B | cam_app | AI capture flag |
| main_timelapse | 1 B | cam_app | Timelapse capture flag |
Each has a POSIX semaphore (sem.*). master-server is the writer to all four main regions; everything else reads.
Unix Domain Sockets#
| Socket | Owner | Purpose |
|---|---|---|
| /tmp/klippy_uds | klipper | Klipper API (moonraker, master-server connect) |
| moonraker.sock | moonraker | Moonraker API (on UDISK) |
| /var/run/h264_uds | cam_app | H264 frame stream (webrtc_local connects) |
| /tmp/sys_sock | master-server | Creality system IPC (display-server connects, protobuf-c) |
| /var/run/ubus/ubus.sock | ubusd | OpenWrt system bus |
| /var/run/wipe.sock | wipe_data | Factory reset trigger |
Serial UART#
| Port | Baud | Owner | Purpose |
|---|---|---|---|
| ttyS0 | 115200 | askfirst | Serial console |
| ttyS2 | 230400 | klipper (fd 10) | Main MCU (GD32F303RET6) |
| ttyS3 | 230400 | klipper (fd 20) | Nozzle MCU (GD32F303CBT6) |
| ttyS5 | 230400 | klipper (fd 32) | RS485 bus (CFS, motors, belts, RFID) |
master-server subscribes to Klipper via /tmp/klippy_uds (~4/sec), writes the four shm regions, listens on /tmp/sys_sock with protobuf-c framing, and orchestrates AI detection via libncnn_yolov5 + libai_capture. 22 threads, heartbeat every ~32 s. display-server reads the four regions and drives /dev/fb0 (LVGL) + /dev/input/event0 (Goodix). Monitor is the watchdog: killall -9 + restart on crash.
7. The RS485 Bus#
One half-duplex RS485 bus on /dev/ttyS5 @ 230400 baud carries the CFS box, the closed-loop motors, the belt sensors, and the RFID reader.
Bus topology (verified addresses)#
+-- addr 0x01: CFS Box (4 slots A-D, temp/humidity sensor)
+-- addr 0x0B (11): RFID reader (accessed via CFS box cmd 0x02, not directly)
+-- addr 0x21/0x22: Belt sensors X/Y
+-- addr 0x81-0x85: Closed-loop motors E/X/Y/Z/Z1
+-- addr 0xFE: Broadcast (material box ping, no response)Addresses from a strace first pass (belt 0x17/0x18, motor mapping from auto_addr_wrapper.py as 0x91/0x92) turned out wrong. The real belt addresses are 0x21/0x22 per belt_mdl.py source. Verify on live hardware, do not trust the first capture.
Wire protocol#
Packet: [0xF7] [addr] [length] [status] [cmd] [data...] [crc8]
CRC-8: polynomial 0x07 over [length, status, cmd, data...] (excludes head + addr)
Input: callers pass [addr, length, status, cmd, data...] without 0xF7 head or CRC
Bus: /dev/ttyS5, 230400 baud, 8N1, half-duplex0xF7: sync/head bytelength:len(data) + 3(counts status + cmd + crc)status:0x00for responses/queries,0xFFfor requestscrc8: CRC-8, polynomial0x07, over[length, status, cmd, data...]
CRC8 algorithm (from auto_addr_wrapper.py, verified against 19 captured packets and on live hardware with Klipper stopped):
def crc8(data, poly=0x07):
crc = 0
for byte in data:
crc ^= byte
for _ in range(8):
if crc & 0x80:
crc = (crc << 1) ^ poly
else:
crc <<= 1
crc &= 0xFF
return crcSlot indexing on the CFS bus is bitmasked, not sequential: A=0x01, B=0x02, C=0x04, D=0x08. Using a sequential index for slots C and D is one of the documented traps in KNOWN_MISTAKES.md.
CFS box commands (addr 0x01, verified on live hardware)#
| Cmd | Name | Request | Response | Timeout (stock) |
|---|---|---|---|---|
| 0x01 | CREATE_CONNECT | [] | ACK | 2 s |
| 0x02 | GET_RFID | [slot_mask] | ASCII {SLOT}:{status}; per slot | 2 s |
| 0x03 | GET_REMAIN_LEN | [slot_mask] | remaining length | 2 s |
| 0x04 | SET_BOX_MODE / motor start-stop | [slot, dir] (01=fwd, 00=stop) | ACK | 2 s |
| 0x05 | GET_BUFFER_STATE / motor state | [sub] (status byte, 2=idle) | status | 2 s |
| 0x06 | CTRL_MATERIAL_MOTOR_ACTION (WRITE) | [slot, action] | ACK | 2 s |
| 0x07 | CTRL_CONNECTION_MOTOR_ACTION (WRITE) | [slot, action] | ACK | 2 s |
| 0x08 | GET_FILAMENT_SENSOR_STATE | [sub] (01=enable, 00=disable) | ACK | 2 s |
| 0x09 | SET_MOTOR_SPEED | [...] | ACK | 2 s |
| 0x0A | GET_BOX_STATE | [] -> [temp, humidity, ...] | data | 3600 s (heat soak) |
| 0x0C | GET_BOX_MODE | [] | mode | 2 s |
| 0x0D | SET_PRE_LOADING / reload-detect | [slot_mask, enable] | ACK (44 s delay) + RFID | 300 s |
| 0x0E | MEASURING_WHEEL / encoder poll | [0x01] | 4-byte float / position | 2 s |
| 0x0F | TIGHTEN_UP_ENABLE | [...] | ACK | 2 s |
| 0x10 | EXTRUDE_PROCESS | [slot, stage, param] state machine | varies | 15 s |
| 0x11 | RETRUDE_PROCESS (sic) | [slot, phase] state machine | ACK | 150 s |
| 0x13 | EXTRUDE_PROCESS_MODEL2 | [...] | varies | 150 s |
| 0x14 | GET_VERSION_SN | [] | version + serial | 2 s |
| 0x15 | GET_HARDWARE_STATUS | [] | status | 2 s |
| 0x55 | COMMUNICATION_TEST | [] | ACK | 2 s |
| 0xA2 | Identity/online | [] | device count + 12-byte UUID | 2 s |
| 0xA3 | Address table | [] | device count + 12-byte UUID | 2 s |
An earlier version of this table had
0x06labelled as a read command and0x07asGET_VERSION_SN. Stock has both as motor control writes (CTRL_MATERIAL_MOTOR_ACTION,CTRL_CONNECTION_MOTOR_ACTION). Emitting them under the wrong name makes the CFS box interpret a query as a motor write. Get the command map right before you send anything.
RFID bitmask detail (cmd 0x02): the data byte selects which slots to return (0x01=A, 0x02=B, 0x04=C, 0x08=D, 0x0F=all). The response is semicolon-delimited ASCII (A:unknown;B:none;C:unknown;). Values are unknown (filament present, no tag match), none (empty slot), or the filament ID string for tagged spools.
Belt sensor commands (addr 0x21/0x22, verified)#
| Cmd | Name | Request | Response | Notes |
|---|---|---|---|---|
| 0 | Read version | (none) | 4 bytes firmware version | Both report 01 02 03 04 |
| 2 | Read flash | flash_num (1 byte) | 13 bytes calibration data | |
| 4 | Write flash | flash data | ACK | Write |
| 6 | Read ADC | (none) | 4 bytes raw strain gauge | |
| 8 | Move slider | distance params | ACK | Motor movement |
The status byte makes no difference for belt commands (both 0x00 and 0xFF produce identical responses).
Closed-loop motor commands (addr 0x81-0x85, partially verified)#
| Cmd | Name | Verified | Notes |
|---|---|---|---|
| 0x06 | Set speed (IEEE 754 float) | strace only | No response when idle |
| 0x0C | Read position | live | 8 bytes, all zero when not homed |
| 0x11 | Set mode (01=homing, 02=done) | strace only | No response when idle |
Motors only respond to 0x0C when idle. Speed (0x06) and mode (0x11) require the controller to be initialised by motor_control_wrapper.so first (during the Klipper homing sequence). Motor 0x85 (Z1) responds only during active CFS operations and returns None during startup init.
Operation sequences (from strace)#
Extrude (feed from CFS to nozzle):
- Closed-loop motors: set homing mode (
0x11), set speed (0x06), complete homing, read positions (0x0C). - CFS: buffer check (
0x08), start motor (0x04), enable sensor (0x0F). - CFS: motor init/config/position/mode (
0x10sub 00/04/05/06). - CFS: motor stop (
0x10sub 07), query state (0x05), stop motor (0x04), disable sensor (0x0F). - Encoder polling (
0x0E) until position stable.
Retract (cut + pull back): start motor (0x04), buffer check (0x08), retract polling (0x11 slot, 00), retract complete (0x11 slot, 01), then a ~30 s physical wait and encoder polling until stable. Reload/detect: cmd 0x0D with slot + 0x02, ~44 s wait, ACK + RFID string. Color/material change is software-only - no RS485 traffic, just shared memory + JSON updates.
8. Reverse-Engineering Cython Without the Source#
The standard advice for an .so you cannot read is "use Ghidra". For Cython it mostly does not pay: the body is one giant state machine through __Pyx_* helpers, and the structure you want lives in the Python type slots, not the function bodies. Four techniques carried most of the weight.
MockConfig injection. Build a fake Klipper config object that logs every config.get*(name, default) call. Hand it to the module. You get back every config field the module reads, plus the default the binary expects for it. Zero disassembly, full config map.
SmartSelf proxy. A __getattribute__ proxy that returns real values for known attributes and tracks every other access. Pass it to a method that runs purely in Python (cal_flush_list, for example - it needs list.append to work, so the proxy returns real numbers for config fields but stubs the rest). You see exactly which attributes get touched, in what order. That tells you the call graph from the outside.
Live introspection. import box_wrapper; obj = BoxAction(...); inspect.signature(obj.motor_send_data). Static decode of the parser said the signature was (self, addr, cmd, timeout, state=0, data=b'', retries_en=False). Live introspection said state=b'\x00' (bytes, not int) and retries_en=True. Both defaults were wrong in the static decode. A thirty-second import had them right.
ARM disassembly only where it pays. Cython compiles getattr as a load from a slot table. Every call to box_action.motor_send_data in the consumer binary compiles to an ldr r1, [Rn, #-3020], because slot 3020 is where the attribute name is cached. Grep the disassembly for that exact instruction encoding, cross-reference with the PyMethodDef table, and you have every call site of every method in the consuming binary with one grep and one awk.
Recovery script:
# dump instruction stream
arm-none-eabi-objdump -d motor_control_wrapper.cpython-39.so > mc.asm
# find slot loads against the cached attribute slot (here #-3020)
grep 'ldr.*\[.*, #-3020\]' mc.asm | grep -oE '^[0-9a-f]+' > sites.txt
# for each address, find the containing function via the PyMethodDef table
# (walk the 16-byte entries in .data: each entry is {ml_name, ml_meth, flags, doc})The technique generalises to any Cython consumer: find the slot offset of the attribute you care about (from the __pyx_string_tab init code), grep for ldr Rt, [Rn, #-imm] against that immediate, then map each match to the containing method via the PyMethodDef table. The single-grep result enumerates every call site in the binary.
Two supporting techniques rounded it out: a lightweight diag shim (box_diag_lite.py) that wraps only the ~37 critical methods - not all 144 - to avoid CPU overload on the ARM printer while capturing method calls, gcode commands, toolhead position, and extruder temp; and strings + offset search in the .so to confirm a config field name exists before relying on it.
For config-default extraction specifically, arm-none-eabi-objdump -d the .so, locate each config.getfloat/getint/get call site, and read the immediate pushed as the default argument. That confirmed all ten runtime parameter defaults are hardcoded literals in the V1.1.4.11 binary.
Firmware-image extraction for the panel timings and SoC peripheral map: unpack the CPIO archive, mount the squashfs, then dtc -I dtb -O dts to decompile the DTB.
What the call-site count taught me#
24 distinct call sites of motor_send_data across 19 methods in motor_control_wrapper.so. 17 of them expected a readback: write a parameter, read the same register back, raise an error if the value did not match.
An earlier attempt synthesised motor_send_data for the motor addresses to dodge a homing failure, returning a fixed synthetic ACK of zero bytes. That answered the 7 fire-and-forget call sites correctly. The 17 readback sites all raised key803 "axis step init fails", because zero is not what they wrote.
Rule of thumb: a fast path through a hot-path API only works if you enumerate every consumer first. The original "5 sites" claim was scoped to one method, not the whole binary, and was wrongly treated as the full set.
9. Module Replacement Priority List#
Ordered by printer safety, most critical first.
Priority 1: RS485 transport layer#
serial_485_wrapper.cpython-39.so (140 KB) -> serial_485.py (232 lines, reactor-aware). Written, unit-tested, not deployed - waiting on the motor-control replacement.
Two classes: Serialhdl_485 (low-level handler, background Python thread owning the /dev/ttyS5 fd, a lock protecting the send queue, and a reactor.pause(monotonic() + poll) wait loop) and Serial_485_Wrapper (high-level Klipper interface owning the handler and the registered-response dispatch table). C-level helpers in chelper: serial_485_queue_alloc/send/pull/free/exit/get_stats, msgblock_485_crc8. MAX_PENDING_BLOCKS_485 = 1 (one outstanding transaction at a time).
Why it cannot be replaced alone: the coupling to box_wrapper/motor_control is via the chelper C queue (MAX_PENDING_BLOCKS_485 = 1) that motor_control_wrapper.so uses directly, not via two Python fd opens as first assumed. The result is the same - replace motor_control first. Deployment order: box_wrapper.py (done) -> motor_control.py (not started) -> serial_485.py (ready).
Priority 2: Belt tension - DONE#
belt_mdl.py (stock 621 lines + box_wrapper.so for transport) -> belt_mdl.py (280 lines, pure Python). Production. Full calibration suite verified, flash persistence confirmed across power cycles.
RS485 to belt tensioner sensors (addr 0x21=X, 0x22=Y), strain-gauge ADC read, linear ADC-to-Newton conversion, iterative auto-tensioning with decaying step size (200 iterations max), XY settling via FORCE_MOVE, flash page 3 read/write for position + calibration persistence. Gcode: BELT_MDL_INFO/MOVE/SET/CALI/TEST; webhook belt_mdl_test.
Lessons: the flash write format is [page, marker(3), pos(4), zero(4), full(4)] = 14 bytes of cmd data - without the marker byte the sensor MCU writes to RAM, not persistent flash. Motor controllers (0x81-0x84) retain state across Klipper restarts but reset on power cycle, so after belt calibration a power cycle may be needed before the next homing succeeds. Stale .pyc files can shadow .py files on the printer; always delete them after deployment.
Priority 3: Closed-loop motor control#
motor_control_wrapper.cpython-39.so (1.0 MB) -> not started. API surface fully enumerated (84 PyMethodDef entries, 23 gcode commands, 17 error codes); RS485 protocol decoded for the Python path; the MCU transparent_send path decoded. This module blocks Phase 1 deployment of box_wrapper.py.
Drives 5 closed-loop stepper drivers at 0x81-0x85: Extruder, X, Y, Z, Z1. Classes: Motor_Control (public extras class) and MotorFlashParam (per-motor flash-param cache, nested in motor_init).
Two independent RS485 paths that look alike:
- Path A (Python):
box_action.motor_send_data(addr, cmd, timeout, data=..., retries_en=...)->serial_485_wrapper.cmd_send_data_with_response->/dev/ttyS5. 17 call sites across 13 methods (stall mode,motor_control,motor_get, calibration, error handling, param read/write). - Path B (MCU command):
self.cmd_transparent_send, registered in_build_configvianozzle_mcu.add_config_cmd('transparent_send oid=%c write=%*s timeout_ms=%u'). Sent to the nozzle MCU on/dev/ttyS3; the nozzle MCU forwards the payload to its local RS485 transceiver. Responses return astransparent_response oid=%c read=%*s. Used bymotor_check_protection_after_homeandmotor_auto_check_protection.
The critical detail: the "protection check" gcodes that run during homing do not go through the FOSS Python path. They go through the nozzle MCU. A replacement box_wrapper.py cannot directly affect protection-check timing.
Timing model: no background thread (threading/Thread/Lock absent from the binary), no reactor timer (register_timer/register_fd absent). One-shot reactor callback in force_stop enqueues extruder_motor_retry. reactor.pause loops in several methods yield the reactor rather than busy-wait. register_lookahead_callback x8 inside the run closure for toolhead stepper-queue integration (fires at move print_time). register_buttons for stall-pin MCU callbacks. All RS485 work happens in the gcode-handler context of whichever MOTOR_* command was called, or in the force_stop -> extruder_motor_retry callback context. No periodic polling.
Open item: the PID register map is not resolved. The captured PID-config byte sequence does not appear as a contiguous array in the binary; the rodata has ~80 parameter keys under controller_{cur,spd,pos}_loop_pid_*, chopper_param_*, motor_param_*, encoder_param_*, param_stall_*. Mapping register ID to parameter name needs a body decode of motor_config_params_init and _motor_flash_param.
Fallback: run motors in standard open-loop Klipper mode (step/dir via the main MCU on ttyS2). Loses encoder feedback and crash protection but the printer functions. Risk if wrong: motor runaway. Test with low speeds and one hand on the power switch.
Priority 4: CFS (Creality Filament System)#
box_wrapper.cpython-39.so (1.88 MB) -> box_wrapper.py (~1400 lines). Phase 1 complete, 22 unit tests passing, G28 first-cycle homing verified. Deployment blocked by MOTOR_PROTECT_ERROR during Z homing on subsequent cycles.
Stock binary shape: ARM EABI5, Cython 0.29.21, Python 3.9, stripped, ~1.88 MB. 251 unique Cython methods across 9 classes (BoxAction, BoxCfg, BoxState, BoxSave, BoxError, ParseData, CutSensor, MultiColorMeterialBoxWrapper, plus a closure scope class). The FOSS replacement implements 140 methods. Of the unaccounted-for stock methods, the load-bearing ones are the state-machine layer: process_msg, handle_event, update_state_process, heart_process, state_init, timeout_process, 15 communication_* methods, the box_extrude_material* multi-stage state machine, and the print-lifecycle entries.
Stock architectural pattern: cmd_* (gcode entry) -> communication_* (state-machine wrapper) -> motor_send_data (RS485 transaction). The FOSS wrapper collapses all three layers into the gcode handler. Functionally equivalent for the happy path, but it skips the retries, timeout backoff, unsolicited-frame routing, and dirty-flag updates that communication_* and process_msg perform.
Live-introspection findings (higher confidence than static decode):
def motor_send_data(self, addr, cmd, timeout,
state=b'\x00', data=b'', retries_en=True):
...state defaults to bytes b'\x00', not int 0; retries_en defaults to True. Attribute access order, recovered via a __getattribute__-tracking proxy: self.parse -> parse.parse_num_to_byte -> self.printer -> lookup_object('gcode') -> self._serial -> cmd_send_data_with_response(buf, timeout, retries_en) -> gcode.respond_info(...). The FOSS wrapper exposes all but the last; it is silent where stock logs every transaction to the gcode console.
Stock auto-starts heart_process in _handle_ready - live klippy.log shows a GET_BOX_STATE write to addr 0x01 every ~4 s with no user gcode. The FOSS wrapper only started its heartbeat on an explicit gcode command, which made the CFS box red-blink when polling stopped. Fix: auto-start in _do_cfs_init after _init_boxes.
Synthesis fast-path removed. An attempt to intercept motor_send_data for 0x81-0x85 and return a synthetic ACK [0xF7, addr, 0x04, 0x00, cmd, 0x00, crc8] to dodge the Z-homing error broke 17 of 24 call sites (the init/param sites do write-then-readback and raise key803 when the readback is not the target). The fast-path was removed; the synthesize_motor_send_data config option remains consumed as a no-op so older configs still load.
The Z-homing blocker. First G28 after boot works fully. On a subsequent G28, during the ZDOWN -> move-to-bed-center phase, the touchscreen shows "abnormal resistance under the hotbed" and Klipper shuts down with key60 Internal error on command: G28:
z_align: ZDOWN G29_flag False, now_pos:[175.0, 175.0, 343.018]
homing:move_z: G1 F1800 X175.000 Y175.000 Z37.019
record_z_pos:37.019
3 motor_error_code...
Z MOTOR_PROTECT_ERROR
homing:cmd_G28:464 No active exception to reraiseThe signature matches motor_control's MCU-path failure in check_protection_code - the path on /dev/ttyS3, physically separate from the FOSS box_wrapper on /dev/ttyS5. The initial hypothesis (reactor-mutex contention from the Python forwarding) was disproved: cmd_send_data_with_response does yield the reactor, and the protection check does not even call the Python path. Current leading hypothesis: GIL or main-thread scheduling latency - if FOSS Python callbacks (get_status, event handlers, deferred CFS init) hold the main reactor thread longer than stock's Cython equivalents, the nozzle MCU response handler misses its timeout_ms window. Deployment deferred until the root cause is confirmed.
Proven working with the FOSS module deployed: boot to ready; box detection (state: connect, correct serial, temperature); all BOX_* query commands; MOTOR_STALL_MODE and MOTOR_CHECK_PROTECTION_AFTER_HOME DATA=11 via Moonraker; QUERY_ENDSTOPS; first-cycle G28 (XY + Z) including post-home nozzle clean; Moonraker subscription returning the correct dict shape for master-server.
One captured-trajectory discovery worth the cost: stock nozzle_clean ends with G0 X160 ; G0 Y345 to exit the wipe zone. Without that final Y move, the toolhead stays inside the wipe module and the next prtouch_v3 Z probe fires fault 2762 (PR_ERR_CODE_PRES_NOT_BE_SENSED).
Risk if wrong: filament jam in the CFS tube or a stuck extrude. Annoying, not dangerous to hardware.
Priority 5: Pressure probe (prtouch_v3)#
prtouch_v3_wrapper.cpython-39.so (1.3 MB) -> not started, complex and safety-critical. Reads the bed pressure sensor (strain gauge via HX711 on the main MCU), non-linear temperature compensation, bed mesh probing (9x9 to 25x25), Z offset and Z-home via probe contact, build-plate presence detection. 25+ gcode commands.
It calls three FOSS box gcode commands during post-home cleanup (BOX_GO_TO_EXTRUDE_POS, BOX_MOVE_TO_SAFE_POS, BOX_NOZZLE_CLEAN). These must leave the toolhead at Y = safe_pos_y outside the wipe zone under the correct SET_LIMITS/RESTORE_LIMITS velocity regime, or prtouch fires fault 2762. The FOSS box_wrapper.py replicates the captured stock trajectory exactly and locks it in with snapshot tests.
Fault codes:
| Code | Name | Meaning |
|---|---|---|
| 2762 | PR_ERR_CODE_PRES_NOT_BE_SENSED | Pressure ADC returned zero or invalid |
| 2764 | PR_ERR_CODE_G28_Z_DETECTION_TIMEOUT | Z probe did not trigger within retry count |
| 2765 | PR_ERR_CODE_SWAP_PIN_DETECTI | Synchronization pin test failed |
| - | PR_ERR_CODE_OUT_MAX_TILT | horizontal_move_z too small or bed tilt too large |
| - | PR_ERR_CODE_NEED_RESET_XYZ | G28 XYZ fail, retry needed |
| - | PR_ERR_CODE_G28_ACCU_FAILE | G28 accuracy check failed |
| - | PR_ERR_CODE_PLATFORM_DETECTI | Platform detection failed |
| - | PR_ERR_CODE_REGION_G29 | Region leveling file read failed |
Fallback: a standard probe (BLTouch, inductive, Cartographer). Risk if wrong: nozzle into bed. Test with a high Z offset first.
Priority 6: Filament rack (material database)#
filament_rack_wrapper.cpython-39.so (80 KB) -> cfs.py (partial). Small pure-data class: material database (type, name, target temp, speed per material), RFID tag decoding, material lookup. Called from box_wrapper.py via lookup_object('filament_rack'). Reads material_database.json (~414 KB), remain_material_data.json, system_config.json. Easy half-day project once motor_control is done; low risk - a dict lookup and nothing more.
Already working (no reverse engineering needed)#
Input shaper (LIS2DW + Klipper built-in), PID tuning, Z-tilt adjust, basic bed mesh, sensorless homing (StallGuard), EEPROM (bl24c16f.py), fan feedback (fan_feedback.py), Z alignment (z_align.py), and auto-addressing (auto_addr_wrapper.py, 691 lines, pure Python, uses the serial_485 async queue API). Belt tension is the FOSS replacement, in production.
10. CFS Software Stack and Gcode Surface#
Four layers, bottom to top.
C chelper (compiled .o, linked into Klipper): serial_485_queue.o (56 KB), msgblock_485.o (15 KB), filament_change.o (23 KB), each with a header.
Cython .so (no source):
| File | Size | Purpose |
|---|---|---|
| serial_485_wrapper.cpython-39.so | ~140 KB | RS485 transport (connect, send, recv, ACK) |
| box_wrapper.cpython-39.so | 1.88 MB | CFS orchestrator (60+ gcode commands, tool change, RFID, state) |
| filament_rack_wrapper.cpython-39.so | ~80-194 KB | Filament handling (temp/speed lookup, flush, material DB) |
Python source (readable, backed up): auto_addr_wrapper.py (RS485 address assignment - DataPackage, AddrManager, broadcast constants 0xFF/0xFE/0xFD/0xFC, CMD_SET_SLAVE_ADDR=0xA0, CMD_GET_ADDR_TABLE=0xA3), load_ai.py (AI-based load detection), and ~120-byte import stubs (box.py, filament_rack.py, serial_485.py, auto_addr.py).
Gcode macros (box.cfg): high-level sequences chaining the native BOX_ commands.
Native gcode commands (from box_wrapper.so)#
CFS control: BOX_EXTRUDE_MATERIAL TNN=T1A, BOX_RETRUDE_MATERIAL,
BOX_RETRUDE_MATERIAL_WITH_TNN, BOX_EXTRUDER_EXTRUDE,
BOX_CUT_MATERIAL, BOX_MATERIAL_FLUSH LEN=100 VELOCITY=360 TEMP=220,
BOX_MATERIAL_CHANGE_FLUSH, BOX_MODE_WAIT
CFS queries: BOX_GET_RFID ADDR=1 NUM=A, BOX_GET_REMAIN_LEN, BOX_GET_BOX_MODE,
BOX_GET_BOX_STATE, BOX_GET_BUFFER_STATE, BOX_GET_HARDWARE_STATUS,
BOX_GET_VERSION_SN, BOX_GET_FILAMENT_SENSOR_STATE, BOX_GET_FLUSH_LEN
CFS config: BOX_SET_BOX_MODE, BOX_SET_PRE_LOADING ADDR=1 NUM=A ACTION=RUN/GET/CLEAN,
BOX_MODIFY_TN, BOX_MODIFY_TN_DATA, BOX_ENABLE_AUTO_REFILL,
BOX_ENABLE_CFS_PRINT ENABLE=1, BOX_UPDATE_SAME_MATERIAL_LIST
Lifecycle: BOX_START_PRINT, BOX_END_PRINT, BOX_POWER_LOSS_RESTORE,
BOX_ERROR_CLEAR, BOX_ERROR_RESUME_PROCESS, BOX_SHOW_ERROR
Motor/hw: BOX_GO_TO_EXTRUDE_POS, BOX_MOVE_TO_SAFE_POS, BOX_MOVE_TO_CUT,
BOX_NOZZLE_CLEAN, BOX_SET_TEMP, BOX_SAVE_FAN, BOX_RESTORE_FAN,
BOX_BLOW, BOX_CREATE_CONNECT, BOX_SEND_DATA,
BOX_CTRL_CONNECTION_MOTOR_ACTION
Heart: BOX_ENABLE_HEART_PROCESS, BOX_DISABLE_HEART_PROCESS, BOX_INFO_REFRESH
Slicer iface: CR_BOX_PRE_OPT, CR_BOX_CUT, CR_BOX_RETRUDE LENGTH=x,
CR_BOX_EXTRUDE TNN=T1A, CR_BOX_WASTE, CR_BOX_FLUSH, CR_BOX_END_OPTParameters: ADDR (box 1-4), NUM (slot A-D), TNN (e.g. T1A), ACTION (RUN/GET/CLEAN), plus ENABLE, LENGTH, LEN, VELOCITY, TEMP, SPEED, MODE.
Slicer tool change (M8200)#
M8200 P S[next] -> CR_BOX_PRE_OPT (prepare)
M8200 C S0 -> CR_BOX_CUT (cut filament)
M8200 R I[prev] p0 -> CR_BOX_RETRUDE (retract, optional LENGTH=E)
M8200 L I[next] p1 -> CR_BOX_EXTRUDE (load new, index->Tnn)
M8200 W -> CR_BOX_WASTE (waste bin check)
M8200 F S[speed]L[len] -> CR_BOX_FLUSH (purge with current Tnn)
M8200 O S[next] -> CR_BOX_END_OPT (finalize)Index-to-Tnn formula:
addr = int(index / 4) + 1
num = ['A','B','C','D'][index % 4]
tnn = 'T' + str(addr) + num # 0=T1A, 1=T1B, ... 4=T2A, 5=T2B ...CFS error codes (from box_wrapper.so)#
| Code | Message |
|---|---|
| 835 | Extrude error, blockage at connections |
| 836 | Extrude error, blockage between connections and sensor |
| 837 | Extrude error, blockage between sensor and extrusion gear |
| 838 | Extrude error, through connections but not extruded |
| 839 | No filament detected at box extrude position |
| 840 | Box switch state error |
| 846 | Empty printing, box speed slower than extruder |
| 849 | Retract error, failed to exit connections |
| 850 | Retract error, multiple connections triggered |
| 851 | Retract error, buffer empty limit not triggered |
| 852 | Check extruder filament sensor and box sensor state |
| 860 | Buffer error |
| 863 | Retract error, sensor still detected after retract |
| 864 | Extrude error, buffer full limit not triggered |
| 865 | Retract error, failed to exit connections |
11. AI Detection and Camera#
Camera pipeline#
USB Camera (STD-9613V2, /dev/video0)
|
cam_app (V4L2 capture 1920x1080@15fps NV21, H264 encode, IPC; no AI libs, no net)
| |
| +--> /var/run/h264_uds -> webrtc_local (:8000, libnice/libwebsockets/libssl)
| | STUN stun.l.google.com:19302; POST /call/webrtc_local (base64 SDP)
| |
| +--> /tmp/shm/main_ai_image -> AI pipeline (master-server orchestrated)
| libai_capture.so H264 -> NV21 -> BMP via Allwinner G2D
| libncnn_yolov5.so NCNN + YOLOv5-nano (ARM NEON) + OpenCV 4.1.2
| in: /mnt/UDISK/ai_image/main_capture.bmp (6.2 MB raw)
| out: /mnt/UDISK/ai_image/main_processed_ai_*.jpg (annotated)cam_app is the only capture path and feeds both WebRTC and AI; killing it breaks both camera viewing and AI monitoring.
AI models (/usr/lib/yolov5/)#
| Model | Purpose |
|---|---|
| yolov5n | Base detection |
| yolov5n-waste | Waste / spaghetti / debris (largest model) |
| yolov5n-warp | Print warping |
| yolov5n-ywjc | Foreign objects on bed |
| yolov5n-zwjc | Mid-print quality check |
| best-ll / best_ll | First-layer detection |
| yolov5n-blob | Blob/stringing (referenced, missing on disk) |
Confidence thresholds (user_print_refer.json): spaghetti 77.5%, waste 80.0%, foreign objects 72.5%, plate missing 40.0%, camera-dirty via MSSIM. Other params: pastaTime 25 s between checks, optimalHeight 20 mm, plateMissingOptimalHeight 35 mm.
Flow-detection thresholds (print_para_config.json, nozzle camera, currently disabled): PLA 0.036/0.044/0.050, ABS 0.040/0.050/0.060, PETG 0.066/0.070/0.076, TPU 0.080/0.090/0.100 (min/optimal/max).
Camera access in Fluidd (go2rtc)#
go2rtc runs on the printer as a camera proxy, connecting to webrtc_local on localhost (avoiding browser mDNS and NAT) and serving the stream to Fluidd via MSE. Binary and config live on UDISK (survive updates); nginx proxies /webcam/ to go2rtc on port 8002; Moonraker webcam is service: iframe, stream_url: /camera.html. Does not interfere with AI detection or Creality Print.
Open http://<printer-ip>:4408 in a phone browser; the camera appears in the Fluidd dashboard (direct test: /camera.html). After firmware updates the init.d service, nginx config, Moonraker config, and camera HTML need reapplying.
Replacement options: mjpg-streamer or go2rtc + Obico for AI; OctoEverywhere/Obico + Tailscale/WireGuard for remote access.
12. Display, Errors, Config Files#
Display#
LVGL compiled into display-server (~13 MB binary), drawing to /dev/fb0. Panel ST7701 (Sitronix), 480x800 portrait, 32bpp, SPI init + parallel RGB, 25 MHz pixel clock. Touch Goodix GT9xx (I2C 0x14), /dev/input/event0. Panel timings extracted from the DTB: HFP 8, HBP 8, HSYNC 4, VFP 8, VBP 8, VSYNC 4. Mainline panel-sitronix-st7701 exists but needs adaptation for the SPI-init + parallel-RGB combination. Replacement: KlipperScreen on HDMI/SPI.
Display-server IPC on /tmp/sys_sock is binary protobuf-c (package len=%ld, origin %d, cmd %d, message len %ld): origin 11 internal, 51 external/wifi; cmd 109 wifi info, 307 printer response, 8107 state push. 26 UI_GET_* + 26 UI_SET_* commands; 65 protobuf-c messages in master-server, 57 in display-server.
Error code system#
| Class | Type | Fault codes |
|---|---|---|
| 1-12 | Error | Hardware/safety faults (thermal, motion, sensor) |
| 100 | Generic | 2000-2001 |
| 101 | Exception | 101, 103-104 |
| 201-212 | Prompt | UI prompts (material, calibration, CFS) |
| 800-801 | Light prompt | 2095, 2211, 2242, 2003 |
| 500 | Unknown | Fallback for unmapped codes |
Full mapping in /etc/sysConfig/defData/error_code_map.json.
Config files#
| File | Path | Purpose |
|---|---|---|
| printer.cfg | /mnt/UDISK/printer_data/config/printer.cfg | Main Klipper config |
| sensorless.cfg | .../config/sensorless.cfg | Homing overrides |
| gcode_macro.cfg | .../config/gcode_macro.cfg | All gcode macros (~40 KB) |
| printer_params.cfg | .../config/printer_params.cfg | Product parameters |
| box.cfg | .../config/box.cfg | CFS filament system |
| motor_control.cfg | .../config/motor_control.cfg | Closed-loop motor PID |
| moonraker.conf | /usr/share/moonraker/moonraker.conf | Moonraker API config |
| nginx.conf | /etc/nginx/nginx.conf | Fluidd web server |
User config under /mnt/UDISK/creality/userdata/config/ holds 19 JSON files (system_config.json, user_print_refer.json, print_para_config.json, maintenance_item.json, etc.). Klipper's init script keeps up to 5 timestamped config backups on version changes.
Network ports#
| Port | Service | Protocol |
|---|---|---|
| 22 | Dropbear SSH | TCP |
| 80 / 443 | Creality web-server | HTTP/HTTPS |
| 4408 | Fluidd (nginx) | HTTP |
| 5037 | ADB | TCP |
| 5353 | mDNS | UDP |
| 7125 | Moonraker API | HTTP/WS |
| 8000 | webrtc_local (camera signaling) | HTTP POST |
| 9998 / 9999 | Creality web-server | TCP |
13. Deployment and Rollback#
The printer has no sftp-server; use scp -O (legacy protocol). Delete .pyc files after every deploy and verify the file is the one you expect with md5sum on both sides. Substitute your printer's IP for <printer-ip> below.
# On the printer (SSH as root)
cd /usr/share/klipper/klippy/extras/
# Backup stock if not already done
mkdir -p /root/klipper-extras-backup
cp box_wrapper.cpython-39.so /root/klipper-extras-backup/ 2>/dev/null || true
# Install (from the workstation, then move into place on the printer)
scp -O ~/k2-open-klipper/src/klipper_extras/box_wrapper.py root@<printer-ip>:/tmp/
scp -O ~/k2-open-klipper/src/klipper_extras/box.py root@<printer-ip>:/tmp/
cp /tmp/box_wrapper.py /usr/share/klipper/klippy/extras/
cp /tmp/box.py /usr/share/klipper/klippy/extras/
rm -f /usr/share/klipper/klippy/extras/box_wrapper.cpython-39.so
rm -f /usr/share/klipper/klippy/extras/*.pyc
rm -f /usr/share/klipper/klippy/extras/__pycache__/box*
# Restart in order - no reboot needed
/etc/init.d/klipper stop
sleep 4
/etc/init.d/klipper start
sleep 35 # wait for klippy:ready + deferred CFS init at T+30
/etc/init.d/moonraker restart
sleep 3
/etc/init.d/app restartRollback#
cd /usr/share/klipper/klippy/extras/
cp /root/klipper-extras-backup/box_wrapper.cpython-39.so .
rm -f box_wrapper.py box.py box.pyc box_wrapper.pyc __pycache__/box*
cp /root/klipper-extras-backup/box.py.stock box.py 2>/dev/null || true
/etc/init.d/klipper stop
sleep 4
/etc/init.d/klipper start
sleep 35
/etc/init.d/moonraker restart
/etc/init.d/app restartCurrently deployed: stock. The FOSS box_wrapper.py is blocked by the Z-homing MOTOR_PROTECT_ERROR. When motor_control_wrapper.so is also replaced, the box_wrapper.py + motor_control.py + serial_485.py patch deploys as one atomic change.
14. Custom OS Feasibility#
All hardware has mainline Linux drivers (kernel 5.19+):
| Component | Mainline support | Notes |
|---|---|---|
| SoC | Yes (5.19+) | Allwinner T113-i, armhf (32-bit, not aarch64) |
| Serial + RS485 | Yes | Standard 8250/UART + GPIO direction pin |
| WiFi | Partial | BCM43456 needs proprietary DHD or brcmfmac |
| Camera | Yes | Standard UVC, plug-and-play |
| Display | Needs work | ST7701 SPI-init + parallel-RGB adaptation |
| Touch | Yes | Goodix GT9xx, standard I2C driver |
| Accelerometer | Yes | ST LIS2DW, standard SPI driver |
| MCU firmware | Unchanged | Standard Klipper serial protocol on ttyS2/ttyS3 |
Key constraint: the SoC is armhf (ARMv7, 32-bit). Cross-compiled toolchains and pre-built binaries must target arm-linux-gnueabihf, not aarch64. The GD32F303 MCU firmware on ttyS2/ttyS3 stays untouched - Klipper talks to it over the standard serial protocol regardless of the host OS.
Rebuild from Creality's released source#
Creality released the K2 Series Klipper source (CrealityOfficial/K2_Series_Klipper, GPL-3.0, December 2025): modified Klipper with K2-specific modules (prtouch_v3, motor_control, belt_mdl, z_align, box) and F008 config templates. 87.6% C, 11.1% G-code, 1.2% Python.
Missing (binary-only): filament_change.o, msgblock_485.o, serial_485_queue.o, the three cpython-39.so wrappers, and all proprietary servers (master-server, web-server, cam_app, webrtc_local, display-server). Rebuilding with custom Klipper compiles with the standard build system and keeps all configs from /mnt/UDISK/printer_data/config/, but loses AI detection, Creality Cloud, the touchscreen UI, CFS RFID, and closed-loop motors. Critical configs to preserve: printer.cfg, motor_control.cfg, box.cfg, sensorless.cfg, and the SAVE_CONFIG section (input shaper, bed mesh, auto-cal). Alternative: jamincollins/k2-improvements (archived).
15. Known Quirks#
Bed / first layer#
- ~0.52 mm thermal deformation when heated (a Prusa MK3S is ~0.02 mm).
- Stock 9x9 mesh is insufficient for the 350 mm bed; bump to 13x13 with adaptive/regional probing.
prtouch_v3has a low error rate but limited resolution; no built-in way to save live Z-offset corrections across prints.- Mesh shifts several tenths of a mm between cold and hot bed - use per-temperature mesh profiles.
- Solutions: a graphite bed (eliminates deformation), a Cartographer probe (100x100 mesh).
Firmware bugs#
- v1.1.2.10: blobs during 4-5 h prints, extruder stops mid-print.
- v1.1.3.13 / v1.1.4.8: over-extrusion, first-layer infill ripples.
- USB firmware updates can brick (stuck at boot logo).
- CFS display can hang on "loading material".
- Exhaust fan may run indefinitely after a print completes.
- Firmware blocks the
eiinput shaper type via theAUTOTUNE_SHAPERSmacro. - Stock resonance testing only measures the Y axis but applies the result to both.
Chamber heating#
Stock uses exhaust-fan watermark control (full speed at 35 C). A custom dynamic proportional controller (target 38 C, max 80%, 15 s loop) behaves far better. For ABS/ASA/PA, force the exhaust off via filament_start_gcode and vent on cooldown. Below 15 C ambient the chamber may never reach target.
System#
- No package manager (opkg database empty, squashfs ROM); BusyBox-only userland.
- Klipper is pinned to CPU1 only (half the available compute).
forced_levelingin[virtual_sdcard]forces a full bed mesh every print - set it tofalseto skip.- Creality's fork does not support upstream's
adaptive_marginforbed_mesh. - NTP servers default to Chinese pools (
aliyun.com,cn.pool.ntp.org); switch to a local pool. - The PETG-clog trigger that started this: stock part-fan profiles (80% part / 100% overhang) cool the hotend block below setpoint during overhangs, the heater cannot catch up, back-pressure spikes, the extruder backs up. Capping the part fan at 45% and the overhang fan at 80% for PETG made it disappear. The touchscreen blamed wet filament; the filament was not wet.
16. Improvement Projects Without Replacing Firmware#
Macro enhancements#
- MasterLufier/Creality-K2-Plus-Custom-Macros - most complete macro set: dynamic exhaust fan control (fixes "always on at 35 C"), per-temperature bed mesh storage and auto-loading, fast reprint, configurable bed soak, resume optimization, raises max bed temp to 140 C, graphite bed configs. Requires firmware 1.1.3.13; avoid 1.1.4.8. Needs SSH.
- Stevetm2/K2_Custom_Macros (K2_FIL_DB+) - RFID-keyed filament database (PA, flow, Z-offset per filament), 20 mm pre-cut retraction (vs stock 80 mm), multi-CFS support. Z-offset saving is experimental.
- minimal3dp/k2_powerups - disables forced bed leveling, optimized thermal sequencing, temperature-aware mesh loading, screws tilt adjust. Does not require root - warranty-safe.
Camera / remote access#
- go2rtc - converts the K2's WebRTC to MJPEG/RTSP/HLS for any client; works with Home Assistant's built-in go2rtc.
- ha_creality_ws - full Home Assistant integration (sensors, controls, camera, CFS monitoring).
- OctoEverywhere + Gadget AI - free unlimited remote access + independent AI failure detection.
Physical mods#
Vibration-dampening feet (reduces ringing/ghosting), a lid riser with ventilation (prevents Bowden tube rubbing), an internal poop bin (contains CFS multicolor purge waste), a chamber-heater recirculation mod (faster warmup), and a graphite bed (eliminates thermal deformation).
Quick wins (config only)#
forced_leveling: falsein[virtual_sdcard]to skip full mesh every print.- Change NTP servers from the Chinese defaults to a local pool.
- Set the timezone correctly (ships mismatched).
- Increase bed mesh to 13x13 or higher.
- Create per-temperature mesh profiles.
- Add an SSH key to
/etc/dropbear/authorized_keysfor passwordless access.