diff --git a/CHANGELOG.md b/CHANGELOG.md index 51231554e..c1ea2c425 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to Bambuddy will be documented in this file. - **Prints on a multi-printer farm started one by one, up to an hour apart (#2555, reporter @Maxtrim3D)** — Start a batch across several printers and they trickle out one at a time; the more printers, the worse it gets. Not a misconfiguration, and nothing in the wiki could have helped: the scheduler awaited each dispatch inline in its selection loop, and a dispatch includes the FTP upload. So every printer queued behind every other printer's transfer, even though they are entirely independent machines. **The arithmetic is the whole bug report.** A Bambu printer's FTP server sustains around 150 KB/s — its own SD-card write is the bottleneck, not the network — so the reporter's 41 MB `.3mf` took **254 seconds per printer**, straight from his logs (`40978500 bytes in 254.1s, 157 KB/s`). Nineteen printers in series is roughly **80 minutes** before the last one starts, which is exactly the "up to 1 hour" he reported, and exactly why it got worse the more printers he selected — the delay is linear in fleet size. The logs show the next upload beginning **131 ms** after the previous one finished, back to back, forever. **Uploads to different printers now run concurrently**, capped by a new **Settings → Workflow → Queue & Dispatch → Concurrent Uploads** value (default 4, up to 16; set it to 1 for the old strictly-serial behaviour if your network or host cannot take parallel transfers). Selection is unchanged and still sequential — only the transfers overlap — so every existing gate (busy printers, plate-clear, filament deficit, shortest-job-first, staggered start) behaves exactly as before, and a queue pass still finishes all of its uploads before the next one begins, which is what stops the same still-`pending` row being dispatched twice. FTP work also moves off asyncio's shared default executor onto its own pool: that executor is sized `min(32, cpu_count + 4)` — six threads on a 2-core NAS — and is shared with everything else in the app, so parallel uploads would have parked one thread each, for minutes at a time, and starved unrelated work. - **A printer that accepted a file but never started printing was retried forever (#2555)** — Surfaced by the same reporter: "I have a printer who, since the morning, still not launch." When a printer takes the file (its `subtask_id` advances) but never actually begins, the start-watchdog waits 270 seconds, reverts the queue item to `pending`, and the next pass re-uploads the entire file and waits it out again — with **no attempt limit**. For a genuinely wedged printer that loop never terminates, and on a farm each lap also consumes an upload slot the other printers are queueing for, so one stuck machine dragged out everybody else's start times. Retrying is right; retrying forever is not. Attempts are now counted on the queue item: the transient causes the watchdog already recovers from (a publish lost on a half-broken MQTT session is fixed by the forced reconnect on the very next try) still get their retries, but after **three** the item is failed with a message pointing at the printer — check its screen for a prompt or error, and check the SD card — rather than being handed back to the queue a fourth time. - **A queued library print with no readable print time crashed the dispatch — and took the rest of that queue pass down with it (#2555)** — Found while reviewing the above. Starting a print from a library file read `library_file.print_time_seconds`, a column `LibraryFile` does not have (its print time lives in the file's parsed metadata). It only fired when the archive carried no print time of its own — a plain `.gcode`, or a 3MF the parser could not read — and it fired *after* the job had already been sent to the printer, so the print itself ran but the "print started" notification was lost. Worse, the error unwound the whole queue pass: every other printer still waiting to be dispatched on that tick silently missed its turn and had to wait for the next one. It now uses the print time the queue item already caches. The concurrent-dispatch change above independently contains this class of failure — one printer's dispatch blowing up can no longer cancel its siblings' in-flight uploads. +- **A print mapped to a different filament than it was sliced for was logged under the sliced material, not the one actually used (#2563, reporter @alexfilimon)** — Slice a model for **Bambu PLA Basic**, open Filament Mapping in the Print dialog, and — because no PLA was loaded — hand-pick the only loaded **PETG** slot. The printer prints from PETG, the PETG spool is correctly debited, but the Archive card, the Print Log and the material statistics all still call the run **PLA**. So "filament used", the one label that should describe what left the spool, described what the slicer asked for instead. The archive's `filament_type` is stamped once from the 3MF at creation and never revisited; the Print Log copies it verbatim at completion and the stats group on it. The material Bambuddy actually consumed was known all along — usage tracking resolves every used slot to the spool that fed it and already carries that spool's `material` — it just wasn't being written back. This is the exact problem that was solved for filament **colour** a while ago (#1494): once usage tracking has matched every used slot to an inventory spool, the spool's curated colour replaces the slicer's, so an archive printed from a `#000000` spool stops showing the slicer's near-black. Material now does the same. When every slot with non-zero usage resolves to a spool that declares a material, the archive's `filament_type` is rewritten from those spools — slot-ordered, de-duplicated, comma-joined exactly like the colour and the original type — and because that rewrite is committed before the Print Log entry is written, the corrected material flows through to the archive card, the Print Log and the stats with no further work. **All-or-nothing, deliberately**, mirroring the colour path: if even one used slot can't be resolved to a spool with a material, nothing is rewritten, so a partial match can never drop a slot's type from the archive or the material graph. A run whose mapping matched the slice is a no-op (the rewrite equals what's already there). **Both inventory backends, same drop.** The built-in Spool inventory does it from the matched spools' `material`; Spoolman does it from the resolved Spoolman spool's `filament.material`, captured at the same point the spool is already fetched for its colour, so no extra Spoolman round-trips. The remain%-delta fallback (no-3MF "Untitled" prints) intentionally sits it out in both backends, exactly as it does for colour — those prints have no 3MF slot to attribute a material to. **Tests.** 7 on the internal helper (the reporter's PLA-slice-to-PETG-spool case; slot-ordered de-dup across a multi-material print; the all-or-nothing gate leaving a partially-matched print untouched; a zero-usage slot needing no spool; no-used-slots and slot_id-less fallback results both declining to rewrite; a blank material not counting as a match). 3 on the Spoolman archive rewrite against a real DB session (a PETG spool overwrites a PLA slice; a partial match leaves `PLA,PLA` untouched; an empty material map is a no-op). Existing usage-tracker and Spoolman suites unchanged and green. - **Every job on a busy farm waited up to 30 seconds after a printer freed up before it was sent (#2555, reporter @Maxtrim3D)** — With the parallel-upload fix in, the reporter still saw prints take "several long minutes" to leave the queue, sometimes going out together and sometimes in dribs. The scheduler's main loop did its work and then slept a **fixed 30 seconds** before looking again, unconditionally. That interval is dead air: a printer that finished a job one second after a pass ended sat idle for the next 29 before its follow-on print was even considered, and a batch fanning out across a fleet — where printers free up a few seconds apart as their current jobs end — dispatched in 30-second steps regardless of how fast the machines were actually becoming available. On nineteen printers that is minutes of nobody-is-uploading time stacked on top of the transfers. The loop now **re-checks within a few seconds whenever the previous pass actually dispatched something**, and only falls back to the 30-second idle sleep when a pass sent nothing. So a draining batch keeps moving at the speed the printers free up, not at the speed of a fixed timer. This cannot become a busy-loop: the fast tick fires *only* after a productive pass, and a pass is productive only while there is ready work to send — the moment the remaining items are all behind printers that are genuinely busy printing (or behind a wedged head-of-line job holding its printer in the post-dispatch cooldown), the pass dispatches nothing and the loop reverts to the slow interval. Selection, the concurrency cap, and the finish-all-uploads-before-the-next-pass invariant are all untouched; only the gap between passes shrinks when shrinking it helps. **Tests.** 2 new cases: a pass that dispatches three items reports that it did (so the caller re-ticks fast), and an empty queue reports that it did not (so it sleeps normally). The existing concurrent-dispatch suite — parallel fan-out, the cap, the serial escape hatch, one-failure-doesn't-cancel-siblings, and the uploads-finish-before-return invariant — passes unchanged against the new return value. - **Debug logging was unusable on a large fleet, and the support bundle only shipped a fraction of what was on disk (#2555)** — We asked the reporter to turn on debug logging and send a support bundle. The bundle came back holding **4 minutes 49 seconds** of history — barely one upload — for a problem that takes an hour to unfold. Two causes, both fixed. The state dumps in the MQTT push_status handler fired whenever their field was *present* in a frame, and a full frame carries every field, so they fired on **every frame** regardless of whether anything had changed; several said "updated" or "when X changes" in their own comment while doing nothing of the sort. On one printer that is ~1.5 lines/s and invisible. On nineteen it is ~100 lines/s: **27,727 of the bundle's 29,830 lines** were these dumps, and they rolled the 5 MB log over in under five minutes. They now log transitions only — every change is still recorded, the steady-state repetition is not. Separately, the bundle shipped only the live `bambuddy.log` and ignored the three rotated backups sitting next to it, even though its own byte budget was four times larger than the file it was reading; it now spans the rotation, oldest first, spending the budget on the most recent history. - **Filament Override vanished for a multi-plate selection in Any [model] mode — but only on the second visit (#2552, reporter @bondjw07)** — Open a sliced multi-plate `.gcode.3mf`, pick **Any [model]**, tick two plates, and the whole Filament Override section is gone. Tick one plate and it comes back. The reporter tied it to having queued or printed the file before, which is the real clue, but not the cause: what actually mattered was that the dialog had been opened once already, so the plates data was still in the cache. The filament requirements are fetched under a key that carries the selected plate, and that key is `null` as soon as two plates are ticked. On the first open the plates are not yet known, so for one render the modal cannot tell it is a multi-plate file and fetches the requirements for the whole file — the union of every plate's filaments — which the override panel then rendered from. On the next open the plates are already cached, the modal knows it is multi-plate from the first render, the whole-file fetch therefore never happens, and the panel had nothing to render. So the section's visibility was decided by a cache race, and the case that "worked" was showing you filaments from plates you had not selected. **Both halves are now wrong-free**: a multi-plate selection in model mode renders one **Filament Override — Plate N** panel per selected plate, each fetched for that plate and listing only the slots that plate actually prints, identical on a cold and a warm cache. A slot's chosen filament and its Force color match tick are shared across plates that print that slot — slot ids are global to the file, so slot 3 is the same filament wherever it appears — and each queued plate is sent only the overrides for its own slots, so a colour forced for plate 2 no longer holds plate 1 back (the API narrows them per plate as of #2551, and the modal no longer sends them wide in the first place). Measured on the old code: warm cache, two plates → zero override panels; cold cache → one panel listing both plates' filaments. Now: two panels, one slot each, either way. **Four further holes in the same per-plate machinery closed while reviewing it**: a manual tray pick on one plate survived a change of printer, and a global tray id names a different spool on a different machine — so the job went out on a tray nobody chose; a plate whose filaments could not be read (or had simply not loaded yet) was indistinguishable from a plate needing none, and was queued with no mapping and no forced colours, to print in whatever happened to be loaded — the Print button now waits for every selected plate to answer and says which one could not be read; the "not enough filament left" check still weighed the whole file's filaments against a mapping the plates no longer use, so it either failed to warn at all or warned about trays the print would not touch — it now follows what each plate actually dispatches, and sums the demand per tray, because 60 g left does not cover two plates of 40 g even though it covers either one of them; and the per-printer tray editor still appeared for a multi-plate fan-out, collecting tray choices that were then discarded. diff --git a/backend/app/services/spoolman_tracking.py b/backend/app/services/spoolman_tracking.py index 87c7d8a87..714654f9d 100644 --- a/backend/app/services/spoolman_tracking.py +++ b/backend/app/services/spoolman_tracking.py @@ -471,6 +471,7 @@ async def _report_spool_usage_for_slots( printer_serial: str = "", printer_id: int | None = None, slot_colors_out: dict[int, str] | None = None, + slot_materials_out: dict[int, str] | None = None, ) -> int: """Report usage to Spoolman for a list of (slot_id, grams) pairs. @@ -511,10 +512,12 @@ async def _report_spool_usage_for_slots( spool_id_to_use: int | None = None resolution_path = "" - # color_hex of the resolved spool's filament, for the #1494 archive - # colour rewrite. The tag path already has the full spool object; - # the slot-assignment path only yields an id and is fetched below. + # color_hex + material of the resolved spool's filament, for the #1494 + # archive colour rewrite and the #2563 type rewrite. The tag path + # already has the full spool object; the slot-assignment path only + # yields an id and is fetched below. spool_color_hex: str | None = None + spool_material: str | None = None spool_tag = _resolve_spool_tag(tray_info, printer_serial, global_tray_id) if spool_tag: @@ -523,6 +526,7 @@ async def _report_spool_usage_for_slots( spool_id_to_use = spool["id"] resolution_path = "tag" spool_color_hex = (spool.get("filament") or {}).get("color_hex") + spool_material = (spool.get("filament") or {}).get("material") if spool_id_to_use is None and printer_id is not None: ams_id, tray_id = _global_tray_id_to_ams_slot(global_tray_id) @@ -538,19 +542,27 @@ async def _report_spool_usage_for_slots( ) continue - # Record the spool's filament colour for the archive rewrite (#1494). - # The slot-assignment path resolved only an id, so fetch the spool. - # Strictly best-effort: a colour-fetch failure must never abort the - # weight reporting for the remaining slots, so the catch is broad. - if slot_colors_out is not None: - if spool_color_hex is None: + # Record the spool's filament colour + material for the archive + # rewrites (#1494, #2563). The slot-assignment path resolved only an + # id, so fetch the spool once for whichever value is still missing. + # Strictly best-effort: a fetch failure must never abort the weight + # reporting for the remaining slots, so the catch is broad. + if slot_colors_out is not None or slot_materials_out is not None: + need_color = slot_colors_out is not None and spool_color_hex is None + need_material = slot_materials_out is not None and spool_material is None + if need_color or need_material: try: - full_spool = await client.get_spool(spool_id_to_use) - spool_color_hex = (full_spool.get("filament") or {}).get("color_hex") - except Exception as exc: # noqa: BLE001 — colour is non-critical - logger.debug("[SPOOLMAN] Slot %s: could not fetch spool colour: %s", slot_id, exc) - if spool_color_hex: + _fil = (await client.get_spool(spool_id_to_use)).get("filament") or {} + if need_color: + spool_color_hex = _fil.get("color_hex") + if need_material: + spool_material = _fil.get("material") + except Exception as exc: # noqa: BLE001 — colour/material are non-critical + logger.debug("[SPOOLMAN] Slot %s: could not fetch spool filament: %s", slot_id, exc) + if slot_colors_out is not None and spool_color_hex: slot_colors_out[slot_id] = spool_color_hex + if slot_materials_out is not None and spool_material: + slot_materials_out[slot_id] = spool_material try: await client.use_spool(spool_id_to_use, grams_used) @@ -582,6 +594,7 @@ async def _report_spool_usage_split_by_tray_changes( printer_serial: str, printer_id: int, slot_colors_out: dict[int, str] | None = None, + slot_materials_out: dict[int, str] | None = None, ) -> tuple[int, set[int]]: """Split each slot's grams across ``tray_changes`` and charge per-segment. @@ -634,6 +647,7 @@ async def _report_spool_usage_split_by_tray_changes( spool_id_to_use: int | None = None resolution_path = "" spool_color_hex: str | None = None + spool_material: str | None = None spool_tag = _resolve_spool_tag(tray_info, printer_serial, tray_global) if tray_info else "" if spool_tag: @@ -642,6 +656,7 @@ async def _report_spool_usage_split_by_tray_changes( spool_id_to_use = spool["id"] resolution_path = "tag" spool_color_hex = (spool.get("filament") or {}).get("color_hex") + spool_material = (spool.get("filament") or {}).get("material") if spool_id_to_use is None: seg_ams_id, seg_tray_id = _global_tray_id_to_ams_slot(tray_global) @@ -659,19 +674,27 @@ async def _report_spool_usage_split_by_tray_changes( ) continue - # Colour rewrite (#1494) — first segment for a slot wins. The - # UI displays a single colour per slot, so later segments on the - # same slot don't overwrite (a backup swap can be a different - # colour but the archive card stays consistent with the origin). - if slot_colors_out is not None and slot_id not in slot_colors_out: - if spool_color_hex is None: - try: - full_spool = await client.get_spool(spool_id_to_use) - spool_color_hex = (full_spool.get("filament") or {}).get("color_hex") - except Exception as exc: # noqa: BLE001 — colour is non-critical - logger.debug("[SPOOLMAN] Split slot %s: could not fetch spool colour: %s", slot_id, exc) - if spool_color_hex: - slot_colors_out[slot_id] = spool_color_hex + # Colour (#1494) + material (#2563) rewrite — first segment for a + # slot wins. The UI displays a single colour/type per slot, so + # later segments on the same slot don't overwrite (a backup swap + # can differ but the archive card stays consistent with the origin). + need_color = slot_colors_out is not None and slot_id not in slot_colors_out and spool_color_hex is None + need_material = ( + slot_materials_out is not None and slot_id not in slot_materials_out and spool_material is None + ) + if need_color or need_material: + try: + _fil = (await client.get_spool(spool_id_to_use)).get("filament") or {} + if need_color: + spool_color_hex = _fil.get("color_hex") + if need_material: + spool_material = _fil.get("material") + except Exception as exc: # noqa: BLE001 — colour/material are non-critical + logger.debug("[SPOOLMAN] Split slot %s: could not fetch spool filament: %s", slot_id, exc) + if slot_colors_out is not None and slot_id not in slot_colors_out and spool_color_hex: + slot_colors_out[slot_id] = spool_color_hex + if slot_materials_out is not None and slot_id not in slot_materials_out and spool_material: + slot_materials_out[slot_id] = spool_material try: await client.use_spool(spool_id_to_use, round(segment_grams, 2)) @@ -978,6 +1001,7 @@ async def report_usage(printer_id: int, archive_id: int): _layer_denom_hint = _total_layers or _current_layer slot_colors: dict[int, str] = {} + slot_materials: dict[int, str] = {} handled_global_tray_ids: set[int] = set() spools_updated = 0 @@ -1022,6 +1046,7 @@ async def report_usage(printer_id: int, archive_id: int): printer_serial, printer_id=printer_id, slot_colors_out=slot_colors, + slot_materials_out=slot_materials, ) spools_updated += split_updated handled_global_tray_ids |= split_handled @@ -1037,6 +1062,7 @@ async def report_usage(printer_id: int, archive_id: int): printer_serial, printer_id=printer_id, slot_colors_out=slot_colors, + slot_materials_out=slot_materials, ) # Track which physical slots the 3MF path already covered so # Path 2 doesn't double-charge them. @@ -1060,6 +1086,7 @@ async def report_usage(printer_id: int, archive_id: int): handled_global_tray_ids=handled_global_tray_ids, archive_id=archive_id, slot_colors_out=slot_colors, + slot_materials_out=slot_materials, ) spools_updated += fallback_updates @@ -1073,6 +1100,10 @@ async def report_usage(printer_id: int, archive_id: int): # value (#1494) — mirrors the built-in inventory path in usage_tracker. await _apply_spool_colors_to_archive(db, archive_id, filament_usage, slot_colors) + # Same for the material: a slot mapped to a differently-typed spool than + # it was sliced for otherwise records the sliced type (#2563). + await _apply_spool_types_to_archive(db, archive_id, filament_usage, slot_materials) + async def _report_remain_delta_for_slots( client, @@ -1083,6 +1114,7 @@ async def _report_remain_delta_for_slots( handled_global_tray_ids: set[int], archive_id: int, slot_colors_out: dict[int, str] | None = None, + slot_materials_out: dict[int, str] | None = None, ) -> int: """AMS remain%-delta path: write ``(start - current) * filament.weight`` grams to Spoolman for slots the 3MF path didn't cover. @@ -1167,15 +1199,19 @@ async def _report_remain_delta_for_slots( continue spools_updated += 1 + # No 3MF slot_id for this path — use the AMS slot key so the maps can + # still be inspected by callers if needed. The archive rewrites + # (#1494 colour, #2563 type) key on 3MF slot_ids, so remain-delta-only + # prints intentionally don't participate (matches usage_tracker's + # slot_id=None). if slot_colors_out is not None: color = filament.get("color_hex") if color: - # No 3MF slot_id for this path — use the AMS slot key so the - # colour map can still be inspected by callers if needed. - # The archive-colour rewrite (#1494) keys on 3MF slot_ids so - # remain-delta-only prints intentionally don't participate - # in that rewrite (matches usage_tracker's slot_id=None). slot_colors_out[-(global_tray_id + 1)] = color + if slot_materials_out is not None: + material = filament.get("material") + if material: + slot_materials_out[-(global_tray_id + 1)] = material logger.info( "[SPOOLMAN] Archive %s AMS%d-T%d: %.2fg via remain-delta (%d%% of %.0fg) -> spool %s", archive_id, @@ -1230,3 +1266,44 @@ async def _apply_spool_colors_to_archive( ) archive.filament_color = joined await db.commit() + + +async def _apply_spool_types_to_archive( + db, + archive_id: int, + filament_usage: list[dict], + slot_materials: dict[int, str], +) -> None: + """Overwrite an archive's ``filament_type`` with the materials of the + Spoolman spools that fed the print (#2563). + + All-or-nothing, exactly like the colour path and the built-in inventory + path: the type is only rewritten when every used slot resolved to a spool + that carries a material, so a partial match never drops slots from the + archive or the material statistics. + """ + if not slot_materials: + return + + from backend.app.models.archive import PrintArchive + from backend.app.services.usage_tracker import _archive_types_from_spools + + results = [{"slot_id": sid, "material": material} for sid, material in slot_materials.items()] + types = _archive_types_from_spools(filament_usage, results) + if not types: + return + + archive = (await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))).scalar_one_or_none() + if archive is None: + return + + joined = ",".join(types) + if joined != archive.filament_type: + logger.info( + "[SPOOLMAN] Archive %s filament_type %r -> %r (from Spoolman spools)", + archive_id, + archive.filament_type, + joined, + ) + archive.filament_type = joined + await db.commit() diff --git a/backend/app/services/usage_tracker.py b/backend/app/services/usage_tracker.py index 500d8d2e8..1ee4a3de1 100644 --- a/backend/app/services/usage_tracker.py +++ b/backend/app/services/usage_tracker.py @@ -117,6 +117,47 @@ def _archive_colors_from_spools(filament_usage: list[dict], results: list[dict]) return ordered +def _archive_types_from_spools(filament_usage: list[dict], results: list[dict]) -> list[str] | None: + """Slot-ordered, de-duplicated materials for an archive's ``filament_type``, + taken from the inventory spools that actually fed the print (#2563). + + The slicer's 3MF records the filament type it was *sliced for*. When the + user manually maps a slot to a differently-typed loaded spool in the Print + dialog — a PLA slice routed to the only loaded PETG slot — that sliced type + misclassifies the run in the archive card, the Print Log and the material + statistics, even though the deduction correctly hit the PETG spool. Once + usage tracking has resolved every used slot to an inventory spool, the + spool's declared material is the authoritative record of what was consumed, + the same reasoning that already adopts the spool colour (#1494). + + Returns ``None`` — leave the 3MF type untouched — unless *every* slot with + non-zero usage was matched to a spool that carries a material. All-or- + nothing, exactly like ``_archive_colors_from_spools``: a partial rewrite + would silently drop the unmatched slots' types from the archive (and the + material stats). + """ + used_slots = {u["slot_id"] for u in filament_usage if u.get("used_g", 0) > 0 and u.get("slot_id") is not None} + if not used_slots: + return None + + slot_material: dict[int, str] = {} + for r in results: + slot_id = r.get("slot_id") + material = (r.get("material") or "").strip() + if slot_id is not None and material: + slot_material.setdefault(slot_id, material) + + if not used_slots.issubset(slot_material): + return None + + ordered: list[str] = [] + for slot_id in sorted(used_slots): + material = slot_material[slot_id] + if material not in ordered: + ordered.append(material) + return ordered + + def _match_slots_by_color( filament_usage: list[dict], ams_raw: dict | list | None, @@ -1396,4 +1437,19 @@ async def _track_from_3mf( ) archive.filament_color = joined + # Adopt the matched spools' materials too (#2563) — a slot mapped to a + # differently-typed spool than it was sliced for otherwise records the + # sliced type in the archive, Print Log and material stats. + spool_types = _archive_types_from_spools(filament_usage, results) + if spool_types: + joined_types = ",".join(spool_types) + if joined_types != archive.filament_type: + logger.info( + "[UsageTracker] 3MF: archive %s filament_type %r -> %r (from inventory spools)", + archive_id, + archive.filament_type, + joined_types, + ) + archive.filament_type = joined_types + return results diff --git a/backend/tests/unit/test_spoolman_tracking.py b/backend/tests/unit/test_spoolman_tracking.py index 1097808b8..b9cb4b276 100644 --- a/backend/tests/unit/test_spoolman_tracking.py +++ b/backend/tests/unit/test_spoolman_tracking.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from backend.app.services.spoolman_tracking import ( + _apply_spool_types_to_archive, _get_fallback_spool_tag, _global_tray_id_to_ams_slot, _hash_serial_to_hex32, @@ -272,3 +273,48 @@ class TestStorePrintData: # plate_id=2 must be passed as the second positional arg assert extract_mock.call_count == 1 assert extract_mock.call_args.args[1] == 2 + + +class TestApplySpoolTypesToArchive: + """_apply_spool_types_to_archive() rewrites the archive's filament_type + from the resolved Spoolman spools' materials (#2563).""" + + @staticmethod + async def _make_archive(db, filament_type): + from backend.app.models.archive import PrintArchive + + archive = PrintArchive( + filename="job.gcode.3mf", + file_path="archive/job.3mf", + file_size=1, + filament_type=filament_type, + ) + db.add(archive) + await db.commit() + await db.refresh(archive) + return archive + + @pytest.mark.asyncio + async def test_overwrites_sliced_type_with_spool_material(self, db_session): + """PLA-sliced job mapped to a PETG Spoolman spool → recorded as PETG.""" + archive = await self._make_archive(db_session, "PLA") + usage = [{"slot_id": 1, "used_g": 2.9}] + await _apply_spool_types_to_archive(db_session, archive.id, usage, {1: "PETG"}) + await db_session.refresh(archive) + assert archive.filament_type == "PETG" + + @pytest.mark.asyncio + async def test_partial_match_leaves_type_untouched(self, db_session): + """All-or-nothing: an unmatched used slot means no rewrite.""" + archive = await self._make_archive(db_session, "PLA,PLA") + usage = [{"slot_id": 1, "used_g": 5.0}, {"slot_id": 2, "used_g": 3.0}] + await _apply_spool_types_to_archive(db_session, archive.id, usage, {1: "PETG"}) + await db_session.refresh(archive) + assert archive.filament_type == "PLA,PLA" + + @pytest.mark.asyncio + async def test_empty_materials_is_noop(self, db_session): + archive = await self._make_archive(db_session, "PLA") + await _apply_spool_types_to_archive(db_session, archive.id, [{"slot_id": 1, "used_g": 2.9}], {}) + await db_session.refresh(archive) + assert archive.filament_type == "PLA" diff --git a/backend/tests/unit/test_usage_tracker.py b/backend/tests/unit/test_usage_tracker.py index af000b9cf..049b519dc 100644 --- a/backend/tests/unit/test_usage_tracker.py +++ b/backend/tests/unit/test_usage_tracker.py @@ -14,6 +14,7 @@ import pytest from backend.app.services.usage_tracker import ( PrintSession, _active_sessions, + _archive_types_from_spools, _decode_mqtt_mapping, _find_3mf_by_filename, _match_slots_by_color, @@ -2471,3 +2472,58 @@ class TestTrackFrom3mfPlateId: ) assert extract_mock.call_args.args[1] is None + + +class TestArchiveTypesFromSpools: + """Tests for _archive_types_from_spools() — adopting the mapped inventory + spool's material as the archive/print-log/stats filament type (#2563).""" + + @staticmethod + def _usage(slots): + """slots: list of (slot_id, used_g).""" + return [{"slot_id": sid, "used_g": g} for sid, g in slots] + + @staticmethod + def _results(entries): + """entries: list of (slot_id, material).""" + return [{"slot_id": sid, "material": m} for sid, m in entries] + + def test_mapped_material_overrides_sliced_type(self): + """The reporter's case: PLA-sliced slot mapped to a PETG spool → PETG.""" + usage = self._usage([(1, 2.9)]) + results = self._results([(1, "PETG")]) + assert _archive_types_from_spools(usage, results) == ["PETG"] + + def test_multi_slot_slot_ordered_and_deduped(self): + """Distinct materials keep slot order; a repeated material collapses.""" + usage = self._usage([(1, 5.0), (2, 3.0), (3, 4.0)]) + results = self._results([(2, "PETG"), (1, "PLA"), (3, "PLA")]) + assert _archive_types_from_spools(usage, results) == ["PLA", "PETG"] + + def test_partial_match_returns_none(self): + """All-or-nothing: an unmatched used slot means no rewrite at all.""" + usage = self._usage([(1, 5.0), (2, 3.0)]) + results = self._results([(1, "PETG")]) # slot 2 never resolved + assert _archive_types_from_spools(usage, results) is None + + def test_zero_usage_slot_ignored(self): + """A slot that consumed nothing doesn't need a spool material.""" + usage = self._usage([(1, 5.0), (2, 0.0)]) + results = self._results([(1, "PETG")]) + assert _archive_types_from_spools(usage, results) == ["PETG"] + + def test_no_used_slots_returns_none(self): + assert _archive_types_from_spools([], []) is None + assert _archive_types_from_spools(self._usage([(1, 0.0)]), []) is None + + def test_fallback_results_without_slot_id_ignored(self): + """AMS remain%-delta results carry slot_id=None and must not count.""" + usage = self._usage([(1, 5.0)]) + results = [{"slot_id": None, "material": "PETG"}] + assert _archive_types_from_spools(usage, results) is None + + def test_blank_material_is_not_a_match(self): + """An empty/whitespace material doesn't satisfy the all-or-nothing gate.""" + usage = self._usage([(1, 5.0)]) + assert _archive_types_from_spools(usage, self._results([(1, " ")])) is None + assert _archive_types_from_spools(usage, self._results([(1, " PETG ")])) == ["PETG"]