mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
fix(filament): don't dispatch an unresolved AMS mapping to the external spool (#2589)
A P1S queue row with use_ams=true but ams_mapping=[-1] was silently printed with no AMS, starting against the empty external feed and pausing with a runout. Two faults combined: - start_print treated -1 (unresolved) the same as >=254 (explicit external) when deciding to force use_ams=False. Only genuine external now downgrades; -1 never does. - The scheduler trusted a stored [-1] as "already resolved" and passed it through. It now recomputes from live AMS trays whenever the stored mapping is entirely unresolved, and clears it if nothing matches rather than sending a doomed command. Frontend: the Print dialog no longer serializes an all-[-1] mapping while the printer status is still loading (the hook returns no mapping), and submit waits for AMS status with a "Waiting for AMS status" notice. Tests: new backend + frontend regression coverage; corrected one existing test that pinned the old [-1] -> use_ams=False behavior.
This commit is contained in:
@@ -8,6 +8,7 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **Orca Cloud profile sync now connects by approving a code instead of the copy-paste sign-in** — Connecting Bambuddy to Orca Cloud used to mean opening an OAuth sign-in in a new tab, watching it redirect to a `localhost` URL that fails to load, then copying that dead URL out of the address bar and pasting it back into Bambuddy. That dance existed only because Orca's auth backend (Supabase) accepts no redirect target other than `localhost`, and the deliberately-broken redirect page confused nearly everyone who reached it. OrcaSlicer has since shipped a first-class external-app pairing API (the OAuth 2.0 Device Authorization Grant, RFC 8628), so the flow is now: click **Connect**, approve a short code on your Orca Cloud settings page, and Bambuddy pairs itself — no redirect, no paste, no client secret, and it behaves identically from a LAN IP, `localhost`, or behind a reverse proxy. Bambuddy requests **read-only** access (it only lists and views your Orca Cloud profiles), keeps the pairing alive with the API's rotating refresh tokens (validated end-to-end against Orca's staging and production servers), and stores nothing beyond the issued token pair. The profile list and detail views are unchanged, so nothing downstream of the connect step looks different. The old paste-based sign-in and the email/password fallback are removed. Points at production Orca Cloud by default; `ORCA_CLOUD_API_BASE` overrides the endpoint for testing.
|
||||
|
||||
### Fixed
|
||||
- **An unresolved AMS mapping silently dispatched a P1S print to the empty external spool (#2589, reporter @Jostxxl)** — A queued P1S job with a regular AMS attached, two compatible PETG spools loaded, and nothing on the external spool holder started against the *external* feed and paused seconds later with a filament-runout HMS. The queue row was correct on its face — `use_ams=true` — but carried `ams_mapping=[-1]`, and Bambuddy turned that into a print with no AMS. Two faults combined. **A `-1` was read as "external spool."** The command builder's rule for "all slots are external, so drop `use_ams`" tested `t < 0 or t >= 254` — folding the *unresolved* sentinel (`-1`) in with a genuine external selection (`254`/`255`). An explicit external print serializes as `[254]`; an unresolved slot serializes as `[-1]`, and the two mean opposite things — one is "use the spool holder", the other is "we never worked out which tray." Only `>= 254` may now force `use_ams=False`; `-1` never does. **The unresolved mapping was trusted instead of recomputed.** The scheduler only computes a mapping when the row has *none*; a stored `[-1]` is non-empty, so it looked "already resolved" and was passed through verbatim — even though the backend had the live AMS trays and the plate's filament requirements right there and could have matched them. Dispatch now recomputes whenever the stored mapping is entirely unresolved, so a bogus `[-1]` self-heals against the trays actually loaded (and any pre-existing stuck row heals on the next scheduler pass); if nothing compatible is loaded it is cleared rather than sent, so the firmware reports a clear mapping error instead of quietly printing to an empty feed. **Where the `[-1]` came from.** The Print dialog builds the mapping from the selected printer's live status; if you submitted a single-printer job in the instant before that status query resolved, it matched against zero known trays and serialized every required slot as `-1`. The dialog now waits for the printer's AMS status before it will submit (showing a brief "Waiting for AMS status from …" notice), and the mapping hook returns *no* mapping rather than an all-`-1` one while the trays are unknown — so the scheduler resolves it at dispatch. A genuine no-match with trays present still serializes `-1` and surfaces the mismatch as before. **Tests.** Backend: the command builder keeps `use_ams=true` for `[-1]`/`[-1,-1]` and a padded `[-1,-1,5]`, still drops it for an explicit `[254]`; the scheduler recomputes a stored `[-1]`, leaves a resolved (or manually-overridden) mapping untouched, and clears an unresolvable one. An existing test that asserted the old `[-1] → use_ams=False` behaviour was corrected to the fixed contract. Frontend: the mapping hook returns `undefined` while status is loading, resolves to the AMS tray once it arrives (type-only match with strict colour off), and still emits `-1` for a real mismatch. Full backend suite and the PrintModal/mapping frontend suites green.
|
||||
- **Pushover Emergency priority (2) was rejected by the Pushover API (#2586)** — Setting a Pushover provider to priority 2 (Emergency) made every notification fail with Pushover's own error that `retry` and `expire` are required. Pushover *mandates* those two parameters for Emergency alerts — `retry` is how often it re-alerts (minimum 30 s) and `expire` is when it stops (maximum 10800 s / 3 h) — and Bambuddy never sent them, so the message was refused before it left the app. Priority 2 now works: two new optional fields (Emergency Retry / Expire) appear on the Pushover provider **only when priority is set to 2**, default to a sensible 60 s / 3600 s, are clamped to Pushover's legal 30–10800 s range, and are sent only at priority 2 (Pushover ignores them at other priorities). Emergency alerts now keep re-alerting until acknowledged, as intended.
|
||||
- **P2S RTSP timeout could leave the fan-out camera stream permanently stalled (#2580, reported and diagnosed by @ronaldheft, fix shape from PR #2581)** — After an RTSP read timeout, the stream cleanup killed the stalled ffmpeg and then waited *unbounded* for it to be reaped. A SIGKILLed ffmpeg stuck in uninterruptible I/O on a dead RTSP socket can take arbitrarily long to exit, so the fan-out stream coroutine sat parked in that wait — in the reported case for 12 hours — while every new viewer attached to the stalled broadcaster and got no frames (snapshots and diagnostics kept working, since those open fresh connections). The post-kill wait is now bounded (2 s): on timeout the stream abandons the zombie — the orphan janitor's /proc scan reaps it on its next pass — and proceeds to its normal reconnect, so live view recovers by itself. The same unbounded wait hid in two more places, both bounded too: the camera *Stop* endpoint (which would hang the very request a user makes to recover a stuck stream) and the periodic orphan-cleanup janitor itself (which is the safety net that recovers stalled streams, and so can least afford to block).
|
||||
- **Queue edit showed the sliced-for model as the scheduler target, and a cross-model queue row could dispatch G-code to an incompatible printer (#2578, reporter @Jostxxl)** — Two bugs with one root. The "Any \<model\>" assignment button labeled itself from the file's slice metadata while the scheduler actually used the row's `target_model`, so an X1C-sliced item targeting H2D read "Any X1C" above "Scheduler will assign to first available idle H2D printer". Worse, the mismatch could be *created* silently: the sliced-for model loads asynchronously, and clicking "Any Model" before it arrived pre-selected the first model alphabetically — on a mixed X1C/P1S/H2D farm that's H2D — after which the model dropdown hid itself, leaving no way to see or fix the wrong target. Nothing downstream checked compatibility, so the scheduler would happily hand X1C G-code to an H2D. Now: the target model is never silently defaulted (the dropdown stays visible in model mode, pre-selected to the sliced-for model when available, and back-fills once the metadata loads); the button reflects the actual target; a warning shows when the target differs from the sliced-for model. Compatibility is enforced end-to-end with an explicit G-code interchange family table (X1/X1C/X1E/P1P/P1S interchange; everything else exact-match — files without slice metadata are never blocked): incompatible models are disabled in the dropdown, queue create/update reject a mismatch with a clear 400 (so API-created rows can't sneak in), and the scheduler holds back pre-existing mismatched rows with an actionable waiting reason instead of dispatching them — fix the target via edit and the job flows again.
|
||||
|
||||
@@ -3885,8 +3885,17 @@ class BambuMQTTClient:
|
||||
# H2S falls through this gate now (#1386): it is single-nozzle and was
|
||||
# hitting the dual-nozzle bypass, which caused 07FF_8012 when printing
|
||||
# without an AMS attached.
|
||||
#
|
||||
# Only an *explicit* external/virtual spool (254/255) may downgrade to
|
||||
# use_ams=False. An unresolved slot (-1) must NOT: it means the mapping
|
||||
# was never resolved — e.g. a frontend status-load race that persisted
|
||||
# [-1] (#2589) — and treating that as "external" silently started the
|
||||
# print against an empty external feed, pausing with a runout. A genuine
|
||||
# external selection is >=254; unresolved is -1. Keep them distinct so an
|
||||
# unresolved mapping fails loudly (or is recomputed upstream) instead of
|
||||
# silently going external.
|
||||
if ams_mapping and use_ams and not is_dual_nozzle:
|
||||
if all(t is None or int(t) < 0 or int(t) >= 254 for t in ams_mapping):
|
||||
if all(t is None or int(t) >= 254 for t in ams_mapping):
|
||||
use_ams = False
|
||||
logger.info(
|
||||
"[%s] All filament slots use external spool — setting use_ams=False",
|
||||
|
||||
@@ -154,6 +154,25 @@ def _canonical_filament_type(ftype: str) -> str:
|
||||
return _FILAMENT_EQUIV_MAP.get(upper, upper)
|
||||
|
||||
|
||||
def _mapping_is_all_unresolved(mapping: list | None) -> bool:
|
||||
"""True if ``mapping`` is a non-empty list whose every entry is the
|
||||
unresolved sentinel (-1 / None) — i.e. no required slot ever matched a tray.
|
||||
|
||||
Such a mapping is a bug artifact: a frontend status-load race can serialize
|
||||
``[-1]`` before the printer's AMS trays are known (#2589). It must be
|
||||
recomputed from live status at dispatch rather than trusted, otherwise it
|
||||
reaches the print command and is silently downgraded to external-spool mode.
|
||||
|
||||
A partially-resolved mapping (``[-1, -1, 5]`` where slot 3 matched, or a
|
||||
padding ``-1`` for a slot this plate does not print) is NOT unresolved. An
|
||||
explicit external selection (``>= 254``) is NOT unresolved either — those
|
||||
keep their meaning.
|
||||
"""
|
||||
if not isinstance(mapping, list) or not mapping:
|
||||
return False
|
||||
return all(t is None or (isinstance(t, int) and t < 0) for t in mapping)
|
||||
|
||||
|
||||
def _installed_nozzle_diameters(status) -> list[float]:
|
||||
"""Parse the installed nozzle diameters from a PrinterState (#1899).
|
||||
|
||||
@@ -503,15 +522,11 @@ class PrintScheduler:
|
||||
)
|
||||
continue
|
||||
|
||||
# Compute AMS mapping if not already set
|
||||
if not item.ams_mapping:
|
||||
computed_mapping = await self._compute_ams_mapping_for_printer(db, item.printer_id, item)
|
||||
if computed_mapping:
|
||||
item.ams_mapping = json.dumps(computed_mapping)
|
||||
logger.info(
|
||||
f"Queue item {item.id}: Computed AMS mapping for printer {item.printer_id}: {computed_mapping}"
|
||||
)
|
||||
await db.commit()
|
||||
# Resolve the AMS mapping when it's missing OR unresolved
|
||||
# (all -1). A stored all-[-1] mapping is a bug artifact — a
|
||||
# frontend status-load race can persist [-1] (#2589) — and
|
||||
# must be recomputed from live trays rather than trusted.
|
||||
await self._ensure_ams_mapping(db, item.printer_id, item)
|
||||
|
||||
# Filament-deficit pre-dispatch check (#1496). If the
|
||||
# assigned spool can't satisfy any required slot grams,
|
||||
@@ -677,16 +692,11 @@ class PrintScheduler:
|
||||
db=db,
|
||||
)
|
||||
|
||||
# Compute AMS mapping for the assigned printer if not already set
|
||||
# This is critical for model-based jobs where mapping wasn't computed upfront
|
||||
if not item.ams_mapping:
|
||||
computed_mapping = await self._compute_ams_mapping_for_printer(db, printer_id, item)
|
||||
if computed_mapping:
|
||||
item.ams_mapping = json.dumps(computed_mapping)
|
||||
logger.info(
|
||||
f"Queue item {item.id}: Computed AMS mapping for printer {printer_id}: {computed_mapping}"
|
||||
)
|
||||
await db.commit()
|
||||
# Resolve the AMS mapping for the assigned printer when it's
|
||||
# missing OR unresolved (all -1). Critical for model-based
|
||||
# jobs where mapping wasn't computed upfront, and it also
|
||||
# self-heals a bogus stored [-1] (#2589).
|
||||
await self._ensure_ams_mapping(db, printer_id, item)
|
||||
|
||||
# Filament-deficit pre-dispatch check (#1496).
|
||||
if await self._block_on_filament_deficit(db, item):
|
||||
@@ -1122,6 +1132,56 @@ class PrintScheduler:
|
||||
matches += 1
|
||||
return matches
|
||||
|
||||
async def _ensure_ams_mapping(self, db: AsyncSession, printer_id: int, item: PrintQueueItem) -> None:
|
||||
"""Ensure the queue item carries a usable AMS mapping before dispatch.
|
||||
|
||||
Recomputes from live printer status when the stored mapping is missing OR
|
||||
unresolved (all -1). A stored all-[-1] mapping is a bug artifact — a
|
||||
frontend status-load race can serialize [-1] before the printer's AMS
|
||||
trays are known (#2589) — and must not be trusted: downstream it would be
|
||||
silently downgraded to external-spool mode and print against an empty
|
||||
feed. A resolved mapping (including manual overrides, or a partially
|
||||
padded one) is left untouched.
|
||||
|
||||
When recompute cannot resolve it either (no compatible tray loaded), the
|
||||
bogus [-1] is cleared to None so it is not later mistaken for an explicit
|
||||
external selection; the print command then keeps use_ams=True and the
|
||||
firmware surfaces a clear AMS-mapping error instead of silently printing
|
||||
to the empty external feed.
|
||||
"""
|
||||
stored_mapping: list | None = None
|
||||
if item.ams_mapping:
|
||||
try:
|
||||
stored_mapping = json.loads(item.ams_mapping)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
stored_mapping = None
|
||||
|
||||
# Already resolved (present and not all-unresolved) — keep as-is so a
|
||||
# user's manual mapping is never overwritten.
|
||||
if item.ams_mapping and not _mapping_is_all_unresolved(stored_mapping):
|
||||
return
|
||||
|
||||
computed_mapping = await self._compute_ams_mapping_for_printer(db, printer_id, item)
|
||||
if computed_mapping and not _mapping_is_all_unresolved(computed_mapping):
|
||||
item.ams_mapping = json.dumps(computed_mapping)
|
||||
logger.info(
|
||||
"Queue item %s: Computed AMS mapping for printer %s: %s",
|
||||
item.id,
|
||||
printer_id,
|
||||
computed_mapping,
|
||||
)
|
||||
await db.commit()
|
||||
elif _mapping_is_all_unresolved(stored_mapping):
|
||||
logger.warning(
|
||||
"Queue item %s: stored ams_mapping %s is unresolved and could not be recomputed "
|
||||
"from live status on printer %s; clearing it so dispatch does not treat it as external",
|
||||
item.id,
|
||||
stored_mapping,
|
||||
printer_id,
|
||||
)
|
||||
item.ams_mapping = None
|
||||
await db.commit()
|
||||
|
||||
async def _compute_ams_mapping_for_printer(
|
||||
self, db: AsyncSession, printer_id: int, item: PrintQueueItem
|
||||
) -> list[int] | None:
|
||||
|
||||
@@ -3931,12 +3931,18 @@ class TestStartPrintAmsMapping:
|
||||
cmd = self._get_published_command(mqtt_client)
|
||||
assert cmd["use_ams"] is False
|
||||
|
||||
def test_all_unmapped_sets_use_ams_false(self, mqtt_client):
|
||||
"""All unmapped slots on non-H2D printer sets use_ams=False."""
|
||||
def test_all_unresolved_keeps_use_ams_true(self, mqtt_client):
|
||||
"""All-unresolved (-1) is NOT external — must keep use_ams=True (#2589).
|
||||
|
||||
A stored [-1] comes from an unresolved mapping (e.g. a frontend
|
||||
status-load race), not an explicit external-spool selection. Treating it
|
||||
as external silently started the print against the empty external feed.
|
||||
Only >=254 may downgrade to use_ams=False.
|
||||
"""
|
||||
mqtt_client.start_print("test.3mf", ams_mapping=[-1, -1], use_ams=True)
|
||||
|
||||
cmd = self._get_published_command(mqtt_client)
|
||||
assert cmd["use_ams"] is False
|
||||
assert cmd["use_ams"] is True
|
||||
|
||||
def test_mixed_ams_and_external_keeps_use_ams_true(self, mqtt_client):
|
||||
"""AMS tray + external spool keeps use_ams=True."""
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Regression tests for the unresolved-mapping -> silent-external bug (#2589).
|
||||
|
||||
A P1S queue item persisted with ``ams_mapping=[-1]`` (an unresolved mapping from
|
||||
a frontend status-load race) was silently dispatched with ``use_ams=False`` — the
|
||||
print then started against the empty external feed and paused with a filament
|
||||
runout. Two behaviours combined:
|
||||
|
||||
1. ``start_print`` treated an all-``-1`` mapping as "all external spool" and
|
||||
forced ``use_ams=False``. Only an explicit external selection (``>=254``) may
|
||||
do that; unresolved ``-1`` must not.
|
||||
2. The scheduler trusted a stored ``[-1]`` (non-empty, so "already resolved") and
|
||||
skipped the recompute that would have matched the live AMS trays.
|
||||
|
||||
These tests lock in the fix at both layers.
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.services.bambu_mqtt import BambuMQTTClient
|
||||
from backend.app.services.print_scheduler import PrintScheduler, _mapping_is_all_unresolved
|
||||
|
||||
|
||||
class TestMappingIsAllUnresolved:
|
||||
"""Unit tests for the ``_mapping_is_all_unresolved`` predicate."""
|
||||
|
||||
def test_all_minus_one_is_unresolved(self):
|
||||
assert _mapping_is_all_unresolved([-1]) is True
|
||||
assert _mapping_is_all_unresolved([-1, -1]) is True
|
||||
|
||||
def test_none_entries_are_unresolved(self):
|
||||
assert _mapping_is_all_unresolved([None]) is True
|
||||
assert _mapping_is_all_unresolved([-1, None]) is True
|
||||
|
||||
def test_partially_resolved_is_not_unresolved(self):
|
||||
# A plate that only prints slot 3 pads earlier slots with -1.
|
||||
assert _mapping_is_all_unresolved([-1, -1, 5]) is False
|
||||
assert _mapping_is_all_unresolved([5, -1]) is False
|
||||
|
||||
def test_explicit_external_is_not_unresolved(self):
|
||||
# 254/255 are explicit external/virtual spool selections, not unresolved.
|
||||
assert _mapping_is_all_unresolved([254]) is False
|
||||
assert _mapping_is_all_unresolved([255, 254]) is False
|
||||
|
||||
def test_resolved_ams_is_not_unresolved(self):
|
||||
assert _mapping_is_all_unresolved([0]) is False
|
||||
assert _mapping_is_all_unresolved([4, 8]) is False
|
||||
|
||||
def test_empty_or_none_is_not_unresolved(self):
|
||||
# Absent/empty is "needs computing", handled by the missing-mapping path,
|
||||
# not this predicate — which is specifically about a *bogus* stored value.
|
||||
assert _mapping_is_all_unresolved([]) is False
|
||||
assert _mapping_is_all_unresolved(None) is False
|
||||
|
||||
|
||||
class TestStartPrintExternalDowngrade:
|
||||
"""``start_print`` must only force use_ams=False for *explicit* external."""
|
||||
|
||||
@pytest.fixture
|
||||
def mqtt_client(self):
|
||||
client = BambuMQTTClient(
|
||||
ip_address="192.168.1.100",
|
||||
serial_number="01P00A452600691",
|
||||
access_code="12345678",
|
||||
)
|
||||
# Single-nozzle P1S so the dual-nozzle bypass does not apply.
|
||||
client.model = "P1S"
|
||||
client._client = MagicMock()
|
||||
client.state.connected = True
|
||||
return client
|
||||
|
||||
def _sent_command(self, mqtt_client) -> dict:
|
||||
"""Parse the JSON payload the client published."""
|
||||
assert mqtt_client._client.publish.called, "start_print did not publish"
|
||||
payload = mqtt_client._client.publish.call_args.args[1]
|
||||
return json.loads(payload)["print"]
|
||||
|
||||
def test_unresolved_mapping_keeps_use_ams_true(self, mqtt_client):
|
||||
"""[-1] is unresolved, NOT external — must not silently go external."""
|
||||
assert mqtt_client.start_print("Turm.3mf", ams_mapping=[-1], use_ams=True) is True
|
||||
cmd = self._sent_command(mqtt_client)
|
||||
assert cmd["use_ams"] is True
|
||||
|
||||
def test_explicit_external_forces_use_ams_false(self, mqtt_client):
|
||||
"""An explicit external selection (254) still downgrades to use_ams=False."""
|
||||
assert mqtt_client.start_print("Turm.3mf", ams_mapping=[254], use_ams=True) is True
|
||||
cmd = self._sent_command(mqtt_client)
|
||||
assert cmd["use_ams"] is False
|
||||
|
||||
def test_resolved_ams_keeps_use_ams_true(self, mqtt_client):
|
||||
"""A real AMS tray keeps use_ams=True."""
|
||||
assert mqtt_client.start_print("Turm.3mf", ams_mapping=[5], use_ams=True) is True
|
||||
cmd = self._sent_command(mqtt_client)
|
||||
assert cmd["use_ams"] is True
|
||||
|
||||
def test_padded_partial_mapping_keeps_use_ams_true(self, mqtt_client):
|
||||
"""A padded mapping ([-1, -1, tray]) is not all-external."""
|
||||
assert mqtt_client.start_print("Turm.3mf", ams_mapping=[-1, -1, 5], use_ams=True) is True
|
||||
cmd = self._sent_command(mqtt_client)
|
||||
assert cmd["use_ams"] is True
|
||||
|
||||
|
||||
class TestEnsureAmsMapping:
|
||||
"""The scheduler must recompute a stored unresolved [-1], not trust it."""
|
||||
|
||||
@pytest.fixture
|
||||
def scheduler(self):
|
||||
return PrintScheduler()
|
||||
|
||||
def _item(self, ams_mapping):
|
||||
item = MagicMock()
|
||||
item.id = 92
|
||||
item.printer_id = 82
|
||||
item.ams_mapping = ams_mapping
|
||||
return item
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stored_unresolved_is_recomputed(self, scheduler):
|
||||
"""A stored [-1] triggers recompute; the live-resolved mapping wins."""
|
||||
db = AsyncMock()
|
||||
item = self._item(json.dumps([-1]))
|
||||
scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=[5])
|
||||
|
||||
await scheduler._ensure_ams_mapping(db, 82, item)
|
||||
|
||||
scheduler._compute_ams_mapping_for_printer.assert_awaited_once()
|
||||
assert json.loads(item.ams_mapping) == [5]
|
||||
db.commit.assert_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_mapping_is_computed(self, scheduler):
|
||||
"""No stored mapping still computes one (existing behaviour preserved)."""
|
||||
db = AsyncMock()
|
||||
item = self._item(None)
|
||||
scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=[4, 8])
|
||||
|
||||
await scheduler._ensure_ams_mapping(db, 82, item)
|
||||
|
||||
assert json.loads(item.ams_mapping) == [4, 8]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolved_mapping_is_left_untouched(self, scheduler):
|
||||
"""A resolved stored mapping (e.g. a manual override) is never recomputed."""
|
||||
db = AsyncMock()
|
||||
item = self._item(json.dumps([5, 9]))
|
||||
scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=[0, 0])
|
||||
|
||||
await scheduler._ensure_ams_mapping(db, 82, item)
|
||||
|
||||
scheduler._compute_ams_mapping_for_printer.assert_not_awaited()
|
||||
assert json.loads(item.ams_mapping) == [5, 9]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unresolvable_stored_mapping_is_cleared(self, scheduler):
|
||||
"""If recompute also can't resolve it, the bogus [-1] is cleared to None
|
||||
so dispatch never mistakes it for an explicit external selection."""
|
||||
db = AsyncMock()
|
||||
item = self._item(json.dumps([-1]))
|
||||
# Live status has no compatible tray -> matcher returns another all-[-1].
|
||||
scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=[-1])
|
||||
|
||||
await scheduler._ensure_ams_mapping(db, 82, item)
|
||||
|
||||
assert item.ams_mapping is None
|
||||
db.commit.assert_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recompute_none_leaves_missing_untouched(self, scheduler):
|
||||
"""Missing mapping + recompute returns None (no status) -> stays None,
|
||||
no spurious clear-warning path, no crash."""
|
||||
db = AsyncMock()
|
||||
item = self._item(None)
|
||||
scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=None)
|
||||
|
||||
await scheduler._ensure_ams_mapping(db, 82, item)
|
||||
|
||||
assert item.ams_mapping is None
|
||||
@@ -6,11 +6,13 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import {
|
||||
buildAmsMapping,
|
||||
buildFilamentComparison,
|
||||
buildLoadedFilaments,
|
||||
computeAmsMapping,
|
||||
useFilamentMapping,
|
||||
} from '../../hooks/useFilamentMapping';
|
||||
import { effectivePreferLowest } from '../../utils/amsHelpers';
|
||||
import type { PrinterStatus } from '../../api/client';
|
||||
@@ -1243,3 +1245,45 @@ describe('per-plate mapping vs. the whole-file union (#2551 follow-up)', () => {
|
||||
expect(buildAmsMapping(buildFilamentComparison(PLATE_1, loaded, {}))).toEqual([0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useFilamentMapping — no [-1] mapping during a status-load race (#2589)', () => {
|
||||
// The file requires one PETG slot; two compatible PETG spools are loaded once
|
||||
// the printer status arrives.
|
||||
const filamentReqs = {
|
||||
filaments: [{ slot_id: 1, type: 'PETG', color: '#161616', used_grams: 141.86 }],
|
||||
};
|
||||
const statusWithPetg = createPrinterStatus([
|
||||
{
|
||||
id: 0,
|
||||
tray: [
|
||||
{ id: 0, tray_type: 'PETG', tray_color: 'BCBCBC' },
|
||||
{ id: 1, tray_type: 'PETG', tray_color: 'FFFFFF' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
it('returns undefined (not [-1]) while printerStatus is still loading', () => {
|
||||
// printerStatus undefined = query not resolved yet. Serializing [-1] here is
|
||||
// exactly what dispatched the P1S print to the empty external feed.
|
||||
const { result } = renderHook(() => useFilamentMapping(filamentReqs, undefined, {}));
|
||||
expect(result.current.amsMapping).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolves to an AMS tray once status has loaded (type-only match, strict color off)', () => {
|
||||
// Black requested, only gray/white PETG loaded: a type-only match is valid.
|
||||
const { result } = renderHook(() => useFilamentMapping(filamentReqs, statusWithPetg, {}));
|
||||
expect(result.current.amsMapping).toEqual([0]);
|
||||
expect(result.current.hasTypeMismatch).toBe(false);
|
||||
});
|
||||
|
||||
it('still emits [-1] for a genuine mismatch when trays are present', () => {
|
||||
// Status loaded but nothing compatible (only PLA) -> the mapping legitimately
|
||||
// carries -1 and the mismatch is surfaced; this must NOT be suppressed.
|
||||
const statusWithPla = createPrinterStatus([
|
||||
{ id: 0, tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }] },
|
||||
]);
|
||||
const { result } = renderHook(() => useFilamentMapping(filamentReqs, statusWithPla, {}));
|
||||
expect(result.current.amsMapping).toEqual([-1]);
|
||||
expect(result.current.hasTypeMismatch).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -372,7 +372,7 @@ export function PrintModal({
|
||||
});
|
||||
|
||||
// Only fetch printer status when single printer selected (for filament mapping)
|
||||
const { data: printerStatus } = useQuery({
|
||||
const { data: printerStatus, isLoading: printerStatusLoading } = useQuery({
|
||||
queryKey: ['printer-status', effectivePrinterId],
|
||||
queryFn: () => api.getPrinterStatus(effectivePrinterId!),
|
||||
enabled: !!effectivePrinterId,
|
||||
@@ -1100,6 +1100,12 @@ export function PrintModal({
|
||||
// the rest — the banner above says which state we are in.
|
||||
if (perPlateReqsPending || perPlateReqsFailed) return false;
|
||||
|
||||
// A single-printer AMS job must wait for the printer's live status before it
|
||||
// can resolve the filament mapping. Submitting mid-load matched against zero
|
||||
// known trays and serialized an all-[-1] mapping, which dispatched the print
|
||||
// to the empty external feed (#2589).
|
||||
if (assignmentMode === 'printer' && selectedPrinters.length === 1 && printerStatusLoading) return false;
|
||||
|
||||
return true;
|
||||
}, [
|
||||
selectedPrinters.length,
|
||||
@@ -1111,6 +1117,7 @@ export function PrintModal({
|
||||
isPending,
|
||||
perPlateReqsPending,
|
||||
perPlateReqsFailed,
|
||||
printerStatusLoading,
|
||||
]);
|
||||
|
||||
// Quantity only applies for single-printer or model-based assignment (not multi-printer)
|
||||
@@ -1482,6 +1489,17 @@ export function PrintModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Waiting for the printer's AMS status: submitting now would map
|
||||
against zero known trays and dispatch to the empty external feed (#2589). */}
|
||||
{assignmentMode === 'printer' && selectedPrinters.length === 1 && printerStatusLoading && (
|
||||
<div className="mb-4 p-3 bg-blue-100 dark:bg-blue-500/20 border border-blue-500/50 rounded-lg text-sm text-blue-700 dark:text-blue-400 flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{t('printModal.waitingForAmsStatus', {
|
||||
printer: printers?.find((p) => p.id === effectivePrinterId)?.name ?? '',
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button type="button" variant="secondary" onClick={onClose} className="flex-1" disabled={isSubmitting}>
|
||||
|
||||
@@ -439,7 +439,17 @@ export function useFilamentMapping(
|
||||
[filamentReqs, loadedFilaments, manualMappings, preferLowest, ftsActive, inventoryByTrayId],
|
||||
);
|
||||
|
||||
const amsMapping = useMemo(() => buildAmsMapping(filamentComparison), [filamentComparison]);
|
||||
// Don't emit a mapping until the printer's trays are known. With no loaded
|
||||
// filaments (e.g. printerStatus still loading), buildFilamentComparison marks
|
||||
// every required slot unmatched and buildAmsMapping would serialize an
|
||||
// all-[-1] array — which the backend used to treat as an explicit
|
||||
// external-spool selection, silently printing to an empty feed (#2589).
|
||||
// Return undefined instead so the scheduler resolves the mapping from live
|
||||
// status at dispatch. Mirrors the guard in computeAmsMapping.
|
||||
const amsMapping = useMemo(
|
||||
() => (loadedFilaments.length === 0 ? undefined : buildAmsMapping(filamentComparison)),
|
||||
[filamentComparison, loadedFilaments.length],
|
||||
);
|
||||
|
||||
const hasTypeMismatch = filamentComparison.some((f) => f.status === 'mismatch');
|
||||
const hasColorMismatch = filamentComparison.some((f) => f.status === 'type_only');
|
||||
|
||||
@@ -4596,6 +4596,7 @@ export default {
|
||||
overrideWith: 'Ersetzen mit',
|
||||
resetToOriginal: 'Auf Original zurücksetzen',
|
||||
insufficientFilamentTitle: 'Nicht genug Filament',
|
||||
waitingForAmsStatus: 'Warte auf AMS-Status von {{printer}}…',
|
||||
insufficientFilamentMessage: 'Einige zugewiesene Spulen haben weniger Filament als dieser Druck benötigt:',
|
||||
insufficientFilamentLine: '{{printer}} - {{slot}}: benötigt {{required}}g, verbleibend {{remaining}}g',
|
||||
printAnyway: 'Trotzdem drucken',
|
||||
|
||||
@@ -4639,6 +4639,7 @@ export default {
|
||||
overrideWith: 'Override with',
|
||||
resetToOriginal: 'Reset to original',
|
||||
insufficientFilamentTitle: 'Not enough filament',
|
||||
waitingForAmsStatus: 'Waiting for AMS status from {{printer}}…',
|
||||
insufficientFilamentMessage: 'Some assigned spools have less filament remaining than this print needs:',
|
||||
insufficientFilamentLine: '{{printer}} - {{slot}}: needs {{required}}g, remaining {{remaining}}g',
|
||||
printAnyway: 'Print anyway',
|
||||
|
||||
@@ -4604,6 +4604,7 @@ export default {
|
||||
overrideWith: 'Anular con',
|
||||
resetToOriginal: 'Restablecer al original',
|
||||
insufficientFilamentTitle: 'No hay suficiente filamento',
|
||||
waitingForAmsStatus: 'Esperando el estado del AMS de {{printer}}…',
|
||||
insufficientFilamentMessage: 'Algunas bobinas asignadas tienen menos filamento restante del que necesita esta impresión:',
|
||||
insufficientFilamentLine: '{{printer}} - {{slot}}: necesita {{required}} g, restante {{remaining}} g',
|
||||
printAnyway: 'Imprimir de todos modos',
|
||||
|
||||
@@ -4585,6 +4585,7 @@ export default {
|
||||
overrideWith: 'Remplacer par',
|
||||
resetToOriginal: 'Revenir à l\'original',
|
||||
insufficientFilamentTitle: 'Filament insuffisant',
|
||||
waitingForAmsStatus: 'En attente de l\'état de l\'AMS de {{printer}}…',
|
||||
insufficientFilamentMessage: 'Certaines bobines assignées ont moins de filament restant que nécessaire pour cette impression :',
|
||||
insufficientFilamentLine: '{{printer}} - {{slot}} : nécessite {{required}}g, restant {{remaining}}g',
|
||||
printAnyway: 'Imprimer quand même',
|
||||
|
||||
@@ -4584,6 +4584,7 @@ export default {
|
||||
overrideWith: 'Sostituisci con',
|
||||
resetToOriginal: 'Ripristina originale',
|
||||
insufficientFilamentTitle: 'Filamento insufficiente',
|
||||
waitingForAmsStatus: 'In attesa dello stato AMS di {{printer}}…',
|
||||
insufficientFilamentMessage: 'Alcune bobine assegnate hanno meno filamento rimanente di quanto necessario per questa stampa:',
|
||||
insufficientFilamentLine: '{{printer}} - {{slot}}: necessita di {{required}}g, rimanenti {{remaining}}g',
|
||||
printAnyway: 'Stampa comunque',
|
||||
|
||||
@@ -4596,6 +4596,7 @@ export default {
|
||||
overrideWith: '変更先',
|
||||
resetToOriginal: 'オリジナルに戻す',
|
||||
insufficientFilamentTitle: 'フィラメントが不足しています',
|
||||
waitingForAmsStatus: '{{printer}} のAMSステータスを待機しています…',
|
||||
insufficientFilamentMessage: '割り当てられたスプールの一部は、この印刷に必要な量より残量が少ないです:',
|
||||
insufficientFilamentLine: '{{printer}} - {{slot}}: 必要 {{required}}g、残り {{remaining}}g',
|
||||
printAnyway: 'それでも印刷',
|
||||
|
||||
@@ -4368,6 +4368,7 @@ export default {
|
||||
overrideWith: '재정의',
|
||||
resetToOriginal: '원래대로 초기화',
|
||||
insufficientFilamentTitle: '필라멘트 부족',
|
||||
waitingForAmsStatus: '{{printer}}의 AMS 상태를 기다리는 중…',
|
||||
insufficientFilamentMessage: '일부 할당된 스풀에 이 인쇄에 필요한 것보다 적은 필라멘트가 남아 있습니다:',
|
||||
insufficientFilamentLine: '{{printer}} - {{slot}}: {{required}}g 필요, {{remaining}}g 남음',
|
||||
printAnyway: '그래도 인쇄',
|
||||
|
||||
@@ -4584,6 +4584,7 @@ export default {
|
||||
overrideWith: 'Substituir por',
|
||||
resetToOriginal: 'Restaurar original',
|
||||
insufficientFilamentTitle: 'Filamento insuficiente',
|
||||
waitingForAmsStatus: 'Aguardando o status do AMS de {{printer}}…',
|
||||
insufficientFilamentMessage: 'Alguns dos carretéis atribuídos têm menos filamento restante do que o necessário para esta impressão:',
|
||||
insufficientFilamentLine: '{{printer}} - {{slot}}: necessário {{required}}g, restante {{remaining}}g',
|
||||
printAnyway: 'Imprimir mesmo assim',
|
||||
|
||||
@@ -4574,6 +4574,7 @@ export default {
|
||||
overrideWith: 'Şununla geçersiz kıl',
|
||||
resetToOriginal: 'Orijinale sıfırla',
|
||||
insufficientFilamentTitle: 'Yeterli filament yok',
|
||||
waitingForAmsStatus: '{{printer}} için AMS durumu bekleniyor…',
|
||||
insufficientFilamentMessage: 'Bazı atanmış makaralarda bu baskının ihtiyaç duyduğundan daha az filament kaldı:',
|
||||
insufficientFilamentLine: '{{printer}} - {{slot}}: {{required}}g gerekli, {{remaining}}g kaldı',
|
||||
printAnyway: 'Yine de yazdır',
|
||||
|
||||
@@ -4584,6 +4584,7 @@ export default {
|
||||
overrideWith: '覆盖为',
|
||||
resetToOriginal: '恢复为原始',
|
||||
insufficientFilamentTitle: '耗材不足',
|
||||
waitingForAmsStatus: '正在等待 {{printer}} 的 AMS 状态…',
|
||||
insufficientFilamentMessage: '部分已分配线轴的剩余耗材少于本次打印所需:',
|
||||
insufficientFilamentLine: '{{printer}} - {{slot}}:需要 {{required}}g,剩余 {{remaining}}g',
|
||||
printAnyway: '仍然打印',
|
||||
|
||||
@@ -4584,6 +4584,7 @@ export default {
|
||||
overrideWith: '覆蓋為',
|
||||
resetToOriginal: '恢復為原始',
|
||||
insufficientFilamentTitle: '耗材不足',
|
||||
waitingForAmsStatus: '正在等待 {{printer}} 的 AMS 狀態…',
|
||||
insufficientFilamentMessage: '部分已分配料盤的剩餘耗材少於本次列印所需:',
|
||||
insufficientFilamentLine: '{{printer}} - {{slot}}:需要 {{required}}g,剩餘 {{remaining}}g',
|
||||
printAnyway: '仍然列印',
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -26,7 +26,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-MvmZEMye.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-z4krdH8i.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BikDm6kr.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user