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 .so modules. 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#

  1. Goal and Status
  2. Hardware Platform
  3. Firmware and OS
  4. eMMC Partition Map
  5. Boot Sequence
  6. Process Map and IPC
  7. The RS485 Bus
  8. Reverse-Engineering Cython Without the Source
  9. Module Replacement Priority List
  10. CFS Software Stack and Gcode Surface
  11. AI Detection and Camera
  12. Display, Errors, Config Files
  13. Deployment and Rollback
  14. Custom OS Feasibility
  15. Known Quirks
  16. 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#

ComponentDetail
SoCAllwinner T113-i (sun8iw20p1), 2x ARM Cortex-A7 @ 1 GHz, NEON SIMD
RAM512 MB DRAM (488 MB usable, 16 MB CMA), no swap
StorageeMMC (mmcblk0): ~122.5 MB squashfs ROM + 240 MB overlay + 27.5 GB UDISK, A/B rootfs
LCDST7701, 480x800, 32bpp, SPI 9-bit init + parallel RGB
TouchGoodix GT9xx capacitive (I2C, address 0x14), /dev/input/event0
WiFiBCM43456 (AP6256), SDIO, dhd driver, 5 GHz capable
CameraSTD-9613V2, USB UVC, /dev/video0, 1920x1080@15fps
Console/dev/ttyS0 @ 115200 (askfirst login)
Main MCUGD32F303RET6, /dev/ttyS2 @ 230400 (main board)
Nozzle MCUGD32F303CBT6, /dev/ttyS3 @ 230400 (nozzle)
RS485/dev/ttyS5 @ 230400 (CFS, motors, belt sensors, RFID)
AccelerometerLIS2DW on nozzle MCU (SPI)
Z Probeprtouch_v3 (pressure sensor, non-linear temp compensation)
EEPROMBL24C16F on i2c.1 @ 400 kHz
MotorsClosed-loop (PID + LESO observer) on all 5 axes
TEEOP-TEE (Trusted Execution Environment)
DSPAllwinner HiFi4 DSP (own A/B partitions, unused by print firmware)
RISC-VE906 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#

PropertyValue
Official firmwareV1.1.4.11 (board ID CR0CN240110C10)
UpcomingV1.1.5.2 (on CDN, not yet stable)
CFS box firmwarev1.1.3
MCU firmwareKlipper 1.1.0.48-293-g493f9a0f-dirty (2024-12-20)
OSAllwinner Tina 5.0 (OpenWrt 21.02-SNAPSHOT base)
KernelLinux 5.4.61 SMP PREEMPT armv7l
libcglibc 2.29, GCC 8.3.0
Shell/bin/ash (BusyBox v1.33.2)
Initprocd (OpenWrt service manager)
PackagesBaked into squashfs ROM, opkg database empty
FilesystemOverlayFS: 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:

PartitionLabelTypePurpose
p1envrawU-Boot environment
p2boot_arawKernel + DTB (slot A)
p3boot_brawKernel + DTB (slot B)
p4rootfs_asquashfsRoot filesystem (slot A, RO, ~122 MB)
p5rootfs_bsquashfsRoot filesystem (slot B, RO)
p6privateext4Per-device keys and calibration
p7appfs_aext4Creality app data (slot A)
p8appfs_bext4Creality app data (slot B)
p9private2ext4Secondary persistent / keybox storage
p10overlayext4OverlayFS upper layer (RW, survives updates, 240 MB)
p11factory / rvAext4 / rawFactory calibration or RISC-V firmware A
p12rsvd / rvBrawReserved or RISC-V firmware B
p13rsvd / rv_datarawReserved or RISC-V data
p14UDISKext4User 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:

  1. upgrade-server receives the .img (USB stick or network).
  2. SWUpdate extracts and validates the CPIO payload.
  3. Writes the inactive rootfs slot (A or B) with the new squashfs.
  4. Updates boot flags in U-Boot env to activate the new slot on next boot.
  5. Printer reboots; U-Boot activates the new slot.
  6. On successful boot the new slot is marked stable; the old slot is retained for rollback.
  7. /overlay and UDISK are 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 above

Underneath sits Python Klipper, pinned to CPU1, OOM score -500. Underneath that sits the Cython.

Shared Memory (POSIX, /tmp/shm/)#

RegionSizeConsumersContent
device_state_shm1 MBmaster, web, app, displayTemperatures, positions, print state (protobuf-c)
print_object_shm1 MBmaster, web, displayPrint job objects, progress (JSON)
materail_box_shm1 MBmaster, web, app, displayCFS slot state, RFID, filament info (JSON)
dev_maintain_shm1 MBmaster, displayMaintenance counters (protobuf-c)
main_ai_image1 Bcam_appAI capture flag
main_timelapse1 Bcam_appTimelapse capture flag

