Когда в конце 2024-го я купил Creality K1C, он показался мне иномаркой по сравнению с «Жигулями» — моим прошлым 3D-принтером. Тот я настраивал месяца три, а K1C заработал, едва я успел его распаковать. Он довольно круто печатает из коробки (вот тут есть примеры изделий), но со временем и у этой «иномарки» нашлись недостатки. И, чтобы попытаться их исправить, я сделал то, что всегда делаю, если железяка позволяет, — рутанул её.

Минусы принтера

Во-первых, обнаружилась проблема со стоковым лаунчером: он залипал при печати и не включал подсветку. Этому багу много месяцев, и неизвестно, когда его пофиксят.

Во-вторых, дефолтный интерфейс не давал посылать кастомные команды и хоть как-то вмешиваться в работу принтера. Да и в целом я не люблю проприетарщину.

И если с этими недостатками можно было как-то смириться, то был ещё один, который меня особенно расстраивал: принтер не позволял заранее узнать, точно ли на изделие хватит филамента или придётся останавливать процесс и менять катушку.

Начало кастомизации

Оказалось, кастомизировать K1C несложно (особенно на старых версиях принтера) — в помощь нам Creality Helper Script.

Первые две проблемы я решил довольно быстро: в качестве веб-интерфейса выбрал опенсорсный Fluidd — он гораздо более гибкий, чем встроенный интерфейс, а залипающий лаунчер заменил на Guppy Screen. При этом на бэке остался дефолтный Moonraker, и вся машинерия облака осталась рабочей.

Теперь про организацию подсчёта филамента. В меню Guppy Screen я заметил интересный пункт — Spoolman. Полез почитать, что это за зверь, и выяснилось, что это по сути менеджер инвентаризации филамента. Можно завести все свои катушки, указать наличие и остатки, а потом во время печати через API Spoolman в реальном времени обновлять расход по конкретному пластику в формате «используется катушка с ID таким-то, потрачено столько-то мм/г».

Сначала я было обрадовался, что вот оно — почти готовое решение. Но потом как понял…

Трудности с подсчётом филамента

Чтобы частично решить проблему с постоянной необходимостью менять филамент руками для разноцветной печати (или если кончалась катушка), я в своё время купил себе CFS — систему автоподачи с четырьмя слотами. Это значило, что для корректного подсчёта филамента в Spoolman пришлось бы каждый раз вручную нажимать кнопку «Сменить катушку». А я часто печатаю несколько деталей разного цвета в рамках одного задания печати. И бдить несколько часов, чтобы вовремя нажать на смену катушки в Spoolman, — не наш путь. Поэтому я решил разобраться, как автоматизировать процесс.

В Spoolman есть понятие Location — там указывается местоположение катушек. Можно получать по API Spoolman содержимое конкретного Location в виде ID катушки. Это можно воплотить в следующую схему: выставляем катушки в CFS соответственно Location руками (заводим четыре слота + пятый со всеми катушками в наличии) и в случае физической смены катушки в CFS простым drag-and-drop тасуем катушки в слотах. 

А дальше всё сводится к тому, что при печати нужно получить содержимое слота (ID катушки) через API Spoolman и выбрать нужную катушку через API Moonraker. Таким образом, при выполнении команд на экструдере принтер будет передавать через API в Spoolman команду списать закодированное в G-Code количество пластика из определённой катушки.

Тут стоит проговорить несколько ограничений вокруг принтера, которые не позволили сделать решение достаточно «лаконичным». K1C «из коробки» не поддерживал CFS, однако спустя пару лет производитель выпустил официальный Upgrade Kit, новую версию прошивки, и это стало возможно. Со стороны слайсера (я использую стоковый Creality Print) поддержка CFS также появилась в интерфейсе. Однако при этом филамент выбирается макросами T0–T3 (по числу слотов CFS), которые намертво захардкожены в прошивке, и переопределить их не получилось. А Moonraker не в курсе, как взаимодействуют принтер и CFS, поэтому не может отобразить текущий филамент и, как следствие, передать его в Spoolman.

Решение проблемы

Мне пришлось создать полностью новый макрос на смену слота и прописать его всюду в настройках gcode слайсера. Для этого я добавил следующий код в файлы:

# /usr/data/printer_data/config/gcode_macro.cfg

[gcode_macro ACTIVATE_SPOOL_BY_CFS_SLOT]
gcode:
    {% set slot = params.SLOT|int + 1 %}
    RUN_SHELL_COMMAND CMD=get_spool_id PARAMS="CFS{slot}"

# /usr/data/printer_data/config/printer.cfg

[gcode_shell_command get_spool_id]
command: python3 /usr/data/scripts/get_spool_id.py
timeout: 10.
verbose: False

# /usr/data/scripts/get_spool_id.py

import urllib.request
import json
import sys
import os
# 1. Disable proxies to ensure local access works
os.environ['no_proxy'] = '*'
SPOOLMAN_URL = "http://172.17.2.222:7912"
MOONRAKER_URL = "http://127.0.0.1:7125/printer/gcode/script"
if len(sys.argv) < 2:
    sys.exit(0)
location = sys.argv[1]
try:
    # 1. Get Spool ID by location
    # Added timeout=2 to prevent hanging
    with urllib.request.urlopen(f"{SPOOLMAN_URL}/api/v1/spool?location={location}&archived=false", timeout=2) as r:
        spools = json.loads(r.read().decode('utf-8'))
    if spools:
        # Take the first matching spool
        spool_id = spools[0]["id"]
        # 2. Update Klipper/Fluidd UI (Proper JSON POST)
        # We send G-code in the body, not in the URL string
        req_ui = urllib.request.Request(
            MOONRAKER_URL,
            data=json.dumps({"script": f"SET_ACTIVE_SPOOL ID={spool_id}"}).encode('utf-8'),
            headers={'Content-Type': 'application/json'},
            method='POST'
        )
        urllib.request.urlopen(req_ui, timeout=2)
except Exception:
    # Silent fail for Klipper stability
    pass

В настройки принтера в слайсере Creality Print добавил новые строки.

Machine start G-code:

START_PRINT EXTRUDER_TEMP=[nozzle_temperature_initial_layer]
BED_TEMP=[bed_temperature_initial_layer_single]
ACTIVATE_SPOOL_BY_CFS_SLOT SLOT=[initial_no_support_extruder] # добавленная строка
T[initial_no_support_extruder]
M204 S2000
M104 S[nozzle_temperature_initial_layer]
G1 Z3 F600
M83
G92 E0
G1 Z1 F600

Change filament G-code:

ACTIVATE_SPOOL_BY_CFS_SLOT SLOT={next_extruder} # добавленная строка
G2 Z{z_after_toolchange + 0.4} I0.86 J0.86 P1 F10000 ; spiral lift a little from second lift
G1 X42 Y180 F30000
G1 Z{z_after_toolchange} F600

Этот код вызывает API Spoolman из G-Code конкретной детали перед загрузкой филамента, забирает катушку из Location и подкладывает ID катушки в API Moonraker для отображения в веб-интерфейсе и корректного учёта при печати.

Естественно, система не лишена недочётов. Навскидку вот самые очевидные:

  • Если Spoolman будет недоступен, то никакой смены не произойдёт.

  • Пластик считается не всегда корректно, потому что вес пустой катушки часто неизвестен.

  • Сам Moonraker может залипать во время печати (видимо, из-за нехватки ОЗУ в принтере), что сводит к нулю все попытки подсчёта.

С какими-то из этих проблем можно смириться, какие-то со временем исправятся. В остальном я доволен как слон — плюс одна автоматизация в копилку :)

И ещё немного улучшений

Но я был бы не я, если бы не попытался ещё немного докрутить автоматизацию. При текущих настройках во время физической смены катушки нужно не забыть положить её в соответствующий Location в Spoolman. Естественно, я забывал это делать. Поэтому, когда появилась свободная минута, доработал логику с автообновлением Locations по крону.

Для этого пришлось парсить логи /usr/data/creality/userdata/log/web-server.log. Других способов сходу не обнаружилось, но зато в логах есть вся нужная информация. У каждой катушки есть уникальный серийник — я просто присваиваю его рандомайзером в “RFID for CFS” при записи метки. При распаковке новой катушки и заведении в базу Spoolman он попадает в дополнительное поле Serial. На принтере запущен CronJob, который раз в минуту парсит лог, находит там все серийники по слотам CFS, и сопоставляет их с соответствующими Location в Spoolman.

Вот скрипт:

root@K1C-73A1 /root [#] cat /usr/data/scripts/cfs2spoolman.py
import json
import urllib.request
import urllib.error
import re
import sys

# --- CONFIG ---
SPOOLMAN_URL = "http://172.17.2.222:7912"
LOG_FILE = "/usr/data/creality/userdata/log/web-server.log"

def get_cfs_slots():
    try:
        with open(LOG_FILE, 'rb') as f:
            content = f.read().decode('utf-8', errors='ignore')
    except:
        return {}

    pattern = r'"materialId":\s*"([A-D])"(?:(?!"materialId").)*?"serialNum":\s*"([^"]+)"'
    matches = re.findall(pattern, content, re.DOTALL)

    slots = {}
    for letter, serial in matches:
        idx = ord(letter) - ord('A')  # 0..3
        slots[idx] = {"serial": serial}

    return slots

def get_spools():
    try:
        req = urllib.request.Request(f"{SPOOLMAN_URL}/api/v1/spool")
        with urllib.request.urlopen(req) as r: return json.loads(r.read().decode())
    except: return []

def update_spool(spool_id, loc, current_loc):
    if current_loc == loc: return
    print(f"    >>> Syncing Spool {spool_id} -> {loc}")
    try:
        data = json.dumps({"location": loc}).encode('utf-8')
        req = urllib.request.Request(f"{SPOOLMAN_URL}/api/v1/spool/{spool_id}", data=data, headers={'Content-Type': 'application/json'}, method='PATCH')
        urllib.request.urlopen(req)
    except Exception as e: print(f"    [!] Update failed: {e}")

def main():
    print("--- CFS Sync V8 (with cleanup) ---")
    slots_db = get_cfs_slots()
    if not slots_db:
        print("  [FATAL] No serials found.")
        sys.exit(0)

    spools = get_spools()

    # Собираем serial'ы, которые СЕЙЧАС в слотах (валидные).
    current_serials = set()
    for i in range(4):
        serial = slots_db.get(i, {}).get("serial")
        if serial not in ["0", "000000", "None", "", "N/A", None]:
            current_serials.add(serial)

    print(f"Current active serials: {sorted(current_serials)}\n")

    # === ПРЯМАЯ СИНХРОНИЗАЦИЯ: кладём катушки в слоты ===
    for i in range(4):
        serial = slots_db.get(i, {}).get("serial")
        slot_name = f"CFS{i+1}"

        if serial in ["0", "000000", "None", "", "N/A", None]:
            print(f"Slot {i+1}: [Empty/No Serial]")
            continue

        print(f"Slot {i+1}: Serial='{serial}'")

        found = None
        for s in spools:
            extra = s.get("extra", {})
            if isinstance(extra, str):
                try: extra = json.loads(extra)
                except: extra = {}
            extra = {k.lower(): str(v) for k,v in extra.items()}

            if extra.get("serial") == serial or extra.get("sn") == serial:
                found = s
                print(f"  -> Match: {s['filament']['name']}")
                break

        if found:
            update_spool(found['id'], slot_name, found.get("location"))
        else:
            print(f"  [WARN] Not found in Spoolman. Add 'serial': '{serial}'")

    # === ОБРАТНАЯ СИНХРОНИЗАЦИЯ: чистим старые катушки из CFS-слотов ===
    print("\n--- Cleanup phase ---")
    cfs_locations = {"CFS1", "CFS2", "CFS3", "CFS4"}

    for s in spools:
        loc = s.get("location", "")
        if loc not in cfs_locations:
            continue

        # Извлекаем serial из extra.
        extra = s.get("extra", {})
        if isinstance(extra, str):
            try: extra = json.loads(extra)
            except: extra = {}
        extra = {k.lower(): str(v) for k,v in extra.items()}

        spool_serial = extra.get("serial") or extra.get("sn")

        # Если serial этой катушки НЕТ среди активных — убираем в Stock.
        if spool_serial not in current_serials:
            print(f"Spool {s['id']} (serial={spool_serial}) is in {loc} but NOT in CFS anymore")
            update_spool(s['id'], "Stock", loc)

if __name__ == "__main__":
    main()

Кладём всё это в Cron:

crontab –e 
* * * * * python3 /usr/data/scripts/cfs2spoolman.py > /dev/null 2>&1

Выяснилось, что Cron без костылей тоже не завести внутри принтера, так как система ожидает путь /var/spool/cron/crontabs. Эта папка находится во временной памяти (tmpfs) принтера и полностью стирается при каждой перезагрузке, из-за чего любые задания пропадают.

Чтобы задания не исчезали, выделяем место в постоянном разделе диска /usr/data. Сначала необходимо создать директорию для надёжного хранения crontab пользователя root:

mkdir -p /usr/data/crontabs

Затем можно создать пустой файл заданий, который будет копироваться в tmpfs при каждом старте системы:

touch /usr/data/crontabs/root

Для автоматического запуска службы создаём файл автозагрузки /etc/init.d/S98cron. Этот скрипт при каждом включении принтера воссоздаёт структуру папок, подтягивает файл заданий с постоянного диска и запускает процесс crond в фоне.

cat << 'EOF' > /etc/init.d/S98cron
#!/bin/sh
mkdir -p /var/spool/cron/crontabs
if [ -f /usr/data/crontabs/root ]; then
    cp /usr/data/crontabs/root /var/spool/cron/crontabs/root
fi
crond -b
EOF

После создания скрипта необходимо выдать ему права командой chmod +x /etc/init.d/S98cron и запустить службу через /etc/init.d/S98cron start.

При добавлении скриптов через стандартную команду crontab -e изменения сохраняются только во временную пам��ть /var/spool/cron/crontabs/root. Чтобы задачи не пропали после перезагрузки принтера, их необходимо копировать обратно на диск. Для этого после каждого редактирования crontab нужно вручную выполнять команду сохранения:

cp /var/spool/cron/crontabs/root /usr/data/crontabs/root

Заключение

Как я писал выше, у решения есть минусы, но в целом они не мешают мне примерно определять, сколько филамента осталось и хватит ли его на изделие. Например, из недавнего: увидел, что на катушке осталось всего 8 г пластика (буквально несколько витков), и не стал запускать печать. Если бы я не видел остатки и запустил процесс, то печать прервалась бы, а после смены филамента на изделии был бы виден стык, где менялась катушка. Так что теперь мои изделия получили бонуск аккуратности.

В будущем хотелось бы ещё разобраться со сменой стола, чтобы можно было печатать непрерывно, ане ждать окончания печати и не чистить стол вручную перед новым заданием. Но это уже, видимо, будет совсем другая история :)

P. S.

Читайте также: