mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
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
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:
parent
55ea4b19c9
commit
bb3e2a710e
9 changed files with 568 additions and 143 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
|
|
|
|||
248
backend/tests/unit/test_scheduler_nozzle_mismatch.py
Normal file
248
backend/tests/unit/test_scheduler_nozzle_mismatch.py
Normal 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()
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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] });
|
||||
|
|
|
|||
|
|
@ -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] });
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue