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("