Support non-0.4mm nozzles in AMS Slot config + guard dispatch (#1899)
Some checks failed
Security Audit / Container Security Scan (Trivy) (push) Failing after 48s
Security Audit / Python Security Analysis (Bandit) (push) Failing after 49s
Security Audit / Backend Security Audit (push) Failing after 47s
Security Audit / Frontend Security Audit (push) Failing after 5s

The Configure AMS Slot picker was hardwired to 0.4mm (nozzleDiameter
prop never passed from PrintersPage / SpoolBuddyAmsPage), so a 0.6
machine could only set 0.4 profiles on its trays. Resolve the real
installed nozzle per-AMS (ams_extruder_map on dual-nozzle) and pass it
in. Separately, nothing validated the sliced nozzle against the
installed one, so a mismatch reached the printer as a cryptic HMS
_8012 "Failed to get AMS mapping table". Add a fail-safe pre-dispatch
guard in _start_print that fails the item with an actionable message
before upload; no slice diameter or no reported nozzles = no-op.
This commit is contained in:
maziggy 2026-07-08 08:57:56 +02:00
parent 55ea4b19c9
commit bb3e2a710e
9 changed files with 568 additions and 143 deletions

File diff suppressed because one or more lines are too long

View file

@ -144,6 +144,50 @@ def _canonical_filament_type(ftype: str) -> str:
return _FILAMENT_EQUIV_MAP.get(upper, upper)
def _installed_nozzle_diameters(status) -> list[float]:
"""Parse the installed nozzle diameters from a PrinterState (#1899).
Returns the diameters the printer actually reports (e.g. [0.4] single-nozzle,
[0.4, 0.6] dual-nozzle), skipping the empty-string defaults that populate a
NozzleInfo before MQTT fills it in. An empty list means "the printer hasn't
told us its nozzle hardware" — callers must treat that as unknown, not as a
mismatch, so we never block a print on missing data.
"""
diameters: list[float] = []
for nozzle in getattr(status, "nozzles", None) or []:
raw = getattr(nozzle, "nozzle_diameter", "") or ""
try:
value = float(raw)
except (TypeError, ValueError):
continue
if value > 0:
diameters.append(value)
return diameters
def _nozzle_mismatch_message(sliced_nozzle: float | None, installed: list[float]) -> str | None:
"""Return an actionable error message when the sliced nozzle can't be
printed on any installed nozzle, else None (#1899).
Fail-safe: returns None whenever we lack the data to judge no sliced
diameter, or the printer reported no nozzles so a print is only ever
blocked on a POSITIVE mismatch. On dual-nozzle printers a match against
EITHER installed nozzle passes (a 0.6 slice is fine if one hotend is 0.6).
The 0.05 tolerance absorbs float noise while staying well inside the 0.2
gap between adjacent nozzle sizes (0.2/0.4/0.6/0.8).
"""
if not sliced_nozzle or not installed:
return None
if any(abs(d - sliced_nozzle) < 0.05 for d in installed):
return None
installed_str = " / ".join(f"{d:g}mm" for d in installed)
return (
f"File sliced for a {sliced_nozzle:g}mm nozzle, but the printer has "
f"{installed_str} installed. Re-slice for the installed nozzle, or "
f"install the matching nozzle before printing."
)
class PrintScheduler:
"""Background scheduler that processes the print queue."""
@ -2629,6 +2673,47 @@ class PrintScheduler:
await self._power_off_if_needed(db, item)
return
# Nozzle-diameter mismatch guard (#1899). A file sliced for one nozzle
# size dispatched to a printer with a different nozzle installed is
# rejected by the firmware with a cryptic HMS ("Failed to get AMS mapping
# table" 0700_8012, or "nozzle diameter … not consistent" 0500_4038) that
# gives the user no idea what went wrong. Catch it here, before we spend
# time preheating and uploading, and fail with an actionable message.
# Fail-safe by construction: only a POSITIVE mismatch blocks — when the
# slice carries no nozzle diameter (archive.nozzle_diameter is None) or
# the printer hasn't reported its nozzles yet, we fall through and let the
# print proceed exactly as before. On dual-nozzle printers (H2D) a match
# against EITHER installed nozzle passes, so a 0.6 slice is fine as long
# as one of the two hotends is a 0.6.
sliced_nozzle = archive.nozzle_diameter if archive else None
if sliced_nozzle:
installed = _installed_nozzle_diameters(printer_manager.get_status(item.printer_id))
mismatch_msg = _nozzle_mismatch_message(sliced_nozzle, installed)
if mismatch_msg:
item.status = "failed"
item.error_message = mismatch_msg
item.completed_at = datetime.now(timezone.utc)
await db.commit()
logger.warning("Queue item %s: nozzle mismatch — %s", item.id, mismatch_msg)
await notification_service.on_queue_job_failed(
job_name=filename.replace(".gcode.3mf", "").replace(".3mf", ""),
printer_id=printer.id,
printer_name=printer.name,
reason=mismatch_msg,
db=db,
)
try:
await ws_manager.send_queue_item_failed(
user_id=item.created_by_id,
queue_item_id=item.id,
printer_id=item.printer_id,
reason="nozzle_mismatch",
)
except Exception:
pass
await self._power_off_if_needed(db, item)
return
# Preheat / heat-soak (#1468) — fires before upload so the printer's
# bed (and chamber, if applicable) is at temperature when the firmware
# starts the actual print routine. Best-effort: any failure logs and

View file

@ -0,0 +1,248 @@
"""Tests for the nozzle-diameter mismatch guard (#1899).
A file sliced for one nozzle size dispatched to a printer with a different
nozzle installed is rejected by the firmware with a cryptic HMS ("Failed to get
AMS mapping table" 0700_8012). The scheduler catches this before upload and
fails the queue item with an actionable message instead.
These cover the two pure helpers that make the decision. The guard is fail-safe
by construction: it only blocks on a POSITIVE mismatch, never on missing data.
"""
from contextlib import ExitStack
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
import backend.app.models # noqa: F401 - populate Base.metadata
import backend.app.services.print_scheduler as scheduler_module
from backend.app.core.database import Base
from backend.app.models.archive import PrintArchive
from backend.app.models.print_queue import PrintQueueItem
from backend.app.models.printer import Printer
from backend.app.services.print_scheduler import (
PrintScheduler,
_installed_nozzle_diameters,
_nozzle_mismatch_message,
)
def _state(*diameters: str):
"""PrinterState-shaped namespace with the given nozzle diameter strings."""
return SimpleNamespace(nozzles=[SimpleNamespace(nozzle_diameter=d) for d in diameters])
# ---------------------------------------------------------------------------
# _installed_nozzle_diameters
# ---------------------------------------------------------------------------
def test_installed_parses_single_nozzle():
assert _installed_nozzle_diameters(_state("0.6")) == [0.6]
def test_installed_parses_dual_nozzle():
assert _installed_nozzle_diameters(_state("0.4", "0.6")) == [0.4, 0.6]
def test_installed_skips_empty_default_stub():
# Single-nozzle printers still emit a 2-entry array; the second is an
# empty-string default until MQTT fills it in.
assert _installed_nozzle_diameters(_state("0.4", "")) == [0.4]
def test_installed_skips_unparseable_and_zero():
assert _installed_nozzle_diameters(_state("", "abc", "0", "0.4")) == [0.4]
def test_installed_handles_no_status_or_no_nozzles():
assert _installed_nozzle_diameters(None) == []
assert _installed_nozzle_diameters(SimpleNamespace()) == []
assert _installed_nozzle_diameters(SimpleNamespace(nozzles=[])) == []
# ---------------------------------------------------------------------------
# _nozzle_mismatch_message
# ---------------------------------------------------------------------------
def test_mismatch_blocks_single_nozzle():
msg = _nozzle_mismatch_message(0.6, [0.4])
assert msg is not None
assert "0.6mm" in msg
assert "0.4mm" in msg
def test_match_single_nozzle_passes():
assert _nozzle_mismatch_message(0.4, [0.4]) is None
def test_match_within_float_tolerance_passes():
# 0.4 slice vs a 0.40000001 reported diameter must not trip.
assert _nozzle_mismatch_message(0.4, [0.40000001]) is None
def test_dual_nozzle_match_on_either_passes():
# 0.6 slice on a printer with a 0.4 and a 0.6 hotend is fine.
assert _nozzle_mismatch_message(0.6, [0.4, 0.6]) is None
def test_dual_nozzle_mismatch_on_both_blocks():
msg = _nozzle_mismatch_message(0.8, [0.4, 0.6])
assert msg is not None
assert "0.4mm / 0.6mm" in msg
def test_no_sliced_diameter_is_failsafe_none():
# Slice didn't declare a nozzle diameter → never block.
assert _nozzle_mismatch_message(None, [0.4]) is None
assert _nozzle_mismatch_message(0.0, [0.4]) is None
def test_no_installed_nozzles_is_failsafe_none():
# Printer hasn't reported nozzles → unknown, never block.
assert _nozzle_mismatch_message(0.6, []) is None
def test_adjacent_sizes_are_distinguished():
# 0.2 gap between adjacent sizes stays well outside the 0.05 tolerance.
assert _nozzle_mismatch_message(0.4, [0.6]) is not None
assert _nozzle_mismatch_message(0.6, [0.8]) is not None
# ---------------------------------------------------------------------------
# End-to-end: the guard fires inside _start_print BEFORE upload
# ---------------------------------------------------------------------------
@pytest.fixture
async def archive_case(tmp_path):
"""Build an archive-based queue item on a real in-memory DB + on-disk 3MF."""
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
session_maker = async_sessionmaker(engine, expire_on_commit=False)
async def make_case(*, sliced_nozzle: float | None):
base_dir = tmp_path / "case"
base_dir.mkdir(exist_ok=True)
archive_rel = Path("archives") / "job.3mf"
archive_abs = base_dir / archive_rel
archive_abs.parent.mkdir(parents=True, exist_ok=True)
archive_abs.write_bytes(b"sliced 3mf")
async with session_maker() as db:
printer = Printer(
name="H2S",
serial_number="SN-H2S",
ip_address="127.0.0.1",
access_code="ac",
model="H2S",
)
db.add(printer)
await db.flush()
archive = PrintArchive(
printer_id=printer.id,
filename="job.3mf",
file_path=str(archive_rel),
file_size=archive_abs.stat().st_size,
nozzle_diameter=sliced_nozzle,
status="completed",
)
db.add(archive)
await db.flush()
item = PrintQueueItem(
printer_id=printer.id,
archive_id=archive.id,
status="pending",
bed_levelling=True,
flow_cali=False,
vibration_cali=True,
layer_inspect=False,
timelapse=False,
use_ams=True,
nozzle_offset_cali=True,
)
db.add(item)
await db.commit()
return SimpleNamespace(
session_maker=session_maker,
base_dir=base_dir,
archive_abs=archive_abs,
printer_id=printer.id,
queue_item_id=item.id,
start_print=MagicMock(return_value=True),
upload=AsyncMock(return_value=True),
)
try:
yield make_case
finally:
await engine.dispose()
async def _run_start_print(ctx, *, installed_nozzles):
scheduler = PrintScheduler()
status = SimpleNamespace(nozzles=[SimpleNamespace(nozzle_diameter=d) for d in installed_nozzles])
# The mismatch case returns before the upload path; the match case drives it
# to start_print, so mirror the post-guard dependency patches the
# cleanup-library harness uses (get_ftp_retry_settings et al. open their own
# DB session, not our in-memory one, so they must be stubbed).
patches = [
patch.object(scheduler_module.settings, "base_dir", ctx.base_dir),
patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=status)),
patch("backend.app.services.print_scheduler.printer_manager.start_print", ctx.start_print),
patch("backend.app.services.print_scheduler.printer_manager.set_awaiting_plate_clear", MagicMock()),
patch("backend.app.services.print_scheduler.upload_file_async", ctx.upload),
patch("backend.app.services.print_scheduler.delete_file_async", AsyncMock(return_value=True)),
patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
patch("backend.app.services.print_scheduler.spawn_background_task", MagicMock()),
patch(
"backend.app.services.print_scheduler.get_ftp_retry_settings", AsyncMock(return_value=(False, 0, 0, 1.0))
),
patch("backend.app.services.notification_service.notification_service.on_queue_job_started", AsyncMock()),
patch("backend.app.services.notification_service.notification_service.on_queue_job_failed", AsyncMock()),
patch("backend.app.services.mqtt_relay.mqtt_relay.on_queue_job_started", AsyncMock()),
patch("backend.app.services.print_scheduler.ws_manager.send_queue_item_failed", AsyncMock()),
patch.object(scheduler, "_preheat_and_soak", AsyncMock()),
patch.object(scheduler, "_propagate_owner_to_printer_manager", AsyncMock()),
patch.object(scheduler, "_power_off_if_needed", AsyncMock()),
]
with ExitStack() as stack:
for p in patches:
stack.enter_context(p)
async with ctx.session_maker() as db:
item = await db.get(PrintQueueItem, ctx.queue_item_id)
await scheduler._start_print(db, item)
@pytest.mark.asyncio
async def test_start_print_blocks_on_nozzle_mismatch_before_upload(archive_case):
"""0.6 slice on a 0.4-only printer: item fails with an actionable message,
and neither upload nor start_print is reached."""
ctx = await archive_case(sliced_nozzle=0.6)
await _run_start_print(ctx, installed_nozzles=["0.4"])
async with ctx.session_maker() as db:
item = await db.get(PrintQueueItem, ctx.queue_item_id)
assert item.status == "failed"
assert "0.6mm" in item.error_message and "0.4mm" in item.error_message
ctx.upload.assert_not_called()
ctx.start_print.assert_not_called()
@pytest.mark.asyncio
async def test_start_print_proceeds_when_nozzle_matches(archive_case):
"""0.6 slice on a 0.6 printer: the guard is a no-op and dispatch proceeds
(item leaves 'pending', start_print is reached)."""
ctx = await archive_case(sliced_nozzle=0.6)
await _run_start_print(ctx, installed_nozzles=["0.6"])
async with ctx.session_maker() as db:
item = await db.get(PrintQueueItem, ctx.queue_item_id)
assert item.status != "failed"
ctx.start_print.assert_called_once()

