deleted. Nothing would have been charged twice, but that printer would have looked mid-print to later spool archiving. It's closed using the "completion in progress"

This commit is contained in:
maziggy
2026-10-20 07:32:46 +02:00
parent 2e30470dfb
commit 005b34af6d
5 changed files with 283 additions and 14 deletions
+1
View File
@@ -164,6 +164,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
- **Spoolman: a print whose 3MF arrived late is charged from the 3MF** — A print that started before Bambuddy could fetch its 3MF (an "Untitled" print, a printer briefly unreachable) gets an empty archive that is filled in once the 3MF turns up. In Spoolman mode its filament was still charged from the AMS remaining-percentage change — in steps of 10 g on a 1 kg spool, nothing for a spool without a percentage, and nothing at all when no slot reported one at the start. The recovered 3MF's usage is now used, as the built-in inventory already did.
- **Spoolman inventory: Cost/kg shows the price prints are charged** — A Spoolman spool priced only through its filament (the spool itself has no price, or a price of 0) showed "-" or "0.00" in the Inventory's Cost/kg column and no price on the SpoolBuddy spool card, although every print off it was charged that filament price. Both now show it, the column marked "(filament price)"; the spool's own price still wins, and editing the spool does not copy the filament's price onto it.
- **Spoolman AMS syncs keep a spool assigned by hand, and say what they changed** — Three cases:
- **A spool assigned to an AMS slot by hand** while the automatic Spoolman sync was waiting for Spoolman could be replaced by the spool the sync had read before. The syncs now change only the slots whose spool they found different, and only while the slot still holds what they read.
+21
View File
@@ -3877,6 +3877,27 @@ async def _recover_fallback_archive_locked(
source_3mf,
recovered.file_size,
)
# Spoolman's tracking stored no 3MF usage at print start and would
# charge the AMS remain% change; the built-in tracker reads the archive
# at completion and needs nothing. Also at the print's end (``price``
# False): that recovery runs inside the close-out, before the charge.
try:
from backend.app.services.spoolman_tracking import attach_recovered_3mf
from backend.app.services.usage_tracker import is_completing
await attach_recovered_3mf(
printer_id,
recovered.id,
recovered.file_path,
db,
printer_manager,
plate_id=_get_start_plate_id(recovered.id) or recovered.plate_id,
may_start=not price or (recovered.status == "printing" and not is_completing(printer_id)),
)
except Exception:
logger.warning(
"[RECOVER] Archive %s: could not add the 3MF to Spoolman's usage tracking", archive_id, exc_info=True
)
if price and recovered.status == "printing":
# Print start priced it from the default rate, having no 3MF to
# estimate from; now it has one. An ended print is left alone: its
+91 -14
View File
@@ -416,6 +416,96 @@ def _snapshot_tray_remain(raw_data: dict, skipped_out: list[str] | None = None)
return snapshot
def _extract_3mf_tracking(full_path, plate_id: int | None) -> tuple[list | None, dict | None, dict | None]:
"""What Spoolman's tracking reads from a print's 3MF: the per-filament
grams, the per-layer usage (JSON keys) and the filament properties."""
from backend.app.utils.threemf_tools import (
extract_filament_properties_from_3mf,
extract_filament_usage_from_3mf,
extract_layer_filament_usage_from_3mf,
)
filament_usage = extract_filament_usage_from_3mf(full_path, plate_id) or None
layer_usage_json: dict | None = None
layer_usage = extract_layer_filament_usage_from_3mf(full_path, plate_id)
if layer_usage:
# Convert int keys to string for JSON serialization
layer_usage_json = {str(k): v for k, v in layer_usage.items()}
logger.debug("[SPOOLMAN] Parsed %s layers from G-code", len(layer_usage))
return filament_usage, layer_usage_json, extract_filament_properties_from_3mf(full_path)
async def attach_recovered_3mf(
printer_id: int,
archive_id: int,
file_path: str,
db,
printer_manager,
*,
plate_id: int | None = None,
may_start: bool = True,
) -> None:
"""Add a fallback archive's recovered 3MF to Spoolman's tracking of its print.
Print start found no 3MF, so it stored only the AMS remain% snapshot and
completion would charge the remain% change. Only the 3MF's own data is
added: the snapshot was taken at print start, and replacing it from the
mid-print state now would charge the wrong change for a slot the 3MF does
not cover. Usage already tracked is left alone.
When print start stored nothing (no slot had a remain%), the 3MF is
tracked on its own if ``may_start``: from the recovery inside the print's
close-out, which runs before the charge, or from any other while the print
runs and is not being closed. During a close-out another recovery's row
could follow the charge that already deleted the print's tracking, and be
left behind.
"""
from backend.app.api.routes.settings import get_setting
from backend.app.models.active_print_spoolman import ActivePrintSpoolman
spoolman_enabled = await get_setting(db, "spoolman_enabled")
if not spoolman_enabled or spoolman_enabled.lower() != "true":
return
full_path = (
app_settings.base_dir / file_path
) # SEC-PATH-OK: file_path is archive.file_path — DB-stored, internally generated
if not file_path or not full_path.exists():
return
row = (
await db.execute(
select(ActivePrintSpoolman)
.where(ActivePrintSpoolman.printer_id == printer_id)
.where(ActivePrintSpoolman.archive_id == archive_id)
)
).scalar_one_or_none()
if row is not None and row.filament_usage:
return
if row is None and not may_start:
return
filament_usage, layer_usage_json, filament_properties = _extract_3mf_tracking(full_path, plate_id)
if not filament_usage:
return
if row is None:
state = printer_manager.get_status(printer_id)
row = ActivePrintSpoolman(
printer_id=printer_id,
archive_id=archive_id,
ams_trays=build_ams_tray_lookup(state.raw_data) if state and state.raw_data else {},
)
db.add(row)
row.filament_usage = filament_usage
row.layer_usage = layer_usage_json
row.filament_properties = filament_properties
await db.commit()
logger.info(
"[SPOOLMAN] Archive %s: tracking now has the recovered 3MF's usage (%d filament(s))",
archive_id,
len(filament_usage),
)
async def store_print_data(
printer_id: int,
archive_id: int,
@@ -442,11 +532,6 @@ async def store_print_data(
from backend.app.api.routes.settings import get_setting
from backend.app.models.active_print_spoolman import ActivePrintSpoolman
from backend.app.models.print_queue import PrintQueueItem
from backend.app.utils.threemf_tools import (
extract_filament_properties_from_3mf,
extract_filament_usage_from_3mf,
extract_layer_filament_usage_from_3mf,
)
# Check if Spoolman is enabled
spoolman_enabled = await get_setting(db, "spoolman_enabled")
@@ -505,15 +590,7 @@ async def store_print_data(
effective_plate_id = (
plate_id if plate_id is not None else (queue_item.plate_id if queue_item is not None else None)
)
filament_usage = extract_filament_usage_from_3mf(full_path, effective_plate_id) or None
layer_usage = extract_layer_filament_usage_from_3mf(full_path, effective_plate_id)
if layer_usage:
# Convert int keys to string for JSON serialization
layer_usage_json = {str(k): v for k, v in layer_usage.items()}
logger.debug("[SPOOLMAN] Parsed %s layers from G-code", len(layer_usage))
filament_properties = extract_filament_properties_from_3mf(full_path)
filament_usage, layer_usage_json, filament_properties = _extract_3mf_tracking(full_path, effective_plate_id)
else:
# No 3MF on disk — common for "Untitled" prints whose .gcode.3mf
# was never on the printer's FTP. Logged at debug since the
+5
View File
@@ -285,6 +285,11 @@ def done_completing(printer_id: int) -> None:
_completing.discard(printer_id)
def is_completing(printer_id: int) -> bool:
"""The printer's finished print is being closed out (on_print_complete runs)."""
return printer_id in _completing
def printer_is_charging(printer_id: int) -> bool:
"""A print's usage is still to be charged: it runs, or it ended and its
completion has not booked it yet. The AMS remain% sync leaves the
@@ -0,0 +1,165 @@
"""A recovered 3MF reaches Spoolman's usage tracking (late-3MF item).
A print that started without its 3MF gets a fallback archive, and Spoolman's
print-start tracking stores no per-filament usage for it: completion charges
the AMS remain% change instead, in 10 g steps on a 1 kg spool, nothing for a
slot without a remain%, and nothing at all when no slot had one. When the 3MF
turned up later, the recovery filled the archive in but left Spoolman's
tracking as it was; the built-in inventory reads the archive at completion and
was not affected. The recovered 3MF's usage is now added to it.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from sqlalchemy import select
from backend.app.models.active_print_spoolman import ActivePrintSpoolman
from backend.app.models.settings import Settings
from backend.tests.unit.test_fallback_archive_recovery_2957 import DISPATCH_FILENAME, _seed, _write_3mf
pytestmark = pytest.mark.asyncio
USED = [{"slot_id": 1, "used_g": 42.5, "type": "PLA", "color": "#00AE42"}]
REMAIN_AT_START = {"0:0": {"remain": 80, "tray_uuid": "A" * 32}}
async def _with(maker, *, spoolman: bool = True, row: dict | None = None, printer_id: int, archive_id: int) -> None:
async with maker() as db:
if spoolman:
db.add(Settings(key="spoolman_enabled", value="true"))
if row is not None:
db.add(ActivePrintSpoolman(printer_id=printer_id, archive_id=archive_id, ams_trays={}, **row))
await db.commit()
async def _recover(maker, tmp_path, printer_id: int, archive_id: int, *, price: bool = True) -> None:
from backend.app import main as main_module
source = _write_3mf(tmp_path / "temp" / DISPATCH_FILENAME)
printer_manager = MagicMock()
printer_manager.get_status.return_value = SimpleNamespace(
raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 71, "tray_type": "PLA"}]}]}, tray_now=0
)
with (
patch.object(main_module, "async_session", maker),
patch.object(main_module, "printer_manager", printer_manager),
):
await main_module._recover_fallback_archive_locked(archive_id, source, printer_id, price=price)
async def _row(maker, archive_id: int) -> ActivePrintSpoolman | None:
async with maker() as db:
return (
await db.execute(select(ActivePrintSpoolman).where(ActivePrintSpoolman.archive_id == archive_id))
).scalar_one_or_none()
@pytest.fixture
async def seeded(test_engine, tmp_path):
from backend.app import main as main_module
maker, printer_id, archive_id = await _seed(test_engine, tmp_path)
with patch.dict(main_module._active_prints, {(printer_id, DISPATCH_FILENAME): archive_id}, clear=True):
yield maker, printer_id, archive_id
async def test_the_recovered_usage_is_added_and_the_start_snapshot_kept(seeded, tmp_path):
maker, printer_id, archive_id = seeded
await _with(
maker,
printer_id=printer_id,
archive_id=archive_id,
row={"tray_remain_start": REMAIN_AT_START, "tray_now_at_start": 0, "slot_to_tray": [0]},
)
await _recover(maker, tmp_path, printer_id, archive_id)
row = await _row(maker, archive_id)
assert row.filament_usage == USED
# Taken at print start; the recovery is mid-print, so it must not replace them.
assert (row.tray_remain_start, row.tray_now_at_start, row.slot_to_tray, row.ams_trays) == (
REMAIN_AT_START,
0,
[0],
{},
)
async def test_so_does_the_recovery_at_the_print_s_end(seeded, tmp_path):
"""It runs just before Spoolman is charged, so the charge reads it."""
maker, printer_id, archive_id = seeded
await _with(maker, printer_id=printer_id, archive_id=archive_id, row={"tray_remain_start": REMAIN_AT_START})
await _recover(maker, tmp_path, printer_id, archive_id, price=False)
assert (await _row(maker, archive_id)).filament_usage == USED
async def test_usage_already_tracked_is_left_alone(seeded, tmp_path):
maker, printer_id, archive_id = seeded
own = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#FFFFFF"}]
await _with(maker, printer_id=printer_id, archive_id=archive_id, row={"filament_usage": own})
await _recover(maker, tmp_path, printer_id, archive_id)
assert (await _row(maker, archive_id)).filament_usage == own
async def test_a_print_tracked_with_nothing_at_its_start_is_tracked_now(seeded, tmp_path):
"""No slot had a remain% at start, so nothing was stored and the print would
be charged nothing. The 3MF is all there is; no start snapshot is invented."""
maker, printer_id, archive_id = seeded
await _with(maker, printer_id=printer_id, archive_id=archive_id)
await _recover(maker, tmp_path, printer_id, archive_id)
row = await _row(maker, archive_id)
assert row.filament_usage == USED
assert row.tray_remain_start is None
assert row.tray_now_at_start is None
async def test_the_recovery_at_the_print_s_end_tracks_it_too(seeded, tmp_path):
"""It runs inside the close-out (marked as such), before the charge, so the
charge reads it."""
from backend.app.services import usage_tracker
maker, printer_id, archive_id = seeded
await _with(maker, printer_id=printer_id, archive_id=archive_id)
usage_tracker.mark_completing(printer_id)
try:
await _recover(maker, tmp_path, printer_id, archive_id, price=False)
finally:
usage_tracker.done_completing(printer_id)
assert (await _row(maker, archive_id)).filament_usage == USED
async def test_no_other_recovery_starts_tracking_while_the_print_is_closed(seeded, tmp_path):
"""The cover endpoint's download can land while the print is being closed:
tracking made then could follow the charge that already deleted it, and be
left behind, making the printer look mid-print to later spool archiving."""
from backend.app.services import usage_tracker
maker, printer_id, archive_id = seeded
await _with(maker, printer_id=printer_id, archive_id=archive_id)
usage_tracker.mark_completing(printer_id)
try:
await _recover(maker, tmp_path, printer_id, archive_id)
finally:
usage_tracker.done_completing(printer_id)
assert await _row(maker, archive_id) is None
async def test_nothing_without_spoolman(seeded, tmp_path):
maker, printer_id, archive_id = seeded
await _with(maker, spoolman=False, printer_id=printer_id, archive_id=archive_id)
await _recover(maker, tmp_path, printer_id, archive_id)
assert await _row(maker, archive_id) is None