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
2 changes: 1 addition & 1 deletion CODEMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Build-level and hardware-level rules live in `.agents/rules/`, indexed in
| `webcfg.cpp`, account page | Signed in, `/login` is the account page, and it **never syncs or switches anything itself**. Its handlers run on the loop task: a sync there nested a TLS handshake in a web handler, overflowed the loop's 8 KB stack on every email sign-in, and held the screen still for the whole sync. So a handler calls `ttcloud::requestSync()` and the loop starts the task; a printer switch is a mask read by `takePrinterSwitch()` and applied by `main.cpp` through `tryToggle()`, the device's own switch and budget check. `/api/account` answers anyone on the LAN, so it carries names, brands and addresses and never a serial or an access code. |
| `webcfg.cpp` | Serves **two** web interfaces — the captive portal for setup, and a separate legacy configuration page over the LAN once provisioned — and has **two** independent Wi-Fi scanners, one async and JSON for the portal, one blocking and HTML for the legacy page. "Change the web page" or "fix the scan" usually means changing the wrong one. |
| `tt_db.cpp` | The TigerTag reference tables exist **twice**, and every lookup goes through here rather than through `tigertag_db.h`. The compiled tables are the floor - what a brand-new box knows offline - and the downloaded ones in LittleFS are preferred when they parse. The fallback is per table, not all-or-nothing, so a corrupt brand file does not throw away a good material file. Calling `tt_material()` directly bypasses the whole mechanism and looks identical until a spool from last month reads as `brand#48804`. The material table keeps more than labels - `crealityID`, `crealityPressureAdvance` and the recommended nozzle window, in the download and in `TT_MATERIAL_INFO` - and `materialInfo()` answers from the same layer as `material()`. |
| `backend_klipper.cpp` | **Which slots exist is decided by the printer, once per connection.** `printer.objects.list` says Happy Hare (`mmu`), AFC (`AFC_lane` / `AFC_stepper` objects) or neither, and the slot list, labels and write command follow from it - the same adapters as Tiger Studio's Klipper driver. A lane published twice is listed once. On a plain extruder `assign()` refuses: Klipper has nowhere to store a filament. The host may carry a port (`hostOnly()` / `portOf()` in `printer.h`), and the reachability probe in `main.cpp` uses it too. `scripts/moonraker-ws-mock.py` is the printer to test it against. |
| `backend_klipper.cpp` | **Which slots exist is decided by the printer, once per connection.** `printer.objects.list` says Happy Hare (`mmu`), AFC (`AFC_lane` / `AFC_stepper` objects) or neither, and the slot list, labels and write command follow from it - the same adapters as Tiger Studio's Klipper driver. A lane published twice is listed once. On a plain extruder `assign()` refuses: Klipper has nowhere to store a filament. On AFC it also sends `SET_WEIGHT`, the grams coming from the source `main.cpp` hands it before every assign (`setWeightFromCloud`, the user's choice under Account): the account (`ttcloud::spoolCached`) or the chip. With no figure it sends no weight, and `weightSkipped()` makes the result screen say so; with one, `sentGrams()` puts it on the result screen. Happy Hare's `MMU_GATE_MAP` takes no weight. Temperatures go with `AFC_SET_SPOOL_TEMP`. AFC has no brand and keeps `initial_weight` at 1000 g, so when the printer lists `save_variables` the brand and the spool's full quantity are written with Klipper's own `SAVE_VARIABLE` (`tt_<lane>_brand`, `tt_<lane>_total`) and read back from there; without it a lane shows no brand and the percentage uses AFC's 1000 g. AFC's `weight` also goes into `SlotState::grams`, which the slot grid draws inside the colour block and updates in place; with `percentKnown` set the block is also filled from the bottom to `percent`, the empty part in the spool's colour darkened. The host may carry a port (`hostOnly()` / `portOf()` in `printer.h`), and the reachability probe in `main.cpp` uses it too. `scripts/moonraker-ws-mock.py` is the printer to test it against. |
| `printer_budget.h` | Decides whether a printer may be **switched on**, in *load slots* - one is a kilobyte of internal RAM. It never sees memory: the prices are constants taken from `/api/memtest`, and `docs/CONNECTION-BUDGET.md` holds the measurements they must stay equal to. A new brand with no entry in `loadSlotsFor()` costs 0 and is never refused. "Load slot", never plain "slot" - a slot is a spool tray everywhere else in this code. |
| `backend_bambu.cpp`, external spool | **Read as ams 255 / tray 254, written as ams 255 / tray 0 / slot 0.** A write to tray 254 is accepted and ignored by current firmware (X1C 01.12). The first report after a new MQTT connection carries an empty external tray before the real one. |
| `bambu_cloud.cpp` | One MQTT/TLS session shared by **every** cloud Bambu on the account; `backend_bambu.cpp` attaches and detaches, ref-counted. Reports are routed by the serial in the topic and nothing else - a report is never handed to a printer it did not name. So one cloud printer's "connected" is the session's, and closing one printer does not close the session while another is attached. LAN Bambus do not use it. |
Expand Down
29 changes: 29 additions & 0 deletions WORKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,35 @@ message, and reset it to this header.