Each has a POSIX semaphore (sem.*). master-server is the writer to all four main regions; everything else reads.

Unix Domain Sockets#

SocketOwnerPurpose
/tmp/klippy_udsklipperKlipper API (moonraker, master-server connect)
moonraker.sockmoonrakerMoonraker API (on UDISK)
/var/run/h264_udscam_appH264 frame stream (webrtc_local connects)
/tmp/sys_sockmaster-serverCreality system IPC (display-server connects, protobuf-c)
/var/run/ubus/ubus.sockubusdOpenWrt system bus
/var/run/wipe.sockwipe_dataFactory reset trigger

Serial UART#

PortBaudOwnerPurpose
ttyS0115200askfirstSerial console
ttyS2230400klipper (fd 10)Main MCU (GD32F303RET6)
ttyS3230400klipper (fd 20)Nozzle MCU (GD32F303CBT6)
ttyS5230400klipper (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-duplex
  • 0xF7: sync/head byte
  • length: len(data) + 3 (counts status + cmd + crc)
  • status: 0x00 for responses/queries, 0xFF for requests
  • crc8: CRC-8, polynomial 0x07, 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 crc

Slot 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)#

CmdNameRequestResponseTimeout (stock)
0x01CREATE_CONNECT[]ACK2 s
0x02GET_RFID[slot_mask]ASCII {SLOT}:{status}; per slot2 s
0x03GET_REMAIN_LEN[slot_mask]remaining length2 s
0x04SET_BOX_MODE / motor start-stop[slot, dir] (01=fwd, 00=stop)ACK2 s
0x05GET_BUFFER_STATE / motor state[sub] (status byte, 2=idle)status2 s
0x06CTRL_MATERIAL_MOTOR_ACTION (WRITE)[slot, action]ACK2 s
0x07CTRL_CONNECTION_MOTOR_ACTION (WRITE)[slot, action]ACK2 s
0x08GET_FILAMENT_SENSOR_STATE[sub] (01=enable, 00=disable)ACK2 s
0x09SET_MOTOR_SPEED[...]ACK2 s
0x0AGET_BOX_STATE[] -> [temp, humidity, ...]data3600 s (heat soak)
0x0CGET_BOX_MODE[]mode2 s
0x0DSET_PRE_LOADING / reload-detect[slot_mask, enable]ACK (44 s delay) + RFID300 s
0x0EMEASURING_WHEEL / encoder poll[0x01]4-byte float / position2 s
0x0FTIGHTEN_UP_ENABLE[...]ACK2 s
0x10EXTRUDE_PROCESS[slot, stage, param] state machinevaries15 s
0x11RETRUDE_PROCESS (sic)[slot, phase] state machineACK150 s
0x13EXTRUDE_PROCESS_MODEL2[...]varies150 s
0x14GET_VERSION_SN[]version + serial2 s
0x15GET_HARDWARE_STATUS[]status2 s
0x55COMMUNICATION_TEST[]ACK2 s
0xA2Identity/online[]device count + 12-byte UUID2 s
0xA3Address table[]device count + 12-byte UUID2 s

An earlier version of this table had 0x06 labelled as a read command and 0x07 as GET_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)#

CmdNameRequestResponseNotes
0Read version(none)4 bytes firmware versionBoth report 01 02 03 04
2Read flashflash_num (1 byte)13 bytes calibration data
4Write flashflash dataACKWrite
6Read ADC(none)4 bytes raw strain gauge
8Move sliderdistance paramsACKMotor 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)#

CmdNameVerifiedNotes
0x06Set speed (IEEE 754 float)strace onlyNo response when idle
0x0CRead positionlive8 bytes, all zero when not homed
0x11Set mode (01=homing, 02=done)strace onlyNo 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):

  1. Closed-loop motors: set homing mode (0x11), set speed (0x06), complete homing, read positions (0x0C).
  2. CFS: buffer check (0x08), start motor (0x04), enable sensor (0x0F).
  3. CFS: motor init/config/position/mode (0x10 sub 00/04/05/06).
  4. CFS: motor stop (0x10 sub 07), query state (0x05), stop motor (0x04), disable sensor (0x0F).
  5. 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_config via nozzle_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 as transparent_response oid=%c read=%*s. Used by motor_check_protection_after_home and motor_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 reraise

The 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:

CodeNameMeaning
2762PR_ERR_CODE_PRES_NOT_BE_SENSEDPressure ADC returned zero or invalid
2764PR_ERR_CODE_G28_Z_DETECTION_TIMEOUTZ probe did not trigger within retry count
2765PR_ERR_CODE_SWAP_PIN_DETECTISynchronization pin test failed
-PR_ERR_CODE_OUT_MAX_TILThorizontal_move_z too small or bed tilt too large
-PR_ERR_CODE_NEED_RESET_XYZG28 XYZ fail, retry needed
-PR_ERR_CODE_G28_ACCU_FAILEG28 accuracy check failed
-PR_ERR_CODE_PLATFORM_DETECTIPlatform detection failed
-PR_ERR_CODE_REGION_G29Region 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):

FileSizePurpose
serial_485_wrapper.cpython-39.so~140 KBRS485 transport (connect, send, recv, ACK)
box_wrapper.cpython-39.so1.88 MBCFS orchestrator (60+ gcode commands, tool change, RFID, state)
filament_rack_wrapper.cpython-39.so~80-194 KBFilament 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_OPT

Parameters: 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)#

CodeMessage
835Extrude error, blockage at connections
836Extrude error, blockage between connections and sensor
837Extrude error, blockage between sensor and extrusion gear
838Extrude error, through connections but not extruded
839No filament detected at box extrude position
840Box switch state error
846Empty printing, box speed slower than extruder
849Retract error, failed to exit connections
850Retract error, multiple connections triggered
851Retract error, buffer empty limit not triggered
852Check extruder filament sensor and box sensor state
860Buffer error
863Retract error, sensor still detected after retract
864Extrude error, buffer full limit not triggered
865Retract 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/)#

ModelPurpose
yolov5nBase detection
yolov5n-wasteWaste / spaghetti / debris (largest model)
yolov5n-warpPrint warping
yolov5n-ywjcForeign objects on bed
yolov5n-zwjcMid-print quality check
best-ll / best_llFirst-layer detection
yolov5n-blobBlob/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#

ClassTypeFault codes
1-12ErrorHardware/safety faults (thermal, motion, sensor)
100Generic2000-2001
101Exception101, 103-104
201-212PromptUI prompts (material, calibration, CFS)
800-801Light prompt2095, 2211, 2242, 2003
500UnknownFallback for unmapped codes

Full mapping in /etc/sysConfig/defData/error_code_map.json.

Config files#

FilePathPurpose
printer.cfg/mnt/UDISK/printer_data/config/printer.cfgMain Klipper config
sensorless.cfg.../config/sensorless.cfgHoming overrides
gcode_macro.cfg.../config/gcode_macro.cfgAll gcode macros (~40 KB)
printer_params.cfg.../config/printer_params.cfgProduct parameters
box.cfg.../config/box.cfgCFS filament system
motor_control.cfg.../config/motor_control.cfgClosed-loop motor PID
moonraker.conf/usr/share/moonraker/moonraker.confMoonraker API config
nginx.conf/etc/nginx/nginx.confFluidd 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#

PortServiceProtocol
22Dropbear SSHTCP
80 / 443Creality web-serverHTTP/HTTPS
4408Fluidd (nginx)HTTP
5037ADBTCP
5353mDNSUDP
7125Moonraker APIHTTP/WS
8000webrtc_local (camera signaling)HTTP POST
9998 / 9999Creality web-serverTCP

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 restart

Rollback#

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 restart

Currently 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+):

ComponentMainline supportNotes
SoCYes (5.19+)Allwinner T113-i, armhf (32-bit, not aarch64)
Serial + RS485YesStandard 8250/UART + GPIO direction pin
WiFiPartialBCM43456 needs proprietary DHD or brcmfmac
CameraYesStandard UVC, plug-and-play
DisplayNeeds workST7701 SPI-init + parallel-RGB adaptation
TouchYesGoodix GT9xx, standard I2C driver
AccelerometerYesST LIS2DW, standard SPI driver
MCU firmwareUnchangedStandard 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_v3 has 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 ei input shaper type via the AUTOTUNE_SHAPERS macro.
  • 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_leveling in [virtual_sdcard] forces a full bed mesh every print - set it to false to skip.
  • Creality's fork does not support upstream's adaptive_margin for bed_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)#

  1. forced_leveling: false in [virtual_sdcard] to skip full mesh every print.
  2. Change NTP servers from the Chinese defaults to a local pool.
  3. Set the timezone correctly (ships mismatched).
  4. Increase bed mesh to 13x13 or higher.
  5. Create per-temperature mesh profiles.
  6. Add an SSH key to /etc/dropbear/authorized_keys for passwordless access.