View file

@ -0,0 +1,63 @@
/**
* Tests for resolveSlotNozzleDiameter helper (#1899).
*
* The AMS Slot config picker must filter filament presets by the nozzle that
* actually feeds a given AMS, not the hardcoded 0.4mm default. This resolver
* reads the installed nozzle diameter from the printer status, honouring the
* per-AMS extruder binding on dual-nozzle printers. It returns undefined when
* the hardware hasn't been reported, so the caller keeps its own default.
*/
import { describe, it, expect } from 'vitest';
import { resolveSlotNozzleDiameter } from '../../utils/amsHelpers';
describe('resolveSlotNozzleDiameter', () => {
it('returns undefined when status is null or undefined', () => {
expect(resolveSlotNozzleDiameter(null, 0)).toBeUndefined();
expect(resolveSlotNozzleDiameter(undefined, 0)).toBeUndefined();
});
it('returns undefined when no nozzles are reported', () => {
expect(resolveSlotNozzleDiameter({ nozzles: [] }, 0)).toBeUndefined();
expect(resolveSlotNozzleDiameter({}, 0)).toBeUndefined();
});
it('returns undefined when the reported nozzle diameter is an empty default', () => {
expect(resolveSlotNozzleDiameter({ nozzles: [{ nozzle_diameter: '' }] }, 0)).toBeUndefined();
});
it('returns the single-nozzle diameter regardless of amsId (no extruder map)', () => {
const status = { nozzles: [{ nozzle_diameter: '0.6' }] };
expect(resolveSlotNozzleDiameter(status, 0)).toBe('0.6');
expect(resolveSlotNozzleDiameter(status, 3)).toBe('0.6');
});
it('resolves the per-AMS nozzle on a dual-nozzle printer via ams_extruder_map', () => {
// AMS 0 → left nozzle (0.4), AMS 1 → right nozzle (0.6)
const status = {
nozzles: [{ nozzle_diameter: '0.4' }, { nozzle_diameter: '0.6' }],
ams_extruder_map: { '0': 0, '1': 1 },
};
expect(resolveSlotNozzleDiameter(status, 0)).toBe('0.4');
expect(resolveSlotNozzleDiameter(status, 1)).toBe('0.6');
});
it('falls back to the primary nozzle when the AMS is not in the extruder map', () => {
const status = {
nozzles: [{ nozzle_diameter: '0.4' }, { nozzle_diameter: '0.6' }],
ams_extruder_map: { '0': 0 },
};
// AMS 5 has no mapping → index 0 (primary)
expect(resolveSlotNozzleDiameter(status, 5)).toBe('0.4');
});
it('falls back to the primary nozzle when the mapped nozzle has no diameter', () => {
// Dual-nozzle stub where the second entry is still an empty default.
const status = {
nozzles: [{ nozzle_diameter: '0.4' }, { nozzle_diameter: '' }],
ams_extruder_map: { '1': 1 },
};
expect(resolveSlotNozzleDiameter(status, 1)).toBe('0.4');
});
});

View file

@ -115,7 +115,7 @@ import { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModa
import { FileUploadModal } from '../components/FileUploadModal';
import { PrintModal } from '../components/PrintModal';
import { PrinterInfoModal } from '../components/PrinterInfoModal';
import { getAmsLabel, getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, isBambuLabSpool } from '../utils/amsHelpers';
import { getAmsLabel, getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, isBambuLabSpool, resolveSlotNozzleDiameter } from '../utils/amsHelpers';
import { getPrinterImage, getWifiStrength, filterCompatibleQueueItems } from '../utils/printer';
import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
import { Collapsible } from '../components/Collapsible';
@ -6255,6 +6255,7 @@ function PrinterCard({
printerId={printer.id}
slotInfo={configureSlotModal}
printerModel={mapModelCode(printer.model) || undefined}
nozzleDiameter={resolveSlotNozzleDiameter(status, configureSlotModal.amsId)}
onSuccess={() => {
// Refresh slot presets to show updated profile name
queryClient.invalidateQueries({ queryKey: ['slotPresets', printer.id] });

View file

@ -6,7 +6,7 @@ import { Layers, Settings2, Package, Unlink, Link2, X } from 'lucide-react';
import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
import { api } from '../../api/client';
import type { PrinterStatus, AMSTray, SpoolAssignment } from '../../api/client';
import { getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, formatSlotLabel, isBambuLabSpool } from '../../utils/amsHelpers';
import { getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, formatSlotLabel, isBambuLabSpool, resolveSlotNozzleDiameter } from '../../utils/amsHelpers';
import { getSwatchStyle } from '../../utils/colors';
import { AmsUnitCard, HumidityIndicator, TemperatureIndicator, NozzleBadge } from '../../components/spoolbuddy/AmsUnitCard';
import type { AmsThresholds } from '../../components/spoolbuddy/AmsUnitCard';
@ -687,6 +687,7 @@ export function SpoolBuddyAmsPage() {
printerId={selectedPrinterId}
slotInfo={configureSlotModal}
printerModel={mapModelCode(printer?.model ?? null) || undefined}
nozzleDiameter={resolveSlotNozzleDiameter(status, configureSlotModal.amsId)}
fullScreen
onSuccess={() => {
queryClient.invalidateQueries({ queryKey: ['slotPresets', selectedPrinterId] });

View file

@ -343,6 +343,32 @@ export function filterFilamentsByNozzle<T extends { extruderId?: number }>(
);
}
/**
* Resolve the installed nozzle diameter feeding a given AMS unit, so the
* Configure-AMS-Slot picker filters filament presets by the nozzle actually on
* the machine instead of assuming 0.4mm (#1899).
*
* On dual-nozzle printers (H2D) each AMS is bound to one extruder via
* `ams_extruder_map` (amsId extruder index, 0=left/primary, 1=right), so we
* read that nozzle's diameter. Single-nozzle printers have no map entry and
* fall back to the primary nozzle (index 0). Returns undefined when the printer
* hasn't reported nozzle hardware yet, letting the caller keep its own default.
* Diameter is the bare decimal string the status carries, e.g. "0.4" / "0.6".
*/
export function resolveSlotNozzleDiameter(
status: {
nozzles?: { nozzle_diameter?: string }[];
ams_extruder_map?: Record<string, number>;
} | null | undefined,
amsId: number,
): string | undefined {
const nozzles = status?.nozzles;
if (!nozzles || nozzles.length === 0) return undefined;
const extruderIdx = status?.ams_extruder_map?.[String(amsId)] ?? 0;
const diameter = nozzles[extruderIdx]?.nozzle_diameter || nozzles[0]?.nozzle_diameter;
return diameter || undefined;
}
/**
* Detect Bambu Lab RFID-tagged spool by tray_uuid (32 hex) or tag_uid (16 hex).
*

File diff suppressed because one or more lines are too long

View file

@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-nQKiiuS3.js"></script>
<script type="module" crossorigin src="/assets/index-CvBwRD12.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DvjR9OL3.css">
</head>
<body>