Просмотр исходного кода

Usage: a print stopped before its first layer charges nothing (#3511)

- the 3MF path worked out 0 g for a slot when the print failed before
  its first layer, but did not claim the tray, so the remain% fallback
  charged it from the AMS reading at the stop
- the AMS can read 0% for a moment as the filament unloads, so a new
  spool at 100% was charged in full: 1000 g for a five-minute print
- a tray the 3MF path resolved from the mapping or the loaded tray now
  counts as handled at 0 g; a tray only guessed by position stays the
  fallback's, and prints without a 3MF still use remain%
maziggy 3 часов назад
Родитель
Сommit
98b7395380

+ 1 - 0
CHANGELOG.md

@@ -21,6 +21,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **"Any model" jobs go to the printer with the fewest print hours (#3480, sponsor vote Q4 2026)** — When several printers of a model can take a job, the queue used to pick the first one in its list, so on a farm the same printer got most of the work. It now picks the one with the fewest print hours (the Maintenance counter, including baseline hours entered there), so jobs and wear spread across the farm. Preferred colours still come first, and the queue powers on the least used printer too when it has to switch one on with an Auto Power On plug.
 
 ### Fixed
+- **A print that fails before its first layer no longer charges a whole spool (#3511)** — When a print failed or was cancelled before its first layer, Bambuddy worked out from the sliced file that no filament was used, but still let the AMS remaining % decide for that slot. The AMS can read 0% for a moment as the filament unloads, so a new spool at 100% was charged in full: 1000 g and its cost for a print stopped after five minutes, which then topped the Stats page as the heaviest and most expensive print. A slot whose spool the sliced file already accounts for is now left to that figure, so such a print charges nothing. Prints without a sliced file still use the AMS remaining %.
 - **The H3C's printer card shows the temperature of the hotend on the toolhead (#3442, reported by @hurr1k4ne, cause found by @daltonclift)** — After a tool swap the H3C keeps reporting the temperature of the hotend that just went back to the rack, so the card could show one cooling down there instead of the one printing. The card now takes the temperature, target and heating state of the hotend on the toolhead, as Bambu Studio does, and names it by its rack position (**Nozzle 5**), the number its chip has in the nozzle rack beside it.
 - **The SpoolBuddy kiosk works with its own device when several are registered (#3504, reported by @pleasantone)** — The kiosk's **Write** page always sent the write to the first registered device, which could be another kiosk or an old reader that had long been offline. The online state and display settings also came from that device, and every kiosk showed the weight and tags of every reader. The kiosk now works with one device: the one picked on its **Write** page, or else the device that is online. **Write** shows a choice of device when more than one is online, and the kiosk remembers it. Weight, tags and online state from other devices no longer show on it.
 - **A tag written with SpoolBuddy moves to the new spool (#3505, reported by @pleasantone)** — With Bambuddy's own inventory, writing a tag that already belonged to another spool linked it to the new spool but left it on the old one too, so scanning it could open the old spool. The tag now moves: the spool that had it gives it up, as it already did with Spoolman.

+ 14 - 0
backend/app/services/usage_tracker.py

@@ -2097,6 +2097,20 @@ async def _track_from_3mf(
             continue
 
         if slot_grams <= 0:
+            if not tray_is_guessed:
+                # The slicer weighs this slot (a 0 g one was skipped above) and
+                # its tray is known, but the print stopped before using any of
+                # it. That is the answer for this tray: left open, the remain%
+                # fallback charged it from the AMS reading at the stop, which
+                # can be a transient 0% and so the whole spool (#3511). A tray
+                # only guessed by position stays the fallback's.
+                handled_trays.add(key)
+                logger.info(
+                    "[UsageTracker] 3MF: AMS%d-T%d used nothing before the print stopped (%s), nothing charged",
+                    ams_id,
+                    tray_id,
+                    status,
+                )
             continue
 
         for share_spool_id, weight_grams in shares or [(spool_id, slot_grams)]:

+ 134 - 0
backend/tests/unit/test_failed_print_bogus_remain_3511.py

@@ -0,0 +1,134 @@
+"""A print stopped before its first layer charges nothing, whatever the AMS reads (#3511).
+
+The reporter's X2D failed 290 s in. The 3MF named slot 2 on AMS0-T3 and worked
+out 0 g at layer 0, but did not claim the tray, so the remain% fallback charged
+it from the AMS reading at the stop: 100% at the start, a transient 0% at the
+end, the whole 1000 g spool.
+"""
+
+from datetime import datetime, timezone
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.usage_tracker import PrintSession, _active_sessions, _track_from_3mf, on_print_complete
+from backend.tests.unit.test_usage_tracker import (
+    _make_archive,
+    _make_assignment,
+    _make_spool,
+    _mock_db_sequential,
+)
+
+pytestmark = [pytest.mark.unit, pytest.mark.asyncio]
+
+# The reporter's plate: one PETG filament in slot 2, mapped to AMS0-T3.
+USAGE = [{"slot_id": 2, "used_g": 28.05, "type": "PETG", "color": ""}]
+MAPPING = [65535, 3]
+
+
+def _stopped_state(
+    remain_at_stop: int = 0, mqtt_mapping: list | None = MAPPING, last_loaded_tray: int = 3
+) -> SimpleNamespace:
+    raw = {"ams": [{"id": 0, "tray": [{"id": 3, "remain": remain_at_stop, "tray_type": "PETG"}]}]}
+    if mqtt_mapping is not None:
+        raw["mapping"] = mqtt_mapping
+    return SimpleNamespace(
+        progress=0,
+        layer_num=0,
+        total_layers=120,
+        tray_now=255,
+        last_loaded_tray=last_loaded_tray,
+        tray_change_log=[],
+        spool_change_log=[],
+        raw_data=raw,
+    )
+
+
+def _threemf(usage):
+    path = MagicMock()
+    path.exists.return_value = True
+    settings = patch("backend.app.core.config.settings")
+    return (
+        settings,
+        patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=usage),
+        patch("backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf", return_value=None),
+        path,
+    )
+
+
+async def _track(usage, ams_mapping, mqtt_mapping=MAPPING, last_loaded_tray=3):
+    printer_manager = MagicMock()
+    printer_manager.get_status.return_value = _stopped_state(
+        mqtt_mapping=mqtt_mapping, last_loaded_tray=last_loaded_tray
+    )
+    handled: set = set()
+    db = _mock_db_sequential([_make_archive(archive_id=32), None, _make_assignment(spool_id=38, ams_id=0, tray_id=3)])
+    settings, extract, layers, path = _threemf(usage)
+    with settings as mock_settings, extract, layers:
+        mock_settings.base_dir.__truediv__ = MagicMock(return_value=path)
+        results = await _track_from_3mf(
+            printer_id=1,
+            archive_id=32,
+            status="failed",
+            print_name="Desiccant core",
+            handled_trays=handled,
+            printer_manager=printer_manager,
+            db=db,
+            ams_mapping=ams_mapping,
+        )
+    return results, handled
+
+
+class TestTheThreeMfClaimsTheTray:
+    async def test_a_mapped_slot_stopped_before_using_any_is_handled(self):
+        results, handled = await _track(USAGE, MAPPING)
+
+        assert results == []
+        assert (0, 3) in handled
+
+    async def test_a_tray_guessed_by_position_is_left_to_the_fallback(self):
+        # No mapping from the dispatch nor from the printer, and no tray loaded.
+        _, handled = await _track(USAGE, None, mqtt_mapping=None, last_loaded_tray=-1)
+
+        assert handled == set()
+
+
+class TestTheCompletion:
+    @pytest.fixture(autouse=True)
+    def _clean(self):
+        _active_sessions.clear()
+        with patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None):
+            yield
+        _active_sessions.clear()
+
+    async def test_the_reporters_print_charges_nothing(self):
+        _active_sessions[1] = PrintSession(
+            printer_id=1,
+            print_name="Desiccant core",
+            started_at=datetime.now(timezone.utc),
+            tray_remain_start={(0, 3): 100},
+            tray_now_at_start=3,
+            ams_mapping=MAPPING,
+        )
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = _stopped_state(remain_at_stop=0)
+        spool = _make_spool(spool_id=38)
+        assignment = _make_assignment(spool_id=38, ams_id=0, tray_id=3)
+        # The 3MF path's archive, queue item and assignment; then what the
+        # fallback would read if it ran: the assignment and the spool.
+        db = _mock_db_sequential([_make_archive(archive_id=32), None, assignment, assignment, spool])
+        settings, extract, layers, path = _threemf(USAGE)
+        with settings as mock_settings, extract, layers:
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=path)
+            results = await on_print_complete(
+                printer_id=1,
+                data={"status": "failed"},
+                printer_manager=printer_manager,
+                db=db,
+                archive_id=32,
+                ams_mapping=MAPPING,
+            )
+
+        assert results == []
+        db.add.assert_not_called()