diff --git a/CODEMAP.md b/CODEMAP.md index ec2f8dd..1f6f249 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -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__brand`, `tt__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. | diff --git a/WORKLOG.md b/WORKLOG.md index f93b86e..856f966 100644 --- a/WORKLOG.md +++ b/WORKLOG.md @@ -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__brand / + tt__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 diff --git a/docs/PRINTER-COMPATIBILITY.md b/docs/PRINTER-COMPATIBILITY.md index 308a7ef..d848047 100644 --- a/docs/PRINTER-COMPATIBILITY.md +++ b/docs/PRINTER-COMPATIBILITY.md @@ -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. | 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/src/backend_klipper.cpp b/firmware/src/backend_klipper.cpp index be38b0b..e41ab05 100644 --- a/firmware/src/backend_klipper.cpp +++ b/firmware/src/backend_klipper.cpp @@ -1,5 +1,6 @@ #include "backend_klipper.h" #include "version.h" +#include "tigertag_cloud.h" #include #include #include @@ -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. @@ -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 ` and `AFC_lane ` (a CANVAS on an OpenCentauri // CC1 does): the lane is listed once, in the order first met, and read @@ -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]; @@ -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()) { + weight_[i] = o["weight"].as(); + 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()) initW_[i] = o["initial_weight"].as(); + 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()) extT_[i] = (int)o["extruder_temp"].as(); + if (o["bed_temp"].is()) bedT_[i] = (int)o["bed_temp"].as(); + s.brand = (!empty && !sv.isNull()) ? shown(sv[svName(lane_[i], "brand")] | "") : String(); } status_ = "Klipper: slots updated"; } else if (sys_ == SYS_EXTRUDERS) { @@ -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; @@ -288,8 +332,10 @@ void KlipperBackend::refresh() { o["AFC"].to().add("current_lane"); for (int i = 0; i < nSlots_; i++) { JsonArray f = o[laneObj_[i]].to(); - 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().add("variables"); } else { o["toolhead"].to().add("extruder"); } @@ -297,6 +343,8 @@ void KlipperBackend::refresh() { } 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 @@ -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; diff --git a/firmware/src/backend_klipper.h b/firmware/src/backend_klipper.h index e87e926..47925a1 100644 --- a/firmware/src/backend_klipper.h +++ b/firmware/src/backend_klipper.h @@ -21,6 +21,17 @@ // shown and never written: there is nothing // on the printer to write the filament into. // +// Weight goes to AFC only, with its own SET_WEIGHT LANE= WEIGHT=, and the +// temperatures with AFC_SET_SPOOL_TEMP. Happy Hare's MMU_GATE_MAP takes no +// weight, so a gate is written without one - and that is not reported as a +// weight skipped, there was nothing to skip. +// +// AFC keeps no brand, and its `initial_weight` stays at 1000 g whatever spool +// is loaded. When the printer has a [save_variables] section (it is Klipper's +// own, nothing to install), both go there with SAVE_VARIABLE, as +// tt__brand and tt__total, and are read back from it. Without +// one, a lane shows no brand and the percentage uses AFC's 1000 g. +// // Commands are the systems' own: nothing has to be installed on the printer. // An optional Moonraker API key comes from the account's `apiKey` (carried in // PrinterCfg::cc) and is presented with server.connection.identify. @@ -37,6 +48,9 @@ class KlipperBackend : public PrinterBackend { const char* slotLabel(int i) override; const SlotState& slot(int i) override; bool assign(int i, const TagInfo& t) override; + bool weightSkipped() override { return weightSkipped_; } + int sentGrams() override { return sentGrams_; } + void setWeightFromCloud(bool v) override { weightFromCloud_ = v; } String status() override; void refresh() override; @@ -64,4 +78,19 @@ class KlipperBackend : public PrinterBackend { String lane_[KMAX]; // AFC lane name, or the extruder object String laneObj_[KMAX]; // AFC: the object the lane is read from uint32_t lastReq_ = 0; + + // AFC: the lane's weight as reported, and the full quantity of the spool + // last sent to it. AFC keeps `initial_weight` at 1000 g whatever spool is + // loaded, so the percentage uses the spool's own quantity when this device + // sent it, and AFC's figure otherwise (after a restart, or a spool loaded + // by hand). + float weight_[KMAX] = {}; + float initW_[KMAX] = {}; + long totalW_[KMAX] = {}; // tt__total, or what was last sent + int extT_[KMAX] = {}; // AFC's extruder_temp / bed_temp, for a + int bedT_[KMAX] = {}; // tag that carries only one of the two + bool hasSv_ = false; // the printer has [save_variables] + bool weightSkipped_ = false; // the last assign went out without SET_WEIGHT + int sentGrams_ = -1; // the WEIGHT the last assign sent, -1 for none + bool weightFromCloud_ = true; // the user's choice, set before every assign }; diff --git a/firmware/src/i18n.cpp b/firmware/src/i18n.cpp index 22061b8..6b81acd 100644 --- a/firmware/src/i18n.cpp +++ b/firmware/src/i18n.cpp @@ -199,6 +199,12 @@ static const Row STR[S_COUNT] = { /* S_POWER_OFF */ {{ "Turn off", "Éteindre", "Ausschalten", "Apagar", "Spegni", "Wyłącz", "Desligar", "Desligar", "关机" , "Выключить", "Uitschakelen"}}, /* S_POWER_OFF_Q */ {{ "Turn the TigerSpool off?", "Éteindre la TigerSpool ?", "TigerSpool ausschalten?", "Apagar la TigerSpool?", "Spegnere la TigerSpool?", "Wyłączyć TigerSpool?", "Desligar o TigerSpool?", "Desligar o TigerSpool?", "关闭 TigerSpool?" , "Выключить TigerSpool?", "TigerSpool uitschakelen?"}}, /* S_POWER_OFF_HINT */ {{ "To turn it back on, unplug the USB cable and plug it back in.", "Pour la rallumer, débranchez le câble USB puis rebranchez-le.", "Zum Einschalten das USB-Kabel ab- und wieder anstecken.", "Para encenderla, desconecte el cable USB y vuelva a conectarlo.", "Per riaccenderla, scollega il cavo USB e ricollegalo.", "Aby włączyć, odłącz kabel USB i podłącz go ponownie.", "Para ligar de novo, desconecte o cabo USB e conecte-o novamente.", "Para voltar a ligar, desligue o cabo USB e volte a ligá-lo.", "重新插拔 USB 线即可开机。" , "Чтобы включить снова, отключите USB-кабель и подключите заново.", "Om hem weer aan te zetten, haal je de USB-kabel eruit en steek je hem er weer in."}}, +/* S_WEIGHT_KEPT */ {{ "Weight not updated - the printer keeps its previous value", "Poids non mis à jour - l'imprimante garde l'ancienne valeur", "Gewicht nicht aktualisiert - der Drucker behält den alten Wert", "Peso no actualizado - la impresora conserva el valor anterior", "Peso non aggiornato - la stampante mantiene il valore precedente", "Waga nie zaktualizowana - drukarka zachowuje starą wartość", "Peso não atualizado - a impressora mantém o valor anterior", "Peso não atualizado - a impressora mantém o valor anterior", "重量未更新" , "Вес не обновлён - принтер сохраняет прежнее значение", "Gewicht niet bijgewerkt - de printer houdt de vorige waarde"}}, +/* S_WEIGHT_FROM */ {{ "Weight from", "Poids depuis", "Gewicht aus", "Peso desde", "Peso da", "Waga z", "Peso de", "Peso de", "重量" , "Вес из", "Gewicht van"}}, +/* S_SRC_CHIP */ {{ "Chip", "Puce", "Chip", "Chip", "Chip", "Chip", "Chip", "Chip", "标签" , "Чип", "Chip"}}, +/* S_SRC_CLOUD */ {{ "Cloud", "Cloud", "Cloud", "Nube", "Cloud", "Chmura", "Nuvem", "Nuvem", "账户" , "Облако", "Cloud"}}, +/* S_WEIGHT_WAIT */ {{ "Reading the weight from the account...", "Lecture du poids depuis le compte...", "Gewicht wird aus der Cloud gelesen...", "Leyendo el peso desde la cuenta...", "Lettura del peso dall'account...", "Odczyt wagi z konta...", "Lendo o peso da conta...", "A ler o peso da conta...", "正在从账户读取重量" , "Чтение веса из аккаунта...", "Gewicht wordt uit het account gelezen..."}}, +/* S_WEIGHT_RETRY */ {{ "No answer - asking the account once more...", "Pas de réponse - nouvelle demande au compte...", "Keine Antwort - frage die Cloud noch einmal...", "Sin respuesta - consultando la cuenta otra vez...", "Nessuna risposta - nuova richiesta all'account...", "Brak odpowiedzi - ponowne zapytanie konta...", "Sem resposta - consultando a conta de novo...", "Sem resposta - a perguntar de novo à conta...", "重试: 正在从账户读取重量" , "Нет ответа - повторный запрос к аккаунту...", "Geen antwoord - het account wordt opnieuw gevraagd..."}}, }; // A mismatch here is silent at runtime and reads as garbled text on screen, so diff --git a/firmware/src/i18n.h b/firmware/src/i18n.h index 684035a..551899d 100644 --- a/firmware/src/i18n.h +++ b/firmware/src/i18n.h @@ -197,6 +197,12 @@ enum StrId : uint8_t { S_POWER_OFF, S_POWER_OFF_Q, S_POWER_OFF_HINT, + S_WEIGHT_KEPT, + S_WEIGHT_FROM, + S_SRC_CHIP, + S_SRC_CLOUD, + S_WEIGHT_WAIT, + S_WEIGHT_RETRY, S_COUNT }; diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index 7b8e322..acdcd09 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -82,6 +82,12 @@ int selectedPrinter = 0; // Send was pressed on the review screen and is waiting, briefly, for the // TigerTag+ product answer - see ST_SENDING. static bool sendWaiting = false; +// The weight from the account, asked once more when the first read brought no +// figure: one further read, and the send waits for it at most WEIGHT_RETRY_MS. +// Both reset when a spool is read for a send. +static const uint32_t WEIGHT_RETRY_MS = 5000; +static uint32_t weightRetryAt = 0; // when the second try began; 0 = not yet +static bool weightRetryStarted = false; PrinterBackend* backend = nullptr; @@ -137,6 +143,8 @@ uint32_t nfcLastTry = 0; int selSlot = -1; TagInfo tag; bool sendOk = false; +bool sendWeightKept = false; // sent, but the slot's weight was left as it was +int sendGrams = -1; // the weight that went with it, -1 for none String resultMsg; uint32_t stateSince = 0; @@ -532,6 +540,22 @@ int screenSleepSec = 60; int screenRotation = SCR_ROTATION; bool screenAutoRot = false; +// Where a spool's weight comes from: the account, which a TigerScale keeps up to +// date, or the chip, which needs no network. The account by default - it is the +// figure that is right; the chip is for a device that is meant to stay offline. +bool weightFromCloud = true; + +static void loadWeightPref() { + nvs.begin("tigerspool", true); + weightFromCloud = nvs.getBool("wcloud", true); + nvs.end(); +} +static void saveWeightPref() { + nvs.begin("tigerspool", false); + nvs.putBool("wcloud", weightFromCloud); + nvs.end(); +} + static void loadScreenPrefs() { nvs.begin("tigerspool", true); screenBrightness = (uint8_t)nvs.getInt("bright", BRIGHTNESS_DEFAULT); @@ -1883,6 +1907,7 @@ void setup() { loadCfg(); imu::begin(); // before loadScreenPrefs: first boot asks it loadScreenPrefs(); + loadWeightPref(); // After lvgl_port::begin(), because turning the panel repaints it. The // boot logo above was drawn at the compiled default, which is the right // way up for the reference shell; a device mounted the other way sees the @@ -2471,6 +2496,12 @@ void loop() { static uint8_t seenUid[7] = {0}; static uint8_t seenLen = 0; static uint32_t lastPoll = 0; + // The account's figure for the spool on screen, once it has answered. + // Kept here rather than asked of ttcloud on every frame: its answer + // goes stale after 30 s, and the row would fall back to "..." under a + // spool that has not moved. + static int cloudGrams = -1; + static bool cloudKnown = false; if (nfcReady && millis() - lastPoll > 300) { lastPoll = millis(); @@ -2482,18 +2513,28 @@ void loop() { seen = t; memcpy(seenUid, uid, ul > 7 ? 7 : ul); seenLen = ul; + cloudGrams = -1; cloudKnown = false; Serial.printf("[reader] tag %s read\n", t.uid.c_str()); } } + // Asked again on every poll until it answers: a request is refused + // while the account sync holds the session, and goes through after. + if (seen.ok && weightFromCloud && !cloudKnown && ttcloud::haveSession()) { + ttcloud::SpoolStock st; + if (ttcloud::spoolCached(seen.uid, st)) { cloudGrams = st.netGrams; cloudKnown = true; } + else ttcloud::requestSpool(seen.uid); + } } if (seen.ok) seen.signature = reader::signatureOf(seen); - if (seen.ok) screen_read::showTag(seen); + if (seen.ok) screen_read::showTag(seen, weightFromCloud, cloudGrams, + weightFromCloud && !cloudKnown && ttcloud::haveSession()); else screen_read::showWaiting(); lvgl_port::loop(); if (screen_read::takeBack()) { seen = TagInfo(); seenLen = 0; + cloudGrams = -1; cloudKnown = false; screen_read::invalidate(); state = ST_MAIN; stateSince = millis(); } @@ -2744,9 +2785,17 @@ void loop() { case ST_SET_ACCOUNT: { int n = 0; for (int i = 0; i < MAX_PRINTERS; i++) if (printers[i].type != PT_NONE) n++; - screen_settings::showAccount(ttcloud::email().c_str(), n, ttcloud::haveSession()); + screen_settings::showAccount(ttcloud::email().c_str(), n, ttcloud::haveSession(), + weightFromCloud); lvgl_port::loop(); BACK_TO_SETTINGS(); + int ws = screen_settings::takeWeightSource(); + if (ws >= 0 && (ws == 1) != weightFromCloud) { + weightFromCloud = (ws == 1); + saveWeightPref(); + Serial.printf("[weight] source: %s\n", weightFromCloud ? "account" : "chip"); + screen_settings::invalidate(); + } if (screen_settings::takeAction() == screen_settings::A_SIGN_OUT) { // The imported printers go with the session. Leaving them would // show a list belonging to an account nobody is logged into. @@ -3105,6 +3154,9 @@ void loop() { const PrinterCfg& to = printers[selectedPrinter]; if (to.type == PT_CREALITY || (to.type == PT_BAMBU && !to.cloud)) product_api::request(tag); + // Not on chip weight: that choice means no network for it. + if (to.type == PT_KLIPPER && weightFromCloud) ttcloud::requestSpool(tag.uid); + weightRetryAt = 0; weightRetryStarted = false; // Straight into the send. The slot was chosen and the spool // was presented; asking a third time to approve what the // user is visibly doing only stands in the way. @@ -3123,10 +3175,47 @@ void loop() { } case ST_SENDING: { - // The same screen the user is already looking at, unchanged: the - // write is short, and a screen that appears and vanishes inside a - // second reads as a fault rather than as progress. - screen_scan::showScan(backend ? backend->slotLabel(selSlot) : "?"); + // The weight from the account. The first read was started when the + // spool was read, and the send waits for it inside its budget. When it + // brought no figure - it failed, it could not start, or it is still + // running past its budget - the account is asked once more and the send + // waits for that at most WEIGHT_RETRY_MS. An answer that the spool has + // no weight is an answer: it is not asked again. + bool weightWait = false, weightRetry = false; + if (sendWaiting && weightFromCloud && ttcloud::haveSession() + && printers[selectedPrinter].type == PT_KLIPPER) { + ttcloud::SpoolStock st; + const bool answered = ttcloud::spoolCached(tag.uid, st) && st.ok; + if (ttcloud::spoolWaiting(tag.uid)) { + weightWait = true; + } else if (!answered) { + if (!weightRetryAt) { + weightRetryAt = millis(); + Serial.printf("[weight] no figure from the account yet - asking once more, %lu ms\n", + (unsigned long)WEIGHT_RETRY_MS); + } + if (millis() - weightRetryAt < WEIGHT_RETRY_MS) { + // Kept trying until it starts: the sync or the presence beat + // may hold the token for a moment. + if (!weightRetryStarted && !ttcloud::spoolRunning(tag.uid)) + weightRetryStarted = ttcloud::requestSpool(tag.uid); + // The second read has ended without a figure: nothing left to wait for. + const bool over = weightRetryStarted && !ttcloud::spoolRunning(tag.uid); + weightWait = weightRetry = !over; + } else { + Serial.println("[weight] second try ran out - sent without the weight"); + } + } + } + + // The same screen the user is already looking at: the write is short, + // and a screen that appears and vanishes inside a second reads as a + // fault rather than as progress. Only a wait for the weight that + // outlasts that half second is put into words. + const char* note = nullptr; + if (weightRetry) note = i18n::T(S_WEIGHT_RETRY); + else if (weightWait && millis() - stateSince > 500) note = i18n::T(S_WEIGHT_WAIT); + screen_scan::showScan(backend ? backend->slotLabel(selSlot) : "?", note); BENCH_MARK("send:draw"); lvgl_port::loop(); BENCH_MARK("send:lvgl"); @@ -3143,9 +3232,14 @@ void loop() { // THIS spool is still inside its budget, and the screen keeps drawing // meanwhile. Past the budget the send goes ahead without it - once: // an answer that turns up later is kept for next time, never sent. - if (sendWaiting && !product_api::waiting(tag.idProduct)) { + if (sendWaiting && !product_api::waiting(tag.idProduct) && !weightWait) { sendWaiting = false; + if (backend) backend->setWeightFromCloud(weightFromCloud); sendOk = backend && backend->connected() && backend->assign(selSlot, tag); + // Asked once, here, and kept: the result screen is drawn from this + // state, not from the backend, whose next assign would change it. + sendWeightKept = sendOk && backend->weightSkipped(); + sendGrams = sendOk ? backend->sentGrams() : -1; BENCH_MARK("send:assign"); char m[48]; if (sendOk) snprintf(m, sizeof(m), i18n::T(S_UPDATED), backend->slotLabel(selSlot)); @@ -3176,7 +3270,8 @@ void loop() { tag.signature = reader::signatureOf(tag); screen_scan::showResult(backend ? backend->slotLabel(selSlot) : "?", sendOk, resultMsg.c_str(), tag, - since >= RESULT_MS ? 0 : RESULT_MS - since); + since >= RESULT_MS ? 0 : RESULT_MS - since, + sendWeightKept ? i18n::T(S_WEIGHT_KEPT) : nullptr, sendGrams); BENCH_MARK("result:draw"); lvgl_port::loop(); BENCH_MARK("result:lvgl"); diff --git a/firmware/src/printer.h b/firmware/src/printer.h index 8768899..8d534af 100644 --- a/firmware/src/printer.h +++ b/firmware/src/printer.h @@ -91,6 +91,12 @@ struct SlotState { String brand; // "eSun", "Creality", ... empty if unknown uint8_t r = 90, g = 90, b = 90; // grey until the printer says otherwise uint8_t percent = 0; + // `percent` is a real figure, not the default 0. Only then does the slot + // grid fill the colour block to that level. + bool percentKnown = false; + // Filament left on the spool, in grams, as the printer reports it; -1 when + // it reports none. Drawn inside the colour block when known. + int16_t grams = -1; bool selected = false; // loaded / active in the printer }; @@ -129,6 +135,19 @@ class PrinterBackend { virtual const char* slotLabel(int i) = 0; // "Ext", "1A", "T3", ... virtual const SlotState& slot(int i) = 0; virtual bool assign(int i, const TagInfo& t) = 0; + // The last assign() succeeded but left the slot's weight as it was, because + // the figure it needed did not arrive in time. A user who sees only a green + // tick believes the weight moved too. False for every backend that sends no + // weight at all - there is nothing there to have missed. + virtual bool weightSkipped() { return false; } + // The grams the last assign() wrote to the slot, for the result screen to + // show; -1 when it wrote none. + virtual int sentGrams() { return -1; } + // Where the next assign() takes the spool's weight from: the account (the + // figure a TigerScale keeps up to date) or the chip (what was written on + // it, but needs no network). The user's choice, held by main.cpp and handed + // over before every assign. Ignored by backends that send no weight. + virtual void setWeightFromCloud(bool) {} // Is slot 0 the printer's single external spool, drawn on a row of its // own? True for every brand that has one - and false for Anycubic, whose diff --git a/firmware/src/tigertag_cloud.cpp b/firmware/src/tigertag_cloud.cpp index b2966b6..7f14a46 100644 --- a/firmware/src/tigertag_cloud.cpp +++ b/firmware/src/tigertag_cloud.cpp @@ -1052,6 +1052,8 @@ bool ttcloud::consumeChanged() { bool v = g_changed; g_changed = false; return v // that reloads printers[] through loadCfg() once asyncDone() turns true. // --------------------------------------------------------------------------- static volatile bool g_asyncBusy = false; +// A read of the spool's stock from the account; see requestSpool() at the foot of the file. +static volatile bool g_spoolBusy = false; static volatile bool g_asyncDone = false; static String g_asyncSummary; @@ -1078,7 +1080,7 @@ bool ttcloud::needsRoom() { return g_wantRoom; } bool ttcloud::startAsyncSync() { // A presence beat on its own task shares the session token: one at a time. - if (g_asyncBusy || ttcloud::heartbeatBusy()) return false; + if (g_asyncBusy || g_spoolBusy || ttcloud::heartbeatBusy()) return false; // Ask for the room BEFORE trying, not after failing. // @@ -1413,7 +1415,7 @@ void heartbeatTaskFn(void*) { } // namespace bool ttcloud::startAsyncHeartbeat(const Presence& p, bool full) { - if (g_hbBusy || g_hbDone || g_asyncBusy) return false; + if (g_hbBusy || g_hbDone || g_asyncBusy || g_spoolBusy) return false; g_hbP = p; const int n = p.printerIdCount < MAX_PRINTERS ? p.printerIdCount : MAX_PRINTERS; for (int i = 0; i < n; i++) g_hbIds[i] = p.printerIds[i]; @@ -1440,3 +1442,105 @@ bool ttcloud::takeHeartbeat(bool& ok, bool& full, String& err) { g_hbDone = false; return true; } + + +// ---- the spool's stock, from the account ----------------------------------- +// +// See SpoolStock in tigertag_cloud.h. The session token is shared with the +// account sync and the presence beat, so the three never overlap: this task does +// not start while either runs, and neither starts while this one does. +namespace { + // One answer is kept, for one chip. The requester sets the uid before the + // task starts and does not touch it while g_spoolBusy is set, so the task and + // the loop never write the same String at once. + String g_stockUid; + ttcloud::SpoolStock g_stock; + uint32_t g_stockStart = 0; // when the running read started + uint32_t g_stockAt = 0; // when the held answer arrived + volatile bool g_stockDone = false; + // A spool can be weighed again. An answer older than this is a wrong one. + const uint32_t STOCK_TTL_MS = 30UL * 1000; + + String stockKey(const String& chipUid) { + String k = chipUid; k.trim(); k.toUpperCase(); // the document id is uppercase hex + return k; + } + + void stockTaskFn(void*) { + ttcloud::SpoolStock r; + const String uid = g_stockUid; + const uint32_t t0 = millis(); + int code = -3; + if (ensureToken()) { + // Only the fields this reads: the document carries links, colours and + // a rack position that nothing here has any use for. + const String url = String("https://firestore.googleapis.com/v1/projects/") + PROJECT + + "/databases/(default)/documents/users/" + g_uid + "/inventory/" + uid + + "?mask.fieldPaths=weight_available&mask.fieldPaths=measure_gr" + "&mask.fieldPaths=measure&mask.fieldPaths=deleted"; + String resp; + code = httpsGET(url, resp, g_idToken.c_str()); + if (code == 404) r.ok = true; // no such spool: an answer too + if (code == 200) { + JsonDocument d; + if (!deserializeJson(d, resp)) { + r.ok = true; + JsonObjectConst f = d["fields"]; + if (fsBool(f, "deleted") != 1) { + const String w = fsStr(f, "weight_available"); + String m = fsStr(f, "measure_gr"); + if (!m.length()) m = fsStr(f, "measure"); + if (w.length()) r.netGrams = (int)w.toFloat(); + if (m.length()) r.totalGrams = (int)m.toFloat(); + } + } + } + } + Serial.printf("[spool] inventory/%s -> http=%d net=%d total=%d in %lu ms\n", + uid.c_str(), code, r.netGrams, r.totalGrams, (unsigned long)(millis() - t0)); + g_stock = r; + g_stockAt = millis(); + g_stockDone = true; + g_spoolBusy = false; // last, so a reader that sees "not busy" sees the answer + vTaskDelete(nullptr); + } +} // namespace + +bool ttcloud::requestSpool(const String& chipUid) { + const String uid = stockKey(chipUid); + if (uid.isEmpty() || g_uid.isEmpty() || g_spoolBusy) return false; + if (g_stockDone && g_stock.ok && uid == g_stockUid && millis() - g_stockAt < STOCK_TTL_MS) return false; + if (g_asyncBusy || g_hbBusy) return false; + // A handshake needs one large block, the same figure the sync asks for. + if (ESP.getMaxAllocHeap() < SYNC_STACK + 2048) { + Serial.printf("[spool] no room for the read: largest block %u\n", + (unsigned)ESP.getMaxAllocHeap()); + return false; + } + g_stockUid = uid; + g_stockDone = false; + g_stockStart = millis(); + g_spoolBusy = true; + if (xTaskCreatePinnedToCore(stockTaskFn, "ttStock", SYNC_STACK, nullptr, 1, nullptr, 0) != pdPASS) { + g_spoolBusy = false; + return false; + } + return true; +} + +bool ttcloud::spoolWaiting(const String& chipUid) { + if (!g_spoolBusy) return false; + return stockKey(chipUid) == g_stockUid && millis() - g_stockStart < SPOOL_BUDGET_MS; +} + +bool ttcloud::spoolRunning(const String& chipUid) { + return g_spoolBusy && stockKey(chipUid) == g_stockUid; +} + +bool ttcloud::spoolCached(const String& chipUid, SpoolStock& out) { + if (!g_stockDone || g_spoolBusy) return false; + if (stockKey(chipUid) != g_stockUid || millis() - g_stockAt >= STOCK_TTL_MS) return false; + out = g_stock; + return true; +} + diff --git a/firmware/src/tigertag_cloud.h b/firmware/src/tigertag_cloud.h index 0b928ae..3b883b2 100644 --- a/firmware/src/tigertag_cloud.h +++ b/firmware/src/tigertag_cloud.h @@ -157,4 +157,39 @@ namespace ttcloud { bool startAsyncHeartbeat(const Presence& p, bool full); bool heartbeatBusy(); bool takeHeartbeat(bool& ok, bool& full, String& err); + + // The spool's own document in the account, users/{uid}/inventory/{chip uid}. + // + // A chip is written once. The weight a TigerScale measures afterwards goes to + // that document, not back onto the chip, so a slot that should show what is left + // on the spool has to ask the account. Same shape as product_api: the fetch + // starts the moment the tag is read (the slot is chosen before), on a task of + // its own, a send waits for it only inside SPOOL_BUDGET_MS, and an answer that + // arrives later is kept for the next send rather than sent as a second one. + // + // 8 s is three times the 2.6 s one Firestore read took on this device, TLS + // handshake included (the account import logs it). + struct SpoolStock { + int netGrams = -1; // weight_available, filament only; -1 = not in the document + int totalGrams = -1; // measure_gr, the spool's full quantity; -1 = not in the document + // The account answered: the document was read, or it does not exist. + // False when the read itself failed (no token, no network, an HTTP + // error) - the one case worth asking again. + bool ok = false; + }; + constexpr uint32_t SPOOL_BUDGET_MS = 8000; + + // Starts the read for this chip. False when nothing was started: no account, a + // read already running, a fresh answer already held, the sync or the presence + // beat running (they share the token), or no room for a handshake. + bool requestSpool(const String& chipUid); + // A read for THIS chip is running and still inside its budget - the one + // condition under which a send should wait. + bool spoolWaiting(const String& chipUid); + // A read for this chip is running, inside its budget or past it. + bool spoolRunning(const String& chipUid); + // The last answer, if it was about this chip and is not stale. A document that + // does not exist answers with both figures at -1. A read that failed is + // held too, with `ok` false, and does not stop requestSpool asking again. + bool spoolCached(const String& chipUid, SpoolStock& out); } diff --git a/firmware/src/ui/screen_read.cpp b/firmware/src/ui/screen_read.cpp index db5e446..39ff651 100644 --- a/firmware/src/ui/screen_read.cpp +++ b/firmware/src/ui/screen_read.cpp @@ -24,7 +24,11 @@ uint32_t s_sig = 0; // The Settings rows dim their labels because the value is what you came for // there. Here the pair IS the content - "Nozzle 215-230" is one fact in two // halves - and a dim half reads as decoration. -void factRow(lv_obj_t* parent, const char* key, const char* value) { +// +// `note`, when given, is a small dim word after the value - where the value came +// from, not part of it. +void factRow(lv_obj_t* parent, const char* key, const char* value, + const char* note = nullptr) { lv_obj_t* row = lv_obj_create(parent); lv_obj_remove_style_all(row); lv_obj_set_width(row, LV_PCT(100)); @@ -50,6 +54,16 @@ void factRow(lv_obj_t* parent, const char* key, const char* value) { lv_obj_set_style_max_width(v, 150, 0); lv_obj_set_style_text_font(v, &font_ui_bold_16, 0); lv_obj_set_style_text_color(v, lv_color_hex(theme::TEXT), 0); + + if (note) { + // The key takes the slack, so value and note stay together on the right. + lv_obj_set_flex_grow(k, 1); + lv_obj_t* n = lv_label_create(row); + lv_label_set_text(n, note); + lv_obj_set_style_text_font(n, &font_ui_12, 0); + lv_obj_set_style_text_color(n, lv_color_hex(theme::TEXT_DIM), 0); + lv_obj_set_style_pad_left(n, 5, 0); + } } } // namespace @@ -94,10 +108,12 @@ void showWaiting() { lv_obj_set_style_pad_top(where, 4, 0); } -void showTag(const TagInfo& tag) { +void showTag(const TagInfo& tag, bool fromCloud, int cloudGrams, bool cloudPending) { lvgl_port::Lock lvglGuard; // LVGL is not reentrant - see lvgl_port.h const uint32_t sig = 0xA5000000u ^ tag.idProduct ^ (tag.available << 3) - ^ ((uint32_t)tag.signature << 24); + ^ ((uint32_t)tag.signature << 24) + ^ (fromCloud ? 0x00800000u ^ ((uint32_t)(cloudGrams + 2) << 5) + ^ (cloudPending ? 0x00400000u : 0u) : 0u); if (sig == s_sig) return; s_sig = sig; @@ -200,13 +216,22 @@ void showTag(const TagInfo& tag) { snprintf(buf, sizeof(buf), "%u °C · %u h", tag.dryTemp, tag.dryHours); factRow(body, i18n::T(S_DRYING), buf); } - // What is left, which the TigerScale keeps up to date on the chip itself. - // Shown only when a scale has written it: a spool nobody weighed reads 0, - // and "0 g" on a full spool is worse than no line at all. - if (tag.available) { + // What is left, from the source the user chose, named after the value so a + // figure from the chip is never taken for the account's, or the reverse. + // + // From the account the row is always there: "..." while the answer is on + // its way, "-" when the account has no figure. From the chip it is shown + // only when a scale has written one - a spool nobody weighed reads 0, and + // "0 g" on a full spool is worse than no line at all. + if (fromCloud) { + if (cloudPending) snprintf(buf, sizeof(buf), "..."); + else if (cloudGrams < 0) snprintf(buf, sizeof(buf), "-"); + else snprintf(buf, sizeof(buf), "%d g", cloudGrams); + factRow(body, i18n::T(S_REMAINING), buf, i18n::T(S_SRC_CLOUD)); + } else if (tag.available) { snprintf(buf, sizeof(buf), "%lu %s", (unsigned long)tag.available, tag.unitLabel.length() ? tag.unitLabel.c_str() : "g"); - factRow(body, i18n::T(S_REMAINING), buf); + factRow(body, i18n::T(S_REMAINING), buf, i18n::T(S_SRC_CHIP)); } } diff --git a/firmware/src/ui/screen_read.h b/firmware/src/ui/screen_read.h index bf87371..7ccfd75 100644 --- a/firmware/src/ui/screen_read.h +++ b/firmware/src/ui/screen_read.h @@ -18,7 +18,13 @@ namespace screen_read { void showWaiting(); // A spool has been read. Cheap to re-call with the same tag. -void showTag(const TagInfo& tag); +// +// What is left on the spool comes from one source, the one the user chose, and +// the row says which. From the chip it is `tag.available`; from the account it +// is `cloudGrams` (-1: the account has no figure), or "..." while +// `cloudPending`, because that answer takes a few seconds to arrive. +void showTag(const TagInfo& tag, bool fromCloud = false, + int cloudGrams = -1, bool cloudPending = false); // The back arrow was pressed. bool takeBack(); diff --git a/firmware/src/ui/screen_scan.cpp b/firmware/src/ui/screen_scan.cpp index 90fb2ba..eff6d82 100644 --- a/firmware/src/ui/screen_scan.cpp +++ b/firmware/src/ui/screen_scan.cpp @@ -43,9 +43,9 @@ void invalidate() { s_which = NONE; s_sig = 0; } // proof: the slot grid is only reachable through a printer that answered, and // the tap that opened this screen came off that grid. A pair of green dots // repeating it spends the top of the panel saying what the user just did. -void showScan(const char* slotLabel) { +void showScan(const char* slotLabel, const char* note) { lvgl_port::Lock lvglGuard; // LVGL is not reentrant - see lvgl_port.h - uint32_t sig = hashStr(slotLabel); + uint32_t sig = hashStr(slotLabel) ^ (note ? hashStr(note) * 31u : 0u); if (s_which == SCAN && sig == s_sig) return; s_which = SCAN; s_sig = sig; @@ -60,8 +60,12 @@ void showScan(const char* slotLabel) { lv_obj_set_style_arc_color(sp, lv_color_hex(0x1E2530), LV_PART_MAIN); lv_obj_set_style_arc_color(sp, lv_color_hex(theme::ACCENT), LV_PART_INDICATOR); - frame::caption(i18n::T(S_BRING_TAG), theme::TEXT, &font_ui_14); - frame::caption(i18n::T(S_TO_READER), theme::TEXT, &font_ui_14); + if (note) { + frame::caption(note, theme::TEXT, &font_ui_14); + } else { + frame::caption(i18n::T(S_BRING_TAG), theme::TEXT, &font_ui_14); + frame::caption(i18n::T(S_TO_READER), theme::TEXT, &font_ui_14); + } // Tone 0, not the destructive red. Cancelling a scan throws nothing away - // it wore the same colour as Sign out and Factory reset, which teaches @@ -84,12 +88,13 @@ static const uint32_t RESULT_MS = 5000; static lv_obj_t* s_timerFill = nullptr; // width driven by msLeft, not rebuilt void showResult(const char* slotLabel, bool ok, const char* message, - const TagInfo& tag, uint32_t msLeft) { + const TagInfo& tag, uint32_t msLeft, const char* warning, int grams) { lvgl_port::Lock lvglGuard; // LVGL is not reentrant - see lvgl_port.h // msLeft is NOT in the signature: it changes on every pass, and rebuilding // this screen sixty times a second is how a device stops answering taps. uint32_t sig = hashStr(message) ^ (uint32_t)ok ^ hashStr(slotLabel) - ^ hashStr(tag.material.c_str()) ^ ((uint32_t)tag.signature << 24); + ^ hashStr(tag.material.c_str()) ^ ((uint32_t)tag.signature << 24) + ^ (warning ? hashStr(warning) * 31u : 0u) ^ ((uint32_t)(grams + 1) << 8); if (s_which == RESULT && sig == s_sig) { if (s_timerFill) { const lv_coord_t full = theme::SCREEN_W - 2 * theme::PAD; @@ -153,19 +158,25 @@ void showResult(const char* slotLabel, bool ok, const char* message, return; } - lv_obj_t* ring = lv_obj_create(body); - lv_obj_remove_style_all(ring); - lv_obj_set_size(ring, 52, 52); - lv_obj_set_style_radius(ring, LV_RADIUS_CIRCLE, 0); - lv_obj_set_style_bg_color(ring, lv_color_hex(theme::OK), 0); - lv_obj_set_style_bg_opa(ring, 40, 0); - lv_obj_clear_flag(ring, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_clear_flag(ring, LV_OBJ_FLAG_CLICKABLE); - lv_obj_t* tick = lv_label_create(ring); - lv_label_set_text(tick, LV_SYMBOL_OK); - lv_obj_set_style_text_font(tick, &font_ui_24, 0); - lv_obj_set_style_text_color(tick, lv_color_hex(theme::OK), 0); - lv_obj_center(tick); + // The screen has no spare height: a warning line under the spool pushes the + // countdown off the bottom, and a bar that drains out of sight is a screen + // that closes without warning. So a warning takes the tick's place - the + // title and the green band still say it went through. + if (!warning || !*warning) { + lv_obj_t* ring = lv_obj_create(body); + lv_obj_remove_style_all(ring); + lv_obj_set_size(ring, 52, 52); + lv_obj_set_style_radius(ring, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(ring, lv_color_hex(theme::OK), 0); + lv_obj_set_style_bg_opa(ring, 40, 0); + lv_obj_clear_flag(ring, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_clear_flag(ring, LV_OBJ_FLAG_CLICKABLE); + lv_obj_t* tick = lv_label_create(ring); + lv_label_set_text(tick, LV_SYMBOL_OK); + lv_obj_set_style_text_font(tick, &font_ui_24, 0); + lv_obj_set_style_text_color(tick, lv_color_hex(theme::OK), 0); + lv_obj_center(tick); + } lv_obj_t* title = lv_label_create(body); lv_label_set_text(title, i18n::T(S_SENT_TO_PRINTER)); @@ -214,6 +225,10 @@ void showResult(const char* slotLabel, bool ok, const char* message, if (sub.length()) sub += " \xC2\xB7 "; sub += tag.diameterLabel; } + if (ok && grams >= 0) { + if (sub.length()) sub += " \xC2\xB7 "; + sub += String(grams) + " g"; + } lv_obj_t* who = lv_label_create(col); lv_label_set_text(who, sub.c_str()); lv_label_set_long_mode(who, LV_LABEL_LONG_DOT); @@ -221,6 +236,20 @@ void showResult(const char* slotLabel, bool ok, const char* message, lv_obj_set_style_text_font(who, &font_ui_12, 0); lv_obj_set_style_text_color(who, lv_color_hex(theme::TEXT_DIM), 0); + // Directly under the spool it is about, before the instruction: the spool + // still goes in the slot, so this must not read as a failure - the warning + // colour, not the danger one. + if (warning && *warning) { + lv_obj_t* w = lv_label_create(body); + lv_label_set_text_fmt(w, "%s %s", LV_SYMBOL_WARNING, warning); + lv_label_set_long_mode(w, LV_LABEL_LONG_WRAP); + lv_obj_set_width(w, LV_PCT(100)); + lv_obj_set_style_text_align(w, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_text_font(w, &font_ui_12, 0); + lv_obj_set_style_text_color(w, lv_color_hex(theme::WARN), 0); + lv_obj_clear_flag(w, LV_OBJ_FLAG_CLICKABLE); + } + // Pushes the instruction to the bottom of the screen, where the thumb is. lv_obj_t* spacer = lv_obj_create(body); lv_obj_remove_style_all(spacer); diff --git a/firmware/src/ui/screen_scan.h b/firmware/src/ui/screen_scan.h index a1ffc95..bc2de41 100644 --- a/firmware/src/ui/screen_scan.h +++ b/firmware/src/ui/screen_scan.h @@ -15,7 +15,11 @@ namespace screen_scan { // hold the spool against the box - and a red line saying the same thing under // somebody who is doing exactly that reads as a fault of their own. The // reader's error goes to the serial log. -void showScan(const char* slotLabel); +// +// `note` replaces the instruction once the spool has been read and the send is +// held up by something the user should know is happening - the weight still +// being fetched from the account. nullptr for the instruction. +void showScan(const char* slotLabel, const char* note = nullptr); // The success screen confirms what was SENT and names the next step: put the // spool in that slot. It deliberately says nothing about what the slot held @@ -26,8 +30,17 @@ void showScan(const char* slotLabel); // `msLeft` is what is left of the five seconds before it closes itself, drawn // as a draining bar. A tap anywhere closes it at once. `message` is only read // on failure. +// +// `warning` is the exception to "says nothing about what the printer did": a +// part of the send that did NOT happen although the send succeeded, such as a +// weight that was not updated. Drawn under the spool, in the warning colour; +// nullptr for none. +// +// `grams` is the weight that was sent with the spool, added to the line under +// its name; -1 when none was. void showResult(const char* slotLabel, bool ok, const char* message, - const TagInfo& tag, uint32_t msLeft); + const TagInfo& tag, uint32_t msLeft, const char* warning = nullptr, + int grams = -1); void invalidate(); bool takeCancel(); diff --git a/firmware/src/ui/screen_settings.cpp b/firmware/src/ui/screen_settings.cpp index f135510..76078e4 100644 --- a/firmware/src/ui/screen_settings.cpp +++ b/firmware/src/ui/screen_settings.cpp @@ -681,6 +681,8 @@ void onAction(lv_event_t* e) { void onBright(lv_event_t* e) { s_newBright = (int)(intptr_t)lv_event_get_user_data(e); } void onSleep(lv_event_t* e) { s_newSleep = (int)(intptr_t)lv_event_get_user_data(e); } void onRotate(lv_event_t* e) { s_newRot = (int)(intptr_t)lv_event_get_user_data(e); } +volatile int s_newWeightSrc = -1; +void onWeightSrc(lv_event_t* e) { s_newWeightSrc = (int)(intptr_t)lv_event_get_user_data(e); } // A row of exclusive choices. Each option is 44 px tall, which is the floor for // something you tap without looking twice. @@ -774,6 +776,7 @@ Action takeAction() { Action v = s_action; s_action = A_NONE; return v; } int takeBrightness() { int v = s_newBright; s_newBright = -1; return v; } int takeSleep() { int v = s_newSleep; s_newSleep = -1; return v; } int takeRotation() { int v = s_newRot; s_newRot = ROT_NONE; return v; } +int takeWeightSource() { int v = s_newWeightSrc; s_newWeightSrc = -1; return v; } // A card: the row style's outline around a flex column, no fill. static lv_obj_t* wifiCard(lv_obj_t* parent, lv_coord_t padV) { @@ -920,9 +923,20 @@ void showWifi(const char* ssid, const char* ip, const char* mac, bool connected, []() { s_action = A_CHANGE_WIFI; }); } -void showAccount(const char* email, int printers, bool linked) { +// Chip or account, as the same pair of chips as every other choice here. Not +// static: a static array holding i18n::T() keeps the first language it saw. +static void weightSourceRow(lv_obj_t* body, bool fromCloud) { + kv(body, i18n::T(S_WEIGHT_FROM), + i18n::T(fromCloud ? S_SRC_CLOUD : S_SRC_CHIP), theme::TEXT); + const char* const wl[] = { i18n::T(S_SRC_CHIP), i18n::T(S_SRC_CLOUD) }; + static const int wv[] = { 0, 1 }; + segmented(body, wl, wv, 2, fromCloud ? 1 : 0, onWeightSrc); +} + +void showAccount(const char* email, int printers, bool linked, bool weightFromCloud) { lvgl_port::Lock lvglGuard; // LVGL is not reentrant - see lvgl_port.h - uint32_t sig = hashOf(email) ^ ((uint32_t)printers << 8) ^ (uint32_t)linked; + uint32_t sig = hashOf(email) ^ ((uint32_t)printers << 8) ^ (uint32_t)linked + ^ (weightFromCloud ? 0x40000000u : 0u); if (sameView((const void*)showAccount, sig)) return; claimView((const void*)showAccount, sig); @@ -932,6 +946,8 @@ void showAccount(const char* email, int printers, bool linked) { if (!linked) { frame::caption(i18n::T(S_ADD_WEB), theme::TEXT_DIM); + gap(body, 22); + weightSourceRow(body, weightFromCloud); return; } @@ -946,6 +962,8 @@ void showAccount(const char* email, int printers, bool linked) { char n[32]; snprintf(n, sizeof(n), "%d", printers); kv(body, i18n::T(S_PRINTER), n, theme::TEXT); + gap(body, 16); + weightSourceRow(body, weightFromCloud); lv_obj_t* spacer = lv_obj_create(body); lv_obj_remove_style_all(spacer); diff --git a/firmware/src/ui/screen_settings.h b/firmware/src/ui/screen_settings.h index 9413ba9..83a2be9 100644 --- a/firmware/src/ui/screen_settings.h +++ b/firmware/src/ui/screen_settings.h @@ -79,7 +79,10 @@ Action takeAction(); void showWifi(const char* ssid, const char* ip, const char* mac, bool connected, int channel, int rssi); // dBm, 0 when not connected -void showAccount(const char* email, int printers, bool linked); +// Also carries where a spool's weight comes from, chip or account - shown signed +// out too, because choosing the chip is how a device is run without an account. +void showAccount(const char* email, int printers, bool linked, bool weightFromCloud); +int takeWeightSource(); // 1 = account, 0 = chip, -1 = no change void showScreen(uint8_t brightness, int sleepSeconds, int rotation, bool autoRot); // What the battery is doing. `volts` is the cell, `pct` the estimate from it. // Reached only from the row that exists only when there is a cell. diff --git a/firmware/src/ui/screen_slots.cpp b/firmware/src/ui/screen_slots.cpp index fbaf85b..4d916d8 100644 --- a/firmware/src/ui/screen_slots.cpp +++ b/firmware/src/ui/screen_slots.cpp @@ -26,6 +26,45 @@ bool s_built = false; volatile int s_tapped = -1; volatile bool s_back = false; lv_obj_t* s_progress = nullptr; // the line under the spinner, or null +// The grams line inside each block, or null where the printer reports none. +// Its number changes while a printer prints; it is written into the label, +// never a reason to rebuild the grid. +const int MAX_CELLS = 24; +lv_obj_t* s_grams[MAX_CELLS] = {}; + +// The fill level, where the printer reports one: the block's top part is the +// spool's colour darkened, and `s_fill` rises from the bottom in the colour +// itself to the percentage left. Like the grams, the level is written into +// the objects already there. +const lv_coord_t BLOCK_H = 62; +lv_obj_t* s_fill[MAX_CELLS] = {}; +lv_obj_t* s_mat[MAX_CELLS] = {}; +int s_pct[MAX_CELLS] = {}; +uint32_t s_ink[MAX_CELLS] = {}; // the text colour on the filled part + +// A label is read against what lies behind its middle: the colour where the +// fill reaches it, the darkened colour above. The darkened part is dark for +// every spool, so it always takes white. +void inkFor(lv_obj_t* label, lv_coord_t midY, lv_coord_t fillH, uint32_t ink) { + if (!label) return; + const uint32_t c = (midY >= BLOCK_H - fillH) ? ink : 0xFFFFFFu; + lv_obj_set_style_text_color(label, lv_color_hex(c), 0); +} + +void applyLevel(int i, int pct) { + if (!s_fill[i]) return; + if (pct < 0) pct = 0; + if (pct > 100) pct = 100; + s_pct[i] = pct; + const lv_coord_t h = (lv_coord_t)(BLOCK_H * pct / 100); + lv_obj_set_height(s_fill[i], h); + lv_obj_update_layout(s_fill[i]); + // Text positions inside the block, from the alignments set when it was built. + inkFor(s_mat[i], s_grams[i] ? BLOCK_H / 2 - 8 : BLOCK_H / 2, h, s_ink[i]); + inkFor(s_grams[i], BLOCK_H - 4 - 7, h, s_ink[i]); +} + +void gramsText(char* buf, size_t n, int grams) { snprintf(buf, n, "%d g", grams); } int s_lastCount = -1; uint32_t s_lastSig = 0; @@ -57,6 +96,9 @@ uint32_t signature(PrinterBackend* b, int n, int selected, int link) { v ^= (uint32_t)s.selected << 25; for (const char* p = s.type.c_str(); *p; p++) v = v * 16777619u ^ (uint8_t)*p; for (const char* p = s.brand.c_str(); *p; p++) v = v * 16777619u ^ (uint8_t)*p; + // Whether a grams line exists, not its value - see s_grams. + v ^= (uint32_t)(s.grams >= 0) << 26; + v ^= (uint32_t)s.percentKnown << 27; h = h * 16777619u ^ v; } return h; @@ -132,10 +174,21 @@ void show(const char* printerName, PrinterBackend* backend, progressText(t, sizeof(t), tries, budget, fetching); lv_label_set_text(s_progress, t); } + for (int i = 0; i < n && i < MAX_CELLS; i++) { + if (!s_grams[i]) continue; + char t[16]; + gramsText(t, sizeof(t), backend->slot(i).grams); + // Only when it differs: setting a label invalidates it, and this + // runs every frame. + if (strcmp(lv_label_get_text(s_grams[i]), t)) lv_label_set_text(s_grams[i], t); + } + for (int i = 0; i < n && i < MAX_CELLS; i++) + if (s_fill[i] && s_pct[i] != backend->slot(i).percent) applyLevel(i, backend->slot(i).percent); return; } s_built = true; s_lastCount = n; s_lastSig = sig; s_progress = nullptr; // whatever it pointed at is about to go + for (int i = 0; i < MAX_CELLS; i++) s_grams[i] = s_fill[i] = s_mat[i] = nullptr; // so are these lv_obj_t* body = frame::build(printerName, onBack); // The reader dot is gone: it was green on every screen, always, because @@ -361,17 +414,35 @@ void show(const char* printerName, PrinterBackend* backend, // Taller than wide: a spool's material name is two or three words and // wants the vertical room, and five portrait blocks fill a 240 px // panel better than five squares with dead space above and below. - lv_obj_set_size(block, CELL_W, 62); + lv_obj_set_size(block, CELL_W, BLOCK_H); lv_obj_set_style_radius(block, 8, 0); lv_obj_set_style_bg_opa(block, LV_OPA_COVER, 0); + const bool level = st.known && st.percentKnown && i < MAX_CELLS; + const lv_color_t spool = st.known ? lv_color_make(st.r, st.g, st.b) : lv_color_hex(0x3A424E); + // With a level, the block itself is the empty part of the spool. lv_obj_set_style_bg_color(block, - st.known ? lv_color_make(st.r, st.g, st.b) : lv_color_hex(0x3A424E), 0); + level ? lv_color_mix(spool, lv_color_black(), 90) : spool, 0); lv_obj_clear_flag(block, LV_OBJ_FLAG_SCROLLABLE); // A hairline edge, because the panel's ground is black and so are // plenty of filaments. Without it a black spool is an invisible cell - // the colour is right and the slot looks empty. lv_obj_set_style_border_width(block, 1, 0); lv_obj_set_style_border_color(block, lv_color_hex(theme::LINE), 0); + if (level) { + // The fill is a child, so the block clips it to its rounded + // corners, and the edge is drawn over it rather than under. + lv_obj_set_style_clip_corner(block, true, 0); + lv_obj_set_style_border_post(block, true, 0); + lv_obj_t* fill = lv_obj_create(block); + lv_obj_remove_style_all(fill); + lv_obj_set_width(fill, LV_PCT(100)); + lv_obj_set_style_bg_opa(fill, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(fill, spool, 0); + lv_obj_align(fill, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_clear_flag(fill, LV_OBJ_FLAG_CLICKABLE); + lv_obj_clear_flag(fill, LV_OBJ_FLAG_SCROLLABLE); + s_fill[i] = fill; + } // And not clickable. A bare lv_obj is clickable by default in LVGL 8, // and this one covers almost the whole cell - so every press aimed at // the slot landed on the colour block and stopped there. The cell was @@ -393,6 +464,28 @@ void show(const char* printerName, PrinterBackend* backend, lv_obj_set_style_text_color(mat, lv_color_hex(lum > 150 ? 0x101010 : 0xFFFFFF), 0); lv_obj_center(mat); + // What is left on the spool, under the material, in the same ink. + if (st.known && st.grams >= 0 && i < MAX_CELLS) { + // The face's 17 px line is sized for CJK; a Latin name needs about + // 12. Pulled together, a three-line name ("PEI-9085-GF") fits in + // the 41 px above the grams instead of running into them. + lv_obj_set_style_text_line_space(mat, -5, 0); + lv_obj_align(mat, LV_ALIGN_CENTER, 0, -8); + lv_obj_t* gr = lv_label_create(block); + char t[16]; + gramsText(t, sizeof(t), st.grams); + lv_label_set_text(gr, t); + lv_obj_set_style_text_font(gr, &font_ui_12, 0); + lv_obj_set_style_text_color(gr, lv_color_hex(lum > 150 ? 0x101010 : 0xFFFFFF), 0); + lv_obj_align(gr, LV_ALIGN_BOTTOM_MID, 0, -4); + s_grams[i] = gr; + } + if (level) { + s_mat[i] = mat; + s_ink[i] = lum > 150 ? 0x101010u : 0xFFFFFFu; + applyLevel(i, st.percent); + } + lv_obj_t* brand = lv_label_create(cell); // One line, or a long vendor wraps and pushes its cell taller than the // three beside it - "Snapmaker" came out as "Snapma / ker" and took the diff --git a/firmware/src/webcfg.cpp b/firmware/src/webcfg.cpp index 768780d..1debfd6 100644 --- a/firmware/src/webcfg.cpp +++ b/firmware/src/webcfg.cpp @@ -610,7 +610,8 @@ namespace { WiFi.macAddress().c_str(), true, 6, -76); else if (preview == "setwifi-none") screen_settings::showWifi("", "-", WiFi.macAddress().c_str(), false, 0, 0); - else if (preview == "setacct") screen_settings::showAccount("benoit@atome3d.com", 6, true); + else if (preview == "setacct") screen_settings::showAccount("benoit@atome3d.com", 6, true, true); + else if (preview == "setacct-out") screen_settings::showAccount("", 0, false, false); else if (preview == "setbatt") screen_settings::showBattery(3.86f, 74, false, 270, true); else if (preview == "setcharge") screen_settings::showBattery(3.76f, 42, true, 95, true); else if (preview == "setbattlow") screen_settings::showBattery(3.45f, 8, false, 25, true); @@ -637,14 +638,25 @@ namespace { else if (preview == "main") screen_home::showMain(2, 3, true, -58, 3); else if (preview == "read") screen_read::showWaiting(); else if (preview == "readtag") { screen_read::invalidate(); screen_read::showTag(previewTag()); } + // The same spool with its weight from the account, answered and on its way. + else if (preview == "readcloud") { screen_read::invalidate(); screen_read::showTag(previewTag(), true, 144); } + else if (preview == "readcloud-wait") { screen_read::invalidate(); + screen_read::showTag(previewTag(), true, -1, true); } // The everyday loop: present the spool, confirm it, and the receipt // that tells you where to put it. `resultlong` is the layout's worst // case - a printer whose slots are called AMS2-4. else if (preview == "scan") screen_scan::showScan("B2"); + else if (preview == "scanweight") screen_scan::showScan("2", i18n::T(S_WEIGHT_WAIT)); + else if (preview == "scanretry") screen_scan::showScan("2", i18n::T(S_WEIGHT_RETRY)); else if (preview == "result") screen_scan::showResult("B2", true, "", previewTag(), 3200); + else if (preview == "resultgrams") screen_scan::showResult("2", true, "", previewTag(), 3200, + nullptr, 500); else if (preview == "resultlong") screen_scan::showResult("AMS2-4", true, "", previewTag(), 5000); else if (preview == "resultfail") screen_scan::showResult("B2", false, i18n::T(S_SEND_FAIL), previewTag(), 0); + // Sent, but the weight stayed: the longest warning sits under the spool. + else if (preview == "resultweight") screen_scan::showResult("T2", true, "", previewTag(), 3200, + i18n::T(S_WEIGHT_KEPT)); else if (preview == "greys") previewGreys(); else if (preview == "icons") previewIcons(); diff --git a/scripts/moonraker-ws-mock.py b/scripts/moonraker-ws-mock.py index bfe8e56..6cf2202 100755 --- a/scripts/moonraker-ws-mock.py +++ b/scripts/moonraker-ws-mock.py @@ -15,7 +15,8 @@ requests its add flow and its card make are answered too (server/printer/ machine info, the object list and query, an empty webcam list). What it answers: server.connection.identify, printer.objects.list, printer.objects.query and printer.gcode.script with -MMU_GATE_MAP and AFC's SET_MATERIAL / SET_COLOR parsed into the state - so a +MMU_GATE_MAP, AFC's SET_MATERIAL / SET_COLOR / SET_WEIGHT / AFC_SET_SPOOL_TEMP +and Klipper's SAVE_VARIABLE parsed into the state - so a write is read back exactly as it would be from a printer. It also pushes a notify_proc_stat_update every second, as Moonraker does to every client. @@ -23,6 +24,7 @@ the way an OpenCentauri CC1 does: the backend must list each lane once. """ +import ast import argparse import base64 import hashlib @@ -47,10 +49,12 @@ def build_state(profile): elif profile == "afc": lanes = ["lane1", "lane2", "lane3", "lane4"] s["AFC"] = {"lanes": lanes, "current_lane": "lane2"} + s["save_variables"] = {"variables": {}} for i, lane in enumerate(lanes): obj = {"map": f"T{i}", "material": ["PLA", "PETG", "", "TPU"][i], "color": ["#FF8800", "#0055FF", "#000000", "#FFFFFF"][i], - "load": i != 2, "prep": i != 2, "tool_loaded": False, "weight": 850} + "load": i != 2, "prep": i != 2, "tool_loaded": False, "weight": 850, + "initial_weight": 1000, "extruder_temp": 210, "bed_temp": 60} s[f"AFC_stepper {lane}"] = obj s[f"AFC_lane {lane}"] = obj # the same lane, published twice else: @@ -83,12 +87,23 @@ def run_script(state, script): if "COLOR" in p: m["gate_color"][g] = p["COLOR"].lstrip("#") m["gate_status"][g] = 1 - elif cmd in ("SET_MATERIAL", "SET_COLOR") and "AFC" in state: + elif cmd == "SAVE_VARIABLE" and "save_variables" in state: + # Klipper lowercases the name and reads the value as a Python literal. + v = p.get("VALUE", "") + if len(v) >= 2 and v[0] == v[-1] == '"': + v = v[1:-1] + state["save_variables"]["variables"][p.get("VARIABLE", "").lower()] = ast.literal_eval(v) + elif cmd in ("SET_MATERIAL", "SET_COLOR", "SET_WEIGHT", "AFC_SET_SPOOL_TEMP") and "AFC" in state: lane = state.get(f"AFC_lane {p.get('LANE', '')}") if lane is None: raise ValueError(f"Unknown lane {p.get('LANE')}") if cmd == "SET_MATERIAL": lane["material"] = p.get("MATERIAL", "") + elif cmd == "SET_WEIGHT": + lane["weight"] = float(p.get("WEIGHT", 0)) + elif cmd == "AFC_SET_SPOOL_TEMP": + lane["extruder_temp"] = float(p.get("EXTRUDER_TEMP", lane["extruder_temp"])) + lane["bed_temp"] = float(p.get("BED_TEMP", lane["bed_temp"])) else: lane["color"] = "#" + p.get("COLOR", "").lstrip("#") else: