Browse Source

fix(archives): MQTT-derived filament type/color on fallback archives (#1533)

  When the source .3mf can't be downloaded at print start (P1S/A1/P2S
  firmwares lock the file mid-print), main.py creates a fallback
  PrintArchive with file_path="" and every filament field NULL — even
  though the MQTT payload already has the AMS state and the slicer's
  slot-per-print-filament mapping (data["ams"]["ams"] and
  data["ams_mapping"]).

  New _extract_filament_data_from_mqtt(data, ams_mapping) builds a
  {global_tray_id: (type, color)} map from the AMS units, then narrows
  to slots referenced by ams_mapping (slicer order preserved, -1 VT-tray
  sentinels skipped) or falls back to every loaded slot when no mapping
  is present. Returns comma-separated filament_type and filament_color
  matching the 3MF-extraction shape, so the inventory page, Quick Stats
  rollup, and len(filament_type.split(",")) per-print count behave
  identically for fallback rows.

  The constructor at the fallback site now passes the resulting values
  into the PrintArchive row.

  This does NOT recover per-filament gram usage — that needs the .3mf's
  slice_info.config or a deeper layer-delta integration via usage_tracker.
  The reporter (maker-space lead evaluating Bambuddy partly for AMS
  expansion planning) asked specifically for "the number of filaments
  used", which is what this gives them.

  15 unit tests cover empty/malformed payloads, the no-mapping path,
  mapping filtering and reordering, VT-tray sentinels, dual-AMS global
  ids, column-limit truncation, and defensive garbage handling.
maziggy 2 days ago
parent
commit
c42e923e4c
3 changed files with 284 additions and 0 deletions
  1. 0 0
      CHANGELOG.md
  2. 78 0
      backend/app/main.py
  3. 206 0
      backend/tests/unit/test_fallback_archive_mqtt_filament.py

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 78 - 0
backend/app/main.py

@@ -628,6 +628,74 @@ def _get_start_ams_mapping(data: dict, archive_id: int | None) -> list[int] | No
     return stored_ams_mapping
 
 
