Browse Source

Keep the external spool in a virtual printer's saved AMS mapping (#3237)

The slicer writes an external spool as -1 in ams_mapping, the same as an
unused filament, and names it only in ams_mapping2. Save AMS mapping read
the flat list alone, so dispatch sent the filament as unmapped and an H2C
stopped with 0700-8012 before the first layer. Resolve it through
ams_mapping2 to the global tray (254/255, by the target's nozzle count),
as the usage capture does since #3166.
maziggy 2 days ago
parent
commit
08a1b68235

+ 1 - 0
CHANGELOG.md

@@ -117,6 +117,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **A virtual printer with Save AMS mapping lost the slicer's external-spool pick (#3237, reported by @erabti)** — The slicer marks a filament fed from the external spool as -1 in its slot list, the same as a filament with no slot, and says which spool it is in a second list. Bambuddy saved only the first list, so the queued print sent that filament as unassigned. On an H2C the printer then stopped before the first layer with 0700-8012, "Failed to get AMS mapping table". The saved mapping now keeps the external spool, left or right on dual-nozzle printers, for the queued print and for reprints from the archive.
 - **A smart plug that reports energy in watt-seconds could not be set up (reported by @CLKRUN in #1251)** — A myStrom Switch reports energy in watt-seconds, and converting that to kWh needs a multiplier of about 0.000000278. The multiplier fields refused anything below 0.0001, so saving the plug failed. Any multiplier above zero is now accepted, for MQTT and REST plugs alike.
 - **The Statistics energy total stayed at 0 for Shelly and other REST plugs with only a lifetime counter (#3232, reported and contributed by @rojosinalma in #3233)** — With **Total consumption** tracking and no date filter, the Statistics page adds up each plug's lifetime counter, but for REST plugs it added the counter for today. Since #2539, a Shelly is set up with only **Lifetime Energy JSON Path**, as it has no counter for today, so its plug added nothing and the energy total and cost showed 0, while any date range showed the right figure. REST plugs now add their lifetime counter, the same as Tasmota and Home Assistant plugs. A plug with only a counter for today still adds that one.
 - **Running out of file descriptors could corrupt a SQLite database (#2883, reported and contributed by @M2ABRAMSTANK in #2884)** — Docker, the systemd service and most native installs start Bambuddy with a limit of 1024 open files. Once a busy instance used them all, every new database connection failed with "disk I/O error"; on the reporter's single-printer install that went on for 44 hours and ended with "database disk image is malformed" and a login outage. Part of the reason is how SQLite works in WAL mode: a database connection that closes keeps its file open until the last connection closes, so the database's share of open files stays at the most connections the pool ever reached. Bambuddy now raises its own open-file limit to the maximum the system allows when it starts, on every install, and logs what it set. The SQLite connection pool is smaller (10 + 90 instead of 20 + 200), which halves its share. A large printer farm still on SQLite that runs out of connections can raise `DB_MAX_OVERFLOW`, but is better served by PostgreSQL. The support bundle now counts open files by kind (database, sockets, pipes and so on) against the limit, so the next report shows what is holding them. Covered by backend tests.

+ 43 - 3
backend/app/services/virtual_printer/manager.py

@@ -20,6 +20,7 @@ from backend.app.models.virtual_printer import (
     VP_MODE_QUEUE,
     normalize_vp_mode,
 )
+from backend.app.services.bambu_mqtt import resolve_external_spools_in_mapping
 from backend.app.services.virtual_printer.bind_server import BindServer
 from backend.app.services.virtual_printer.certificate import CertificateService
 from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPServer, compute_passive_port_slice
@@ -155,7 +156,7 @@ def _tristate_from_slicer(data: dict, bool_field: str, int_field: str) -> str |
     return None
 
 
-def _extract_slicer_ams_mapping_json(data: dict, log_prefix: str) -> str | None:
+def _extract_slicer_ams_mapping_json(data: dict, log_prefix: str, *, is_dual_nozzle: bool = False) -> str | None:
     """Pull the slicer's own AMS-slot pick out of a captured project_file payload.
 
     BambuStudio/OrcaSlicer resolves the physical AMS tray for each filament
@@ -184,6 +185,14 @@ def _extract_slicer_ams_mapping_json(data: dict, log_prefix: str) -> str | None:
     per-slot force-color overrides all live inside that function. Callers are
     responsible for the gating — this parser only says what the slicer sent.
 
+    An external spool is ``-1`` in the flat list, the same as a filament with
+    no tray, and is named only in ``ams_mapping2``. It is resolved from there
+    to its global tray (254/255), as the usage capture does (#3166); dispatch
+    turns that back into the external-spool entry. Kept as ``-1`` it dispatched
+    as unmapped and the printer stopped with 0700-8012 (#3237).
+    ``is_dual_nozzle`` is the target printer's, since that decides which
+    global tray an external spool is.
+
     Returns ``None`` when the field is absent, unparsable, or the classic
     "all -1" unresolved-race sentinel (#2589) — never worth trusting over a
     fresh live computation.
@@ -202,6 +211,13 @@ def _extract_slicer_ams_mapping_json(data: dict, log_prefix: str) -> str | None:
     # valid mapping.
     if not isinstance(raw, list) or not raw or not all(isinstance(v, int) and not isinstance(v, bool) for v in raw):
         return None
+    detail = data.get("ams_mapping2")
+    if isinstance(detail, str):
+        try:
+            detail = json.loads(detail)
+        except json.JSONDecodeError:
+            detail = None
+    raw = resolve_external_spools_in_mapping(raw, detail, is_dual_nozzle)
     if all(v < 0 for v in raw):
         # #2589 sentinel — every slot unresolved. Let the scheduler compute a
         # fresh mapping from live AMS state instead of trusting this.
@@ -395,6 +411,26 @@ class VirtualPrinterInstance:
             # IDLE reset was wrong — staying at FINISH is the designed
             # behaviour. The next upload's PREPARE→FINISH cycle starts fresh.
 
+    def _target_is_dual_nozzle(self) -> bool:
+        """Whether the target printer has two nozzles, which decides the global
+        tray of an external spool in the slicer's mapping (#3237).
+
+        Same signal dispatch uses: the live client's detection, else its model.
+        Without a client, the VP's own model is what the slicer sliced for.
+        """
+        from backend.app.utils.printer_models import is_dual_nozzle_model
+
+        client = (
+            self._printer_manager.get_client(self.target_printer_id)
+            if self._printer_manager is not None and self.target_printer_id is not None
+            else None
+        )
+        if client is not None:
+            return bool(getattr(client, "_is_dual_nozzle", False)) or is_dual_nozzle_model(
+                getattr(client, "model", None)
+            )
+        return is_dual_nozzle_model(self.model)
+
     async def on_print_command(self, filename: str, data: dict) -> None:
         """Handle print command from MQTT.
 
@@ -534,7 +570,9 @@ class VirtualPrinterInstance:
         # `_compute_ams_mapping_for_printer`, and with it prefer-lowest and the
         # #1766 backup gate).
         ams_mapping_json = (
-            _extract_slicer_ams_mapping_json(data, f"[VP {self.name}] Late MQTT")
+            _extract_slicer_ams_mapping_json(
+                data, f"[VP {self.name}] Late MQTT", is_dual_nozzle=self._target_is_dual_nozzle()
+            )
             if self.target_printer_id is not None and self.save_ams_mapping
             else None
         )
@@ -979,7 +1017,9 @@ class VirtualPrinterInstance:
                 # printer actually gets the job.
                 ams_mapping_json: str | None = None
                 if slicer_opts is not None and self.target_printer_id is not None and self.save_ams_mapping:
-                    ams_mapping_json = _extract_slicer_ams_mapping_json(slicer_opts, f"[VP {self.name}]")
+                    ams_mapping_json = _extract_slicer_ams_mapping_json(
+                        slicer_opts, f"[VP {self.name}]", is_dual_nozzle=self._target_is_dual_nozzle()
+                    )
 
                 # `Force color match` is the user asking Bambuddy to do the
                 # matching strictly, against the printer's live trays. Its only

+ 145 - 0
backend/tests/unit/services/test_vp_external_spool_mapping_3237.py

@@ -0,0 +1,145 @@
+"""A VP with "Save AMS mapping" keeps the slicer's external-spool pick (#3237).
+
+The slicer writes an external spool as -1 in the flat ``ams_mapping``, the same
+as a filament with no tray, and names it only in ``ams_mapping2``. Saving the
+flat list alone made dispatch send the external filament as unmapped, and the
+printer stopped with 0700-8012 before the first layer.
+"""
+
+import json
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.virtual_printer.manager import (
+    VirtualPrinterInstance,
+    _extract_slicer_ams_mapping_json,
+)
+
+EXT_LEFT = {"ams_id": 254, "slot_id": 0}
+EXT_RIGHT = {"ams_id": 255, "slot_id": 0}
+UNMAPPED = {"ams_id": 255, "slot_id": 255}
+
+# The reporter's H2C job: left external spool plus AMS 0 slots 3 and 2.
+H2C_PAYLOAD = {
+    "command": "project_file",
+    "ams_mapping": [-1, 3, 2],
+    "ams_mapping2": [EXT_LEFT, {"ams_id": 0, "slot_id": 3}, {"ams_id": 0, "slot_id": 2}],
+}
+
+
+def _extract(data, is_dual_nozzle):
+    raw = _extract_slicer_ams_mapping_json(data, "[test]", is_dual_nozzle=is_dual_nozzle)
+    return None if raw is None else json.loads(raw)
+
+
+class TestExtract:
+    def test_left_external_spool_on_dual_nozzle(self):
+        assert _extract(H2C_PAYLOAD, is_dual_nozzle=True) == [254, 3, 2]
+
+    def test_right_external_spool_on_dual_nozzle(self):
+        data = {"ams_mapping": [-1, 3], "ams_mapping2": [EXT_RIGHT, {"ams_id": 0, "slot_id": 3}]}
+        assert _extract(data, is_dual_nozzle=True) == [255, 3]
+
+    def test_single_nozzle_external_spool_is_254(self):
+        # Single-nozzle printers have one external spool; the wire calls it
+        # 255, Bambuddy's global tray is 254.
+        data = {"ams_mapping": [0, -1], "ams_mapping2": [{"ams_id": 0, "slot_id": 0}, EXT_RIGHT]}
+        assert _extract(data, is_dual_nozzle=False) == [0, 254]
+
+    def test_unused_filament_stays_unmapped(self):
+        data = {"ams_mapping": [-1, 3], "ams_mapping2": [UNMAPPED, {"ams_id": 0, "slot_id": 3}]}
+        assert _extract(data, is_dual_nozzle=True) == [-1, 3]
+
+    def test_external_only_job_is_kept(self):
+        # Every flat entry is -1, but this is a real pick, not the #2589
+        # unresolved sentinel.
+        assert _extract({"ams_mapping": [-1], "ams_mapping2": [EXT_LEFT]}, is_dual_nozzle=True) == [254]
+
+    def test_unresolved_sentinel_still_dropped(self):
+        data = {"ams_mapping": [-1, -1], "ams_mapping2": [UNMAPPED, UNMAPPED]}
+        assert _extract(data, is_dual_nozzle=True) is None
+
+    def test_stringified_mapping2_is_parsed(self):
+        data = {"ams_mapping": "[-1, 3, 2]", "ams_mapping2": json.dumps(H2C_PAYLOAD["ams_mapping2"])}
+        assert _extract(data, is_dual_nozzle=True) == [254, 3, 2]
+
+    def test_missing_or_unusable_mapping2_keeps_flat_list(self):
+        assert _extract({"ams_mapping": [-1, 3, 2]}, is_dual_nozzle=True) == [-1, 3, 2]
+        assert _extract({"ams_mapping": [-1, 3, 2], "ams_mapping2": "not json"}, is_dual_nozzle=True) == [-1, 3, 2]
+        # Length mismatch: positions can't be paired, leave it alone.
+        assert _extract({"ams_mapping": [-1, 3, 2], "ams_mapping2": [EXT_LEFT]}, is_dual_nozzle=True) == [-1, 3, 2]
+
+
+def _instance(tmp_path, *, model="O1C", printer_manager=None, session_factory=None):
+    return VirtualPrinterInstance(
+        vp_id=3237,
+        name="ExtSpool",
+        mode="queue",
+        model=model,
+        access_code="12345678",
+        serial_suffix="391803237",
+        base_dir=tmp_path,
+        session_factory=session_factory,
+        save_ams_mapping=True,
+        target_printer_id=7,
+        printer_manager=printer_manager,
+    )
+
+
+def _manager_with(client):
+    manager = MagicMock()
+    manager.get_client = MagicMock(return_value=client)
+    return manager
+
+
+class TestTargetIsDualNozzle:
+    def test_live_detection_wins(self, tmp_path):
+        client = MagicMock(_is_dual_nozzle=True, model="Some future model")
+        assert _instance(tmp_path, model="BL-P001", printer_manager=_manager_with(client))._target_is_dual_nozzle()
+
+    def test_client_model(self, tmp_path):
+        client = MagicMock(_is_dual_nozzle=False, model="H2C")
+        assert _instance(tmp_path, model="BL-P001", printer_manager=_manager_with(client))._target_is_dual_nozzle()
+
+    def test_single_nozzle_client(self, tmp_path):
+        client = MagicMock(_is_dual_nozzle=False, model="X1C")
+        assert not _instance(tmp_path, printer_manager=_manager_with(client))._target_is_dual_nozzle()
+
+    def test_falls_back_to_vp_model_without_client(self, tmp_path):
+        assert _instance(tmp_path, model="O1C", printer_manager=_manager_with(None))._target_is_dual_nozzle()
+        assert not _instance(tmp_path, model="BL-P001")._target_is_dual_nozzle()
+
+
+@pytest.mark.asyncio
+async def test_queue_item_and_archive_keep_left_external_spool(tmp_path):
+    added_items = []
+    mock_db = AsyncMock()
+    mock_db.add = MagicMock(side_effect=added_items.append)
+    mock_db.commit = AsyncMock()
+    session_ctx = AsyncMock()
+    session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+    session_ctx.__aexit__ = AsyncMock(return_value=False)
+    session_factory = MagicMock(return_value=session_ctx)
+
+    client = MagicMock(_is_dual_nozzle=True, model="H2C")
+    inst = _instance(tmp_path, printer_manager=_manager_with(client), session_factory=session_factory)
+
+    file_path = tmp_path / "test.3mf"
+    file_path.write_bytes(b"fake3mf")
+    await inst.on_print_command(file_path.name, dict(H2C_PAYLOAD))
+
+    mock_archive = MagicMock(id=1, print_name="test")
+    with (
+        patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None),
+        patch(
+            "backend.app.services.archive.ArchiveService.archive_print",
+            new_callable=AsyncMock,
+            return_value=mock_archive,
+        ) as archive_print,
+    ):
+        await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+    assert len(added_items) == 1
+    assert json.loads(added_items[0].ams_mapping) == [254, 3, 2]
+    assert archive_print.await_args.kwargs["slicer_ams_mapping"] == [254, 3, 2]