feat(ams-backup): add status badge + toggle, fix prefer-lowest (#1766)

Two tightly-coupled deliverables in one drop -- a new AMS Filament Backup
      status/control surface, and the #1766 fix that depends on it.

      Added -- AMS Filament Backup status + control
      - Parse bit 18 of top-level print.cfg into PrinterState.ams_filament_backup
        on every push_status. Verified against OrcaSlicer source
        (DeviceManager.cpp:4961) and a live H2D ON/OFF capture. Tri-state
        (None = A1 family / pre-cfg push) preserves today's behaviour.
      - Hold-timer guard (3 s) prevents stale frames from flickering the badge
        back to the printer's old cfg after a user-initiated toggle.
      - POST /printers/{id}/ams-backup toggle, set_ams_filament_backup() client
        method calling _set_print_option("auto_switch_filament", enabled).
      - GET /printers/{id}/inventory-remain endpoint exposes the same map the
        dispatcher uses (internal and Spoolman modes both work uniformly).
      - Small icon badge in the printer card's "Filaments" section header
        (placement reads as printer-wide because the cfg bit is printer-wide,
        not per-AMS). Click to toggle, success toast.
      - 5 i18n keys x 11 locales for the badge UI.

      Fixed -- #1766: prefer_lowest didn't pick lowest, ignored backup state
      - Backend gate in _compute_ams_mapping_for_printer: coerce prefer_lowest
        to False when status.ams_filament_backup is False; log the skip.
      - New effectivePreferLowest(setting, backup) helper applied at every
        frontend sort entry point: single-printer PrintModal, multi-printer
        hook per-printer, PrinterSelector InlineMappingEditor, FilamentMapping
        standalone editor (the last had NO preferLowest awareness at all
        before this change).
      - New preferLowestSortKey(f, inventoryByTrayId) mirrors backend's two-tier
        key exactly, including the banding tie-break (regular AMS < AMS-HT <
        external) so the client-side pre-compute matches the dispatch-time pick.
        An earlier draft used a flat `amsId * 4 + trayId` priority which gave
        external slots (ams_id = -1) a NEGATIVE priority -- caught in code
        review before commit.
      - Settings -> Filament -> "Prefer lowest remaining filament" gets an
        explanatory note about the printer-side AMS Backup dependency, with
        i18n key in all 11 locales.
This commit is contained in:
maziggy
2026-06-28 12:37:00 +02:00
35 changed files with 1016 additions and 51 deletions
+4
View File
File diff suppressed because one or more lines are too long
+51
View File
@@ -724,6 +724,7 @@ async def get_printer_status(
heatbreak_fan_speed=state.heatbreak_fan_speed,
firmware_version=state.firmware_version,
developer_mode=state.developer_mode if state else None,
ams_filament_backup=state.ams_filament_backup if state else None,
awaiting_plate_clear=printer_manager.is_awaiting_plate_clear(printer_id),
supports_drying=supports_drying(printer.model, state.firmware_version),
supports_chamber_heater=supports_chamber_heater(printer.model),
@@ -1874,6 +1875,56 @@ async def set_print_option(
}
@router.post("/{printer_id}/ams-backup")
async def set_ams_backup(
printer_id: int,
enabled: bool,
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
db: AsyncSession = Depends(get_db),
):
"""Toggle AMS Filament Backup (auto-switch to a backup spool when one runs out)."""
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
if not printer:
raise HTTPException(404, "Printer not found")
client = printer_manager.get_client(printer_id)
if not client or not client.state.connected:
raise HTTPException(400, "Printer not connected")
success = client.set_ams_filament_backup(enabled)
if not success:
raise HTTPException(500, "Failed to send command to printer")
return {"success": True, "ams_filament_backup": enabled}
@router.get("/{printer_id}/inventory-remain")
async def get_inventory_remain(
printer_id: int,
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
db: AsyncSession = Depends(get_db),
):
"""Per-globalTrayId remaining grams for slots bound to an inventory spool.
Mirrors `_build_inventory_remain_overrides` server-side so the PrintModal
client can apply the same two-tier "Prefer Lowest Remaining Filament" sort
the dispatcher uses (#1766). Works for both internal inventory and
Spoolman; unbound slots are absent from the map (client falls back to the
printer's MQTT `remain` for those).
"""
from backend.app.services.print_scheduler import PrintScheduler
state = printer_manager.get_status(printer_id)
if not state:
return {"inventory_remain_g": {}}
scheduler = PrintScheduler()
loaded = scheduler._build_loaded_filaments(state)
overrides = await scheduler._build_inventory_remain_overrides(db, printer_id, loaded)
return {"inventory_remain_g": {str(k): v for k, v in overrides.items()}}
# ============================================
# Calibration
# ============================================
+1 -1
View File
@@ -1098,7 +1098,7 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
f"{state.stg_cur}:{bed_target}:{nozzle_target}:"
f"{state.cooling_fan_speed}:{state.big_fan1_speed}:{state.big_fan2_speed}:"
f"{state.chamber_light}:{state.active_extruder}:{state.tray_now}:{vt_tray_key}:"
f"{ams_dry_key}:{ams_tray_key}:{state.door_open}"
f"{ams_dry_key}:{ams_tray_key}:{state.door_open}:{state.ams_filament_backup}"
)
# MQTT relay - publish status (before dedup check - always publish to MQTT)
+4
View File
@@ -324,6 +324,10 @@ class PrinterStatus(BaseModel):
firmware_version: str | None = None
# Developer LAN mode: True = enabled, False = disabled (MQTT encryption), None = unknown
developer_mode: bool | None = None
# AMS Filament Backup ("auto-switch" to a second spool when one runs out).
# True = ON, False = OFF, None = unknown / unsupported (A1 family — protocol field
# not yet identified). UI treats None as "status unavailable", not as a hard disable.
ams_filament_backup: bool | None = None
# Queue: printer is awaiting the user to acknowledge the build plate is cleared
# after a finished/failed print. Persisted across restarts (#961).
awaiting_plate_clear: bool = False
+59
View File
@@ -31,6 +31,23 @@ logger = logging.getLogger(__name__)
_AMS_MODULE_PREFIXES = ("ams/", "n3f/", "n3s/")
def parse_ams_filament_backup_from_cfg(cfg_raw: object) -> bool | None:
"""Extract AMS Filament Backup state from a Bambu push_status ``print.cfg`` value.
OrcaSlicer reads bit 18 of the hex string via
``get_flag_bits(cfg, 18)`` (DeviceManager.cpp:4961). Old-protocol families
(A1 / A1 Mini) omit ``cfg`` entirely; this returns ``None`` for any input
that doesn't yield a clean integer so downstream consumers preserve today's
behaviour rather than treating "absent" as "OFF".
"""
if not isinstance(cfg_raw, str) or not cfg_raw:
return None
try:
return bool((int(cfg_raw, 16) >> 18) & 1)
except ValueError:
return None
def apply_tray_exist_bits(
units: list,
tray_exist_bits_str: str | int | None,
@@ -330,6 +347,11 @@ class PrinterState:
# Developer LAN mode: parsed from MQTT "fun" field bit 0x20000000
# True = dev mode ON (no encryption), False = dev mode OFF (encryption required), None = unknown
developer_mode: bool | None = None
# AMS Filament Backup: bit 18 of top-level print.cfg hex on new-protocol Bambu
# printers (H/X/P/H2 families). True=ON, False=OFF, None=unknown (e.g. A1 family
# which uses the old protocol path; field not yet found). Consumers must treat
# None as "no opinion" — preserving today's behaviour, NOT as "disabled".
ams_filament_backup: bool | None = None
# Stage name mapping from BambuStudio DeviceManager.cpp
@@ -1018,6 +1040,33 @@ class BambuMQTTClient:
f"gcode_file: {print_data.get('gcode_file')}, subtask_name: {print_data.get('subtask_name')}"
)
# AMS Filament Backup state lives in bit 18 of top-level print.cfg on
# new-protocol printers. Verified against OrcaSlicer's
# DeviceManager.cpp:4961 SetAutoRefillEnabled(get_flag_bits(cfg, 18))
# and live H2D ON/OFF capture 2026-06-20.
#
# Hold-timer guard: when the user just toggled via the badge, the
# next 1-2 push_status frames may still carry the printer's OLD cfg
# for ~3 s before the firmware reflects the change. Without this
# gate the UI would flicker ON→OFF→ON. Same pattern xcam uses.
new_backup = parse_ams_filament_backup_from_cfg(print_data.get("cfg"))
if new_backup is not None and new_backup != self.state.ams_filament_backup:
hold_start = self._xcam_hold_start.get("print_option_auto_switch_filament")
if hold_start is not None and (time.time() - hold_start) <= self._xcam_hold_time:
logger.debug(
"[%s] AMS Filament Backup push ignored (hold active for %.1fs)",
self.serial_number,
time.time() - hold_start,
)
else:
logger.info(
"[%s] AMS Filament Backup: %s",
self.serial_number,
"ON" if new_backup else "OFF",
)
self.state.ams_filament_backup = new_backup
self._xcam_hold_start.pop("print_option_auto_switch_filament", None)
# Detect dual-nozzle BEFORE processing AMS data (tray_now disambiguation needs it)
# device.extruder.info with >= 2 entries only exists on dual-nozzle printers (H2D, H2D Pro)
if not self._is_dual_nozzle and "device" in print_data:
@@ -3822,9 +3871,19 @@ class BambuMQTTClient:
# Update local state immediately
if option_name == "auto_recovery":
self.state.print_options.auto_recovery_step_loss = enabled
elif option_name == "auto_switch_filament":
self.state.ams_filament_backup = enabled
return True
def set_ams_filament_backup(self, enabled: bool) -> bool:
"""Toggle AMS Filament Backup (a.k.a. auto-switch / auto-refill).
Mirrors BambuStudio's "AMS Filament Backup" checkbox. Verified payload
shape from H2D capture 2026-06-20.
"""
return self._set_print_option("auto_switch_filament", enabled)
def start_calibration(
self,
bed_leveling: bool = False,
+9
View File
@@ -860,6 +860,15 @@ class PrintScheduler:
# Check if user prefers lowest remaining filament when multiple spools match
prefer_lowest = await self._get_bool_setting(db, "prefer_lowest_filament")
# Gate prefer_lowest on the printer's AMS Filament Backup state (#1766).
# Without backup, the printer will not switch to a second spool when the
# picked one runs out — so sorting toward the lowest leaves the print
# at risk of running dry mid-job. None (unknown / A1 family) preserves
# today's behaviour intentionally.
if prefer_lowest and status.ams_filament_backup is False:
logger.info("[prefer-lowest] skipped (AMS Backup OFF on printer %s)", printer_id)
prefer_lowest = False
# When the preference is on, surface Bambuddy's inventory-side
# remaining for each slot that's bound to a tracked spool, so the
# sort beats the MQTT-only blind spot (#1508). Skip the lookup
+4
View File
@@ -1049,6 +1049,10 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
"wifi_signal": state.wifi_signal,
"wired_network": state.wired_network,
"door_open": state.door_open,
# AMS Filament Backup state (auto-switch to second spool). Tri-state:
# True / False / None. None = unknown or unsupported (A1 family). UI
# uses this to drive the small status icon next to the AMS drying icon.
"ams_filament_backup": state.ams_filament_backup,
# Calibration stage tracking
"stg_cur": state.stg_cur,
"stg_cur_name": get_derived_status_name(state, model),
@@ -5793,3 +5793,59 @@ class TestPrintRunningObservedCallback:
"raw_data",
"ams_mapping",
}
class TestAmsFilamentBackupHoldTimer:
"""Regression: stale push_status arriving within the hold window after a
toggle command MUST NOT flip ams_filament_backup back to the printer's
old cfg. Same race-guard pattern xcam uses for spaghetti / first-layer
detector settings.
"""
@pytest.fixture
def mqtt_client(self):
from unittest.mock import MagicMock
from backend.app.services.bambu_mqtt import BambuMQTTClient
client = BambuMQTTClient(
ip_address="192.168.1.100",
serial_number="TEST123",
access_code="12345678",
)
# Pretend we're connected so _set_print_option actually publishes.
client.state.connected = True
client._client = MagicMock()
return client
def test_cfg_push_with_old_value_is_ignored_during_hold(self, mqtt_client):
# User toggled ON via badge → command sent → state optimistically set.
mqtt_client.set_ams_filament_backup(True)
assert mqtt_client.state.ams_filament_backup is True
# Within the 3 s hold window, a stale push_status arrives still showing
# the printer's old cfg (bit 18 cleared). The parser must NOT flip our
# optimistic state back to OFF — otherwise the badge flickers ON→OFF→ON.
mqtt_client._process_message({"print": {"cfg": "C0340BC219"}}) # bit18=0
assert mqtt_client.state.ams_filament_backup is True
def test_cfg_push_after_hold_expires_overrides_state(self, mqtt_client):
# After the hold window, the printer's real cfg becomes authoritative
# so a genuine slicer-side or display toggle that we did NOT initiate
# propagates correctly.
mqtt_client.set_ams_filament_backup(True)
mqtt_client._xcam_hold_start["print_option_auto_switch_filament"] = time.time() - 10.0
mqtt_client._process_message({"print": {"cfg": "C0340BC219"}}) # bit18=0
assert mqtt_client.state.ams_filament_backup is False
def test_cfg_push_with_matching_value_during_hold_is_a_noop(self, mqtt_client):
# Same-value push during hold doesn't trigger the change branch at all
# (no state mutation, no log spam, hold timer stays armed).
mqtt_client.set_ams_filament_backup(True)
before_hold = mqtt_client._xcam_hold_start["print_option_auto_switch_filament"]
mqtt_client._process_message({"print": {"cfg": "C0340FC219"}}) # bit18=1
assert mqtt_client.state.ams_filament_backup is True
# Hold timer still armed — sub-second push didn't reset it.
assert mqtt_client._xcam_hold_start["print_option_auto_switch_filament"] == before_hold
@@ -0,0 +1,58 @@
"""Tests for ``parse_ams_filament_backup_from_cfg`` (#1766 prefer_lowest gate).
The function extracts bit 18 of Bambu's top-level ``print.cfg`` hex string,
which OrcaSlicer's DeviceManager.cpp:4961 maps to AMS Filament Backup. These
tests pin the bit position + cover the absent / malformed cases A1-family
printers and pre-init pushes produce.
"""
import pytest
from backend.app.services.bambu_mqtt import parse_ams_filament_backup_from_cfg
class TestParseAmsFilamentBackupFromCfg:
def test_h2d_on_capture(self):
# Captured 2026-06-20 from H2D fw 01.03.00.00 with backup ON.
# Hex "C0340FC219" has bit 18 set (nibble 5 = F = 0b1111).
assert parse_ams_filament_backup_from_cfg("C0340FC219") is True
def test_h2d_off_capture(self):
# Same printer, backup toggled OFF — only bit 18 flips:
# "C0340BC219" — nibble 5 = B = 0b1011.
assert parse_ams_filament_backup_from_cfg("C0340BC219") is False
def test_x1c_short_hex_string_on(self):
# X1C cfg in the investigation snapshots is short ("FCA09").
# Bit 18 of 0xFCA09 = 0b1111110010100001001, bit18 set.
assert parse_ams_filament_backup_from_cfg("FCA09") is True
def test_lowercase_hex(self):
# Robustness: int(s, 16) accepts both cases; check we don't regress.
assert parse_ams_filament_backup_from_cfg("c0340fc219") is True
def test_only_bit_18_isolated(self):
# Sanity: a value with ONLY bit 18 set must parse as True.
assert parse_ams_filament_backup_from_cfg(hex(1 << 18)[2:]) is True
def test_bit_18_clear_but_others_set(self):
# Set every bit EXCEPT 18 — must parse as False.
mask = (~(1 << 18)) & 0xFFFFFFFF
assert parse_ams_filament_backup_from_cfg(hex(mask)[2:]) is False
@pytest.mark.parametrize(
"value",
[
None, # field omitted (A1 family old protocol)
"", # empty string
123, # firmware-emitted int instead of hex string (defensive)
"not_hex", # malformed
"0xZZ", # invalid hex
["FCA09"], # wrong shape
{"cfg": "FCA09"}, # nested by mistake
],
)
def test_invalid_returns_none(self, value):
# None preserves today's behaviour for callers gating on backup state —
# NOT False. Treating absent as OFF would regress A1-family scheduling.
assert parse_ams_filament_backup_from_cfg(value) is None
@@ -0,0 +1,86 @@
"""Tests for GET /printers/{id}/inventory-remain (#1766).
The endpoint exposes the same `_build_inventory_remain_overrides` map the
dispatcher uses so PrintModal's client-side "Prefer Lowest Remaining Filament"
sort agrees with what gets dispatched — closes the gap where Spoolman-mode
users couldn't see inventory grams from the frontend.
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from backend.app.api.routes.printers import get_inventory_remain
@pytest.fixture
def db():
return MagicMock()
async def _call_endpoint(db, printer_id=1):
return await get_inventory_remain(printer_id=printer_id, _=None, db=db)
class TestGetInventoryRemain:
@pytest.mark.asyncio
async def test_returns_empty_when_printer_has_no_status(self, db):
# Printer disconnected / unknown — endpoint must not error, return {}.
with patch(
"backend.app.services.printer_manager.printer_manager.get_status",
return_value=None,
):
result = await _call_endpoint(db)
assert result == {"inventory_remain_g": {}}
@pytest.mark.asyncio
async def test_serialises_globaltrayid_keys_as_strings(self, db):
# JSON requires string keys; client converts back to Number on receive.
# Asserts the key-shape contract the frontend depends on.
state = SimpleNamespace(raw_data={})
with (
patch(
"backend.app.services.printer_manager.printer_manager.get_status",
return_value=state,
),
patch(
"backend.app.services.print_scheduler.PrintScheduler._build_loaded_filaments",
return_value=[
{"ams_id": 0, "tray_id": 0, "global_tray_id": 0, "is_external": False},
{"ams_id": 0, "tray_id": 3, "global_tray_id": 3, "is_external": False},
],
),
patch(
"backend.app.services.print_scheduler.PrintScheduler._build_inventory_remain_overrides",
new=AsyncMock(return_value={0: 950.0, 3: 50.0}),
),
):
result = await _call_endpoint(db)
assert result == {"inventory_remain_g": {"0": 950.0, "3": 50.0}}
@pytest.mark.asyncio
async def test_returns_empty_dict_when_no_bound_slots(self, db):
# Loaded filaments exist but none are bound to an inventory spool.
# Backend returns {}; route serialises it unchanged.
state = SimpleNamespace(raw_data={})
with (
patch(
"backend.app.services.printer_manager.printer_manager.get_status",
return_value=state,
),
patch(
"backend.app.services.print_scheduler.PrintScheduler._build_loaded_filaments",
return_value=[
{"ams_id": 0, "tray_id": 0, "global_tray_id": 0, "is_external": False},
],
),
patch(
"backend.app.services.print_scheduler.PrintScheduler._build_inventory_remain_overrides",
new=AsyncMock(return_value={}),
),
):
result = await _call_endpoint(db)
assert result == {"inventory_remain_g": {}}
@@ -99,6 +99,7 @@ def _fake_state(**overrides):
"tray_now": None,
"wifi_signal": None,
"wired_network": None,
"ams_filament_backup": None,
}
base.update(overrides)
return SimpleNamespace(**base)
@@ -44,6 +44,7 @@ def _state(connected: bool, state: str = "IDLE") -> SimpleNamespace:
tray_now=0,
door_open=False,
subtask_name="",
ams_filament_backup=None,
)
@@ -0,0 +1,96 @@
"""Tests for the AMS Filament Backup gate on prefer_lowest sort (#1766).
The reporter set ``prefer_lowest_filament=True`` but the printer kept picking
the first matching spool. Root cause: without the printer's AMS Filament
Backup enabled, switching to the second spool is impossible — so sorting
toward the lowest leaves the print at risk. The gate coerces prefer_lowest
to False whenever the printer reports backup OFF, with None (unknown / A1
family) preserving today's behaviour.
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from backend.app.services.print_scheduler import PrintScheduler
@pytest.fixture
def scheduler():
return PrintScheduler()
def _patch_status(backup):
"""Patch ``printer_manager.get_status`` to return a stub PrinterState whose
``ams_filament_backup`` is the requested tri-state value."""
return patch(
"backend.app.services.print_scheduler.printer_manager.get_status",
return_value=SimpleNamespace(ams_filament_backup=backup, raw_data={}),
)
async def _run_with_backup(scheduler, backup_state, prefer_lowest_setting):
"""Drive ``_compute_ams_mapping_for_printer`` past the gate point and
return whatever ``prefer_lowest`` value gets handed to the matcher."""
db = MagicMock()
item = SimpleNamespace(filament_overrides=None)
filament_reqs = [{"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": ""}]
loaded = [
{
"ams_id": 0,
"tray_id": 0,
"global_tray_id": 0,
"is_external": False,
"type": "PLA",
"color": "#000000",
"tray_info_idx": "",
},
]
captured: dict = {}
def _capture_match(reqs, loaded_, prefer, overrides):
captured["prefer_lowest"] = prefer
return [0]
with (
_patch_status(backup_state),
patch.object(scheduler, "_get_filament_requirements", new=AsyncMock(return_value=filament_reqs)),
patch.object(scheduler, "_build_loaded_filaments", return_value=loaded),
patch.object(scheduler, "_get_bool_setting", new=AsyncMock(return_value=prefer_lowest_setting)),
patch.object(scheduler, "_build_inventory_remain_overrides", new=AsyncMock(return_value={})),
patch.object(scheduler, "_match_filaments_to_slots", side_effect=_capture_match),
):
await scheduler._compute_ams_mapping_for_printer(db, printer_id=1, item=item)
return captured.get("prefer_lowest")
class TestPreferLowestBackupGate:
@pytest.mark.asyncio
async def test_backup_off_disables_prefer_lowest(self, scheduler):
# User setting ON but printer reports backup OFF — gate must coerce.
# This is the #1766 fix: previously the sort applied and picked the
# near-empty spool, leaving the print to fail mid-job.
out = await _run_with_backup(scheduler, backup_state=False, prefer_lowest_setting=True)
assert out is False
@pytest.mark.asyncio
async def test_backup_on_preserves_prefer_lowest(self, scheduler):
# Backup ON — sort applies as the user intended.
out = await _run_with_backup(scheduler, backup_state=True, prefer_lowest_setting=True)
assert out is True
@pytest.mark.asyncio
async def test_backup_unknown_preserves_prefer_lowest(self, scheduler):
# None = unknown / unsupported (A1 family). Must NOT be treated as OFF
# — that would regress A1 users who currently get the sort applied.
out = await _run_with_backup(scheduler, backup_state=None, prefer_lowest_setting=True)
assert out is True
@pytest.mark.asyncio
async def test_user_setting_off_short_circuits(self, scheduler):
# User setting OFF — backup state is irrelevant; sort never applies.
out = await _run_with_backup(scheduler, backup_state=True, prefer_lowest_setting=False)
assert out is False
@@ -247,3 +247,40 @@ describe('autoMatchFilament preferLowest', () => {
expect(result!.globalTrayId).toBe(1); // Only tray on correct nozzle
});
});
// #1766: identical-material spools that only differ in inventory grams used to
// tie at the printer's `remain%` and the first one always won. The map lets
// the sort see the bound spool's `label_weight - weight_used` instead.
describe('autoMatchFilament preferLowest with inventory map (#1766)', () => {
it('picks lower inventory grams when remain% ties', () => {
const filaments = [
makeFilament({ globalTrayId: 0, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 100 }),
makeFilament({ globalTrayId: 1, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 100 }),
];
const req = makeReq({ type: 'PLA', color: '#FF0000' });
const inventory = new Map<number, number>([[0, 900], [1, 60]]);
const result = autoMatchFilament(req, filaments, new Set(), true, inventory);
expect(result!.globalTrayId).toBe(1); // 60 g < 900 g
});
it('inventory-bound spool beats MQTT-only one even when remain% would order them differently', () => {
const filaments = [
makeFilament({ globalTrayId: 0, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 10 }),
makeFilament({ globalTrayId: 1, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 90 }),
];
const req = makeReq({ type: 'PLA', color: '#FF0000' });
const inventory = new Map<number, number>([[1, 200]]); // Only tray 1 bound.
const result = autoMatchFilament(req, filaments, new Set(), true, inventory);
expect(result!.globalTrayId).toBe(1); // Tier 0 always beats tier 1.
});
it('falls back to remain% sort when map is undefined', () => {
const filaments = [
makeFilament({ globalTrayId: 0, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 80 }),
makeFilament({ globalTrayId: 1, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 30 }),
];
const req = makeReq({ type: 'PLA', color: '#FF0000' });
const result = autoMatchFilament(req, filaments, new Set(), true, undefined);
expect(result!.globalTrayId).toBe(1); // Same as the pre-#1766 path.
});
});
@@ -10,6 +10,7 @@ import {
buildLoadedFilaments,
computeAmsMapping,
} from '../../hooks/useFilamentMapping';
import { effectivePreferLowest } from '../../utils/amsHelpers';
import type { PrinterStatus } from '../../api/client';
// Helper to create a minimal printer status with AMS data
@@ -1048,3 +1049,145 @@ describe('computeAmsMapping preferLowest', () => {
expect(result).toEqual([1]); // Known 60% over unknown
});
});
// #1766: the user reported that "Prefer lowest remaining filament" picked the
// wrong spool when two identical-material/color spools differed only in the
// inventory-tracked grams (not the printer's `remain%`). The pre-fix sort
// looked at `remain%` only and ignored Bambuddy's bound inventory entirely;
// now we pass a globalTrayId -> grams map and the sort lifts inventory-bound
// spools to tier 0 (matching backend _prefer_lowest_sort_key).
describe('computeAmsMapping preferLowest with inventory map (#1766)', () => {
it('picks spool with lower inventory grams when both spools report same remain%', () => {
// Reporter's scenario: two identical Bambu-branded spools, both report
// `remain=100` because they were freshly inserted, but inventory has them
// at 950 g vs 50 g remaining. Pre-fix sort ties and picks the first.
const reqs = {
filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, tray_info_idx: 'GFA00' }],
};
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', remain: 100 },
{ id: 1, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', remain: 100 },
],
},
]);
const inventory = new Map<number, number>([[0, 950], [1, 50]]);
const result = computeAmsMapping(reqs, status, true, inventory);
expect(result).toEqual([1]); // Inventory says tray 1 is nearly empty — use it first.
});
it('prefers inventory-tracked spool over non-tracked one even when remain% would order them differently', () => {
// Two spools both match by type+color, both have the same tray_info_idx
// (identical SKU). Tray 0 has no inventory binding but reports remain=20.
// Tray 1 has an inventory binding with 100 g remaining. Tier 0 (bound)
// always beats tier 1 (MQTT-only) regardless of value — matches backend.
const reqs = {
filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, tray_info_idx: 'GFA00' }],
};
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', remain: 20 },
{ id: 1, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', remain: 80 },
],
},
]);
const inventory = new Map<number, number>([[1, 100]]); // Only tray 1 bound
const result = computeAmsMapping(reqs, status, true, inventory);
expect(result).toEqual([1]);
});
it('falls back to remain% sort when no inventory map provided (pre-#1766 behaviour)', () => {
// Regression guard: callers that haven't yet wired the map must get the
// same sort they always got. None of the existing tests in this file pass
// a map; this asserts the default path is unchanged.
const reqs = {
filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],
};
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: 'FF0000', remain: 80 },
{ id: 1, tray_type: 'PLA', tray_color: 'FF0000', remain: 25 },
],
},
]);
const result = computeAmsMapping(reqs, status, true, undefined);
expect(result).toEqual([1]); // Same as the existing no-inventory case.
});
});
// #1766 safety gate: when the printer has AMS Filament Backup OFF, the sort
// MUST NOT run, even with the user setting on. Otherwise the dispatch picks a
// near-empty spool the printer can't switch off of when it runs out mid-print.
// Mirrors backend `_compute_ams_mapping_for_printer` gate.
describe('effectivePreferLowest gate (#1766)', () => {
it('coerces to false when backup is OFF', () => {
expect(effectivePreferLowest(true, false)).toBe(false);
});
it('passes through when backup is ON', () => {
expect(effectivePreferLowest(true, true)).toBe(true);
});
it('passes through when backup is unknown (null/undefined — A1 family)', () => {
expect(effectivePreferLowest(true, null)).toBe(true);
expect(effectivePreferLowest(true, undefined)).toBe(true);
});
it('stays false when the user setting is off, regardless of backup state', () => {
expect(effectivePreferLowest(false, true)).toBe(false);
expect(effectivePreferLowest(false, false)).toBe(false);
expect(effectivePreferLowest(undefined, true)).toBe(false);
});
it('slot-priority tie-break: external/VT spools sort AFTER regular AMS', () => {
// Mirrors backend `_slot_priority` banding. When tier and value tie, slot
// position decides — external (ams_id = -1) must clamp to 10_000 so it
// can't beat AMS slot 0 (priority 0).
const reqs = {
filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],
};
const status = createPrinterStatus(
[
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: 'FF0000', remain: -1 }, // priority 0
],
},
],
[{ id: 254, tray_type: 'PLA', tray_color: 'FF0000', remain: -1 }], // priority 10_000
);
const result = computeAmsMapping(reqs, status, true);
expect(result).toEqual([0]); // AMS slot wins the tie; VT does not.
});
it('end-to-end: backup OFF prevents lowest-pick at dispatch (caller-coerced)', () => {
// PrintModal computes the effective flag and passes it to computeAmsMapping.
// This pins that flow: with backup=false the flag becomes false, the sort
// doesn't run, and the first matching tray wins (today's behaviour).
const reqs = {
filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],
};
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: 'FF0000', remain: 80 },
{ id: 1, tray_type: 'PLA', tray_color: 'FF0000', remain: 5 }, // near-empty
],
},
]);
const gated = effectivePreferLowest(true, false);
const result = computeAmsMapping(reqs, status, gated);
expect(result).toEqual([0]); // First match wins; the 5%-remain spool is NOT selected.
});
});
+19
View File
@@ -502,6 +502,9 @@ export interface PrinterStatus {
firmware_version: string | null; // Firmware version from MQTT
// Developer LAN mode: true = enabled, false = disabled, null = unknown
developer_mode: boolean | null;
// AMS Filament Backup ("auto-switch" to a backup spool when one runs out).
// true = ON, false = OFF, null = unknown / unsupported (A1 family).
ams_filament_backup: boolean | null;
// Queue: printer is awaiting user ack that the build plate was cleared after a
// finished/failed print. Persisted across restarts (#961).
awaiting_plate_clear: boolean;
@@ -3632,6 +3635,22 @@ export const api = {
{ method: 'POST' }
),
// AMS Filament Backup (auto-switch to a backup spool when one runs out)
setAmsFilamentBackup: (printerId: number, enabled: boolean) =>
request<{ success: boolean; ams_filament_backup: boolean }>(
`/printers/${printerId}/ams-backup?enabled=${enabled}`,
{ method: 'POST' }
),
// Per-globalTrayId remaining grams for this printer's inventory-bound slots
// (#1766). Drives the client-side "Prefer Lowest Remaining Filament" sort
// when computing the AMS mapping; mirrors backend `_build_inventory_remain_overrides`
// so internal and Spoolman modes both work uniformly.
getInventoryRemain: (printerId: number) =>
request<{ inventory_remain_g: Record<string, number> }>(
`/printers/${printerId}/inventory-remain`,
),
// Skip Objects
getPrintableObjects: (printerId: number) =>
request<{
@@ -4,7 +4,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp, Palette } from 'lucide-react';
import { api } from '../../api/client';
import { useFilamentMapping } from '../../hooks/useFilamentMapping';
import { getGlobalTrayId } from '../../utils/amsHelpers';
import { getGlobalTrayId, effectivePreferLowest } from '../../utils/amsHelpers';
import { getColorName } from '../../utils/colors';
import { useFilamentLabels } from './useFilamentLabels';
import type { FilamentMappingProps } from './types';
@@ -42,8 +42,35 @@ export function FilamentMapping({
enabled: !!printerId,
});
// Settings + inventory map drive the same prefer-lowest + AMS-backup gate
// the dispatcher uses (#1766). Without this, the per-slot dropdown's
// auto-suggestion could disagree with what actually gets dispatched.
const { data: settings } = useQuery({
queryKey: ['settings'],
queryFn: api.getSettings,
});
const { data: inventoryRemain } = useQuery({
queryKey: ['printer-inventory-remain', printerId],
queryFn: () => api.getInventoryRemain(printerId),
enabled: !!printerId,
staleTime: 30 * 1000,
});
const inventoryByTrayId = useMemo(() => {
if (!inventoryRemain?.inventory_remain_g) return undefined;
const map = new Map<number, number>();
Object.entries(inventoryRemain.inventory_remain_g).forEach(([key, grams]) => {
const gtid = Number(key);
if (!Number.isNaN(gtid)) map.set(gtid, grams);
});
return map;
}, [inventoryRemain]);
const gatedPreferLowest = effectivePreferLowest(
settings?.prefer_lowest_filament,
printerStatus?.ams_filament_backup,
);
const { loadedFilaments, filamentComparison, hasTypeMismatch, hasColorMismatch } =
useFilamentMapping(filamentReqs, printerStatus, manualMappings);
useFilamentMapping(filamentReqs, printerStatus, manualMappings, gatedPreferLowest, inventoryByTrayId);
// Per-slot sub-brand + material-disambiguated colour labels (#1718). Same
// shared hook the model-mode FilamentOverride uses so both panels render
@@ -18,6 +18,7 @@ import {
colorsAreSimilar,
autoMatchFilament,
filterFilamentsByNozzle,
effectivePreferLowest,
} from '../../utils/amsHelpers';
import type { PrinterSelectorProps, AssignmentMode } from './types';
import type { PrinterMappingResult, PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
@@ -108,7 +109,13 @@ function InlineMappingEditor({
} else {
const usedTrayIds = new Set<number>(Object.values(printerResult.config.manualMappings));
const cachedSettings = queryClient.getQueryData<{ prefer_lowest_filament?: boolean }>(['settings']);
loaded = autoMatchFilament(req, printerResult.loadedFilaments, usedTrayIds, cachedSettings?.prefer_lowest_filament) as LoadedFilament | undefined;
loaded = autoMatchFilament(
req,
printerResult.loadedFilaments,
usedTrayIds,
effectivePreferLowest(cachedSettings?.prefer_lowest_filament, printerResult.status?.ams_filament_backup),
printerResult.inventoryByTrayId,
) as LoadedFilament | undefined;
}
// Determine status
+46 -3
View File
@@ -1,4 +1,4 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
import { AlertCircle, AlertTriangle, Calendar, Code, Layers, Loader2, Pencil, Printer, X } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -15,7 +15,7 @@ import { getColorName } from '../../utils/colors';
import { getCurrencySymbol } from '../../utils/currency';
import { getBedTypeInfo } from '../../utils/bedType';
import { toDateTimeLocalValue, parseUTCDate } from '../../utils/date';
import { getGlobalTrayId, isPlaceholderDate } from '../../utils/amsHelpers';
import { getGlobalTrayId, isPlaceholderDate, effectivePreferLowest } from '../../utils/amsHelpers';
import { FilamentMapping } from './FilamentMapping';
import { FilamentOverride } from './FilamentOverride';
import { PlateSelector } from './PlateSelector';
@@ -270,6 +270,34 @@ export function PrintModal({
enabled: ((mode === 'reprint' || mode === 'add-to-queue') && assignmentMode === 'printer') || (isLibraryFile && mode === 'reprint'),
});
// Fetch per-printer Map<globalTrayId, gramsRemaining> via the dedicated
// backend endpoint (#1766). Server-side mirrors `_build_inventory_remain_overrides`
// so internal and Spoolman modes both work uniformly, VT/external slots are
// excluded, and negative grams are clamped — single source of truth between
// the client-side preview and dispatch-time picks.
const inventoryRemainQueries = useQueries({
queries: selectedPrinters.map((printerId) => ({
queryKey: ['printer-inventory-remain', printerId],
queryFn: () => api.getInventoryRemain(printerId),
staleTime: 30 * 1000,
enabled: selectedPrinters.length > 0,
})),
});
const inventoryByTrayIdPerPrinter = useMemo(() => {
const result = new Map<number, Map<number, number>>();
selectedPrinters.forEach((printerId, idx) => {
const data = inventoryRemainQueries[idx]?.data?.inventory_remain_g;
if (!data) return;
const printerMap = new Map<number, number>();
Object.entries(data).forEach(([key, grams]) => {
const gtid = Number(key);
if (!Number.isNaN(gtid)) printerMap.set(gtid, grams);
});
result.set(printerId, printerMap);
});
return result;
}, [selectedPrinters, inventoryRemainQueries]);
// Fetch archive details to get sliced_for_model
const { data: archiveDetails } = useQuery({
queryKey: ['archive', archiveId],
@@ -346,8 +374,22 @@ export function PrintModal({
enabled: !!effectivePrinterId,
});
// Single-printer flow: gate prefer_lowest on this printer's backup state.
// Multi-printer flow gates per-printer inside the hook (different printers
// may have different backup states), so we pass the raw setting down.
const singlePrinterPreferLowest = effectivePreferLowest(
settings?.prefer_lowest_filament,
printerStatus?.ams_filament_backup,
);
// Get AMS mapping from hook (only when single printer selected)
const { amsMapping } = useFilamentMapping(effectiveFilamentReqs, printerStatus, manualMappings, settings?.prefer_lowest_filament);
const { amsMapping } = useFilamentMapping(
effectiveFilamentReqs,
printerStatus,
manualMappings,
singlePrinterPreferLowest,
effectivePrinterId ? inventoryByTrayIdPerPrinter.get(effectivePrinterId) : undefined,
);
// Multi-printer filament mapping (for per-printer configuration)
const multiPrinterMapping = useMultiPrinterFilamentMapping(
@@ -358,6 +400,7 @@ export function PrintModal({
perPrinterConfigs,
setPerPrinterConfigs,
settings?.prefer_lowest_filament,
inventoryByTrayIdPerPrinter,
);
// Auto-select first plate when plates load (single or multi-plate)
+33 -23
View File
@@ -6,6 +6,8 @@ import {
colorsAreSimilar,
formatSlotLabel,
getGlobalTrayId,
preferLowestSortKey,
compareSortKeys,
} from '../utils/amsHelpers';
import type { PrinterStatus } from '../api/client';
@@ -102,6 +104,7 @@ export function computeAmsMapping(
filamentReqs: { filaments: FilamentRequirement[] } | undefined,
printerStatus: PrinterStatus | undefined,
preferLowest?: boolean,
inventoryByTrayId?: Map<number, number>,
): number[] | undefined {
if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return undefined;
@@ -129,13 +132,15 @@ export function computeAmsMapping(
available = available.filter((f) => f.extruderId === req.nozzle_id);
}
// Sort by remaining filament (ascending) so .find() picks the lowest-remain spool first
// Sort lowest-first when the preference is on. Inventory-tracked spools
// sort before MQTT-only ones; see preferLowestSortKey for the rationale.
if (preferLowest) {
available = [...available].sort((a, b) => {
const ra = a.remain >= 0 ? a.remain : 101;
const rb = b.remain >= 0 ? b.remain : 101;
return ra - rb;
});
available = [...available].sort((a, b) =>
compareSortKeys(
preferLowestSortKey(a, inventoryByTrayId),
preferLowestSortKey(b, inventoryByTrayId),
),
);
}
let idxMatch: LoadedFilament | undefined;
@@ -152,11 +157,12 @@ export function computeAmsMapping(
} else if (idxMatches.length > 1) {
// Multiple trays with same tray_info_idx - use color matching among them
if (preferLowest) {
idxMatches.sort((a, b) => {
const ra = a.remain >= 0 ? a.remain : 101;
const rb = b.remain >= 0 ? b.remain : 101;
return ra - rb;
});
idxMatches.sort((a, b) =>
compareSortKeys(
preferLowestSortKey(a, inventoryByTrayId),
preferLowestSortKey(b, inventoryByTrayId),
),
);
}
exactMatch = idxMatches.find(
(f) =>
@@ -325,6 +331,7 @@ export function useFilamentMapping(
printerStatus: PrinterStatus | undefined,
manualMappings: Record<number, number>,
preferLowest?: boolean,
inventoryByTrayId?: Map<number, number>,
): UseFilamentMappingResult {
const loadedFilaments = useLoadedFilaments(printerStatus);
@@ -389,13 +396,15 @@ export function useFilamentMapping(
available = available.filter((f) => f.extruderId === req.nozzle_id);
}
// Sort by remaining filament (ascending) so .find() picks the lowest-remain spool first
// Sort lowest-first when the preference is on. Inventory-tracked spools
// sort before MQTT-only ones; see preferLowestSortKey for the rationale.
if (preferLowest) {
available = [...available].sort((a, b) => {
const ra = a.remain >= 0 ? a.remain : 101;
const rb = b.remain >= 0 ? b.remain : 101;
return ra - rb;
});
available = [...available].sort((a, b) =>
compareSortKeys(
preferLowestSortKey(a, inventoryByTrayId),
preferLowestSortKey(b, inventoryByTrayId),
),
);
}
let idxMatch: LoadedFilament | undefined;
@@ -412,11 +421,12 @@ export function useFilamentMapping(
} else if (idxMatches.length > 1) {
// Multiple trays with same tray_info_idx - use color matching among them
if (preferLowest) {
idxMatches.sort((a, b) => {
const ra = a.remain >= 0 ? a.remain : 101;
const rb = b.remain >= 0 ? b.remain : 101;
return ra - rb;
});
idxMatches.sort((a, b) =>
compareSortKeys(
preferLowestSortKey(a, inventoryByTrayId),
preferLowestSortKey(b, inventoryByTrayId),
),
);
}
exactMatch = idxMatches.find(
(f) =>
@@ -491,7 +501,7 @@ export function useFilamentMapping(
isManual: false,
};
});
}, [filamentReqs, loadedFilaments, manualMappings, preferLowest, ftsActive]);
}, [filamentReqs, loadedFilaments, manualMappings, preferLowest, ftsActive, inventoryByTrayId]);
// Build AMS mapping from matched filaments
// Format: array matching 3MF filament slot structure
@@ -11,6 +11,9 @@ import {
import {
normalizeColorForCompare,
colorsAreSimilar,
preferLowestSortKey,
compareSortKeys,
effectivePreferLowest,
} from '../utils/amsHelpers';
/**
@@ -58,6 +61,8 @@ export interface PrinterMappingResult {
totalSlots: number;
/** Per-printer config */
config: PerPrinterConfig;
/** Per-globalTrayId inventory grams remaining, for the lowest-remain sort (#1766) */
inventoryByTrayId?: Map<number, number>;
}
/**
@@ -90,6 +95,7 @@ function computeMatchDetails(
loadedFilaments: LoadedFilament[],
manualMappings: Record<number, number>,
preferLowest?: boolean,
inventoryByTrayId?: Map<number, number>,
): { exactMatches: number; typeOnlyMatches: number; missingTypes: number; totalSlots: number; status: PrinterMatchStatus } {
if (!filamentReqs || filamentReqs.length === 0) {
return { exactMatches: 0, typeOnlyMatches: 0, missingTypes: 0, totalSlots: 0, status: 'full' };
@@ -135,11 +141,12 @@ function computeMatchDetails(
}
if (preferLowest) {
candidates = [...candidates].sort((a, b) => {
const ra = a.remain >= 0 ? a.remain : 101;
const rb = b.remain >= 0 ? b.remain : 101;
return ra - rb;
});
candidates = [...candidates].sort((a, b) =>
compareSortKeys(
preferLowestSortKey(a, inventoryByTrayId),
preferLowestSortKey(b, inventoryByTrayId),
),
);
}
const exactMatch = candidates.find(
@@ -194,6 +201,7 @@ function computeMappingWithOverrides(
printerStatus: PrinterStatus | undefined,
manualMappings: Record<number, number>,
preferLowest?: boolean,
inventoryByTrayId?: Map<number, number>,
): number[] | undefined {
if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return undefined;
@@ -222,11 +230,12 @@ function computeMappingWithOverrides(
}
if (preferLowest) {
candidates = [...candidates].sort((a, b) => {
const ra = a.remain >= 0 ? a.remain : 101;
const rb = b.remain >= 0 ? b.remain : 101;
return ra - rb;
});
candidates = [...candidates].sort((a, b) =>
compareSortKeys(
preferLowestSortKey(a, inventoryByTrayId),
preferLowestSortKey(b, inventoryByTrayId),
),
);
}
const exactMatch = candidates.find(
@@ -290,6 +299,7 @@ export function useMultiPrinterFilamentMapping(
perPrinterConfigs: Record<number, PerPrinterConfig>,
setPerPrinterConfigs: React.Dispatch<React.SetStateAction<Record<number, PerPrinterConfig>>>,
preferLowest?: boolean,
inventoryByTrayIdPerPrinter?: Map<number, Map<number, number>>,
): UseMultiPrinterFilamentMappingResult {
// Fetch printer status for all selected printers in parallel
const statusQueries = useQueries({
@@ -311,9 +321,14 @@ export function useMultiPrinterFilamentMapping(
const loadedFilaments = buildLoadedFilaments(printerStatus);
const config = perPrinterConfigs[printerId] || DEFAULT_PRINTER_CONFIG;
const inventoryByTrayId = inventoryByTrayIdPerPrinter?.get(printerId);
// Per-printer gate (#1766): two printers in the same dispatch can have
// different AMS Backup states; the sort must be skipped on the OFF ones
// and kept on the ON ones. Computing inside the loop captures both.
const printerPreferLowest = effectivePreferLowest(preferLowest, printerStatus?.ams_filament_backup);
// Compute auto mapping for this printer
const autoMapping = computeAmsMapping(filamentReqs, printerStatus, preferLowest);
const autoMapping = computeAmsMapping(filamentReqs, printerStatus, printerPreferLowest, inventoryByTrayId);
// Determine which mappings to use:
// If printer has override (useDefault=false), use its custom mappings
@@ -323,14 +338,15 @@ export function useMultiPrinterFilamentMapping(
: defaultMappings;
// Compute final mapping with overrides
const finalMapping = computeMappingWithOverrides(filamentReqs, printerStatus, effectiveMappings, preferLowest);
const finalMapping = computeMappingWithOverrides(filamentReqs, printerStatus, effectiveMappings, printerPreferLowest, inventoryByTrayId);
// Compute match details
const matchDetails = computeMatchDetails(
filamentReqs?.filaments,
loadedFilaments,
effectiveMappings,
preferLowest,
printerPreferLowest,
inventoryByTrayId,
);
return {
@@ -347,9 +363,10 @@ export function useMultiPrinterFilamentMapping(
missingTypes: matchDetails.missingTypes,
totalSlots: matchDetails.totalSlots,
config,
inventoryByTrayId,
};
});
}, [selectedPrinterIds, statusQueries, printers, filamentReqs, perPrinterConfigs, defaultMappings, preferLowest]);
}, [selectedPrinterIds, statusQueries, printers, filamentReqs, perPrinterConfigs, defaultMappings, preferLowest, inventoryByTrayIdPerPrinter]);
const isLoading = statusQueries.some((q) => q.isLoading);
@@ -370,7 +387,12 @@ export function useMultiPrinterFilamentMapping(
if (!result || !result.status || !filamentReqs?.filaments) return;
// Compute optimal mapping for this printer
const autoMapping = computeAmsMapping(filamentReqs, result.status, preferLowest);
const autoMapping = computeAmsMapping(
filamentReqs,
result.status,
effectivePreferLowest(preferLowest, result.status?.ams_filament_backup),
inventoryByTrayIdPerPrinter?.get(printerId),
);
if (!autoMapping) return;
// Convert autoMapping array to manualMappings record
+8
View File
@@ -516,6 +516,13 @@ export default {
stoppingDrying: 'Trocknung wird gestoppt...',
rotateTray: 'Spule während der Trocknung drehen',
},
amsBackup: {
titleOn: 'AMS Filament Backup ist EIN. Zum Deaktivieren klicken.',
titleOff: 'AMS Filament Backup ist AUS. Zum Aktivieren klicken.',
titleUnknown: 'AMS-Filament-Backup-Status auf diesem Drucker nicht verfügbar.',
toastEnabled: 'AMS Filament Backup aktiviert',
toastDisabled: 'AMS Filament Backup deaktiviert',
},
// Filaments section
filaments: 'Filamente',
// Camera
@@ -1718,6 +1725,7 @@ export default {
disableFilamentWarningsDesc: 'Keine Warnungen über unzureichendes Filament beim Drucken oder Einreihen anzeigen',
preferLowestFilament: 'Niedrigsten Filamentrest bevorzugen',
preferLowestFilamentDesc: 'Bei mehreren passenden Spulen die mit dem geringsten Restfilament verwenden',
preferLowestFilamentBackupNote: 'Wirkt nur, wenn AMS Filament Backup am Drucker aktiviert ist — sonst kann der Drucker beim Aufbrauchen der ersten Spule nicht auf eine zweite Spule wechseln.',
trackingModeBuiltIn: 'Integriertes Inventar',
trackingModeBuiltInDesc: 'RFID-Erkennung und Verbrauchserfassung inklusive',
trackingModeSpoolmanDesc: 'Externer Filament-Management-Server',
+9
View File
@@ -516,6 +516,14 @@ export default {
stoppingDrying: 'Stopping drying...',
rotateTray: 'Rotate spool during drying',
},
// AMS Filament Backup status badge (printer-wide auto-switch to another spool)
amsBackup: {
titleOn: 'AMS Filament Backup is ON. Click to disable.',
titleOff: 'AMS Filament Backup is OFF. Click to enable.',
titleUnknown: 'AMS Filament Backup status unavailable on this printer.',
toastEnabled: 'AMS Filament Backup enabled',
toastDisabled: 'AMS Filament Backup disabled',
},
// Filaments section
filaments: 'Filaments',
// Camera
@@ -1728,6 +1736,7 @@ export default {
disableFilamentWarningsDesc: 'Don\'t show warnings about insufficient filament when printing or queueing',
preferLowestFilament: 'Prefer lowest remaining filament',
preferLowestFilamentDesc: 'When multiple spools match, use the one with the least filament remaining',
preferLowestFilamentBackupNote: 'Only takes effect when AMS Filament Backup is enabled on the printer — otherwise the printer cannot switch to a second spool when the picked one runs out.',
trackingModeBuiltIn: 'Built-in Inventory',
trackingModeBuiltInDesc: 'RFID auto-matching and usage tracking included',
trackingModeSpoolmanDesc: 'External filament management server',
+8
View File
@@ -516,6 +516,13 @@ export default {
stoppingDrying: 'Deteniendo el secado...',
rotateTray: 'Girar la bobina durante el secado',
},
amsBackup: {
titleOn: 'AMS Filament Backup está ACTIVADO. Haz clic para desactivar.',
titleOff: 'AMS Filament Backup está DESACTIVADO. Haz clic para activar.',
titleUnknown: 'Estado de AMS Filament Backup no disponible en esta impresora.',
toastEnabled: 'AMS Filament Backup activado',
toastDisabled: 'AMS Filament Backup desactivado',
},
// Filaments section
filaments: 'Filamentos',
// Camera
@@ -1721,6 +1728,7 @@ export default {
disableFilamentWarningsDesc: 'No mostrar advertencias sobre filamento insuficiente al imprimir o encolar',
preferLowestFilament: 'Preferir el filamento con menos restante',
preferLowestFilamentDesc: 'Cuando varias bobinas coincidan, usar la que tenga menos filamento restante',
preferLowestFilamentBackupNote: 'Solo surte efecto cuando AMS Filament Backup está activado en la impresora — de lo contrario, la impresora no puede cambiar a una segunda bobina cuando la elegida se acabe.',
trackingModeBuiltIn: 'Inventario integrado',
trackingModeBuiltInDesc: 'Incluye coincidencia automática por RFID y seguimiento del uso',
trackingModeSpoolmanDesc: 'Servidor externo de gestión de filamento',
+8
View File
@@ -516,6 +516,13 @@ export default {
stoppingDrying: 'Arrêt du séchage...',
rotateTray: 'Tourner la bobine pendant le séchage',
},
amsBackup: {
titleOn: "AMS Filament Backup est ACTIVÉ. Cliquez pour désactiver.",
titleOff: "AMS Filament Backup est DÉSACTIVÉ. Cliquez pour activer.",
titleUnknown: "État de l'AMS Filament Backup indisponible sur cette imprimante.",
toastEnabled: "AMS Filament Backup activé",
toastDisabled: "AMS Filament Backup désactivé",
},
// Filaments section
filaments: 'Filaments',
// Camera
@@ -1674,6 +1681,7 @@ export default {
disableFilamentWarningsDesc: 'Ne pas afficher les avertissements de filament insuffisant lors de l\'impression ou de la mise en file d\'attente',
preferLowestFilament: 'Préférer le filament le plus bas',
preferLowestFilamentDesc: 'Lorsque plusieurs bobines correspondent, utiliser celle avec le moins de filament restant',
preferLowestFilamentBackupNote: "Ne prend effet que si AMS Filament Backup est activé sur l'imprimante — sinon, l'imprimante ne peut pas basculer vers une seconde bobine quand celle choisie est vide.",
trackingModeBuiltIn: 'Inventaire Intégré',
trackingModeBuiltInDesc: 'Correspondance RFID et suivi de consommation inclus',
trackingModeSpoolmanDesc: 'Serveur de gestion externe',
+8
View File
@@ -516,6 +516,13 @@ export default {
stoppingDrying: 'Arresto essiccazione...',
rotateTray: 'Ruota la bobina durante l\'essiccazione',
},
amsBackup: {
titleOn: 'AMS Filament Backup è ATTIVO. Clicca per disabilitare.',
titleOff: 'AMS Filament Backup è DISATTIVATO. Clicca per abilitare.',
titleUnknown: 'Stato di AMS Filament Backup non disponibile su questa stampante.',
toastEnabled: 'AMS Filament Backup abilitato',
toastDisabled: 'AMS Filament Backup disabilitato',
},
// Filaments section
filaments: 'Filamenti',
// Camera
@@ -1674,6 +1681,7 @@ export default {
disableFilamentWarningsDesc: 'Non mostrare avvisi per filamento insufficiente durante la stampa o l\'accodamento',
preferLowestFilament: 'Preferisci il filamento con meno residuo',
preferLowestFilamentDesc: 'Quando più bobine corrispondono, usa quella con meno filamento rimanente',
preferLowestFilamentBackupNote: 'Ha effetto solo se AMS Filament Backup è abilitato sulla stampante — altrimenti la stampante non può passare a una seconda bobina quando quella scelta finisce.',
trackingModeBuiltIn: 'Inventario integrato',
trackingModeBuiltInDesc: 'Riconoscimento RFID automatico e tracciamento dell\'uso inclusi',
trackingModeSpoolmanDesc: 'Server esterno per la gestione del filamento',
+8
View File
@@ -515,6 +515,13 @@ export default {
stoppingDrying: '乾燥を停止しています...',
rotateTray: '乾燥中にスプールを回転',
},
amsBackup: {
titleOn: 'AMSフィラメントバックアップはONです。クリックして無効化します。',
titleOff: 'AMSフィラメントバックアップはOFFです。クリックして有効化します。',
titleUnknown: 'このプリンタではAMSフィラメントバックアップ状態を確認できません。',
toastEnabled: 'AMSフィラメントバックアップを有効化しました',
toastDisabled: 'AMSフィラメントバックアップを無効化しました',
},
// Filaments section
filaments: 'フィラメント',
// Camera
@@ -1717,6 +1724,7 @@ export default {
disableFilamentWarningsDesc: '印刷またはキュー追加時にフィラメント不足の警告を表示しない',
preferLowestFilament: '残量が少ないフィラメントを優先',
preferLowestFilamentDesc: '複数のスプールが一致する場合、残量が最も少ないものを使用します',
preferLowestFilamentBackupNote: 'プリンタ側でAMSフィラメントバックアップが有効な場合のみ動作します。無効の場合、選択したスプールが切れても予備スプールに切り替えできません。',
trackingModeBuiltIn: '内蔵インベントリ',
trackingModeBuiltInDesc: 'RFID自動検出と使用量追跡を含む',
trackingModeSpoolmanDesc: '外部フィラメント管理サーバー',
+8
View File
@@ -479,6 +479,13 @@ export default {
stoppingDrying: '건조 정지 중...',
rotateTray: '건조 중 스풀 회전'
},
amsBackup: {
titleOn: 'AMS 필라멘트 백업이 켜져 있습니다. 비활성화하려면 클릭하세요.',
titleOff: 'AMS 필라멘트 백업이 꺼져 있습니다. 활성화하려면 클릭하세요.',
titleUnknown: '이 프린터에서는 AMS 필라멘트 백업 상태를 확인할 수 없습니다.',
toastEnabled: 'AMS 필라멘트 백업이 활성화되었습니다',
toastDisabled: 'AMS 필라멘트 백업이 비활성화되었습니다'
},
filaments: '필라멘트',
openCameraOverlay: '카메라 오버레이 열기',
openCameraWindow: '새 창에서 카메라 열기',
@@ -1625,6 +1632,7 @@ export default {
disableFilamentWarningsDesc: '인쇄 또는 대기열 추가 시 필라멘트 부족 경고 표시 안 함',
preferLowestFilament: '남은 필라멘트가 가장 적은 것 우선',
preferLowestFilamentDesc: '여러 스풀이 일치할 때 남은 필라멘트가 가장 적은 것 사용',
preferLowestFilamentBackupNote: '프린터에서 AMS 필라멘트 백업이 활성화된 경우에만 적용됩니다. 그렇지 않으면 선택한 스풀이 다 떨어졌을 때 두 번째 스풀로 전환할 수 없습니다.',
trackingModeBuiltIn: '내장 인벤토리',
trackingModeBuiltInDesc: 'RFID 자동 매칭 및 사용량 추적 포함',
trackingModeSpoolmanDesc: '외부 필라멘트 관리 서버',
+8
View File
@@ -516,6 +516,13 @@ export default {
stoppingDrying: 'Parando secagem...',
rotateTray: 'Girar o carretel durante a secagem',
},
amsBackup: {
titleOn: 'AMS Filament Backup está LIGADO. Clique para desativar.',
titleOff: 'AMS Filament Backup está DESLIGADO. Clique para ativar.',
titleUnknown: 'Estado do AMS Filament Backup indisponível nesta impressora.',
toastEnabled: 'AMS Filament Backup ativado',
toastDisabled: 'AMS Filament Backup desativado',
},
// Filaments section
filaments: 'Filamentos',
// Camera
@@ -1674,6 +1681,7 @@ export default {
disableFilamentWarningsDesc: 'Não mostrar avisos sobre filamento insuficiente ao imprimir ou adicionar à fila',
preferLowestFilament: 'Preferir filamento com menor resto',
preferLowestFilamentDesc: 'Quando vários carretéis correspondem, usar o com menos filamento restante',
preferLowestFilamentBackupNote: 'Só tem efeito quando o AMS Filament Backup está habilitado na impressora — caso contrário, a impressora não pode trocar para um segundo carretel quando o escolhido acabar.',
trackingModeBuiltIn: 'Inventário Interno',
trackingModeBuiltInDesc: 'Correspondência automática de RFID e rastreamento de uso incluídos',
trackingModeSpoolmanDesc: 'Servidor de gerenciamento de filamento externo',
+8
View File
@@ -516,6 +516,13 @@ export default {
stoppingDrying: 'Kurutma durduruluyor...',
rotateTray: 'Kurutma sırasında makarayı döndür',
},
amsBackup: {
titleOn: 'AMS Filament Backup AÇIK. Devre dışı bırakmak için tıklayın.',
titleOff: 'AMS Filament Backup KAPALI. Etkinleştirmek için tıklayın.',
titleUnknown: 'Bu yazıcıda AMS Filament Backup durumu kullanılamıyor.',
toastEnabled: 'AMS Filament Backup etkinleştirildi',
toastDisabled: 'AMS Filament Backup devre dışı bırakıldı',
},
// Filamentler bölümü
filaments: 'Filamentler',
// Kamera
@@ -1721,6 +1728,7 @@ export default {
disableFilamentWarningsDesc: 'Yazdırırken veya kuyruğa eklerken yetersiz filamentle ilgili uyarıları gösterme',
preferLowestFilament: 'En az kalan filamenti tercih et',
preferLowestFilamentDesc: 'Birden fazla makara eşleştiğinde, kalan filamenti en az olanı kullan',
preferLowestFilamentBackupNote: 'Yalnızca yazıcıda AMS Filament Backup etkinleştirildiğinde uygulanır — aksi takdirde seçilen makara bittiğinde yazıcı ikinci bir makaraya geçemez.',
trackingModeBuiltIn: 'Yerleşik Envanter',
trackingModeBuiltInDesc: 'RFID otomatik eşleştirme ve kullanım takibi dahil',
trackingModeSpoolmanDesc: 'Harici filament yönetim sunucusu',
+8
View File
@@ -516,6 +516,13 @@ export default {
stoppingDrying: '正在停止干燥...',
rotateTray: '干燥时旋转料盘',
},
amsBackup: {
titleOn: 'AMS 备用料盘已开启。点击以禁用。',
titleOff: 'AMS 备用料盘已关闭。点击以启用。',
titleUnknown: '本打印机不支持读取 AMS 备用料盘状态。',
toastEnabled: 'AMS 备用料盘已启用',
toastDisabled: 'AMS 备用料盘已禁用',
},
// Filaments section
filaments: '耗材',
// Camera
@@ -1719,6 +1726,7 @@ export default {
disableFilamentWarningsDesc: '在打印或加入队列时不显示耗材不足警告',
preferLowestFilament: '优先使用剩余最少的耗材',
preferLowestFilamentDesc: '当多个料盘匹配时,使用剩余耗材最少的那个',
preferLowestFilamentBackupNote: '仅当打印机端启用了 AMS 备用料盘时才生效——否则当选中的料盘耗尽时,打印机无法切换到备用料盘。',
trackingModeBuiltIn: '内置库存',
trackingModeBuiltInDesc: '包含 RFID 自动匹配和用量追踪',
trackingModeSpoolmanDesc: '外部耗材管理服务器',
+8
View File
@@ -516,6 +516,13 @@ export default {
stoppingDrying: '正在停止乾燥...',
rotateTray: '乾燥時旋轉料盤',
},
amsBackup: {
titleOn: 'AMS 備用料盤已開啟。點擊以停用。',
titleOff: 'AMS 備用料盤已關閉。點擊以啟用。',
titleUnknown: '本印表機不支援讀取 AMS 備用料盤狀態。',
toastEnabled: 'AMS 備用料盤已啟用',
toastDisabled: 'AMS 備用料盤已停用',
},
// Filaments section
filaments: '耗材',
// Camera
@@ -1719,6 +1726,7 @@ export default {
disableFilamentWarningsDesc: '在列印或加入佇列時不顯示耗材不足警告',
preferLowestFilament: '優先使用剩餘最少的耗材',
preferLowestFilamentDesc: '當多個料盤匹配時,使用剩餘耗材最少的那個',
preferLowestFilamentBackupNote: '僅當印表機端啟用了 AMS 備用料盤時才生效——否則當選中的料盤耗盡時,印表機無法切換到備用料盤。',
trackingModeBuiltIn: '內建庫存',
trackingModeBuiltInDesc: '包含 RFID 自動匹配和用量追蹤',
trackingModeSpoolmanDesc: '外部耗材管理伺服器',
+69
View File
@@ -70,6 +70,7 @@ import {
Info,
Cable,
Flame,
Repeat,
Snowflake,
Gauge,
DoorOpen,
@@ -691,6 +692,53 @@ function HeaterThermometer({ className, color, isHeating }: HeaterThermometerPro
);
}
// AMS Filament Backup tri-state indicator + toggle.
// state=true → ON, click to disable
// state=false → OFF, click to enable
// state=null → unknown/unsupported (e.g. A1 family), no click action
interface AmsBackupBadgeProps {
state: boolean | null;
canToggle: boolean;
pending: boolean;
onToggle: (next: boolean) => void;
}
function AmsBackupBadge({ state, canToggle, pending, onToggle }: AmsBackupBadgeProps) {
const { t } = useTranslation();
const known = state !== null;
const clickable = canToggle && known && !pending;
let className = 'flex items-center justify-center w-[18px] h-[18px] rounded text-[10px] transition-colors ';
let title: string;
if (state === true) {
className += clickable
? 'bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 cursor-pointer'
: 'bg-blue-500/20 text-blue-400 cursor-default';
title = t('printers.amsBackup.titleOn');
} else if (state === false) {
className += clickable
? 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark/80 cursor-pointer'
: 'bg-bambu-dark text-bambu-gray cursor-default';
title = t('printers.amsBackup.titleOff');
} else {
className += 'bg-bambu-dark text-bambu-gray/50 cursor-default';
title = t('printers.amsBackup.titleUnknown');
}
return (
<button
type="button"
disabled={!clickable}
onClick={() => clickable && onToggle(!state)}
className={className}
title={title}
aria-label={title}
>
{known ? <Repeat className="w-3 h-3" /> : <span>?</span>}
</button>
);
}
// Humidity indicator with water drop that fills based on level (Bambu Lab style)
// Reference: https://github.com/theicedmango/bambu-humidity
interface HumidityIndicatorProps {
@@ -2157,6 +2205,21 @@ function PrinterCard({
onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'),
});
// AMS Filament Backup toggle (auto-switch to a backup spool when one runs out).
// Invalidate BOTH printer-status cache keys — the codebase has two conventions
// ('printerStatus' camelCase + 'printer-status' kebab-case used by PrintModal /
// useMultiPrinterFilamentMapping). Hitting only one would leave PrintModal
// showing the old backup state until the user reopens it.
const setAmsBackupMutation = useMutation({
mutationFn: (enabled: boolean) => api.setAmsFilamentBackup(printer.id, enabled),
onSuccess: (_data, enabled) => {
queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });
queryClient.invalidateQueries({ queryKey: ['printer-status', printer.id] });
showToast(t(enabled ? 'printers.amsBackup.toastEnabled' : 'printers.amsBackup.toastDisabled'), 'success');
},
onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'),
});
// Smart plug control mutations
const powerControlMutation = useMutation({
mutationFn: (action: 'on' | 'off') =>
@@ -4176,6 +4239,12 @@ function PrinterCard({
<span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
{t('printers.filaments')}
</span>
<AmsBackupBadge
state={status.ams_filament_backup}
canToggle={hasPermission('printers:control')}
pending={setAmsBackupMutation.isPending}
onToggle={(next) => setAmsBackupMutation.mutate(next)}
/>
<div className="flex-1 h-[2px] bg-bambu-dark-tertiary" />
</div>
+3
View File
@@ -4657,6 +4657,9 @@ export function SettingsPage() {
<p className="text-sm text-bambu-gray">
{t('settings.preferLowestFilamentDesc')}
</p>
<p className="text-xs text-bambu-gray/70 mt-1">
{t('settings.preferLowestFilamentBackupNote')}
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
+73 -6
View File
@@ -203,24 +203,91 @@ export function isPlaceholderDate(scheduledTime: string | null | undefined): boo
return (parseUTCDate(scheduledTime)?.getTime() ?? 0) > sixMonthsFromNow;
}
/**
* Banding tie-break for `preferLowestSortKey`, mirroring backend
* `PrintScheduler._slot_priority` so regular AMS < AMS-HT < external on ties
* regardless of the raw `ams_id`. In particular, `ams_id = -1` (VT / external
* in `buildLoadedFilaments`) MUST NOT sort to a negative number or it would
* beat AMS slot 0 — backend clamps to 10_000.
*/
function slotPriority(amsId: number | undefined, trayId: number | undefined): number {
if (amsId == null || amsId < 0) return 10_000;
if (amsId >= 128) return 1_000 + (amsId - 128) * 4 + (trayId ?? 0);
return amsId * 4 + (trayId ?? 0);
}
/**
* Two-tier sort key for the "Prefer Lowest Remaining Filament" preference (#1766).
*
* Mirrors backend `_prefer_lowest_sort_key` in `print_scheduler.py:1161` so the
* client-side sort that PrintModal pre-computes lines up with the dispatch-time
* sort. Inventory-bound spools sort before MQTT-only ones (tier 0 vs tier 1) so
* the user's tracked grams beat the printer's per-cent estimate; within each
* tier the lowest value wins, with the slot-position tie-break above so the
* order is deterministic across identical spools.
*
* `inventoryByTrayId` is the `globalTrayId -> grams_remaining` map derived from
* the user's spool assignments. Pass `undefined` to fall back to remain%-only
* sorting (preserves pre-#1766 behaviour for callers that don't yet wire it in).
*/
export function preferLowestSortKey(
f: { globalTrayId: number; amsId?: number; trayId?: number; remain?: number },
inventoryByTrayId: Map<number, number> | undefined,
): [number, number, number] {
const slot = slotPriority(f.amsId, f.trayId);
if (inventoryByTrayId && inventoryByTrayId.has(f.globalTrayId)) {
return [0, inventoryByTrayId.get(f.globalTrayId) ?? 0, slot];
}
const remain = f.remain ?? -1;
return [1, remain >= 0 ? remain : 101, slot];
}
/** Tuple compare for `preferLowestSortKey` outputs. */
export function compareSortKeys(
a: [number, number, number],
b: [number, number, number],
): number {
return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
}
/**
* Effective "Prefer lowest remaining filament" preference for a given printer,
* gated on its AMS Filament Backup state (#1766).
*
* Without backup, the printer can't switch to a second spool when the picked
* one runs out — so even with the user setting on, sorting toward the lowest
* leaves the print at risk. Mirrors the backend gate in
* `print_scheduler.py::_compute_ams_mapping_for_printer`. `null`/`undefined`
* (unknown state, e.g. A1 family) preserves today's behaviour intentionally.
*/
export function effectivePreferLowest(
setting: boolean | undefined,
amsFilamentBackup: boolean | null | undefined,
): boolean {
if (!setting) return false;
return amsFilamentBackup !== false;
}
/**
* Auto-match a filament requirement to a loaded filament, respecting nozzle constraints.
* Used by both single-printer (FilamentMapping) and multi-printer (InlineMappingEditor) paths.
*/
export function autoMatchFilament(
req: { type?: string; color?: string; nozzle_id?: number | null },
loadedFilaments: { globalTrayId: number; type?: string; color?: string; extruderId?: number; remain?: number }[],
loadedFilaments: { globalTrayId: number; amsId?: number; trayId?: number; type?: string; color?: string; extruderId?: number; remain?: number }[],
usedTrayIds: Set<number>,
preferLowest?: boolean,
inventoryByTrayId?: Map<number, number>,
): typeof loadedFilaments[number] | undefined {
let nozzleFilaments = filterFilamentsByNozzle(loadedFilaments, req.nozzle_id);
if (preferLowest) {
nozzleFilaments = [...nozzleFilaments].sort((a, b) => {
const ra = (a.remain ?? -1) >= 0 ? (a.remain ?? -1) : 101;
const rb = (b.remain ?? -1) >= 0 ? (b.remain ?? -1) : 101;
return ra - rb;
});
nozzleFilaments = [...nozzleFilaments].sort((a, b) =>
compareSortKeys(
preferLowestSortKey(a, inventoryByTrayId),
preferLowestSortKey(b, inventoryByTrayId),
),
);
}
const exactMatch = nozzleFilaments.find(