Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions content/customisable_qidibox_firmware/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ All of the work here is very strongly a Work In Progress. If you wish to use it

The following moves a significant portion of the QidiBox filament changing, purging, and cleaning out of the obfuscated `*.so` files that the QidiBox firmware ships with, and into a single gcode macro config file that can be easily edited to modify the behaviour of the g-code macros. Additionally more generic filament change macros are added that allows for the QidiBox to be used with OrcaSlicer (tested), QidiStudio (tested), BambuStido (untested) and PrusaSlicer (maybe?)

## Sandbox-verified fix bundle (2026-08-05)

A patch set on top of this module collection — **9 fixes** (6 fork-independent + 3 mainline-drift),
verified in a Klipper software sandbox (**NOT yet tested on a physical printer** — use with care):

- [sandbox-fixes-2026-08-05/](./sandbox-fixes-2026-08-05/) — patches, required config files, install checklist, and verification evidence.

## Patching Qidi Plus 4 firmware

Using an SSH shell to the printer, perform the following changes:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# QIDI BOX — Sandbox-Verified Fix Bundle (2026-08-05)

> ## ⚠️ IMPORTANT DISCLAIMER
>
> **This bundle has NOT been tested on a physical QIDI Plus 4 printer.**
>
> All fixes were verified in a **Klipper software sandbox only** (Klipper's own CI file-output
> debug mode — virtual MCUs, no serial, no hardware). The sandbox runs **mainline Klipper**, while
> the real printer runs QIDI's fork (1.8.1), so fork-only API calls were guarded but not exercised
> on real hardware.
>
> **Use at your own risk.** Follow the install checklist, keep backups of your `*.so` files, and be
> ready to restore. If in doubt, test one change at a time. This is a community contribution with
> no guarantees — same as the parent module set.

---

## Authorship & testing status

- **Coded by:** Hermes, an AI coding assistant, with human review (2026-08-05).
- **Tested on real hardware:** **NO** — sandbox verification only (see disclaimer above).
- **Audience:** the QIDI Plus 4 community. Contributions, corrections and real-hardware
testing reports are very welcome.

## What this is

A verified patch set on top of the community `.so → .py` swap
(`customisable_qidibox_firmware`). It fixes **9 crashes/bugs** found by running the swap's modules
through a full Klipper sandbox (all box commands: load/unload, tool change, purge, temp set, RFID,
self-inspection, retry paths, unload macro).

- **6 fixes are fork-independent** (real-printer relevant)
- **3 fixes are mainline-drift shims** (only needed for FreeDi/mainline Klipper installs; the QIDI
fork already has those APIs)

## Files

```
sandbox-fixes-2026-08-05/
├── patches/ # apply with: patch -p1 < <file>
│ ├── box_extras.py.patch # fixes 2,3,6,7,8,9
│ ├── aht20_f.py.patch # fix 1
│ ├── box_stepper.py.patch # fix 4 (mainline-drift)
│ ├── filament_motion_sensor.py.patch # fix 5 (mainline-drift)
│ └── unload-macro-E-25.patch # macro fix (stock unload extruded 25 mm)
└── required_config/ # files the swap silently depends on
├── box_macros.cfg # companion macro file (CLEAR_FLUSH dependency)
├── officiall_filas_list.cfg # filament temp list (real, from QIDI firmware dump)
└── box_extras.example.cfg # example [box_extras] incl. filament_list_path
```

## Fix inventory

| # | File | Fix | Class |
|---|------|-----|-------|
| 1 | aht20_f.py | 4× None-guards on i2c reads (connect/CCP/AFE paths) — prevents connect crash when a read returns None | fork-independent |
| 2 | box_extras.py | accept `buffer_pin` config option (the auto-generated box.cfg always emits it; without this the .py swap fails to BOOT) | fork-independent |
| 3 | box_extras.py | E_UNLOAD: `get_temp_by_num()` None → 190 °C default (front-panel unload crash) | fork-independent |
| 4 | box_stepper.py | `note_kinematic_activity()` → `get_last_move_time()` (removed from mainline) | mainline-drift |
| 5 | filament_motion_sensor.py | QIDI-fork sensor + `note_filament_present(eventtime, bool)` signature | mainline-drift |
| 6 | box_extras.py | `set_box_temp`: `box_max_temps` KeyError + temp_info None guards (BOX_TEMP_SET crash when no box detected) | fork-independent |
| 7 | box_extras.py | TRY_MOVE_AGAIN: `_00`-suffix int-normalization + 2× None-temp guards (retry-button crash) | fork-independent |
| 8 | box_extras.py | `heater.set_can_wait()` guarded with `hasattr` (QIDI-fork-only API — TOOL_CHANGE_START crashes mainline) | mainline-drift |
| 9 | box_extras.py | `filament_list_path` config option (hardcoded `/home/mks/...` path broke non-QIDI hosts) + 3× None-guards (button_extruder_load ×2, QDE_004_009 retry path) | fork-independent |
| — | box.cfg macro | `G1 E25 F300` → `G1 E-25 F300` in `UNLOAD_FILAMENT` (stock unload EXTRUDED 25 mm instead of retracting — verified against QIDI's own generated template) | fork-independent |

## Install checklist (ALL items verified required in sandbox)

1. **`box_macros.cfg`** must be present — the registered `CLEAR_FLUSH` command runs `BOX_CLEAR_FLUSH`;
without this file flushing **silently no-ops** (clogged nozzle / color bleed risk).
2. **`officiall_filas_list.cfg`** + `filament_list_path` in `[box_extras]` — temp lookups read this
file; the hardcoded default path only exists on QIDI printers.
3. **`[filament_switch_sensor fila]`** section must exist (macros reference `SENSOR=fila`; missing →
SET_FILAMENT_SENSOR error → shutdown).
4. **`[box_extras]` pins** (real, from QIDI's own template): `b_button_pin: ^mcu_box1:PB1`,
`b_endstop_pin: mcu_box1:PA9`, `e_endstop_pin: mcu_box1:PA10`, `buffer_pin: ^mcu_box1:PB0`.
5. Box includes load AFTER heaters/extruder (box includes at the END of printer.cfg).

## What was NOT fixed (stock-firmware side)

- **QDE_004_024 / M104 S0 temp-stability** (GitHub issue I-12): emitted by the stock `.so` module,
not the community `.py`. The sandbox confirmed the `.py` path handles M104 S0 + re-heat cleanly,
but the `.so` behavior needs QIDI to fix.
- **Purge amounts** (issue I-06): `EXTRUSION_AND_FLUSH` hardcodes ~180 mm of purge per change — a
separate config-level change.

## How it was verified (reproducible)

- Klipper file-output debug mode (`-d dict -o out -i gcode`), synthesized STM32-pin dictionary,
3 virtual MCUs (mainboard/toolhead/box).
- 9 test files, all exit 0 with clean "Exiting" markers: boot, box command surface, unload macro
(E-25 A/B), auto-reload + retry, **tool change** (TOOL_CHANGE_START/END + BOX_CHANGE_FILAMENT),
**purge** (EXTRUSION_AND_FLUSH), **temp stability** (M104 S0 + re-heat), **misc batch**
(RELOAD_ALL / CLEAR_RUNOUT_NUM / TIGHTEN_FILAMENT).
- Fork-only command warnings observed on mainline (fine on the real fork): `QIDI_PROBE_PIN_2`,
`BED_MESH_CLEAR`, `CLEAR_MOTION_DATA`.
- **Again: sandbox only. Not yet tested on a physical printer.**

## Related

- Parent module set: `../README.md` (customisable_qidibox_firmware)
- Full sandbox + findings log maintained separately (see session docs 2026-08-05)
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
--- a/aht20_f.py
+++ b/aht20_f.py
@@ -101,7 +101,7 @@

# Read data
read = self.i2c.i2c_read([], 7)
- if read is None:
+ if read is None or 'response' not in read:
logging.warning("aht20_f: received data from" +
" i2c_read is None")
continue
@@ -125,7 +125,10 @@
except Exception as e:
logging.exception("aht20_f: exception encountered" +
" reading data: %s"%str(e))
- self._reset_device()
+ try:
+ self._reset_device()
+ except Exception:
+ logging.exception("aht20_f: reset_device failed")
return False


@@ -156,7 +159,10 @@
self.i2c.i2c_write(AHT20_F_COMMANDS['OTP_CCP'])
self.reactor.pause(self.reactor.monotonic() + .10)

- read = self.i2c.i2c_read([], 3)
+ read = self.i2c.i2c_read([], 3)
+ if read is None or 'response' not in read:
+ logging.warning("aht20_f: reset read#1 returned no data - skipping CCP calibration")
+ return
data = bytearray(read['response'])

AHT20_F_COMMANDS['CCP_CCN'][1] = data[1]
@@ -167,7 +173,10 @@
self.i2c.i2c_write(AHT20_F_COMMANDS['OTP_AFE'])
self.reactor.pause(self.reactor.monotonic() + .10)

- read = self.i2c.i2c_read([], 3)
+ read = self.i2c.i2c_read([], 3)
+ if read is None or 'response' not in read:
+ logging.warning("aht20_f: reset read#2 returned no data - skipping AFE calibration")
+ return
data = bytearray(read['response'])

AHT20_F_COMMANDS['AFE_CFG'][1] = data[1]
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
--- a/box_extras.py
+++ b/box_extras.py
@@ -179,7 +179,9 @@
self.next_transform = self.gcode_move.set_move_transform(self, force=True)
self.last_position = self.next_transform.get_position()
toolhead = self.printer.lookup_object('toolhead')
- toolhead.get_extruder().get_heater().set_can_wait(False)
+ heater = toolhead.get_extruder().get_heater()
+ if hasattr(heater, 'set_can_wait'): # QIDI-fork only; mainline lacks it
+ heater.set_can_wait(False)
def cmd_TOOL_CHANGE_END(self, gcmd):
box_extras = self.printer.lookup_object('box_extras')
box_extras.save_variable("is_tool_change", 0)
@@ -189,7 +191,9 @@
self.next_transform = None
self.gcode_move.reset_last_position()
toolhead = self.printer.lookup_object('toolhead')
- toolhead.get_extruder().get_heater().set_can_wait(True)
+ heater = toolhead.get_extruder().get_heater()
+ if hasattr(heater, 'set_can_wait'): # QIDI-fork only; mainline lacks it
+ heater.set_can_wait(True)

def get_position(self):
return list(self.last_position)
@@ -203,6 +207,11 @@
reactor = self.printer.get_reactor()
b_button_pin = config.get('b_button_pin')
self.b_button = BoxButton(config, b_button_pin, self.b_button_callback)
+ # Sandbox Fix #9: filament temp list path must be configurable — the
+ # hardcoded /home/mks path only exists on QIDI printers (breaks the
+ # community .py swap on FreeDi/mainline/other hosts).
+ self.filament_list_path = config.get('filament_list_path',
+ '/home/mks/printer_data/config/officiall_filas_list.cfg')
self.b_endstop_state = 0
b_endstop_pin = config.get('b_endstop_pin')
self.b_endstop = BoxEndstop(config, 'b_endstop', b_endstop_pin)
@@ -213,6 +222,9 @@
self.e_endstop = BoxEndstop(config, 'e_endstop', e_endstop_pin)
self.e_output = BoxOutput(config, e_endstop_pin)
self.e_endstop_timer = reactor.register_timer(self.update_e_endstop)
+ # Sandbox Fix #2: accept buffer_pin from the auto-generated box.cfg
+ # (stock .so consumes it; without this the .py swap fails config validation)
+ self.buffer_pin = config.get('buffer_pin', None)

self.tool_change = ToolChange(config)
self.can_load_slot = True
@@ -394,8 +406,11 @@
stepper_number = match.group(2)
else:
self.gcode.respond_info("Invalid retry_step format")
+ return
self.save_variable('retry_step', None)
- retry_stpper = self.printer.lookup_object('box_stepper slot' + stepper_number)
+ # Sandbox Fix #7: saved retry_steps use '_00' suffix; int() normalizes to slot0.
+ # Previously 'box_stepper slot00' lookup crashed klippy (real-printer retry button).
+ retry_stpper = self.printer.lookup_object('box_stepper slot' + str(int(stepper_number)))
self.gcode.run_script_from_command("DISABLE_ALL_SENSOR")

if step_name == 'QDE_004_001':
@@ -419,7 +434,8 @@
self.gcode.run_script_from_command("M109 S%s" %(str(print_temp)))
retry_stpper.cmd_EXTRUDER_LOAD(gcmd)
else:
- print_temp = self.get_temp_by_slot(retry_stpper.stepper_name)['max_temp']
+ temp_info = self.get_temp_by_slot(retry_stpper.stepper_name)
+ print_temp = temp_info['max_temp'] if temp_info is not None else 250
self.gcode.run_script_from_command("M109 S%s" %(str(print_temp)))
retry_stpper.cmd_EXTRUDER_LOAD(gcmd)
self.gcode.run_script_from_command("M104 S0")
@@ -430,7 +446,8 @@
self.gcode.run_script_from_command("M109 S%s" %(str(print_temp)))
retry_stpper.cmd_EXTRUDER_LOAD(gcmd)
else:
- print_temp = self.get_temp_by_slot(retry_stpper.stepper_name)['max_temp']
+ temp_info = self.get_temp_by_slot(retry_stpper.stepper_name)
+ print_temp = temp_info['max_temp'] if temp_info is not None else 250
self.gcode.run_script_from_command("M109 S%s" %(str(print_temp)))
retry_stpper.cmd_EXTRUDER_LOAD(gcmd)
self.gcode.run_script_from_command("M104 S0")
@@ -443,7 +460,8 @@
self.gcode.run_script_from_command("M109 S%s" %(str(print_temp)))
retry_stpper.cmd_EXTRUDER_UNLOAD(gcmd)
else:
- print_temp = self.get_temp_by_slot(retry_stpper.stepper_name)['max_temp']
+ temp_info = self.get_temp_by_slot(retry_stpper.stepper_name)
+ print_temp = temp_info['max_temp'] if temp_info is not None else 250 # Fix #9 None-guard
self.gcode.run_script_from_command("M109 S%s" %(str(print_temp)))
retry_stpper.cmd_EXTRUDER_UNLOAD(gcmd)
self.gcode.run_script_from_command("M104 S0")
@@ -475,13 +493,15 @@
return

# Unload the old filament and purge what we can. Use minimum temperatures to minimise oozing
- old_filament_extrude_temp = self.get_temp_by_slot(loaded_extruder)['min_temp']
+ temp_info = self.get_temp_by_slot(loaded_extruder)
+ old_filament_extrude_temp = temp_info['min_temp'] if temp_info is not None else 190 # Fix #9 None-guard
self.gcode.run_script_from_command("BOX_MOVE_HEATING HOTEND=%s" %(str(old_filament_extrude_temp)))
self.printer.lookup_object('box_stepper ' + str(loaded_extruder)).cmd_EXTRUDER_UNLOAD(gcmd)
self.gcode.run_script_from_command("M400\nM83\nG1 E20 F300\nM400")

# Load in the new filament. Use the higher of the new filament and any old filament temperatures
- new_filament_extrude_temp = self.get_temp_by_num(selected_slot)['min_temp']
+ temp_info = self.get_temp_by_num(selected_slot)
+ new_filament_extrude_temp = temp_info['min_temp'] if temp_info is not None else 190 # Fix #9 None-guard
extrude_temp = max(new_filament_extrude_temp, old_filament_extrude_temp)
self.gcode.run_script_from_command("BOX_MOVE_HEATING HOTEND=%s" %(str(extrude_temp)))
stepper.cmd_EXTRUDER_LOAD(gcmd)
@@ -503,7 +523,10 @@
self.box_button_state = 0
return False

- extrude_temp = self.get_temp_by_num(selected_slot)['min_temp']
+ # Sandbox Fix #3: get_temp_by_num() returns None for unconfigured slots;
+ # subscripts crash klippy (front-panel E_UNLOAD = brick-scare path).
+ temp_info = self.get_temp_by_num(selected_slot)
+ extrude_temp = temp_info['min_temp'] if temp_info is not None else 190
self.gcode.run_script_from_command("BOX_MOVE_HEATING HOTEND=%s" %(str(extrude_temp)))
self.gcode.run_script_from_command("M400\nCUT_FILAMENT\nMOVE_TO_TRASH\nM83")
stepper.cmd_EXTRUDER_UNLOAD(gcmd,False)
@@ -574,7 +597,10 @@
stepper = self.printer.lookup_object('box_stepper slot' + str(j + 4 * i))
if not stepper.r_endstop_state:##had load filament
#filament = self.get_value_by_key(f'filament_slot' + str(j + 4 * i))
- box_max_temp = self.get_box_temp_by_slot(slot)['max_temp']
+ temp_info = self.get_box_temp_by_slot(slot)
+ if temp_info is None:
+ continue
+ box_max_temp = temp_info['max_temp']
if current_box_max_temp == -1:
current_box_max_temp = box_max_temp
else:
@@ -592,7 +618,11 @@
stepper = self.printer.lookup_object('box_stepper ' + str(slot))
slot_num = stepper.stepper_num

- box_num = slot_num // 4
+ box_num = slot_num // 4
+ # Sandbox Fix #6: box_max_temps only covers detected boxes (box_count);
+ # a stale/zero box_count previously crashed with KeyError on any VTx set.
+ if box_num not in box_max_temps:
+ continue
if box_max_temps[f'box_{box_num}']['set'] == -1:
if value >= box_max_temps[f'box_{box_num}']['max_temp']:
box_max_temps[f'box_{box_num}']['set'] = box_max_temps[f'box_{box_num}']['max_temp']
@@ -867,7 +897,7 @@
return -1

def get_temp_by_num(self, num):
- list_path="/home/mks/printer_data/config/officiall_filas_list.cfg"
+ list_path = self.filament_list_path
filament_id = self.get_value_by_key(f"filament_slot{num}",1)
section = f"fila{filament_id}"
config = configparser.ConfigParser()
@@ -887,7 +917,7 @@
return None

def get_temp_by_slot(self, slot):
- list_path="/home/mks/printer_data/config/officiall_filas_list.cfg"
+ list_path = self.filament_list_path
filament_id = self.get_value_by_key(f"filament_{slot}",1)
section = f"fila{filament_id}"
config = configparser.ConfigParser()
@@ -907,7 +937,7 @@
return None

def get_box_temp_by_slot(self, slot):
- list_path="/home/mks/printer_data/config/officiall_filas_list.cfg"
+ list_path = self.filament_list_path
filament_id = self.get_value_by_key(f"filament_{slot}",1)
section = f"fila{filament_id}"
config = configparser.ConfigParser()
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
--- a/box_stepper.py
+++ b/box_stepper.py
@@ -113,7 +113,9 @@
self.next_cmd_time = self.next_cmd_time + accel_t + cruise_t + accel_t
self.stepper.set_position((0., 0., 0.))
toolhead = self.printer.lookup_object('toolhead')
- toolhead.note_kinematic_activity(self.next_cmd_time)
+ # Sandbox Fix #4: mainline Klipper removed toolhead.note_kinematic_activity();
+ # get_last_move_time() is the modern clock-sync equivalent (flushes step gen).
+ toolhead.get_last_move_time()
def dwell(self, delay):
self.next_cmd_time += max(0., delay)
def drip_move(self, newpos, speed, accel, drip_completion):
Loading