## Unreleased

### Added

- Klipper with AFC: the spool's remaining weight is sent with the lane, with
AFC's own `SET_WEIGHT`, so still nothing to install on the printer. Where it
comes from is the user's choice under Settings > Account, "Weight from: Chip
/ Cloud" (NVS `wcloud`, default Cloud): the account's spool document
(`users/{uid}/inventory/{chip uid}`, the figure a TigerScale keeps current,
read by `ttcloud::requestSpool` the moment the tag is read), or the chip,
which needs no network. Happy Hare's `MMU_GATE_MAP` takes no weight.
- AFC lanes also get the tag's temperatures (`AFC_SET_SPOOL_TEMP`, the middle of
each window), and - when the printer has a `[save_variables]` section - the
brand and the spool's full quantity (`SAVE_VARIABLE tt_<lane>_brand /
tt_<lane>_total`), read back so the brand shows under the slot again and the
percentage is not taken against AFC's fixed 1000 g.
- The slot grid shows what is left in each colour block: the grams, and the
block filled from the bottom to the percentage (`SlotState::grams`,
`percentKnown`), both updated in place while the printer prints.
- The result screen adds the weight that was sent to the line under the spool,
and warns in orange when the send went out without one.
- The reader screen shows the remaining weight from the chosen source.
- When the first account read brings no figure, the send asks once more and
waits for it at most 5 s, saying so on the screen ("Reading the weight from
the account...", then "No answer - asking the account once more..."). An
answer that the spool has no weight is not asked again.
- `scripts/moonraker-ws-mock.py` understands `SET_WEIGHT`, `AFC_SET_SPOOL_TEMP`
and `SAVE_VARIABLE`.
- Previews: `setacct-out`, `readcloud`, `readcloud-wait`, `scanweight`,
`scanretry`, `resultweight`, `resultgrams`.

## 2026-11-04 - The sign-in and account pages in eleven languages (released in 1.74.0)

### Added
Expand Down
2 changes: 1 addition & 1 deletion docs/PRINTER-COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ someone has to do work before it does.
| **FlashForge** | Creator 5 / 5 Pro, **AD5X** | ⚙️ | HTTP `:8898` | 🟢 proven on hardware | Serial number **and check code**, both from the printer's network info screen. Imported automatically if the printer is in your TigerTag account. |
| **Bambu Lab** | A1, A1 mini, A2L | ⚙️ | MQTT/TLS `:8883` | 🟢 proven on hardware | **LAN mode on**, plus the serial and the 8-character access code from the printer screen. Imported automatically from your account. |
| **Snapmaker** | Artisan, J1, J1s, U1 | ⚙️ | Moonraker WebSocket `:7125` | 🟢 proven on hardware | Nothing — Moonraker needs no authentication on the LAN. The printer's IP is all it takes. |
| **Klipper** | any Klipper + Moonraker printer with AFC or Happy Hare | 🧪 | Moonraker WebSocket `:7125`, or the port in the account | 🟡 written against Tiger Studio's driver and a local Moonraker mock (`scripts/moonraker-ws-mock.py`); not yet run on a printer | Add it to Tiger Studio as a Klipper printer. Slots come from AFC lanes (`SET_MATERIAL` / `SET_COLOR`) or Happy Hare gates (`MMU_GATE_MAP`), the systems' own commands - nothing to install. A Moonraker API key, if the printer needs one, comes from the account. A plain extruder keeps no filament data: shown, never written. |
| **Klipper** | any Klipper + Moonraker printer with AFC or Happy Hare | 🧪 | Moonraker WebSocket `:7125`, or the port in the account | 🟡 written against Tiger Studio's driver and a local Moonraker mock (`scripts/moonraker-ws-mock.py`); not yet run on a printer | Add it to Tiger Studio as a Klipper printer. Slots come from AFC lanes (`SET_MATERIAL` / `SET_COLOR`, and `SET_WEIGHT` with the remaining grams from the account or the chip, `AFC_SET_SPOOL_TEMP`; brand and full quantity in `save_variables` when the printer has that section) or Happy Hare gates (`MMU_GATE_MAP`), the systems' own commands - nothing to install. A Moonraker API key, if the printer needs one, comes from the account. A plain extruder keeps no filament data: shown, never written. |
| **Bambu Lab (cloud)** | X1, P1, and any printer not on your LAN | 🧪 | MQTT/TLS to Bambu's broker, one session shared by every cloud printer on the account | 🟢 **reading** proven on hardware (A1, X1C) · writing not supported | **Read only**: slots are shown, a tag cannot be written - Bambu's cloud does not take the command. Depends on a Bambu session token that **Tiger Studio** obtains and stores in your account; the device never signs in to Bambu itself. The token expires roughly every three months and has to be renewed in Studio. |
| **Elegoo** | Centauri 2, Centauri 2 Combo, Centauri Carbon 2, Centauri Carbon 2 Combo | ⚙️ | MQTT `:1883` (plain TCP) | 🟢 **reading** proven on hardware (Centauri Carbon 2 Combo, with and without its CANVAS - every Centauri runs the same firmware) · writing not yet confirmed on a printer | Serial number and the MQTT password (an "Access Code" on the printer). Imported automatically from your account. |
| **Anycubic** | Kobra 3 / 3 Max (V2), Kobra X, ACE units - or the single external spool of a printer without one (`extfilbox`) | ⚙️ | MQTT/TLS `:9883` | 🟢 **reading** proven on hardware (Kobra X) · writing not yet confirmed on a printer | **LAN mode**, and **the printer must be paired in AnycubicSlicerNext at least once** — its broker credentials exist nowhere else. Tiger Studio reads them from there into your account. |
Expand Down
5 changes: 5 additions & 0 deletions firmware/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch
105 changes: 103 additions & 2 deletions firmware/src/backend_klipper.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#include "backend_klipper.h"
#include "version.h"
#include "tigertag_cloud.h"
#include <WebSocketsClient.h>
#include <ArduinoJson.h>
#include <algorithm>
Expand Down Expand Up @@ -36,6 +37,22 @@ namespace {
return o;
}

// A save_variables value is a Python literal, sent quoted: VALUE="'eSUN'".
// A quote or a backslash inside would end it early, so they are dropped.
String svText(const String& in) {
String o = gv(in);
o.replace("'", ""); o.replace("\"", ""); o.replace("\\", "");
return o;
}

// The variable a lane's brand or quantity is kept under. Klipper stores
// the names lowercased, so they are written that way.
String svName(const String& lane, const char* what) {
String n = String("tt_") + gv(lane) + "_" + what;
n.toLowerCase();
return n;
}

// What a slot shows: gv() turned the label's spaces into '_' on the way
// out, and the printer hands it back that way. "PLA High Speed" is what
// the spool says and what the screen should say.
Expand All @@ -60,11 +77,13 @@ namespace {

void KlipperBackend::detect(JsonArrayConst objects) {
bool mmu = false;
hasSv_ = false;
int extruders[KMAX]; int nExt = 0;
int nLane = 0;
for (JsonVariantConst v : objects) {
const char* name = v | "";
if (!strcmp(name, "mmu")) mmu = true;
if (!strcmp(name, "save_variables")) hasSv_ = true;
// One slot per AFC lane. A unit can publish the same lane twice, as
// `AFC_stepper <lane>` and `AFC_lane <lane>` (a CANVAS on an OpenCentauri
// CC1 does): the lane is listed once, in the order first met, and read
Expand Down Expand Up @@ -139,7 +158,12 @@ void KlipperBackend::applyStatus(JsonObjectConst st) {
status_ = "Klipper: slots updated";
} else if (sys_ == SYS_AFC) {
const char* cur = st["AFC"]["current_lane"] | "";
JsonObjectConst sv = st["save_variables"]["variables"];
for (int i = 0; i < nSlots_; i++) {
if (!sv.isNull()) {
const long tot = sv[svName(lane_[i], "total")] | 0L;
if (tot > 0 && tot <= 10000) totalW_[i] = tot;
}
JsonObjectConst o = st[laneObj_[i]];
if (o.isNull()) continue;
SlotState& s = slots_[i];
Expand All @@ -151,6 +175,21 @@ void KlipperBackend::applyStatus(JsonObjectConst st) {
s.r = s.g = s.b = 90;
if (!empty) hexRGB(o["color"] | "", s.r, s.g, s.b);
s.selected = lane_[i] == cur || (o["tool_loaded"] | false);
if (o["weight"].is<float>()) {
weight_[i] = o["weight"].as<float>();
s.grams = (int16_t)(weight_[i] < 0 ? 0 : weight_[i] > 30000 ? 30000 : weight_[i] + 0.5f);
}
if (empty) s.grams = -1;
if (o["initial_weight"].is<float>()) initW_[i] = o["initial_weight"].as<float>();
const float den = totalW_[i] > 0 ? (float)totalW_[i] : initW_[i];
float pct = (!empty && 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.percentKnown = !empty && den > 0 && s.grams >= 0;
if (o["extruder_temp"].is<float>()) extT_[i] = (int)o["extruder_temp"].as<float>();
if (o["bed_temp"].is<float>()) bedT_[i] = (int)o["bed_temp"].as<float>();
s.brand = (!empty && !sv.isNull()) ? shown(sv[svName(lane_[i], "brand")] | "") : String();
}
status_ = "Klipper: slots updated";
} else if (sys_ == SYS_EXTRUDERS) {
Expand Down Expand Up @@ -241,7 +280,12 @@ bool KlipperBackend::rpc(const char* method, JsonDocument& params, int id) {
}

void KlipperBackend::begin(const PrinterCfg& cfg) {
for (int i = 0; i < KMAX; i++) { slots_[i] = SlotState{}; lane_[i] = laneObj_[i] = String(); }
for (int i = 0; i < KMAX; i++) {
slots_[i] = SlotState{}; lane_[i] = laneObj_[i] = String();
weight_[i] = initW_[i] = 0; totalW_[i] = 0; extT_[i] = bedT_[i] = 0;
}
hasSv_ = false;
weightSkipped_ = false;
nSlots_ = 0;
sys_ = SYS_UNKNOWN;
connected_ = false;
Expand Down Expand Up @@ -288,15 +332,19 @@ void KlipperBackend::refresh() {
o["AFC"].to<JsonArray>().add("current_lane");
for (int i = 0; i < nSlots_; i++) {
JsonArray f = o[laneObj_[i]].to<JsonArray>();
for (const char* k : { "material", "color", "load", "prep", "tool_loaded" }) f.add(k);
for (const char* k : { "material", "color", "load", "prep", "tool_loaded",
"weight", "initial_weight", "extruder_temp", "bed_temp" }) f.add(k);
}
if (hasSv_) o["save_variables"].to<JsonArray>().add("variables");
} else {
o["toolhead"].to<JsonArray>().add("extruder");
}
rpc("printer.objects.query", p, ID_QUERY);
}

bool KlipperBackend::assign(int idx, const TagInfo& t) {
weightSkipped_ = false;
sentGrams_ = -1;
if (idx < 0 || idx >= nSlots_ || !connected_) return false;
if (sys_ == SYS_EXTRUDERS || sys_ == SYS_UNKNOWN) {
// Klipper keeps no filament identity on a plain extruder: there is no
Expand All @@ -317,6 +365,59 @@ bool KlipperBackend::assign(int idx, const TagInfo& t) {
const String lane = gv(lane_[idx]);
if (mat.length()) script = "SET_MATERIAL LANE=" + lane + " MATERIAL=" + mat + "\n";
script += "SET_COLOR LANE=" + lane + " COLOR=" + col;

// Weight, from the source the user chose (setWeightFromCloud).
//
// Account: a chip is written once; what a TigerScale measures afterwards
// goes to the spool's document in the account (ttcloud::requestSpool,
// started the moment the tag is read). When the account has not answered,
// the lane gets everything else and the weight already on the printer
// stays - a stale weight is worse than none.
//
// Chip: what is written on the tag, for a device that works offline. The
// remaining quantity once a scale has written it, else the quantity it
// left the factory with. Only grams and kilograms are understood.
long net = -1, total = -1;
if (weightFromCloud_) {
ttcloud::SpoolStock st;
if (ttcloud::spoolCached(t.uid, st) && st.netGrams >= 0) { net = st.netGrams; total = st.totalGrams; }
} else {
const char u = t.unitLabel.length() ? (char)(t.unitLabel[0] | 0x20) : 0;
const long mul = (u == 'g') ? 1L : (u == 'k') ? 1000L : 0L;
// Unlike the account, a chip reads 0 when nothing was written, so 0
// is not a figure here.
const uint32_t qty = t.available > 0 ? t.available : t.measure;
if (mul && qty > 0) { net = (long)qty * mul; total = (long)t.measure * mul; }
}
// An empty spool reads 0, and that is a figure. Over 10 kg is not a spool.
const bool haveWeight = net >= 0 && net <= 10000;
Serial.printf("[klipper] weight %ld of %ld g from the %s%s\n", net, total,
weightFromCloud_ ? "account" : "chip",
haveWeight ? "" : " - not available, weight left as it is");
weightSkipped_ = !haveWeight;
if (haveWeight) {
script += "\nSET_WEIGHT LANE=" + lane + " WEIGHT=" + net;
sentGrams_ = (int)net;
totalW_[idx] = (total > 0 && total <= 10000) ? total : 0;
}

// Temperatures: the middle of the tag's windows - a choice, not a rule.
// A tag that carries only one keeps the lane's other.
const int ext = (t.nozMin > 0 && t.nozMax >= t.nozMin) ? (t.nozMin + t.nozMax) / 2 : 0;
const int bed = (t.bedMin > 0 && t.bedMax >= t.bedMin) ? (t.bedMin + t.bedMax) / 2 : 0;
if (ext || bed)
script += "\nAFC_SET_SPOOL_TEMP LANE=" + lane
+ " EXTRUDER_TEMP=" + (ext ? ext : extT_[idx])
+ " BED_TEMP=" + (bed ? bed : bedT_[idx]);

// Brand and full quantity, which AFC has no field for. Written every
// time, empty included, so nothing of the previous spool stays behind.
if (hasSv_) {
script += "\nSAVE_VARIABLE VARIABLE=" + svName(lane_[idx], "brand")
+ " VALUE=\"'" + svText(t.brand) + "'\"";
script += "\nSAVE_VARIABLE VARIABLE=" + svName(lane_[idx], "total")
+ " VALUE=" + totalW_[idx];
}
}

JsonDocument p;
Expand Down
Loading