mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-20 09:03:43 +02:00
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:
File diff suppressed because one or more lines are too long
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '外部フィラメント管理サーバー',
|
||||
|
||||
@@ -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: '외부 필라멘트 관리 서버',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '外部耗材管理服务器',
|
||||
|
||||
@@ -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: '外部耗材管理伺服器',
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user