mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-08 15:11:21 +02:00
fix(drying): P1 AMS drying is screen-only — stop offering it (#2533)
The reporter found what his P1S was doing, and it is in Bambu's P1 manual: "P1S connected AMS drying functions may only be controlled from the P1S screen." The firmware acks ams_filament_drying with result: success and then discards it, which is why three commands on an idle printer left the AMS 2 Pro at dry_status 0. No command can start a cycle on a P1, on any firmware, so don't offer one. supports_drying() now excludes the P1 series outright, replacing the 01.08+ gate carried since #292 — that version is when P1 firmware gained AMS 2 Pro support, not remote drying, and it was never checked against a live P1. Both drying routes refuse with a specific 400 instead of publishing a message the printer will drop; queue and ambient auto-drying skip P1s via the same helper. A new drying_screen_only flag keeps the control on the card, disabled, saying why — a P1 owner needs to learn where to dry, not watch the button disappear. A cycle started at the printer still shows with its countdown; only Stop goes away, since a P1 ignores stop exactly as it ignores start. Also corrects the wiki firmware matrix, which listed P1P/P1S as supported and (separately) P2S/H2S/H2C as unsupported. 8 tests.
This commit is contained in:
@@ -39,6 +39,7 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
### Fixed
|
||||
- **Skip Objects listed the wrong plate's objects (#2522, reporter @bordermultimedia)** — The reporter uploads all-plates sliced files (`*.gcode.3mf`) and picks the plate at dispatch, which works. But Skip Objects then offered him four objects while the plate he was printing had one — and the four IDs it showed were, verbatim, the four copies sitting on a *different* plate of the same file. His 3MF confirms it exactly: plate 1 holds four `stand_pillow_01.stl` instances (identify_ids 2040 / 2062 / 2084 / 2106), plate 2 holds one (2168). He printed plate 2 and got plate 1's list. **Root cause, two defects in one helper.** `extract_printable_objects_from_3mf()` has taken a `plate_number` argument all along, and not one of its three call sites ever passed it — print start from an archived file, print start from the FTP-downloaded file, and the modal's `reload` all called it bare, so it fell to `root.find(".//plate")`, the first plate in the file. And passing one would not have helped: the lookup was `.//plate[@plate_idx='N']`, a predicate on an attribute neither Bambu Studio nor OrcaSlicer writes — the plate index lives in a `<metadata key="index" value="N"/>` child, which is how the rest of the codebase (`threemf_tools.py`, `filament_requirements.py`) already reads it. The XPath simply never matched and fell back to plate 1 regardless. The thumbnail behind the markers was right the whole time, because `/cover` resolves the plate properly via `resolve_plate_id()` — which is why the reporter saw plate 1's four markers arranged in a square over plate 2's single pillow. **Fix.** The plate is now selected on its `index` metadata, and all three call sites pass `resolve_plate_id(client.state)` — the same resolver `/cover` uses, so the object list and the thumbnail it is drawn over cannot disagree again. It covers both cases: Bambuddy-dispatched prints (where the plate is known from the dispatch, and must be — the reporter's P1S firmware echoes a `gcode_file` with no plate path, #1166) and prints started from the printer or Studio (where it is parsed from that path). When no plate can be resolved, or the requested plate isn't in the file, the first plate is still used — the single-plate export, i.e. the common case, is unaffected. **Also fixed, same file shape.** `peek_plate_index_in_3mf()` read the first plate too. It backs the #1204 guard, which compares the plate inside a freshly-downloaded 3MF against the plate the printer reports and *discards the file* on mismatch. Fed an all-plates upload it always answered "plate 1", so anyone printing plate 2+ of such a file from the printer screen had their perfectly good 3MF thrown away and got a no-3MF fallback archive — no metadata, no skip objects at all. It now returns `None` when the file carries more than one plate, because "which plate is this file" has no answer for an all-plates export; single-plate exports still report their index and #1204 behaves as before. **Tests.** 10 cases over a fixture mirroring the reporter's file: per-plate object lists and marker positions resolve independently, an unknown plate falls back without mixing one plate's objects with another's positions, a single-plate export keeps its own index, the multi-plate file yields no peek index, and the print-start wiring passes the dispatched plate and the gcode-parsed plate. Verified by mutation — dropping the plate argument reproduces the reporter's exact symptom, "Loaded 4 printable objects" for a one-object plate. **Scope.** Backend only. No DB migration, no schema change, no new permission, no i18n change.
|
||||
- **Large prints uploaded twice at once and never landed (#2529, reporter @PDXDave23)** — On a 14-printer A1 farm, dispatches "failed over and over" and looked like flaky WiFi. The reporter's screen recording is the proof: the dispatch toast tracks a single job (96.1 MB, one printer), yet its byte counter alternates between two *independently rising* series — 69.4 → 70.8 MB and 1.6 → 2.9 MB, both climbing at ~75 KB/s. That is not a progress bar jumping. That is two FTP transfers of the same file, to the same printer, at the same time, reporting into the same bar. **Root cause.** `upload_file_async` carried a flat `timeout: float = 600.0` and ran the transfer as `asyncio.wait_for(loop.run_in_executor(...))`. `wait_for` cancels the *future*; it cannot cancel a thread already running in an executor. So on a link slow enough that a big file needs more than ten minutes — 96 MB at 75 KB/s needs twenty — the await gave up at 600 s, returned `False`, and `with_ftp_retry` started attempt two *while attempt one was still streaming*, both writing the same `remote_path`. Do the arithmetic and the stale series sits right where the ten-minute clock expires. With `ftp_retry_count: 3` plus the A1's prot_p→prot_c fallback that is up to six concurrent STORs onto one SD card; each orphaned thread also permanently held a slot in the default executor pool, which on a farm can starve every other FTP operation. The flat cap was never a failure detector in the first place — a link that has actually *died* is caught within `socket_timeout` (30 s) by the blocking `sendall`. It only ever punished large files. **Fix, three parts.** The deadline is now derived from the file size against a deliberately pessimistic 25 KB/s floor (`_upload_deadline`), so a slow-but-healthy transfer is allowed to finish. A deadline expiry now genuinely *stops* the transfer: it signals the worker, which raises `UploadCancelled` from its progress callback — the existing cancel path in `upload_file`, which breaks the send loop and deletes the partial file from the printer — and the caller waits for the thread to actually go before returning. And `with_ftp_retry` never retries an `UploadCancelled`: the deadline means the link sustained less than the floor rate for the whole transfer, so a retry would only spend another full deadline learning that again, and with `check_queue` serialized four of those would block the entire print queue for hours. A per-printer upload lock closes the last hole, so no two uploads can ever overlap on one printer regardless of how they were triggered. Queue items that hit the deadline now say the upload was too slow and to check the printer's WiFi, rather than the old (and here entirely misleading) "check if SD card is inserted". **Tests.** 6 cases: the deadline scales with file size and floors correctly; a timeout stops the worker thread and cleans its partial file off the printer; a timeout is not retried; concurrent dispatches to one printer serialize; and the real client's cancel path removes the partial file, driven end to end against the mock FTPS server. Verified by mutation — dropping the cancel signal, the retry guard, or the lock each fails its test. **Scope.** Backend only. No DB migration, no schema change, no new permission, no i18n change.
|
||||
- **P1 AMS drying can only be started at the printer — Bambuddy no longer offers it (#2533, reporter @naldo29)** — The reporter found the answer to why his P1S accepted every drying command and never dried, and it is in Bambu's own P1 manual: *"P1S connected AMS drying functions may only be controlled from the P1S screen."* The P1 firmware acks `ams_filament_drying` with `result: success` and then discards it. Nothing we send can start a cycle, on any firmware version — so the honest thing is not to offer. `supports_drying()` now excludes the P1 series outright, in place of the `01.08+` firmware gate it has carried since the feature shipped (#292); that version was when P1 firmware gained AMS 2 Pro *support*, and it was never verified against a live P1 that the printer would take a command. The `/drying/start` and `/drying/stop` routes refuse with a specific 400 rather than publishing a message the printer will drop, and queue / ambient auto-drying skip P1 printers for free, since they gate on the same helper. **The control stays visible.** A new `drying_screen_only` capability flag keeps the flame button on the card, disabled, with a tooltip saying drying on this printer is screen-only — a P1 owner should learn *where* to dry, not watch the feature quietly disappear. A cycle started at the printer still shows in Bambuddy with its live countdown, because reading state was never the problem; only the Stop control goes away, since a P1 ignores stop exactly as it ignores start. **Docs.** The wiki's firmware matrix said P1P/P1S were supported from `01.08.00.00`, and (separately, and also wrongly) that P2S / H2S / H2C were not supported at all — the whole table has been rewritten against the code. **Tests.** 8 new cases: the model gate (both P1 variants, case- and whitespace-insensitive; screen-only ≠ unsupported, since an A1 has no drying-capable AMS at all while a P1 does), the two routes refusing without publishing anything to MQTT, a commandable model still dispatching, and two component cases pinning the disabled-with-explanation button and the stop-less countdown. **Scope.** One new response field. No DB migration, no schema change, no new permission; 1 new i18n key across all 11 locales.
|
||||
- **"Start Drying" gave no feedback and reported success the printer never delivered (#2533, reporter @naldo29)** — On the reporter's P1S the button appeared dead: no toast, no badge, no drying. Their bundle shows Bambuddy doing everything right — the `ams_filament_drying` payload matches BambuStudio field-for-field, was sent three times with the printer idle, and the firmware answered `result: success` to each — while the AMS 2 Pro (`module_type: n3f`) stayed at `dry_status: 0`. The printer takes the command and drops it. Two gaps on our side made that indistinguishable from a broken button. **No confirmation.** `startDryingMutation` / `stopDryingMutation` closed the popover and invalidated the status cache without ever toasting, alone among the printer card's actions — so a user who clicked Start had nothing at all to tell them the click had registered. Both now toast on success — and the start toast says "Drying command sent", not "Drying started", because at that moment the printer has only *taken* the command. What says the cycle is genuinely live is the amber countdown badge, which is keyed on `dry_time` and therefore only appears when firmware reports one. **Success was inferred from the MQTT ack.** The #971 guard that turns firmware's refusal into a real message ("Plug in the external AMS power adapter", "AMS is already drying", …) reads the per-unit `dry_sf_reason` array — and P1-family firmware never publishes that field, so on a P1S the guard is inert and the ack is all we have. The card now watches the unit after the ack: `dry_status` and `dry_time` come straight from the `info` bitmask on every push, and firmware moves to `DryStatus 1` (Checking) within seconds of a real start. If the unit is still at zero 30 seconds later the cycle never began, and Bambuddy says so and names the two things that cause it (AMS power adapter not connected; printer not idle — P1S cannot dry mid-print, see `supports_drying_while_printing`). Unlike the `dry_sf_reason` guard this is model-agnostic: it catches any firmware that acks and declines. **Tests.** 4 component cases — the send toast, the stop toast, the warning firing when the AMS never leaves `dry_status: 0`, and no warning when it does enter a cycle. **Scope.** Frontend only; 3 new i18n keys across all 11 locales. No DB migration, no schema change, no new permission.
|
||||
- **Enabling authentication silently disconnected Bambu Cloud (#2530, reporter @hburn7)** — Cloud credentials live in two different places depending on auth state: `get_stored_token()` reads the global `Settings` rows (`bambu_cloud_token` / `_email` / `_region`) when auth is off, and `User.cloud_token` when it's on. Completing `POST /auth/setup` flipped which store the `/cloud/*` routes consult, but nothing carried the token across — so an operator who linked their Bambu account *before* turning on auth found the freshly-created admin had `cloud_token = NULL`. `build_authenticated_cloud()` then returned `None` and every cloud route degraded: `get_filament_info` skipped its cloud phase entirely and answered `200` from the local-preset and built-in-name fallbacks, while `/cloud/devices` began returning `401`. Nothing surfaced the disconnect. The reporter observed the *symptom inverted* — cloud `400` warnings vanished after enabling auth — and reasonably read that as a fix; in fact the warnings stopped because Bambuddy had stopped calling the cloud at all. The tell is in their own timestamps: the pre-auth request spent ~960 ms on cloud round-trips, the post-auth one answered immediately. **Fix.** `setup_auth()` now migrates a globally-stored token onto the owning admin (and deletes the global rows, so a live credential isn't left at rest in a table nothing reads), and `disable_auth()` performs the mirror hand-off back to global storage. Both refuse to guess when ownership is ambiguous: setup migrates only when it creates the admin or exactly one admin already exists — with several admins it leaves the credential in place and logs a warning rather than handing one admin another's Bambu session; disable declines to overwrite a pre-existing global token. The `region` survives both hops rather than silently resetting to `global`. **Note for existing installs.** The migration runs at the auth on/off transition, so instances that already crossed it must re-link their Bambu account once from Settings → Bambu Cloud; the stranded `bambu_cloud_*` rows in `settings` can then be deleted. **Tests.** 7 integration cases pinning both directions, the two refuse-to-guess paths, the `auth_enabled=false` no-op, and region preservation. **Scope.** No DB migration, no schema change, no new permission, no i18n change.
|
||||
- **Routine cloud preset misses no longer log at WARNING (#2530)** — The `Failed to get cloud preset … 400 {"message":"missing"}` lines that led to #2530 being filed are an *expected* answer, not a fault, and `get_filament_info`'s Phase 3 already resolves the name from local presets (a bare `GFL05` lands on "Overture Matte PLA" without the cloud). Two routine causes, both confirmed against the live Bambu catalog: many official presets are only addressable with a printer-variant suffix — `GFSA00` and `GFSL99` resolve bare, but `GFSL05` and `GFSG00` exist *only* as `GFSL05_07` (`@BBL A1`), `GFSG00_06` and so on, while the AMS reports the bare ID; and personal presets (`P…`, e.g. `Pb5b7d17`) belong to whichever Bambu account sliced the file, so no other account will ever resolve them. Emitting a WARNING per tray on every AMS tooltip refresh trains operators to ignore the log. `BambuCloudError` now carries the upstream `status_code`, and the preset lookup logs an HTTP 400 at DEBUG while leaving every other failure — expired token, 5xx, connection error — at WARNING, so a genuine fault is still loud. Deliberately *not* fixed here: resolving the variant suffix. The suffix selects a printer profile, and the endpoint returns `pressure_advance` (the K value), which is per-printer — picking a suffix arbitrarily would populate AMS tooltips with another printer's K value, which is worse than the current blank. Doing that correctly requires threading the tray's printer model into `get_filament_info`, which changes the endpoint contract. **Tests.** 4 parametrised cases drive the real route with a stubbed cloud and assert the 400 lands at DEBUG while 401 / 502 / transport failures stay at WARNING; verified by mutation (forcing the classification off fails the 400 case).
|
||||
|
||||
@@ -50,6 +50,7 @@ from backend.app.services.bambu_ftp import (
|
||||
)
|
||||
from backend.app.services.printer_diagnostic import run_connection_diagnostic
|
||||
from backend.app.services.printer_manager import (
|
||||
drying_screen_only,
|
||||
get_derived_status_name,
|
||||
printer_manager,
|
||||
resolve_plate_id,
|
||||
@@ -779,6 +780,7 @@ async def get_printer_status(
|
||||
awaiting_plate_clear=printer_manager.is_awaiting_plate_clear(printer_id),
|
||||
supports_drying=supports_drying(printer.model, state.firmware_version),
|
||||
supports_drying_while_printing=supports_drying_while_printing(printer.model, state.firmware_version),
|
||||
drying_screen_only=drying_screen_only(printer.model),
|
||||
supports_chamber_heater=supports_chamber_heater(printer.model),
|
||||
current_archive_id=current_archive_id,
|
||||
current_plate_id=current_plate_id,
|
||||
@@ -1756,6 +1758,11 @@ async def clear_mqtt_logs(
|
||||
# AMS Drying Endpoints
|
||||
# ============================================
|
||||
|
||||
# The P1 firmware acks `ams_filament_drying` with result: success and then ignores it
|
||||
# — Bambu's own P1 manual says drying "may only be controlled from the P1S screen"
|
||||
# (#2533). Refuse the command rather than let the caller believe it landed.
|
||||
_DRYING_SCREEN_ONLY_DETAIL = "This printer only supports AMS drying from its own screen"
|
||||
|
||||
|
||||
@router.post("/{printer_id}/drying/start")
|
||||
async def start_drying(
|
||||
@@ -1777,6 +1784,8 @@ async def start_drying(
|
||||
# Server-side guard: reject if this model/firmware doesn't support drying
|
||||
live_state = printer_manager.get_status(printer_id)
|
||||
firmware = live_state.firmware_version if live_state else None
|
||||
if drying_screen_only(printer.model):
|
||||
raise HTTPException(400, _DRYING_SCREEN_ONLY_DETAIL)
|
||||
if not supports_drying(printer.model, firmware):
|
||||
raise HTTPException(400, "Drying not supported for this printer model or firmware version")
|
||||
|
||||
@@ -1849,6 +1858,11 @@ async def stop_drying(
|
||||
if not printer:
|
||||
raise HTTPException(404, "Printer not found")
|
||||
|
||||
# Screen-only models ignore stop just as they ignore start — a cycle running on a
|
||||
# P1S was started at the printer and has to be ended there too (#2533).
|
||||
if drying_screen_only(printer.model):
|
||||
raise HTTPException(400, _DRYING_SCREEN_ONLY_DETAIL)
|
||||
|
||||
success = printer_manager.send_drying_command(printer_id, ams_id, temp=0, duration=0, mode=0)
|
||||
if not success:
|
||||
raise HTTPException(400, "Printer not connected")
|
||||
|
||||
@@ -365,6 +365,10 @@ class PrinterStatus(BaseModel):
|
||||
# AMS "Print While Drying" — drying mid-print. Verified per Bambu wiki release notes;
|
||||
# see _DRY_WHILE_PRINTING_MIN_FIRMWARE in printer_manager.py for the matrix.
|
||||
supports_drying_while_printing: bool = False
|
||||
# The AMS can dry, but only from the printer's own screen (P1 series, #2533).
|
||||
# supports_drying is False on these; the UI keeps the control visible but disabled
|
||||
# and says why, rather than dropping it without explanation.
|
||||
drying_screen_only: bool = False
|
||||
# Active chamber heater (responds to M141). True only for H2C/H2D/H2DPro/H2S/X2D.
|
||||
supports_chamber_heater: bool = False
|
||||
# Linked archive for the active print (resolved via subtask_id). Frontend uses
|
||||
|
||||
@@ -209,27 +209,48 @@ _DRYING_MIN_FIRMWARE: dict[str, str] = {
|
||||
"O1C2": "01.02.00.00", # H2C dual-nozzle SSDP model code
|
||||
"X1": "01.09.00.00",
|
||||
"X1C": "01.09.00.00",
|
||||
"P1P": "01.08.00.00",
|
||||
"P1S": "01.08.00.00",
|
||||
"P2S": "01.02.00.00",
|
||||
"N7": "01.02.00.00", # P2S internal model code
|
||||
}
|
||||
# Models that definitely don't support AMS drying (no AMS 2 Pro / AMS-HT compatibility)
|
||||
_DRYING_UNSUPPORTED_MODELS = frozenset({"A1", "A1MINI", "A1-MINI", "A1 MINI", "O1S", "N1", "N2S"})
|
||||
|
||||
# Models whose AMS can dry, but only from the printer's own touchscreen. Bambu's P1
|
||||
# manual is explicit: "P1S connected AMS drying functions may only be controlled from
|
||||
# the P1S screen." The firmware still answers `ams_filament_drying` with
|
||||
# result: success and then does nothing — the reporter of #2533 sent it three times
|
||||
# on an idle P1S with an AMS 2 Pro and the unit never left dry_status 0. Bambuddy
|
||||
# originally listed P1P/P1S here as fw-gated (01.08+, #292); that version is when P1
|
||||
# firmware gained AMS 2 Pro *support*, not remote drying, and it was never verified
|
||||
# against a live P1. Nothing we can send will start a cycle, so we don't offer to.
|
||||
_DRYING_SCREEN_ONLY_MODELS = frozenset({"P1P", "P1S"})
|
||||
|
||||
|
||||
def drying_screen_only(model: str | None) -> bool:
|
||||
"""True when the model's AMS dries only via the printer's own screen (#2533).
|
||||
|
||||
Distinct from "unsupported": these printers *can* dry, and Bambuddy still shows
|
||||
a cycle started on the printer. They just can't be commanded to start or stop
|
||||
one remotely, so the UI explains that instead of silently dropping the control.
|
||||
"""
|
||||
if not model:
|
||||
return False
|
||||
return model.strip().upper() in _DRYING_SCREEN_ONLY_MODELS
|
||||
|
||||
|
||||
def supports_drying(model: str | None, firmware: str | None) -> bool:
|
||||
"""Check if a printer model supports AMS drying commands.
|
||||
"""Check if a printer model accepts remote AMS drying commands.
|
||||
|
||||
Known models with confirmed min firmware get version-gated.
|
||||
Known unsupported models are blocked.
|
||||
Known unsupported models, and models that only dry from their own screen,
|
||||
are blocked.
|
||||
All other models (H2D Pro, X1E, future models) are allowed —
|
||||
the command fails gracefully with result: "fail" if unsupported.
|
||||
"""
|
||||
if not model:
|
||||
return False
|
||||
model_upper = model.strip().upper()
|
||||
if model_upper in _DRYING_UNSUPPORTED_MODELS:
|
||||
if model_upper in _DRYING_UNSUPPORTED_MODELS or model_upper in _DRYING_SCREEN_ONLY_MODELS:
|
||||
return False
|
||||
if model_upper in _DRYING_MIN_FIRMWARE:
|
||||
return bool(firmware and firmware >= _DRYING_MIN_FIRMWARE[model_upper])
|
||||
@@ -1263,6 +1284,7 @@ def printer_state_to_dict(
|
||||
# AMS drying support
|
||||
"supports_drying": supports_drying(model, state.firmware_version),
|
||||
"supports_drying_while_printing": supports_drying_while_printing(model, state.firmware_version),
|
||||
"drying_screen_only": drying_screen_only(model),
|
||||
# 1-indexed plate number parsed from gcode_file (e.g. /Metadata/plate_2.gcode).
|
||||
# Pushed via WebSocket so the printer card picks up plate transitions within
|
||||
# a multi-plate 3MF without waiting for the 30 s REST poll (#881 follow-up).
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""P1-series AMS drying is screen-only — the API must refuse it (#2533).
|
||||
|
||||
Bambu's P1 manual states that "P1S connected AMS drying functions may only be
|
||||
controlled from the P1S screen". The firmware still answers
|
||||
``ams_filament_drying`` with ``result: success`` and then ignores it, which is
|
||||
exactly what the reporter saw: three commands accepted on an idle P1S with an
|
||||
AMS 2 Pro, and the unit never left ``dry_status: 0``.
|
||||
|
||||
So a command we can't fulfil must be refused rather than acked, and that has to
|
||||
hold for stop as well as start — a cycle a P1S user started at the printer can
|
||||
only be ended there.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mqtt_send():
|
||||
"""Watch the MQTT command so we can assert nothing was published."""
|
||||
with patch(
|
||||
"backend.app.services.printer_manager.printer_manager.send_drying_command",
|
||||
new=MagicMock(return_value=True),
|
||||
) as m:
|
||||
yield m
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def live_state():
|
||||
"""A connected printer on firmware new enough that only the model gates drying."""
|
||||
state = MagicMock()
|
||||
state.firmware_version = "01.10.00.00"
|
||||
state.raw_data = {"ams": [{"id": 0, "module_type": "n3f", "tray": []}]}
|
||||
with patch(
|
||||
"backend.app.services.printer_manager.printer_manager.get_status",
|
||||
new=MagicMock(return_value=state),
|
||||
) as m:
|
||||
yield m
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.parametrize("model", ["P1S", "P1P"])
|
||||
@pytest.mark.parametrize("action", ["start", "stop"])
|
||||
async def test_screen_only_model_refuses_drying(
|
||||
async_client: AsyncClient, printer_factory, mqtt_send, live_state, model, action
|
||||
):
|
||||
printer = await printer_factory(model=model)
|
||||
|
||||
response = await async_client.post(f"/api/v1/printers/{printer.id}/drying/{action}?ams_id=0")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "screen" in response.json()["detail"].lower()
|
||||
# And nothing went out on the wire — an ack the printer would drop is worse
|
||||
# than a refusal, because it leaves the user believing drying is running.
|
||||
mqtt_send.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.parametrize("action", ["start", "stop"])
|
||||
async def test_commandable_model_still_dries(async_client: AsyncClient, printer_factory, mqtt_send, live_state, action):
|
||||
printer = await printer_factory(model="X1C")
|
||||
|
||||
response = await async_client.post(f"/api/v1/printers/{printer.id}/drying/{action}?ams_id=0")
|
||||
|
||||
assert response.status_code == 200
|
||||
mqtt_send.assert_called_once()
|
||||
@@ -10,6 +10,7 @@ import pytest
|
||||
|
||||
from backend.app.services.printer_manager import (
|
||||
PrinterManager,
|
||||
drying_screen_only,
|
||||
get_derived_status_name,
|
||||
has_stg_cur_idle_bug,
|
||||
init_printer_connections,
|
||||
@@ -1490,7 +1491,6 @@ class TestSupportsDrying:
|
||||
def test_known_supported_with_firmware(self):
|
||||
"""Verify known models with sufficient firmware return True."""
|
||||
assert supports_drying("X1C", "01.09.00.00") is True
|
||||
assert supports_drying("P1S", "01.08.00.00") is True
|
||||
assert supports_drying("H2D", "01.02.30.00") is True
|
||||
assert supports_drying("H2S", "01.02.00.00") is True
|
||||
assert supports_drying("H2C", "01.02.00.00") is True
|
||||
@@ -1502,7 +1502,6 @@ class TestSupportsDrying:
|
||||
def test_known_supported_old_firmware(self):
|
||||
"""Verify known models with old firmware return False."""
|
||||
assert supports_drying("X1C", "01.08.00.00") is False
|
||||
assert supports_drying("P1S", "01.07.00.00") is False
|
||||
assert supports_drying("H2S", "01.01.00.00") is False
|
||||
assert supports_drying("H2C", "01.01.99.99") is False
|
||||
assert supports_drying("O1C", "01.01.99.99") is False
|
||||
@@ -1545,6 +1544,32 @@ class TestSupportsDrying:
|
||||
assert supports_drying("a1", "99.99.99.99") is False
|
||||
|
||||
|
||||
class TestDryingScreenOnly:
|
||||
"""P1-series AMS drying is screen-only (#2533).
|
||||
|
||||
Bambu's P1 manual: "P1S connected AMS drying functions may only be controlled
|
||||
from the P1S screen." The firmware acks `ams_filament_drying` with
|
||||
result: success and then does nothing — so no command we send can ever start a
|
||||
cycle, whatever the firmware version.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize("model", ["P1S", "P1P", "p1s", " p1p "])
|
||||
def test_screen_only_models_reject_remote_drying(self, model):
|
||||
assert drying_screen_only(model) is True
|
||||
# Not firmware-gated: even the newest firmware won't take the command.
|
||||
assert supports_drying(model, "99.99.99.99") is False
|
||||
|
||||
@pytest.mark.parametrize("model", ["X1C", "P2S", "H2D", "A1", None])
|
||||
def test_other_models_are_not_screen_only(self, model):
|
||||
assert drying_screen_only(model) is False
|
||||
|
||||
def test_screen_only_is_not_the_same_as_unsupported(self):
|
||||
# The A1 has no drying-capable AMS at all; the P1S does, it just can't be
|
||||
# driven remotely. The UI needs to tell those two apart.
|
||||
assert drying_screen_only("A1") is False
|
||||
assert supports_drying("A1", "99.99.99.99") is False
|
||||
|
||||
|
||||
class TestSupportsDryingWhilePrinting:
|
||||
"""Tests for the supports_drying_while_printing gate (concurrent drying during print).
|
||||
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
* and Bambuddy treated the MQTT ack as proof the cycle had begun. These tests
|
||||
* cover the toast and the post-ack watcher that catches a printer which takes
|
||||
* the command and drops it.
|
||||
*
|
||||
* The reporter's own printer is now handled further up: Bambu's P1 manual says
|
||||
* P1-series AMS drying is screen-only, so the card no longer offers to command
|
||||
* it (last describe block). The watcher stays for any other firmware that acks
|
||||
* and declines — on the models we *can* command, the ack is still all we have,
|
||||
* because the `dry_sf_reason` refusal array only exists on some of them.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
@@ -23,11 +29,11 @@ vi.mock('../../contexts/ToastContext', async (importOriginal) => {
|
||||
|
||||
const mockPrinter = {
|
||||
id: 1,
|
||||
name: 'P1S',
|
||||
name: 'X1C',
|
||||
ip_address: '192.168.1.100',
|
||||
serial_number: '01P00A000000001',
|
||||
access_code: '12345678',
|
||||
model: 'P1S',
|
||||
model: 'X1C',
|
||||
enabled: true,
|
||||
nozzle_diameter: 0.4,
|
||||
nozzle_type: 'stainless_steel',
|
||||
@@ -55,8 +61,11 @@ const baseTray = {
|
||||
state: 3,
|
||||
};
|
||||
|
||||
/** AMS 2 Pro (n3f) on an idle printer — the reporter's hardware. */
|
||||
function makeStatus(dry: { dry_time: number; dry_status: number }) {
|
||||
/** AMS 2 Pro (n3f) on an idle printer that accepts remote drying commands. */
|
||||
function makeStatus(
|
||||
dry: { dry_time: number; dry_status: number },
|
||||
caps: { supports_drying?: boolean; drying_screen_only?: boolean } = {},
|
||||
) {
|
||||
return {
|
||||
connected: true,
|
||||
state: 'IDLE',
|
||||
@@ -68,7 +77,8 @@ function makeStatus(dry: { dry_time: number; dry_status: number }) {
|
||||
filename: null,
|
||||
wifi_signal: -29,
|
||||
speed_level: 2,
|
||||
supports_drying: true,
|
||||
supports_drying: caps.supports_drying ?? true,
|
||||
drying_screen_only: caps.drying_screen_only ?? false,
|
||||
vt_tray: [],
|
||||
ams: [
|
||||
{
|
||||
@@ -96,6 +106,19 @@ function makeStatus(dry: { dry_time: number; dry_status: number }) {
|
||||
const IDLE = makeStatus({ dry_time: 0, dry_status: 0 });
|
||||
const DRYING = makeStatus({ dry_time: 720, dry_status: 2 });
|
||||
|
||||
/** A P1: the AMS dries, but only from the printer's own screen. */
|
||||
const SCREEN_ONLY = makeStatus(
|
||||
{ dry_time: 0, dry_status: 0 },
|
||||
{ supports_drying: false, drying_screen_only: true },
|
||||
);
|
||||
const SCREEN_ONLY_DRYING = makeStatus(
|
||||
{ dry_time: 720, dry_status: 2 },
|
||||
{ supports_drying: false, drying_screen_only: true },
|
||||
);
|
||||
|
||||
const SCREEN_ONLY_TITLE =
|
||||
"AMS drying on this printer can only be controlled from the printer's own screen (Bambu limitation)";
|
||||
|
||||
/**
|
||||
* Open the drying popover and press Start. The card renders the AMS in two
|
||||
* layouts, so the flame icon appears more than once — either opens the same
|
||||
@@ -205,3 +228,39 @@ describe('PrintersPage - AMS drying feedback (#2533)', () => {
|
||||
expect(mockShowToast).not.toHaveBeenCalledWith(expect.stringContaining('never started drying'), 'error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PrintersPage - screen-only AMS drying (#2533)', () => {
|
||||
beforeEach(() => {
|
||||
mockShowToast.mockClear();
|
||||
server.use(
|
||||
http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])),
|
||||
http.get('/api/v1/queue/', () => HttpResponse.json([])),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the drying control visible but disabled, and says why', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(SCREEN_ONLY)));
|
||||
|
||||
render(<PrintersPage />);
|
||||
|
||||
// Present, so the user learns the AMS *can* dry and where to do it — silently
|
||||
// dropping the button would just look like the feature vanished.
|
||||
const buttons = await screen.findAllByTitle(SCREEN_ONLY_TITLE);
|
||||
expect(buttons[0]).toBeDisabled();
|
||||
|
||||
await user.click(buttons[0]);
|
||||
expect(screen.queryByTestId('drying-start-confirm')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a cycle started at the printer, without offering to stop it', async () => {
|
||||
server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(SCREEN_ONLY_DRYING)));
|
||||
|
||||
render(<PrintersPage />);
|
||||
|
||||
// The countdown is pure observation and still works.
|
||||
expect((await screen.findAllByText(/12h 0m/)).length).toBeGreaterThan(0);
|
||||
// Stop is a command, and a P1 ignores it exactly as it ignores start.
|
||||
expect(screen.queryByTitle('Stop Drying')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -526,6 +526,8 @@ export interface PrinterStatus {
|
||||
awaiting_plate_clear: boolean;
|
||||
// AMS drying support
|
||||
supports_drying: boolean;
|
||||
// The AMS can dry, but only from the printer's own screen (P1 series, #2533).
|
||||
drying_screen_only?: boolean;
|
||||
// Active chamber heater (responds to M141). True only for H2C/H2D/H2DPro/H2S/X2D.
|
||||
supports_chamber_heater?: boolean;
|
||||
}
|
||||
|
||||
@@ -572,6 +572,7 @@ export default {
|
||||
toastCommandSent: 'Trocknungsbefehl gesendet',
|
||||
toastStopped: 'Trocknung gestoppt',
|
||||
toastNotStarted: 'Der Drucker hat den Befehl angenommen, aber das AMS hat die Trocknung nicht gestartet. Prüfe, ob das AMS-Netzteil angeschlossen ist und der Drucker im Leerlauf ist.',
|
||||
screenOnly: 'Die AMS-Trocknung kann bei diesem Drucker nur am Bildschirm des Druckers selbst gesteuert werden (Einschränkung von Bambu)',
|
||||
stoppingDrying: 'Trocknung wird gestoppt...',
|
||||
rotateTray: 'Spule während der Trocknung drehen',
|
||||
rotateUnavailableReason: 'Nicht verfügbar — in diesem AMS ist ein Slot zum Druckkopf hin geladen. Die Spule ist durch den Zuführschlauch blockiert und kann nicht rotieren. Filament zuerst zurückziehen.',
|
||||
|
||||
@@ -575,6 +575,7 @@ export default {
|
||||
toastCommandSent: 'Drying command sent',
|
||||
toastStopped: 'Drying stopped',
|
||||
toastNotStarted: 'The printer accepted the command but the AMS never started drying. Check that the AMS power adapter is connected and that the printer is idle.',
|
||||
screenOnly: 'AMS drying on this printer can only be controlled from the printer\'s own screen (Bambu limitation)',
|
||||
stoppingDrying: 'Stopping drying...',
|
||||
rotateTray: 'Rotate spool during drying',
|
||||
rotateUnavailableReason: 'Unavailable — a slot in this AMS is loaded to the toolhead. The spool is locked by the feed tube and cannot rotate. Retract the filament first.',
|
||||
|
||||
@@ -572,6 +572,7 @@ export default {
|
||||
toastCommandSent: 'Comando de secado enviado',
|
||||
toastStopped: 'Secado detenido',
|
||||
toastNotStarted: 'La impresora aceptó el comando, pero el AMS no inició el secado. Comprueba que el adaptador de corriente del AMS esté conectado y que la impresora esté inactiva.',
|
||||
screenOnly: 'En esta impresora, el secado del AMS solo se puede controlar desde la pantalla de la propia impresora (limitación de Bambu)',
|
||||
stoppingDrying: 'Deteniendo el secado...',
|
||||
rotateTray: 'Girar la bobina durante el secado',
|
||||
rotateUnavailableReason: 'No disponible — un slot de este AMS está cargado hacia el cabezal. La bobina está bloqueada por el tubo de alimentación y no puede girar. Retira el filamento primero.',
|
||||
|
||||
@@ -572,6 +572,7 @@ export default {
|
||||
toastCommandSent: 'Commande de séchage envoyée',
|
||||
toastStopped: 'Séchage arrêté',
|
||||
toastNotStarted: 'L\'imprimante a accepté la commande, mais l\'AMS n\'a pas démarré le séchage. Vérifiez que l\'adaptateur secteur de l\'AMS est branché et que l\'imprimante est inactive.',
|
||||
screenOnly: 'Sur cette imprimante, le séchage de l\'AMS ne peut être commandé que depuis l\'écran de l\'imprimante (limitation de Bambu)',
|
||||
stoppingDrying: 'Arrêt du séchage...',
|
||||
rotateTray: 'Tourner la bobine pendant le séchage',
|
||||
rotateUnavailableReason: 'Indisponible — un emplacement de cet AMS est chargé vers la tête d\'impression. La bobine est bloquée par le tube d\'alimentation et ne peut pas tourner. Rétractez d\'abord le filament.',
|
||||
|
||||
@@ -572,6 +572,7 @@ export default {
|
||||
toastCommandSent: 'Comando di essiccazione inviato',
|
||||
toastStopped: 'Essiccazione interrotta',
|
||||
toastNotStarted: 'La stampante ha accettato il comando, ma l\'AMS non ha avviato l\'essiccazione. Verifica che l\'alimentatore dell\'AMS sia collegato e che la stampante sia inattiva.',
|
||||
screenOnly: 'Su questa stampante l\'essiccazione dell\'AMS può essere comandata solo dallo schermo della stampante (limitazione di Bambu)',
|
||||
stoppingDrying: 'Arresto essiccazione...',
|
||||
rotateTray: 'Ruota la bobina durante l\'essiccazione',
|
||||
rotateUnavailableReason: 'Non disponibile — uno slot di questo AMS è caricato verso la testa di stampa. La bobina è bloccata dal tubo di alimentazione e non può ruotare. Ritrai prima il filamento.',
|
||||
|
||||
@@ -571,6 +571,7 @@ export default {
|
||||
toastCommandSent: '乾燥コマンドを送信しました',
|
||||
toastStopped: '乾燥を停止しました',
|
||||
toastNotStarted: 'プリンターはコマンドを受け付けましたが、AMS は乾燥を開始しませんでした。AMS の電源アダプターが接続されているか、プリンターがアイドル状態かを確認してください。',
|
||||
screenOnly: 'このプリンターでは、AMS の乾燥はプリンター本体の画面からのみ操作できます (Bambu の仕様上の制限)',
|
||||
stoppingDrying: '乾燥を停止しています...',
|
||||
rotateTray: '乾燥中にスプールを回転',
|
||||
rotateUnavailableReason: '利用不可 — このAMSのスロットがツールヘッドにロードされています。スプールが供給チューブで固定されているため回転できません。先にフィラメントを引き戻してください。',
|
||||
|
||||
@@ -535,6 +535,7 @@ export default {
|
||||
toastCommandSent: '건조 명령을 전송했습니다',
|
||||
toastStopped: '건조를 중지했습니다',
|
||||
toastNotStarted: '프린터가 명령을 수락했지만 AMS가 건조를 시작하지 않았습니다. AMS 전원 어댑터가 연결되어 있는지, 프린터가 대기 상태인지 확인하십시오.',
|
||||
screenOnly: '이 프린터에서는 AMS 건조를 프린터 자체 화면에서만 제어할 수 있습니다 (Bambu 제한 사항)',
|
||||
stoppingDrying: '건조 정지 중...',
|
||||
rotateTray: '건조 중 스풀 회전',
|
||||
rotateUnavailableReason: '사용할 수 없음 — 이 AMS의 슬롯이 툴헤드로 로드되어 있습니다. 스풀이 공급 튜브에 의해 고정되어 회전할 수 없습니다. 먼저 필라멘트를 뺀 후 다시 시도하십시오.'
|
||||
|
||||
@@ -572,6 +572,7 @@ export default {
|
||||
toastCommandSent: 'Comando de secagem enviado',
|
||||
toastStopped: 'Secagem interrompida',
|
||||
toastNotStarted: 'A impressora aceitou o comando, mas o AMS não iniciou a secagem. Verifique se o adaptador de energia do AMS está conectado e se a impressora está ociosa.',
|
||||
screenOnly: 'Nesta impressora, a secagem do AMS só pode ser controlada pela tela da própria impressora (limitação da Bambu)',
|
||||
stoppingDrying: 'Parando secagem...',
|
||||
rotateTray: 'Girar o carretel durante a secagem',
|
||||
rotateUnavailableReason: 'Indisponível — um slot deste AMS está carregado em direção ao cabeçote. O carretel está travado pelo tubo de alimentação e não pode girar. Retraia o filamento primeiro.',
|
||||
|
||||
@@ -572,6 +572,7 @@ export default {
|
||||
toastCommandSent: 'Kurutma komutu gönderildi',
|
||||
toastStopped: 'Kurutma durduruldu',
|
||||
toastNotStarted: 'Yazıcı komutu kabul etti ancak AMS kurutmayı başlatmadı. AMS güç adaptörünün bağlı olduğundan ve yazıcının boşta olduğundan emin olun.',
|
||||
screenOnly: 'Bu yazıcıda AMS kurutma yalnızca yazıcının kendi ekranından kontrol edilebilir (Bambu kısıtlaması)',
|
||||
stoppingDrying: 'Kurutma durduruluyor...',
|
||||
rotateTray: 'Kurutma sırasında makarayı döndür',
|
||||
rotateUnavailableReason: 'Kullanılamaz — bu AMS\'nin bir yuvası kafaya doğru yüklenmiş durumda. Makara besleme borusu tarafından kilitlendiği için döndürülemez. Önce filamenti geri çekin.',
|
||||
|
||||
@@ -572,6 +572,7 @@ export default {
|
||||
toastCommandSent: '已发送干燥命令',
|
||||
toastStopped: '已停止干燥',
|
||||
toastNotStarted: '打印机已接受命令,但 AMS 未开始干燥。请检查 AMS 电源适配器是否已连接,以及打印机是否处于空闲状态。',
|
||||
screenOnly: '此打印机的 AMS 干燥只能在打印机自带的屏幕上操作(Bambu 的限制)',
|
||||
stoppingDrying: '正在停止干燥...',
|
||||
rotateTray: '干燥时旋转料盘',
|
||||
rotateUnavailableReason: '不可用 — 此 AMS 中有插槽已装入打印头。料盘被送料管固定,无法旋转。请先回退耗材。',
|
||||
|
||||
@@ -572,6 +572,7 @@ export default {
|
||||
toastCommandSent: '已傳送乾燥命令',
|
||||
toastStopped: '已停止乾燥',
|
||||
toastNotStarted: '印表機已接受命令,但 AMS 未開始乾燥。請檢查 AMS 電源變壓器是否已連接,以及印表機是否處於閒置狀態。',
|
||||
screenOnly: '此印表機的 AMS 乾燥只能在印表機本身的螢幕上操作(Bambu 的限制)',
|
||||
stoppingDrying: '正在停止乾燥...',
|
||||
rotateTray: '乾燥時旋轉料盤',
|
||||
rotateUnavailableReason: '無法使用 — 此 AMS 中有插槽已裝入列印頭。料盤被進料管固定,無法旋轉。請先退回耗材。',
|
||||
|
||||
@@ -4549,10 +4549,12 @@ function PrinterCard({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Drying button — only for AMS 2 Pro (n3f) and AMS-HT (n3s) */}
|
||||
{status.supports_drying && (ams.module_type === 'n3f' || ams.module_type === 'n3s') && hasPermission('printers:control') && (
|
||||
{/* Drying button — only for AMS 2 Pro (n3f) and AMS-HT (n3s).
|
||||
Screen-only models (P1 series) keep the control but can't
|
||||
be commanded: it stays disabled and says why (#2533). */}
|
||||
{(status.supports_drying || status.drying_screen_only) && (ams.module_type === 'n3f' || ams.module_type === 'n3s') && hasPermission('printers:control') && (
|
||||
<button
|
||||
disabled={!!(ams.dry_sf_reason?.length && ams.dry_time === 0)}
|
||||
disabled={status.drying_screen_only || !!(ams.dry_sf_reason?.length && ams.dry_time === 0)}
|
||||
onClick={(e) => {
|
||||
if (ams.dry_time > 0) {
|
||||
stopDryingMutation.mutate(ams.id);
|
||||
@@ -4576,11 +4578,11 @@ function PrinterCard({
|
||||
className={`ml-1 flex items-center gap-0.5 px-1 py-0.5 rounded text-[9px] transition-colors ${
|
||||
ams.dry_time > 0
|
||||
? 'bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400'
|
||||
: ams.dry_sf_reason?.length
|
||||
: status.drying_screen_only || ams.dry_sf_reason?.length
|
||||
? 'bg-bambu-dark text-bambu-gray/50 cursor-not-allowed'
|
||||
: 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark/80'
|
||||
}`}
|
||||
title={ams.dry_time > 0 ? t('printers.drying.stop') : ams.dry_sf_reason?.length ? t('printers.drying.powerRequired') : t('printers.drying.start')}
|
||||
title={status.drying_screen_only ? t('printers.drying.screenOnly') : ams.dry_time > 0 ? t('printers.drying.stop') : ams.dry_sf_reason?.length ? t('printers.drying.powerRequired') : t('printers.drying.start')}
|
||||
>
|
||||
<Flame className="w-3 h-3" />
|
||||
</button>
|
||||
@@ -4605,14 +4607,18 @@ function PrinterCard({
|
||||
: `${ams.dry_time}m`
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => stopDryingMutation.mutate(ams.id)}
|
||||
disabled={stopDryingMutation.isPending}
|
||||
className="ml-auto text-amber-700 dark:text-amber-400 hover:text-amber-900 dark:hover:text-amber-300 transition-colors disabled:opacity-50"
|
||||
title={t('printers.drying.stop')}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
{/* A cycle on a screen-only model was started at the printer
|
||||
and can only be stopped there (#2533). */}
|
||||
{!status.drying_screen_only && (
|
||||
<button
|
||||
onClick={() => stopDryingMutation.mutate(ams.id)}
|
||||
disabled={stopDryingMutation.isPending}
|
||||
className="ml-auto text-amber-700 dark:text-amber-400 hover:text-amber-900 dark:hover:text-amber-300 transition-colors disabled:opacity-50"
|
||||
title={t('printers.drying.stop')}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Slots grid: 4 columns - always render 4 slots */}
|
||||
@@ -5036,9 +5042,10 @@ function PrinterCard({
|
||||
)}
|
||||
</div>
|
||||
{/* Drying button for HT AMS */}
|
||||
{status.supports_drying && (ams.module_type === 'n3f' || ams.module_type === 'n3s') && hasPermission('printers:control') && (
|
||||
{(status.supports_drying || status.drying_screen_only) && (ams.module_type === 'n3f' || ams.module_type === 'n3s') && hasPermission('printers:control') && (
|
||||
<div className="relative ml-auto">
|
||||
<button
|
||||
disabled={status.drying_screen_only}
|
||||
onClick={(e) => {
|
||||
if (ams.dry_time > 0) {
|
||||
stopDryingMutation.mutate(ams.id);
|
||||
@@ -5062,9 +5069,11 @@ function PrinterCard({
|
||||
className={`flex items-center gap-0.5 px-1 py-0.5 rounded text-[9px] transition-colors ${
|
||||
ams.dry_time > 0
|
||||
? 'bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400'
|
||||
: 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark/80'
|
||||
: status.drying_screen_only
|
||||
? 'bg-bambu-dark text-bambu-gray/50 cursor-not-allowed'
|
||||
: 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark/80'
|
||||
}`}
|
||||
title={ams.dry_time > 0 ? t('printers.drying.stop') : t('printers.drying.start')}
|
||||
title={status.drying_screen_only ? t('printers.drying.screenOnly') : ams.dry_time > 0 ? t('printers.drying.stop') : t('printers.drying.start')}
|
||||
>
|
||||
<Flame className="w-3 h-3" />
|
||||
</button>
|
||||
@@ -5085,14 +5094,16 @@ function PrinterCard({
|
||||
? `${Math.floor(ams.dry_time / 60)}h ${ams.dry_time % 60}m`
|
||||
: `${ams.dry_time}m`}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => stopDryingMutation.mutate(ams.id)}
|
||||
disabled={stopDryingMutation.isPending}
|
||||
className="ml-auto text-amber-700 dark:text-amber-400 hover:text-amber-900 dark:hover:text-amber-300 transition-colors disabled:opacity-50 shrink-0"
|
||||
title={t('printers.drying.stop')}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
{!status.drying_screen_only && (
|
||||
<button
|
||||
onClick={() => stopDryingMutation.mutate(ams.id)}
|
||||
disabled={stopDryingMutation.isPending}
|
||||
className="ml-auto text-amber-700 dark:text-amber-400 hover:text-amber-900 dark:hover:text-amber-300 transition-colors disabled:opacity-50 shrink-0"
|
||||
title={t('printers.drying.stop')}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Row 2: Slot (left) + Stats (right stacked) */}
|
||||
|
||||
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-mGOsF6O8.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-D8XeZXKl.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-blSspT6K.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user