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-20 12:07:20 +02:00
parent a39ed5b42e
commit b1cb26f6ee
39 changed files with 1155 additions and 190 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(
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -26,8 +26,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-pp8r-Sv1.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-iyQgdQ5p.css">
<script type="module" crossorigin src="/assets/index-B3zC_9bK.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CEe69URA.css">
</head>
<body>
<div id="root"></div>