+def _extract_filament_data_from_mqtt(data: dict, ams_mapping: list[int] | None = None) -> dict[str, str]:
+    """Best-effort filament metadata from the MQTT print-start snapshot.
+
+    Used when the 3MF can't be downloaded (P1S/A1/P2S firmwares lock the
+    file during print, see #1533) so the fallback PrintArchive still has
+    enough filament info to support the inventory views and AMS-expansion
+    planning the operator opens it for. Returns a dict with optional
+    ``filament_type`` and ``filament_color`` keys in the same
+    comma-separated format the 3MF extractor produces, so the rest of the
+    codebase treats the fallback archive identically to a normal one.
+
+    ``ams_mapping`` is the slicer's slot-per-print-filament list captured
+    from the MQTT print payload (global tray IDs, possibly -1 for VT-tray
+    entries). When supplied, only the slots actually consumed by this
+    print contribute. Without it the function falls back to every loaded
+    AMS slot — less accurate but still useful.
+    """
+    result: dict[str, str] = {}
+    ams_root = (data or {}).get("ams") or {}
+    ams_units = ams_root.get("ams") if isinstance(ams_root, dict) else None
+    if not isinstance(ams_units, list) or not ams_units:
+        return result
+
+    # Map global tray id (unit * 4 + tray) → (type, color).
+    loaded: dict[int, tuple[str, str]] = {}
+    for unit in ams_units:
+        if not isinstance(unit, dict):
+            continue
+        try:
+            unit_id = int(unit.get("id", 0))
+        except (TypeError, ValueError):
+            continue
+        for tray in unit.get("tray") or []:
+            if not isinstance(tray, dict):
+                continue
+            try:
+                tray_id = int(tray.get("id", 0))
+            except (TypeError, ValueError):
+                continue
+            ttype = (tray.get("tray_type") or "").strip()
+            tcolor = (tray.get("tray_color") or "").strip().upper()
+            if not ttype:
+                continue  # Empty / unloaded slot.
+            loaded[unit_id * 4 + tray_id] = (ttype, tcolor)
+
+    if not loaded:
+        return result
+
+    if ams_mapping:
+        used_ids = [int(x) for x in ams_mapping if isinstance(x, (int, float)) and int(x) >= 0]
+        filaments = [loaded[g] for g in used_ids if g in loaded]
+        if not filaments:
+            return result  # Mapping points entirely at slots we have no data for.
+    else:
+        filaments = [loaded[g] for g in sorted(loaded.keys())]
+
+    types_joined = ",".join(f[0] for f in filaments)
+    colors_joined = ",".join(f[1] for f in filaments if f[1])
+
+    # Column limits per backend/app/models/archive.py: filament_type=50,
+    # filament_color=200.
+    if types_joined:
+        result["filament_type"] = types_joined[:50]
+    if colors_joined:
+        result["filament_color"] = colors_joined[:200]
+    return result
+
+
 def _maybe_start_layer_timelapse(printer, printer_id: int, archive_id: int) -> bool:
     """Start a layer-timelapse session for *archive_id* when the printer has
     an external camera configured. Returns True if a session was started.
@@ -2592,6 +2660,14 @@ async def on_print_start(printer_id: int, data: dict):
                     if mc_remaining and isinstance(mc_remaining, (int, float)) and mc_remaining > 0:
                         fallback_print_time = int(mc_remaining * 60)
 
+                # Best-effort filament metadata from MQTT — see
+                # _extract_filament_data_from_mqtt. Without this the fallback
+                # archive's filament fields stayed NULL even though the AMS
+                # state at print start was sitting right there in `data`.
+                # The slicer's ams_mapping (when present) narrows the result
+                # to slots actually used by the print (#1533).
+                mqtt_filament_meta = _extract_filament_data_from_mqtt(data, _get_start_ams_mapping(data, None))
+
                 # Create minimal archive entry
                 fallback_archive = PrintArchive(
                     printer_id=printer_id,
@@ -2603,6 +2679,8 @@ async def on_print_start(printer_id: int, data: dict):
                     status="printing",
                     started_at=datetime.now(timezone.utc),
                     subtask_id=subtask_id,
+                    filament_type=mqtt_filament_meta.get("filament_type"),
+                    filament_color=mqtt_filament_meta.get("filament_color"),
                     extra_data={"no_3mf_available": True, "original_subtask": subtask_name, "_print_data": data},
                 )
 

+ 206 - 0
backend/tests/unit/test_fallback_archive_mqtt_filament.py

@@ -0,0 +1,206 @@
+"""Tests for _extract_filament_data_from_mqtt (#1533).
+
+The fallback PrintArchive path in main.py fires when the source 3MF can't
+be downloaded from the printer at print start — common on P1S / A1 / P2S
+firmwares that lock the file during printing. Before this fix the
+fallback archive had every filament field NULL even though the MQTT
+print-start payload already carried the AMS state and the slicer's
+slot-per-print-filament mapping. The helper extracts a comma-separated
+``filament_type`` / ``filament_color`` from that payload so the inventory
+views can at least show what's loaded, and operators planning AMS
+expansion can count filaments per print.
+"""
+
+import pytest
+
+from backend.app.main import _extract_filament_data_from_mqtt
+
+
+def _ams_unit(unit_id: int, trays: list[dict]) -> dict:
+    return {"id": unit_id, "tray": trays}
+
+
+def _tray(tray_id: int, ttype: str | None, color: str | None) -> dict:
+    out: dict = {"id": tray_id}
+    if ttype is not None:
+        out["tray_type"] = ttype
+    if color is not None:
+        out["tray_color"] = color
+    return out
+
+
+class TestExtractFilamentDataFromMqtt:
+    def test_empty_payload_returns_empty_dict(self):
+        assert _extract_filament_data_from_mqtt({}) == {}
+        assert _extract_filament_data_from_mqtt({"ams": None}) == {}
+        assert _extract_filament_data_from_mqtt({"ams": {}}) == {}
+        assert _extract_filament_data_from_mqtt({"ams": {"ams": []}}) == {}
+
+    def test_no_loaded_slots_returns_empty(self):
+        """All slots empty (no tray_type) → nothing to report."""
+        data = {
+            "ams": {
+                "ams": [
+                    _ams_unit(0, [_tray(i, "", "") for i in range(4)]),
+                ],
+            }
+        }
+        assert _extract_filament_data_from_mqtt(data) == {}
+
+    def test_no_mapping_lists_all_loaded_slots_sorted(self):
+        data = {
+            "ams": {
+                "ams": [
+                    _ams_unit(
+                        0,
+                        [
+                            _tray(0, "PLA", "FF0000"),
+                            _tray(1, "PETG", "00FF00"),
+                            _tray(2, "", ""),  # Empty slot — skipped.
+                            _tray(3, "ABS", "0000ff"),
+                        ],
+                    ),
+                ],
+            }
+        }
+        result = _extract_filament_data_from_mqtt(data)
+        # Order is by ascending global tray id, colors uppercased.
+        assert result == {"filament_type": "PLA,PETG,ABS", "filament_color": "FF0000,00FF00,0000FF"}
+
+    def test_ams_mapping_narrows_to_used_slots(self):
+        """The slicer's slot-per-print-filament mapping wins — only used
+        slots contribute, in the slicer's order (which is the order the
+        print materially consumes them)."""
+        data = {
+            "ams": {
+                "ams": [
+                    _ams_unit(
+                        0,
+                        [
+                            _tray(0, "PLA", "FF0000"),
+                            _tray(1, "PETG", "00FF00"),
+                            _tray(2, "ABS", "0000FF"),
+                            _tray(3, "TPU", "FFFF00"),
+                        ],
+                    ),
+                ],
+            }
+        }
+        # Print uses slots 3 then 0 then 1 (slot 2 untouched, no entry).
+        result = _extract_filament_data_from_mqtt(data, ams_mapping=[3, 0, 1])
+        assert result == {"filament_type": "TPU,PLA,PETG", "filament_color": "FFFF00,FF0000,00FF00"}
+
+    def test_ams_mapping_with_vt_tray_sentinels_filtered_out(self):
+        """ams_mapping entries equal to -1 represent the VT tray (external
+        spool feed). We have no AMS tray data for them — they must be
+        skipped, not treated as global tray id 0."""
+        data = {
+            "ams": {
+                "ams": [
+                    _ams_unit(
+                        0,
+                        [
+                            _tray(0, "PLA", "FF0000"),
+                            _tray(1, "PETG", "00FF00"),
+                        ],
+                    ),
+                ],
+            }
+        }
+        result = _extract_filament_data_from_mqtt(data, ams_mapping=[-1, 0, 1])
+        assert result == {"filament_type": "PLA,PETG", "filament_color": "FF0000,00FF00"}
+
+    def test_dual_ams_global_ids_use_unit4_offset(self):
+        """A dual-AMS rig has unit 0 → trays 0-3, unit 1 → trays 4-7.
+        ``ams_mapping=4`` must resolve to unit 1, tray 0 — not unit 0."""
+        data = {
+            "ams": {
+                "ams": [
+                    _ams_unit(0, [_tray(0, "PLA", "FF0000")]),
+                    _ams_unit(1, [_tray(0, "PETG-CF", "112233")]),
+                ],
+            }
+        }
+        result = _extract_filament_data_from_mqtt(data, ams_mapping=[4, 0])
+        assert result == {"filament_type": "PETG-CF,PLA", "filament_color": "112233,FF0000"}
+
+    def test_mapping_pointing_at_unknown_slot_falls_through_to_known_only(self):
+        data = {
+            "ams": {
+                "ams": [
+                    _ams_unit(0, [_tray(0, "PLA", "FF0000")]),
+                ],
+            }
+        }
+        # Slot 7 isn't in our AMS — entry skipped, only slot 0 remains.
+        result = _extract_filament_data_from_mqtt(data, ams_mapping=[7, 0])
+        assert result == {"filament_type": "PLA", "filament_color": "FF0000"}
+
+    def test_mapping_entirely_unknown_returns_empty(self):
+        """If every mapped slot is unknown the helper returns {} rather
+        than silently misreporting from the all-slots fallback — the
+        slicer was explicit about which slots to use."""
+        data = {
+            "ams": {
+                "ams": [
+                    _ams_unit(0, [_tray(0, "PLA", "FF0000")]),
+                ],
+            }
+        }
+        assert _extract_filament_data_from_mqtt(data, ams_mapping=[5, 6]) == {}
+
+    def test_color_truncation_at_column_limit(self):
+        """filament_color column is VARCHAR(200); long multi-color prints
+        must not exceed it."""
+        # 16 trays of 6-char colors + 15 commas = 96+15 = 111 chars. Safe.
+        # Construct an oversized synthetic case with many distinct colors.
+        trays = [_tray(i, "PLA", f"{i:06X}") for i in range(4)]
+        data = {"ams": {"ams": [_ams_unit(u, trays) for u in range(8)]}}
+        result = _extract_filament_data_from_mqtt(data)
+        assert "filament_color" in result
+        assert len(result["filament_color"]) <= 200
+
+    def test_type_truncation_at_column_limit(self):
+        """filament_type column is VARCHAR(50). Many filaments must truncate."""
+        # 16 PETG-CF entries: 7 chars × 16 + 15 commas = 127 chars.
+        trays = [_tray(i, "PETG-CF", "AABBCC") for i in range(4)]
+        data = {"ams": {"ams": [_ams_unit(u, trays) for u in range(4)]}}
+        result = _extract_filament_data_from_mqtt(data)
+        assert "filament_type" in result
+        assert len(result["filament_type"]) <= 50
+
+    def test_color_missing_only_emits_type(self):
+        """A tray with type but blank color still contributes to filament_type."""
+        data = {
+            "ams": {
+                "ams": [
+                    _ams_unit(0, [_tray(0, "PLA", "")]),
+                ],
+            }
+        }
+        result = _extract_filament_data_from_mqtt(data)
+        assert result == {"filament_type": "PLA"}
+        # filament_color absent — not empty string.
+        assert "filament_color" not in result
+
+    def test_malformed_unit_skipped_without_crash(self):
+        """Defensive: unexpected MQTT shapes (non-dict in ams list, missing
+        id, string tray.id) must not raise. The fallback-archive write
+        runs in a hot path during print start — anything that throws here
+        would bubble up and break the print log entirely."""
+        data = {
+            "ams": {
+                "ams": [
+                    "garbage",
+                    {"id": "not-an-int", "tray": []},
+                    _ams_unit(0, [_tray(0, "PLA", "FF0000"), {"id": "x", "tray_type": "PETG"}]),
+                ],
+            }
+        }
+        result = _extract_filament_data_from_mqtt(data)
+        # Only the well-formed entry contributes; no exception.
+        assert result.get("filament_type") == "PLA"
+
+    @pytest.mark.parametrize("data", [None, {}, {"ams": "weird-string"}])
+    def test_garbage_top_level_is_empty(self, data):
+        assert _extract_filament_data_from_mqtt(data or {}) == {}

Some files were not shown because too many files changed in this diff