diff --git a/CODEMAP.md b/CODEMAP.md index e9d2968..0ff9cea 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -27,6 +27,7 @@ repeated here. | `filament_resolve.cpp` | Decides what a printer is sent for a spool - temperatures, material family, Creality material id, Bambu filament id, pressure advance, name - field by field from the TigerTag+ product endpoint, the chip, the material table, a default. **Pure**: no network, no Arduino, the endpoint's answer is handed in, so it is tested on a computer with injected answers. The chip never carries a printer's material id; only the endpoint and the table do. A plain TigerTag never gets the endpoint's answer considered, even if one is passed. | | `product_api.cpp` | Fetches the product endpoint on a task, **started when a TigerTag+ is read** (for a Creality today), not when Send is pressed; Send waits only while that fetch is inside its 3 s budget. One cached answer, by product id. It asks main.cpp to stand background links down only if the heap was short of a handshake when it started: standing them down cost nothing during the request and 800-900 ms of loop AFTER it, while they dialled back. `/api/resolve` runs the whole resolution from query parameters without a spool and without sending. | | `tigertag_cloud.cpp` | Its network calls are on a **mixed** regime, not a uniform one. The sync and the pairing start run on their own FreeRTOS tasks; `pairPoll()` and `signInWithCustomToken()` are called straight from the main loop and stall it for about a second each. Neither pattern is the rule, so check which one a call is on before adding another. | +| `backend_klipper.cpp` | Writes through **macros that must exist on the printer** (`klipper/tigertag.cfg`): `TT_SET_LANE` is neither a Klipper nor an AFC command, so without the file every assign comes back as Moonraker's "Unknown command" in the status line. The lane layout is fixed at `extruder0`..`extruder3`. Brand, variant and the spool's full quantity are not in AFC at all; they are read from `save_variables` (`tt_tN_*`), and the percentage uses `tt_tN_total` because AFC keeps `initial_weight` at 1000 g. | ### One board, three TLS talkers - and what it actually costs diff --git a/README.md b/README.md index 8bf4581..4a7bfcc 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ +

@@ -437,3 +438,5 @@ Third-party components keep their own licenses; see [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md). "TigerTag" and "TigerSpool" are project names, not a license to imply endorsement — the terms for using them on a product you sell are in [TRADEMARK.md](TRADEMARK.md). + + diff --git a/firmware/.gitignore b/firmware/.gitignore new file mode 100644 index 0000000..89cc49c --- /dev/null +++ b/firmware/.gitignore @@ -0,0 +1,5 @@ +.pio +.vscode/.browse.c_cpp.db* +.vscode/c_cpp_properties.json +.vscode/launch.json +.vscode/ipch diff --git a/firmware/README-klipper.md b/firmware/README-klipper.md new file mode 100644 index 0000000..3312e58 --- /dev/null +++ b/firmware/README-klipper.md @@ -0,0 +1,107 @@ +## Klipper printers with AFC (experimental) + +> **Status: experimental.** Written against the Snapmaker backend and run on one +> setup: FlashForge Creator 5 Pro, OpenCreator, AFC 1.2.8. Other AFC printers +> have not been tried. + +TigerSpool can follow a Klipper printer that uses +[AFC](https://github.com/ArmoredTurtle/AFC-Klipper-Add-On) for its filament +slots, and write a scanned TigerTag into one of them. It talks to Moonraker over +a WebSocket (`ws://:7125/websocket`, no authentication), the same transport +the Snapmaker backend uses. The printer comes down with the account like every +other brand: Tiger Studio stores it under `printers/klipper/devices`. + +### What it needs + +- Klipper with Moonraker, reachable on the LAN at port 7125. +- AFC with four lanes named `extruder0` to `extruder3`, one per tool (T0 to T3). + Other lane layouts are not handled yet. +- A `[save_variables]` section in the printer config. +- The printer added in Tiger Studio Manager as a **Klipper** printer. +- The macros in [`klipper/tigertag.cfg`](klipper/tigertag.cfg) on the printer. + +### Printer setup + +1. Copy `tigertag.cfg` into the printer's config folder. +2. Add `[include tigertag.cfg]` to `printer.cfg` and restart Klipper. +3. Check that AFC accepts the commands the macro uses, one at a time in the + console, with the printer idle: + + ``` + SET_MATERIAL LANE=extruder0 MATERIAL=PLA + SET_COLOR LANE=extruder0 COLOR=FF0000 + SET_WEIGHT LANE=extruder0 WEIGHT=800 + AFC_SET_SPOOL_TEMP LANE=extruder0 EXTRUDER_TEMP=210 BED_TEMP=60 + ``` + + `AFC_SET_SPOOL_TEMP` is the command the "Set spool" button in Fluidd's lane + dialog sends. +4. Then try the macro itself: + + ``` + TT_SET_LANE TOOL=0 MATERIAL=PLA COLOR=FF0000 TEMP=210 BEDTEMP=60 WEIGHT=250 INITIAL=300 BRAND=Test + ``` + + The console answers with every parameter it received. Open + `http://:7125/printer/objects/query?AFC_lane%20extruder0` to see + what the lane now holds. + +### What the spool shows + +One row of four slots, labelled T0 to T3, read from Moonraker every five +seconds: + +| Shown | Comes from | +| --- | --- | +| Material and variant ("PLA Silk") | `AFC_lane extruderN.material`, variant from `save_variables` | +| Colour | `AFC_lane extruderN.color` | +| Brand | `save_variables` (`tt_tN_brand`), else AFC's `spool_vendor` | +| Remaining | `weight` divided by the spool's full quantity (`tt_tN_total`), else AFC's `initial_weight` | +| Active tool | `AFC.current_load` | + +### What a scan writes + +Assigning a tag to a slot sends one command: + +``` +TT_SET_LANE TOOL=n MATERIAL=PLA SUBTYPE=Silk COLOR=RRGGBB BRAND=eSUN TEMP=210 BEDTEMP=60 WEIGHT=232 INITIAL=300 +``` + +- `MATERIAL` is the first word of the tag's material, `SUBTYPE` the rest, the + way the Snapmaker backend splits them. +- `TEMP` and `BEDTEMP` are the middle of the tag's nozzle and bed ranges. The + macro passes both to `AFC_SET_SPOOL_TEMP` and refuses values above the + heaters' `max_temp`. +- `WEIGHT` is the tag's remaining quantity (`available`), or its full quantity + (`measure`) when no scale has written one. `INITIAL` is the full quantity. + Both are sent only when the tag's unit is grams or kilograms. +- Afterwards the spool reads the lane back. The status line says **confirmed** + when the colour arrived, **sent, not confirmed** after twelve seconds if it did + not, or shows Moonraker's own error text if the command was refused. + +### Limits + +- A slot is not written while the printer is printing or paused. +- The weight is whatever the chip carries. A spool whose chip has not been + updated since it was written shows that value. Tiger Studio's inventory in the + account is not read. +- AFC keeps `initial_weight` at 1000 g. Fluidd draws its spool graphic from it, + so a 300 g spool looks about a quarter full there when it is full. The spool's + own percentage uses the real full quantity and is not affected. +- Brand, variant, full quantity and tag id live in `save_variables`, not in AFC. + AFC itself does not see them. +- Spoolman is not involved. +- Memory: charged as 5 load slots, the same as a Snapmaker. That figure is + borrowed, not measured on this device. + +### Files + +| File | Change | +| --- | --- | +| `src/backend_klipper.h`, `src/backend_klipper.cpp` | new backend | +| `src/printer.h` | `PT_KLIPPER = 7` | +| `src/printer_budget.h` | load slots for `PT_KLIPPER` | +| `src/main.cpp` | backend creation, port, link cost, brand name | +| `src/tigertag_cloud.cpp` | `klipper` brand in the account import | +| `src/webcfg.cpp` | "Klipper" in the brand list of the web page | +| `klipper/tigertag.cfg` | printer-side macros (`TT_SET_LANE`, `TT_LOAD`) | diff --git a/firmware/patch_cloud.py b/firmware/patch_cloud.py new file mode 100644 index 0000000..8a4aa0e --- /dev/null +++ b/firmware/patch_cloud.py @@ -0,0 +1,51 @@ +# patch_cloud.py - traegt PT_KLIPPER in src/tigertag_cloud.cpp und src/webcfg.cpp ein. +# Aufruf im Ordner firmware: python patch_cloud.py +# Jede Stelle muss genau einmal gefunden werden, sonst bricht das Skript ab und +# schreibt NICHTS (auch nicht in die andere Datei). +import sys + +def load(path): + raw = open(path, "rb").read().decode("utf-8") + return raw, ("\r\n" if "\r\n" in raw else "\n") + +def replace_once(text, old, new, what): + n = text.count(old) + if n != 1: + sys.exit("ABBRUCH: '%s' wurde %d mal gefunden (erwartet: 1). Nichts geaendert." % (what, n)) + return text.replace(old, new) + +def insert_after_line(text, nl, needle, new_line, what): + lines = text.split(nl) + hits = [i for i, l in enumerate(lines) if needle in l] + if len(hits) != 1: + sys.exit("ABBRUCH: '%s' wurde %d mal gefunden (erwartet: 1). Nichts geaendert." % (what, len(hits))) + i = hits[0] + ind = lines[i][:len(lines[i]) - len(lines[i].lstrip())] + lines.insert(i + 1, ind + new_line) + return nl.join(lines) + +# ---- tigertag_cloud.cpp +cp = "src/tigertag_cloud.cpp" +c, nl = load(cp) +c = replace_once(c, '"snapmaker", "elegoo", "anycubic" };', + '"snapmaker", "elegoo", "anycubic", "klipper" };', "BRANDS-Liste") +c = replace_once(c, 'PT_SNAPMAKER, PT_ELEGOO, PT_ANYCUBIC };', + 'PT_SNAPMAKER, PT_ELEGOO, PT_ANYCUBIC, PT_KLIPPER };', "BRAND_TYPE-Liste") +c = insert_after_line(c, nl, 'if (brand == "snapmaker")', + 'if (brand == "klipper") return PT_KLIPPER; // Moonraker ws :7125, AFC', + "mapType snapmaker") + +# ---- webcfg.cpp +wp = "src/webcfg.cpp" +w, wnl = load(wp) +w = replace_once(w, "'Elegoo','Anycubic'],", "'Elegoo','Anycubic','Klipper'],", "Markenliste im Web-Skript") + +# Erst nach erfolgreichen Pruefungen schreiben +open(cp, "wb").write(c.encode("utf-8")) +open(wp, "wb").write(w.encode("utf-8")) +print("OK: tigertag_cloud.cpp und webcfg.cpp angepasst.") +print() +print("Zur Kontrolle, Stellen die von der Markenzahl abhaengen koennten:") +for i, l in enumerate(c.split(nl), 1): + if "okBrands" in l or "BRAND_TYPE" in l or "BRANDS[" in l or "sizeof(BRANDS" in l: + print(" tigertag_cloud.cpp:%d: %s" % (i, l.strip())) diff --git a/firmware/patch_main.py b/firmware/patch_main.py new file mode 100644 index 0000000..4e41700 --- /dev/null +++ b/firmware/patch_main.py @@ -0,0 +1,50 @@ +# patch_main.py - traegt PT_KLIPPER in src/main.cpp ein. +# Aufruf im Ordner firmware: python patch_main.py +# Es aendert nur src/main.cpp. Jede Stelle muss genau einmal gefunden werden, +# sonst bricht das Skript ab und schreibt NICHTS. +import sys + +PATH = "src/main.cpp" +raw = open(PATH, "rb").read().decode("utf-8") +nl = "\r\n" if "\r\n" in raw else "\n" +lines = raw.split(nl) + +def find_one(pred, what): + hits = [i for i, l in enumerate(lines) if pred(l)] + if len(hits) != 1: + sys.exit("ABBRUCH: '%s' wurde %d mal gefunden (erwartet: 1). Nichts geaendert." % (what, len(hits))) + return hits[0] + +def indent_of(l): + return l[:len(l) - len(l.lstrip())] + +edits = [] # (index, kind, payload) + +# 1. Include +i = find_one(lambda l: l.strip() == '#include "backend_snapmaker.h"', 'include backend_snapmaker.h') +edits.append((i, "after", '#include "backend_klipper.h"')) + +# 2. typeTag +i = find_one(lambda l: 't == PT_SNAPMAKER ? "Snap" : "--";' in l, 'typeTag') +edits.append((i, "replace", lines[i].replace('t == PT_SNAPMAKER ? "Snap" : "--";', + 't == PT_SNAPMAKER ? "Snap" : t == PT_KLIPPER ? "Klip" : "--";'))) + +# 3..6. switch-Zeilen +def after_case(anchor, newline_text, what): + i = find_one(lambda l: l.strip() == anchor, what) + edits.append((i, "after", indent_of(lines[i]) + newline_text)) + +after_case("case PT_SNAPMAKER: return 7125;", "case PT_KLIPPER: return 7125;", "ctrlPort") +after_case("case PT_SNAPMAKER: return new SnapmakerBackend();", "case PT_KLIPPER: return new KlipperBackend();", "newBackend") +after_case("case PT_SNAPMAKER: return 8000;", "case PT_KLIPPER: return 8000;", "linkCost") +after_case('case PT_SNAPMAKER: return "snapmaker";', 'case PT_KLIPPER: return "klipper";', "brandName") + +# Anwenden von hinten nach vorn, damit die Indizes stimmen +for i, kind, payload in sorted(edits, key=lambda e: -e[0]): + if kind == "after": + lines.insert(i + 1, payload) + else: + lines[i] = payload + +open(PATH, "wb").write(nl.join(lines).encode("utf-8")) +print("OK: %d Stellen in %s angepasst (Zeilenende: %s)" % (len(edits), PATH, "CRLF" if nl == "\r\n" else "LF")) diff --git a/firmware/src/backend_klipper.cpp b/firmware/src/backend_klipper.cpp new file mode 100644 index 0000000..651e562 --- /dev/null +++ b/firmware/src/backend_klipper.cpp @@ -0,0 +1,273 @@ +#include "backend_klipper.h" +#include +#include + +namespace { + const char* SLOTS[4] = { "T0", "T1", "T2", "T3" }; + + // AFC reports colours as "#RRGGBB". + bool hexRGB(const char* s, uint8_t& r, uint8_t& g, uint8_t& b) { + if (!s) return false; + if (*s == '#') s++; + if (strlen(s) < 6) return false; + auto hx = [](char c) -> int { + if (c >= '0' && c <= '9') return c - '0'; + c |= 0x20; return (c >= 'a' && c <= 'f') ? c - 'a' + 10 : 0; + }; + r = hx(s[0]) * 16 + hx(s[1]); + g = hx(s[2]) * 16 + hx(s[3]); + b = hx(s[4]) * 16 + hx(s[5]); + return true; + } + + // G-code arguments are separated by spaces and a quote would break the + // value stored in save_variables, so strip both out of vendor and type. + String clean(const String& in) { + String o; o.reserve(in.length()); + for (size_t i = 0; i < in.length(); i++) { + char c = in[i]; + if (c == ' ') o += '_'; + else if (c == '\'' || c == '"' || c == '#' || c == ';') continue; + else o += c; + } + return o; + } + + String unclean(const char* s) { + String o(s ? s : ""); + o.replace('_', ' '); + return o; + } +} + +// Rebuild what the UI shows for one slot from the pieces we keep. +void KlipperBackend::recompute(int i) { + SlotState& s = slots_[i]; + s.type = sub_[i].length() ? (mat_[i] + " " + sub_[i]) : mat_[i]; + s.known = mat_[i].length() > 0; + // The full quantity of the spool the tag describes beats AFC's own initial + // weight, which stays at 1000 g whatever spool is loaded. + float den = (totalW_[i] > 0) ? totalW_[i] : initW_[i]; + float pct = (den > 0) ? (weight_[i] * 100.0f / den) : 0.0f; + if (pct < 0) pct = 0; + if (pct > 100) pct = 100; + s.percent = (uint8_t)(pct + 0.5f); + s.selected = (i == currentLane_); +} + +void KlipperBackend::applyLane(int i, JsonObjectConst lane) { + SlotState& s = slots_[i]; + // Pushes carry only what changed, so every field is read on its own. + if (lane["material"].is()) mat_[i] = (const char*)lane["material"]; + if (lane["color"].is()) { + uint8_t r = s.r, g = s.g, b = s.b; + if (hexRGB((const char*)lane["color"], r, g, b)) { s.r = r; s.g = g; s.b = b; } + } + if (lane["weight"].is()) weight_[i] = lane["weight"].as(); + if (lane["initial_weight"].is()) initW_[i] = lane["initial_weight"].as(); + if (s.brand.length() == 0 && lane["spool_vendor"].is()) + s.brand = (const char*)lane["spool_vendor"]; + + if (pendIdx_ == i) { + if (s.r == pendR_ && s.g == pendG_ && s.b == pendB_) { + status_ = String("Klipper: ") + SLOTS[i] + " confirmed"; + pendIdx_ = -1; + } + } +} + +void KlipperBackend::applyStatus(JsonObjectConst st) { + JsonObjectConst afc = st["AFC"]; + if (!afc.isNull() && afc.containsKey("current_load")) { + currentLane_ = -1; + if (afc["current_load"].is()) { + const char* cl = (const char*)afc["current_load"]; + size_t n = strlen(cl); + if (n > 0 && cl[n - 1] >= '0' && cl[n - 1] <= '3') currentLane_ = cl[n - 1] - '0'; + } + } + + const char* ps = st["print_stats"]["state"] | ""; + if (*ps) printing_ = !strcmp(ps, "printing") || !strcmp(ps, "paused"); + + JsonObjectConst vars = st["save_variables"]["variables"]; + for (int i = 0; i < 4; i++) { + if (!vars.isNull()) { + String kb = String("tt_t") + i + "_brand"; + String ks = String("tt_t") + i + "_sub"; + if (vars[kb].is()) slots_[i].brand = unclean((const char*)vars[kb]); + if (vars[ks].is()) sub_[i] = unclean((const char*)vars[ks]); + String kt = String("tt_t") + i + "_total"; + if (vars[kt].is()) totalW_[i] = vars[kt].as(); + } + String key = String("AFC_lane extruder") + i; + JsonObjectConst lane = st[key]; + if (!lane.isNull()) applyLane(i, lane); + recompute(i); + } + if (pendIdx_ < 0) status_ = "Klipper: slots updated"; +} + +void KlipperBackend::onMsg(uint8_t* payload, size_t len) { + JsonDocument d; + if (deserializeJson(d, payload, len)) return; + + // A refused command comes back as an error object. Saying so is the only + // way to tell "sent" from "done". + if (!d["error"].isNull()) { + const char* m = d["error"]["message"] | "error"; + status_ = String("Klipper: ") + m; + Serial.printf("[klipper] error: %s\n", m); + pendIdx_ = -1; + return; + } + + JsonObjectConst st = d["result"]["status"]; + if (st.isNull() && String(d["method"] | "") == "notify_status_update") + st = d["params"][0]; + if (st.isNull()) return; + applyStatus(st); +} + +void KlipperBackend::onEvent(WStype_t type, uint8_t* payload, size_t len) { + switch (type) { + case WStype_CONNECTED: + connected_ = true; status_ = "Klipper: connected"; + Serial.println("[klipper] websocket connected"); + break; + case WStype_DISCONNECTED: + if (connected_) Serial.println("[klipper] websocket closed"); + connected_ = false; status_ = "Klipper: disconnected"; + break; + case WStype_TEXT: onMsg(payload, len); break; + case WStype_ERROR: + Serial.printf("[klipper] websocket error: %.*s\n", (int)len, + payload ? (const char*)payload : ""); + break; + default: break; + } +} + +bool KlipperBackend::sendRaw(String s) { + if (!connected_) return false; + Serial.printf("[klipper] -> %s\n", s.c_str()); + return ws_.sendTXT(s); +} + +void KlipperBackend::begin(const PrinterCfg& cfg) { + for (int i = 0; i < 4; i++) { + slots_[i] = SlotState{}; + mat_[i] = ""; sub_[i] = ""; + weight_[i] = 0; initW_[i] = 0; totalW_[i] = 0; + } + connected_ = false; + currentLane_ = -1; + printing_ = false; + pendIdx_ = -1; + status_ = "Klipper: connecting..."; + // Same as the Snapmaker backend: no Origin header, no subprotocol. + // Moonraker answers 403 to the library's default "Origin: file://" and the + // client then fires no event at all. + ws_.setExtraHeaders(""); + ws_.begin(cfg.host, 7125, "/websocket", ""); + ws_.onEvent([this](WStype_t t, uint8_t* p, size_t l) { onEvent(t, p, l); }); + ws_.setReconnectInterval(10000); + ws_.enableHeartbeat(15000, 3000, 2); +} + +void KlipperBackend::loop() { + ws_.loop(); + if (connected_ && millis() - lastReq_ > 5000) { lastReq_ = millis(); refresh(); } + if (pendIdx_ >= 0 && (int32_t)(millis() - pendUntil_) > 0) { + status_ = String("Klipper: ") + SLOTS[pendIdx_] + " sent, not confirmed"; + pendIdx_ = -1; + } +} + +void KlipperBackend::stop() { + ws_.disconnect(); + connected_ = false; + status_ = "Klipper: stopped"; +} + +bool KlipperBackend::connected() { return connected_; } +String KlipperBackend::status() { return status_; } +const char* KlipperBackend::slotLabel(int i) { return SLOTS[(i >= 0 && i < 4) ? i : 0]; } +const SlotState& KlipperBackend::slot(int i) { return slots_[(i >= 0 && i < 4) ? i : 0]; } + +void KlipperBackend::refresh() { + JsonDocument d; + d["jsonrpc"] = "2.0"; + d["method"] = "printer.objects.query"; + JsonObject o = d["params"]["objects"].to(); + for (int i = 0; i < 4; i++) { + String key = String("AFC_lane extruder") + i; // String: the key is copied + o[key] = nullptr; + } + o["AFC"] = nullptr; + o["save_variables"] = nullptr; + o["print_stats"] = nullptr; + d["id"] = 1001; + String out; serializeJson(d, out); + sendRaw(out); +} + +bool KlipperBackend::assign(int idx, const TagInfo& t) { + if (idx < 0 || idx >= 4 || !connected_) return false; + // AFC keeps lane data per tool. Rewriting a lane while the printer is + // working is not something this code has been checked against. + if (printing_) { status_ = "Klipper: printer is printing"; return false; } + + // A tag carries one string, "PLA High Speed". AFC gets the family only, + // the variant goes to save_variables as the subtype. + String full = t.material.length() ? t.material : String("PLA"); + int sp = full.indexOf(' '); + String mat = clean(sp > 0 ? full.substring(0, sp) : full); + String sub = clean(sp > 0 ? full.substring(sp + 1) : String()); + String vend = clean(t.brand); + + char col[8]; + snprintf(col, sizeof(col), "%02X%02X%02X", t.r, t.g, t.b); + + String script = String("TT_SET_LANE TOOL=") + idx + " MATERIAL=" + mat + " COLOR=" + col; + if (sub.length()) script += " SUBTYPE=" + sub; + if (vend.length()) script += " BRAND=" + vend; + + // Temperatures: the middle of the tag's windows. A choice, not a rule - the + // macro stores them and the printer side decides what to do with them. + if (t.nozMin > 0 && t.nozMax >= t.nozMin) + script += String(" TEMP=") + (int)((t.nozMin + t.nozMax) / 2); + if (t.bedMin > 0 && t.bedMax >= t.bedMin) + script += String(" BEDTEMP=") + (int)((t.bedMin + t.bedMax) / 2); + + // Weight. The tag keeps the remaining quantity only once a scale has + // written it, so a spool that never met one has available == 0; then the + // quantity it left the factory with (measure) is the best figure there is. + // Only grams and kilograms are understood. Anything else is not guessed. + uint32_t qty = t.available > 0 ? t.available : t.measure; + char u = t.unitLabel.length() ? (char)(t.unitLabel[0] | 0x20) : 0; + long grams = (u == 'g') ? (long)qty : (u == 'k') ? (long)qty * 1000L : 0; + Serial.printf("[klipper] tag: measure=%u available=%u unit='%s' -> %ld g\n", + (unsigned)t.measure, (unsigned)t.available, t.unitLabel.c_str(), grams); + if (grams > 0 && grams <= 10000) script += String(" WEIGHT=") + grams; + // Full quantity of this spool, kept on the printer for the percentage. + long total = (u == 'g') ? (long)t.measure : (u == 'k') ? (long)t.measure * 1000L : 0; + if (total > 0 && total <= 10000) script += String(" INITIAL=") + total; + + JsonDocument d; + d["jsonrpc"] = "2.0"; + d["method"] = "printer.gcode.script"; + d["params"]["script"] = script; + d["id"] = 201; + String out; serializeJson(d, out); + + bool ok = sendRaw(out); + if (!ok) { status_ = "Klipper: send failed"; return false; } + + pendIdx_ = idx; pendR_ = t.r; pendG_ = t.g; pendB_ = t.b; + pendUntil_ = millis() + 12000; + status_ = String("Klipper: sent -> ") + SLOTS[idx]; + delay(200); + refresh(); // read it back; applyLane() confirms or the timeout reports it + return true; +} diff --git a/firmware/src/backend_klipper.h b/firmware/src/backend_klipper.h new file mode 100644 index 0000000..fb542d5 --- /dev/null +++ b/firmware/src/backend_klipper.h @@ -0,0 +1,54 @@ +#pragma once +#include "printer.h" +#include +#include + +// Klipper with AFC (e.g. OpenCreator on a FlashForge Creator 5 Pro) - Moonraker +// over WebSocket, same transport as the Snapmaker backend: +// ws://:7125/websocket , JSON-RPC 2.0 , no authentication. +// 4 slots = AFC lanes extruder0..extruder3 = tools T0..T3. +// Assigning filament: printer.gcode.script -> "TT_SET_LANE TOOL=n MATERIAL=.. +// SUBTYPE=.. BRAND=.. COLOR=RRGGBB" (macro in tigertag.cfg on the printer, +// it calls AFC's SET_MATERIAL / SET_COLOR and stores brand + subtype in +// save_variables). +// Slot state: objects "AFC_lane extruderN" (material, color "#RRGGBB", weight, +// initial_weight), "AFC" (current_load), "save_variables" (tt_tN_brand, +// tt_tN_sub) and "print_stats" (state). +class KlipperBackend : public PrinterBackend { +public: + void begin(const PrinterCfg& cfg) override; + void loop() override; + void stop() override; + bool connected() override; + bool firstIsExternal() override { return false; } // four tools, no external spool + int slotCount() override { return 4; } + const char* slotLabel(int i) override; + const SlotState& slot(int i) override; + bool assign(int i, const TagInfo& t) override; + String status() override; + void refresh() override; +private: + void applyStatus(JsonObjectConst st); + void applyLane(int i, JsonObjectConst lane); + void recompute(int i); + void onMsg(uint8_t* payload, size_t len); + void onEvent(WStype_t type, uint8_t* payload, size_t len); + bool sendRaw(String s); + + WebSocketsClient ws_; + bool connected_ = false; + String status_ = "Klipper: connecting..."; + SlotState slots_[4]; + String mat_[4], sub_[4]; // AFC material, subtype from save_variables + float weight_[4] = {0, 0, 0, 0}; + float initW_[4] = {0, 0, 0, 0}; // AFC's own initial_weight + float totalW_[4] = {0, 0, 0, 0}; // tag's full quantity, from save_variables + int currentLane_ = -1; + bool printing_ = false; + uint32_t lastReq_ = 0; + + // Read-back check after assign(): did the colour actually land? + int pendIdx_ = -1; + uint8_t pendR_ = 0, pendG_ = 0, pendB_ = 0; + uint32_t pendUntil_ = 0; +}; diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index b62a26d..9e06b61 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -31,6 +31,7 @@ #include "backend_ff.h" #include "backend_bambu.h" #include "backend_snapmaker.h" +#include "backend_klipper.h" #include "backend_elegoo.h" #include "backend_anycubic.h" #include "webcfg.h" @@ -146,7 +147,7 @@ static int printerCount() { } static const char* typeTag(PrinterType t) { return t == PT_CREALITY ? "K2" : t == PT_FF_C5 ? "FF C5" : t == PT_BAMBU ? "Bambu" - : t == PT_SNAPMAKER ? "Snap" : "--"; + : t == PT_SNAPMAKER ? "Snap" : t == PT_KLIPPER ? "Klip" : "--"; } // ---- "is it online?" probe (TCP connect to the control port) ------------ @@ -176,6 +177,7 @@ static uint16_t ctrlPort(PrinterType t) { case PT_FF_C5: return 8898; case PT_BAMBU: return 8883; case PT_SNAPMAKER: return 7125; + case PT_KLIPPER: return 7125; case PT_ELEGOO: return 1883; case PT_ANYCUBIC: return 9883; default: return 9999; // K2 @@ -780,6 +782,7 @@ static PrinterBackend* newBackend(PrinterType t) { case PT_FF_C5: return new FlashForgeC5Backend(); case PT_BAMBU: return new BambuBackend(); case PT_SNAPMAKER: return new SnapmakerBackend(); + case PT_KLIPPER: return new KlipperBackend(); case PT_ELEGOO: return new ElegooBackend(); case PT_ANYCUBIC: return new AnycubicBackend(); default: return new CrealityBackend(); @@ -861,6 +864,7 @@ static uint32_t linkCost(PrinterType t, bool foreground) { case PT_BAMBU: return 56000; case PT_ANYCUBIC: return 48000; case PT_SNAPMAKER: return 8000; + case PT_KLIPPER: return 8000; case PT_CREALITY: return 6000; case PT_ELEGOO: return 4000; case PT_FF_C5: return 3000; @@ -1221,6 +1225,7 @@ static const char* brandName(PrinterType t) { case PT_BAMBU: return "bambu"; case PT_ANYCUBIC: return "anycubic"; case PT_SNAPMAKER: return "snapmaker"; + case PT_KLIPPER: return "klipper"; case PT_CREALITY: return "creality"; case PT_ELEGOO: return "elegoo"; case PT_FF_C5: return "flashforge"; diff --git a/firmware/src/printer.h b/firmware/src/printer.h index 39cbdce..eb63c30 100644 --- a/firmware/src/printer.h +++ b/firmware/src/printer.h @@ -24,7 +24,8 @@ enum PrinterType : uint8_t { PT_BAMBU = 3, // MQTT/TLS :8883 PT_SNAPMAKER = 4, // Moonraker WebSocket :7125 PT_ELEGOO = 5, // MQTT :1883, no TLS - PT_ANYCUBIC = 6 // MQTT/TLS :9883, self-signed + PT_ANYCUBIC = 6, // MQTT/TLS :9883, self-signed + PT_KLIPPER = 7 // Moonraker WebSocket :7125, AFC lanes (OpenCreator) }; // One printer, as imported from the user's TigerTag account. diff --git a/firmware/src/printer_budget.h b/firmware/src/printer_budget.h index eeea7cf..51aca08 100644 --- a/firmware/src/printer_budget.h +++ b/firmware/src/printer_budget.h @@ -69,6 +69,9 @@ inline uint16_t loadSlotsFor(const PrinterCfg& p, bool sharesCloud) { // Replace them with readings. case PT_ANYCUBIC: return 6; case PT_SNAPMAKER: return 5; + // Klipper/AFC uses the same Moonraker WebSocket as Snapmaker. Charged + // like it; NOT measured on this device yet. + case PT_KLIPPER: return 5; case PT_CREALITY: return 6; case PT_ELEGOO: return 3; case PT_FF_C5: return 2; diff --git a/firmware/src/tigertag_cloud.cpp b/firmware/src/tigertag_cloud.cpp index d486e75..04c3490 100644 --- a/firmware/src/tigertag_cloud.cpp +++ b/firmware/src/tigertag_cloud.cpp @@ -257,6 +257,7 @@ namespace { if (brand == "flashforge") return (m == 5 || m == 6) ? PT_FF_C5 : PT_NONE; if (brand == "bambulab") return PT_BAMBU; // protocolo LAN comum if (brand == "snapmaker") return PT_SNAPMAKER; // Moonraker ws :7125 + if (brand == "klipper") return PT_KLIPPER; // Moonraker ws :7125, AFC // Elegoo speaks one protocol across the range - the Centauri Carbon // family and everything after it - so there is no model to check. if (brand == "elegoo") return PT_ELEGOO; // MQTT :1883 @@ -503,12 +504,12 @@ bool ttcloud::syncNow(String& summary) { // fetched anyway so the log can say why they do not appear rather than // leaving the user to guess. See docs/PRINTER-COMPATIBILITY.md. const char* BRANDS[] = { "creality", "flashforge", "bambulab", - "snapmaker", "elegoo", "anycubic" }; + "snapmaker", "elegoo", "anycubic", "klipper" }; // Each brand maps to exactly one backend type, which is what makes it // possible to say "these stored printers came from that brand" when its // request fails. Kept beside the list above so the two cannot drift. const PrinterType BRAND_TYPE[] = { PT_CREALITY, PT_FF_C5, PT_BAMBU, - PT_SNAPMAKER, PT_ELEGOO, PT_ANYCUBIC }; + PT_SNAPMAKER, PT_ELEGOO, PT_ANYCUBIC, PT_KLIPPER }; String base = String("https://firestore.googleapis.com/v1/projects/") + PROJECT + "/databases/(default)/documents"; // The fields this import reads, in ONE list. diff --git a/firmware/src/webcfg.cpp b/firmware/src/webcfg.cpp index 351325a..d647dbf 100644 --- a/firmware/src/webcfg.cpp +++ b/firmware/src/webcfg.cpp @@ -1156,7 +1156,7 @@ namespace { // seconds; a switch that waited for it felt dead. main.cpp decides in // the same loop pass that takes the request, so the read-back right // after it is the verdict: a refusal (the load budget) puts it back. - h += F("