mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
feat(ams): name the expected slot when a paused print hits an AMS runout (#2587)
The firmware's runout HMS text says "insert into the same AMS slot", which is wrong under AMS Filament Backup: the firmware won't re-accept the depleted slot and advances to the next compatible one. Bambuddy parsed print.ams.tray_now only and dropped tray_tar/tray_pre, so the expected slot never reached the UI. Capture tray_tar/tray_pre on PrinterState and, while paused, resolve them to global tray IDs (expected_tray/previous_tray) on both the REST and WebSocket status payloads via a shared resolver: single-AMS passthrough, multi-AMS snow-mapping resolution, AMS-HT/external passthrough, and an honest null when the slot can't be placed. The AMS graphic highlights the expected slot (amber) and the ran-out slot (red); the HMS modal re-describes runout codes to name both, falling back to "check the printer" when unresolved. Runout copy translated in all 11 locales. Reporter @Jostxxl confirmed tray_pre=1/tray_tar=2 during the pause (ran out in Slot 2, printer expected Slot 3).
This commit is contained in:
@@ -49,6 +49,7 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **Energy Summary stuck at zero for Yesterday and Total on REST smart plugs — and the Statistics energy figure with it (#2539, reporter @R3play210)** — A Shelly Plug S Gen3 wired up over the REST integration showed live power and a Today figure that climbed, but Yesterday and Total never moved off zero, through five days of printing. **The bug.** `RESTSmartPlugService.get_energy()` returned a dict with two keys, `power` and `today`. It never set `yesterday` or `total` at all, so `SmartPlugEnergy` defaulted them to null and the summary card summed nothing. Tasmota returns all three; Home Assistant returns two; REST returned one. **The number that looked right was also wrong.** A Shelly has no notion of "today" — its only energy figure is `aenergy.total`, a lifetime counter in watt-hours that climbs forever and never resets. Bambuddy had a single energy field, so the reporter put the lifetime counter in it, and line 230 filed it under `today`. It *looked* correct because it grows; it just never dropped back to zero at midnight. The one figure he trusted was the least trustworthy of the four. **It broke more than the card.** With `total` never populated, the hourly snapshot recorder skipped the plug outright (its own comment said so: *"REST plugs that only expose today can't be used for cumulative snapshots"*), `_sum_live_plug_totals()` summed zero, and since the reporter's `energy_tracking_mode` is `total`, the **Statistics page's energy figure was zero too** — he simply hadn't got to it yet. **The fix.** A REST plug now says which counter it has: `rest_energy_path` still means "energy used today", and a new `rest_energy_total_path` means "lifetime counter that never resets". A Shelly has only the latter; a Tasmota behind a REST bridge has both; both are read from one HTTP fetch when they share a URL. Then, because the snapshot table already records that lifetime counter hourly, **Today and Yesterday are derived from it**: today = the counter now minus its value at the last local midnight, yesterday = that midnight's value minus the one before. So a Shelly gets all four numbers with no new device capability — and Home Assistant's permanently-null Yesterday is fixed for free. Today appears after the first midnight the install lives through, Yesterday after the second; a counter that goes backwards (factory reset zeroes `aenergy.total`) reports nothing rather than a negative. **Local midnight, not UTC.** With `TZ=Europe/Berlin` a UTC boundary would roll Today over at 02:00 wall-clock. The snapshot loop now ticks on the *local* hour instead of every 3600s from boot, so a reading lands exactly on the day boundary — including in the half-hour-offset zones (India, Nepal) where local midnight isn't on a UTC hour at all. Previously the last snapshot before midnight could be up to an hour early, and an hour of a printer's draw is real watt-hours to lose off the day. **Collateral: the whole smart-plug subsystem was broken on Postgres.** Every `DateTime` column in the smart-plug tables is naive and holds UTC, but the code wrote *aware* datetimes into them. SQLite tolerates that — its bind processor reads the fields and drops the offset — which is why it went unnoticed. asyncpg does not: it raises `DataError: invalid input for query argument`. So on Postgres every energy-snapshot capture raised (silently, inside the loop's `except`), leaving the snapshot table empty and the date-filtered energy stat permanently zero, and every plug status poll raised on `last_checked`. Postgres is what Bambuddy recommends for multi-printer installs, so this was not a corner. All smart-plug timestamps are now naive UTC via a shared `utcnow_naive()` / `to_naive_utc()`, and the snapshot-delta query normalises its bounds the same way. **Tests.** 8 cases on the derivation (today and yesterday from the counter; yesterday absent until two midnights have passed; nothing derivable before the first; a counter reset reports nothing rather than a negative; another plug's snapshots are not borrowed; a device-reported figure is never overwritten by our arithmetic). 4 on the REST driver, using the reporter's own `Switch.GetStatus` payload (the lifetime counter lands in `total` and *not* in `today`; a plug reporting both keeps them apart; a total path alone is enough to read energy at all; both counters share one HTTP fetch). 4 more pin the Postgres-unsafe datetime — mutation-verified: reintroducing the aware timestamp fails the guard. Migration applied and re-applied against a real Postgres to confirm it is idempotent and defaults to NULL. **Existing REST users:** if your Energy JSON Path points at a cumulative counter (anything from a Shelly does), move it to the new **Energy JSON Path (lifetime)** field — the form and the wiki now say which field wants which counter.
|
||||
|
||||
### Added
|
||||
- **A paused AMS runout now names the physical slot the printer is actually waiting for (#2587, reporter @Jostxxl)** — When a spool runs out mid-print, Bambuddy showed the firmware's generic HMS text — "insert a new filament into the same AMS slot" — which is exactly wrong when AMS Filament Backup is on: the firmware won't re-accept the depleted slot and advances to the next compatible one, so "the same slot" sends the operator to the wrong place. On the reporter's farm this meant reinserting into Slot 2 (where it ran out) did nothing, and the print only resumed after moving the spool to Slot 3 — with no on-screen hint that Slot 3 was what the printer wanted. **Root cause.** The printer's AMS payload carries `tray_tar` (the slot the paused print now expects) and `tray_pre` (the slot that ran out) right next to `tray_now`, but Bambuddy parsed `tray_now` only and dropped the other two at ingest, so "which slot does the print expect" never reached the API or the UI. **What changed.** `tray_tar`/`tray_pre` are now captured on printer state and, while the print is **paused**, resolved to global tray IDs and surfaced on the status payload (both the REST poll and the live WebSocket push) as `expected_tray` / `previous_tray`. The AMS graphic highlights the expected slot with a pulsing amber ring (and a down-arrow badge) and marks the ran-out slot in red, and the HMS error is re-described to name them directly — e.g. "Filament ran out in AMS-A · Slot 2. The printer is now waiting for compatible filament in AMS-A · Slot 3. Insert a spool into AMS-A · Slot 3, then select Retry." **Honest when it can't tell.** On a single regular AMS the reported slot is already the global ID; on multi-AMS it's a local slot that's resolved against the print's snow-encoded mapping field, and AMS-HT IDs (128–135) pass through. When the slot can't be resolved unambiguously (multi-AMS with no usable mapping), the graphic highlights nothing and the message says so — "Bambuddy could not determine which slot the printer now expects — check the printer screen" — rather than pointing at a guess. User AMS friendly-names are honored in the labels. **Scope.** Guidance is populated only while paused, so a healthy print's normal target churn never highlights a slot or spams the log. Backend resolver, the ingest parse, and the modal re-description are covered by new unit/component tests; the runout copy is translated in all 11 locales.
|
||||
- **The sponsor surfaces now ask a print farm a different question than they ask a hobbyist** — Since the in-app sponsor banner and milestone toast shipped in v0.2.4.8, both have made exactly one ask, to everyone: chip in a few dollars to keep Bambuddy independent. That ask works — new sponsorships went from 0.40/day to 1.40/day in the fifteen days after the release, and clicks through to GitHub Sponsors rose 4.3x on a *falling* web traffic base. But it is the wrong ask for part of the audience. Someone running twelve printers as a business does not want to donate $5; they want a support contract, an invoice, and somebody accountable when the line stops. They were being shown a donation button and, unsurprisingly, ignoring it. **What changed.** At **5 or more configured printers** the Settings → General banner and the milestone toast both make the commercial ask instead — priority support, commercial licensing, invoicing — and link to the new **bambuddy.cool/business.html** rather than the sponsor tiers. Below that, nothing changes at all. It is the same single interruption either way: same milestones, same 14-day cooldown, same one-toast-per-session guard. Only the ask changes, so nobody sees more nagging than before. **Configured printers, not active ones.** The count deliberately ignores `is_active`, which is the maintenance-mode flag rather than a fleet-size signal. A farm with eight machines and five of them on the bench for nozzle swaps is still a farm — filtering on `is_active` would have counted three, downgraded them to the hobbyist pitch, and done it precisely when they were having the worst day. **The page concedes the licence up front.** business.html opens by stating plainly that Bambuddy is AGPL-3.0, that running it inside your own business costs nothing, and that no licence is required no matter how many printers you have — because that is true, and a page that implied otherwise would be a lie the audience would catch immediately. What it then offers is the set of things a licence cannot give you: priority support with a named contact and agreed response times, commercial licensing for the narrow case where you actually need it (redistribution, OEM, shipping Bambuddy on an appliance), fleet deployment and custom development, and operator training. No price list — those conversations are scoped individually. **Attribution is preserved.** Both surfaces keep their existing Matomo `?from=` tags (`app-settings`, `app-toast-{milestone}`), so the business funnel is measurable from day one on the same dashboards as the personal one, and the split between the two is visible without any new instrumentation. **No telemetry was added**, and none is needed: fleet size is read from the printers list the app already has cached. **Scope.** Frontend only — no backend change, no schema change, no migration, no new permission, no new setting. The audience split is one shared helper (`utils/fleetAudience.ts`) so the threshold lives in exactly one place. **Tests.** 7 new cases: the boundary in both directions (4 printers → personal, 5 → business); the maintenance-mode trap (8 printers with 5 inactive still reads as business); the cold-cache race (the toast waits for the fleet to load rather than defaulting to zero printers and pitching a farm as a hobbyist); both banner variants including the assertion that the commercial copy *replaces* the donation copy rather than sitting beside it; and the `?from=` tag surviving on both paths. All 7 mutation-verified — forcing the threshold out of reach, or dropping the fleet-load gate, fails them. **i18n.** 4 new keys (`sponsors.toastBusiness`, `businessCta`, `businessTitle`, `businessTagline`) translated in all 11 locales; parity 5616 keys.
|
||||
- **Cam Wall on its own URL, and on a TV that isn't logged in (#2531, reporter @cadtoolbox)** — The Cam Wall was reachable exactly one way: click the Cam wall button on the Printers page. It had no URL, so you couldn't bookmark it, link to it, or point a wall-mounted screen at it. It now lives at **`/camwall`**, and a button next to the Cards / Cam wall toggle opens it there. Signed in, that page is the same wall you already know — tiles clickable, settings popover working, the knobs shared with the Printers page through the same localStorage keys, so a change in one follows you to the other. **The TV case is the hard half.** A screen in a workshop has no login session, and a wall tile needs two things a camera token could not previously fetch: the list of printers, and each one's status for the state badge. Both sit behind `PRINTERS_READ`, so a kiosk got a 401 and an empty wall. The obvious fix — let the existing `camera_stream` token through to `GET /printers` — is the wrong one: that response carries every printer's `serial_number` and `ip_address` even in its non-secret shape, and a URL pinned to a lobby TV lives in the browser history, in the kiosk's config file, and on the screen itself. So the Cam Wall gets a **purpose-built read-only feed** at `GET /api/v1/camwall/printers` that serves only what a tile draws: id, name, camera rotation, connected, state, progress, layers, remaining time, HMS codes. No serial. No IP. No access code. **And no filename** — a token wall renders the compact overlay, so the field simply isn't served rather than being served and then hidden client-side; the part on the bed is never named to a room anyone can walk into. **A second scope, not a wider one.** The feed is gated on a new `camwall` token scope alongside `camera_stream`. A Cam Wall token reaches the video *and* the tile metadata; a camera-stream token reaches the video and is refused by the feed. That matters because `camera_stream` tokens are already in the wild, minted by people who agreed to hand out a picture — shipping this must not retroactively grant them the ability to enumerate a fleet by name. Pick the scope when you create the token in **Settings → API Keys → Camera API Tokens**; the create dialog then hands you the finished kiosk URL, fully assembled, so nobody has to build it from the docs. **What a token wall gives up.** No settings popover and no click-through: a TV has nobody standing at it, and click-through would open a page the token cannot authenticate. The controls are not merely hidden — they aren't rendered, so a kiosk carries no focusable control it cannot act on. The overlay is capped at `compact` even if the URL asks for `full`. The screen can still be tuned from the URL: `?maxLive=9&interval=10&status=compact`, all clamped to the same ranges the popover enforces. **Statuses are polled, not pushed** — the page renders outside the app layout and its WebSocket provider, and a kiosk token cannot mint a WS ticket anyway; a wall is watched, not operated, so a 5-second cadence costs nothing. **Revoking the token cuts the display off** on its next request. **Tests.** 11 backend cases: no token / garbage token / revoked token all rejected; a `camera_stream` token refused by the feed (the assertion the separate scope exists for); a `camwall` token accepted; the payload's key set pinned so a future field can't quietly add a serial, an IP or a filename; a Cam Wall token passes the camera-stream gate so its own tiles fill; a camera-stream token still passes its own gate (regression guard on #1108); and the scope allowlist pinned so adding a third scope has to be a deliberate act. 7 frontend cases covering the kiosk feed being called with the URL token, the token reaching the `<img>` URLs, no settings popover, inert tiles, `?status=full` refused, the expired-token message, and — the negative — a tokenless visit never touching the kiosk endpoint. **Scope.** New endpoint, new token scope, new route. No DB migration, no new permission, no change to the in-page wall.
|
||||
- **Live print progress for Virtual Printers in Bambu Studio / OrcaSlicer (#1887, reporter @YozenPL)** — Connect the slicer to a server-mode VP with a target printer bound and the Device tab shows the printer's AMS, temperatures and camera, but the print itself reads as a name and nothing else: no stage, no percentage, no layer count, no time remaining. The data was never missing — the bridge has the target's real `push_status` cached, `mc_percent` and all — Bambuddy was deliberately overwriting it with zeros. **Why it was zeroed.** #1558, the exact inverse complaint: a queue-mode VP that passed the live values through was read by Bambu Studio as *busy*, and the Send button went away for as long as the printer printed, which defeats the entire purpose of queueing. **Why you cannot simply have both.** Both slicers gate the Device-tab progress panel and the Send button on one and the same predicate — `MachineObject::is_in_printing()`, true when `gcode_state` is RUNNING / PAUSE / SLICING / PREPARE. `StatusPanel::update_subtask()` draws the progress bar on it; `SelectMachineDialog::update_show_status()` disables Send on it. Report the printer's state honestly and you get progress at the cost of Send; zero it and you get Send at the cost of progress. There is no field-level trick, because it is one boolean. **The fix.** There is exactly one state in the gap: `FINISH`. StatusPanel renders the full progress panel for it (`is_in_printing() || print_status == "FINISH"`), SelectMachineDialog does not consider it busy. The VP already parks there after every upload — that is the #1280 / #1658 send-modal handshake — which is precisely why the reporter saw a file name and no numbers: the slicer was already drawing the widget, and we were feeding it zeros. So while the target printer is printing and the VP has no upload of its own in flight, the report now holds `gcode_state=FINISH` and passes the real `mc_print_stage`, `mc_percent`, `mc_remaining_time`, `stg`, `stg_cur`, `layer_num` and `total_layer_num` through underneath it, at the existing 1 Hz push. Send stays enabled in every mode and #1558 does not come back. **What it costs.** The slicer's Pause / Resume / Stop buttons stay greyed for a server-mode VP, since it now reports a finished job rather than a running one — they were greyed before this change too, so nothing is lost; drive the print from Bambuddy, or use Proxy Mode, where the slicer talks to the printer directly and they work. `print_error` is never mirrored either: a fault on the printer would raise a modal error dialog in the slicer for a machine that did not throw it, and the printer's own card already reports it. **The upload handshake wins.** Mirroring is suppressed while a job is being handed over (`gcode_state=PREPARE`) and for five seconds after the last upload transition — the slicer only releases its in-flight-job lock when it sees FINISH carrying the `subtask_name` it just uploaded, so swapping in the printer's filename mid-handshake would wedge the send modal at "Downloading". Once settled, the report switches to the job that is actually on the bed, which is the one the user wants to watch. **Tests.** 8 cases: progress mirrors while the target prints; the mirrored state is never one the slicer reads as busy (parametrised over RUNNING and PAUSE — this is the assertion that keeps #1558 fixed); progress stays zeroed while the target is idle, while an upload is in flight, and inside the settle window, where the slicer's own filename is still echoed back at it; mirroring resumes once the handshake has settled; `print_error` is suppressed while the rest still mirrors. Verified by mutation — forcing the mirror off fails five of the eight. **Scope.** Backend only, non-proxy VPs with a target printer bound. No DB migration, no schema change, no new setting, no new permission, no i18n change.
|
||||
|
||||
@@ -54,6 +54,7 @@ from backend.app.services.printer_manager import (
|
||||
drying_screen_only,
|
||||
get_derived_status_name,
|
||||
printer_manager,
|
||||
resolve_expected_tray,
|
||||
resolve_plate_id,
|
||||
supports_chamber_heater,
|
||||
supports_chamber_temp,
|
||||
@@ -766,6 +767,26 @@ async def get_printer_status(
|
||||
ams_mapping=ams_mapping,
|
||||
ams_extruder_map=ams_extruder_map,
|
||||
tray_now=tray_now,
|
||||
# Runout guidance (#2587): resolve the firmware's target/previous slot to a
|
||||
# global tray ID, but only while PAUSED — the moment the operator needs it.
|
||||
expected_tray=(
|
||||
resolve_expected_tray(
|
||||
state.tray_tar,
|
||||
[(u.id, u.is_ams_ht) for u in ams_units],
|
||||
raw_data.get("mapping"),
|
||||
)
|
||||
if state.state == "PAUSE"
|
||||
else None
|
||||
),
|
||||
previous_tray=(
|
||||
resolve_expected_tray(
|
||||
state.tray_pre,
|
||||
[(u.id, u.is_ams_ht) for u in ams_units],
|
||||
raw_data.get("mapping"),
|
||||
)
|
||||
if state.state == "PAUSE"
|
||||
else None
|
||||
),
|
||||
ams_status_main=state.ams_status_main,
|
||||
ams_status_sub=state.ams_status_sub,
|
||||
mc_print_sub_stage=state.mc_print_sub_stage,
|
||||
|
||||
@@ -332,6 +332,17 @@ class PrinterStatus(BaseModel):
|
||||
fila_switch: FilaSwitchResponse | None = None
|
||||
# Currently loaded tray (global ID): 254 = external spool, 255 = no filament
|
||||
tray_now: int = 255
|
||||
# Runout / filament-replacement guidance (#2587). Populated only while the
|
||||
# print is PAUSED. Both are globalised tray IDs (ams_id*4+slot, or 128-135 for
|
||||
# AMS-HT, or 254 for external) so the frontend can highlight them with the same
|
||||
# logic it uses for tray_now:
|
||||
# expected_tray = the slot the firmware now expects filament in (from tray_tar).
|
||||
# None when idle, not paused, or the slot can't be resolved
|
||||
# (multi-AMS ambiguity) — the UI then says "check the printer".
|
||||
# previous_tray = the slot loaded before the pause, i.e. the one that ran out
|
||||
# (from tray_pre). None when unknown.
|
||||
expected_tray: int | None = None
|
||||
previous_tray: int | None = None
|
||||
# AMS status for filament change tracking
|
||||
# Main status: 0=idle, 1=filament_change, 2=rfid_identifying, 3=assist, 4=calibration
|
||||
ams_status_main: int = 0
|
||||
|
||||
@@ -321,6 +321,15 @@ class PrinterState:
|
||||
active_extruder: int = 0
|
||||
# Currently loaded tray (global ID): 254/255 = external spools, 255 = no filament on legacy printers
|
||||
tray_now: int = 255
|
||||
# Firmware's target/previous tray as reported in print.ams (RAW, not globalised):
|
||||
# tray_tar = the slot the paused/loading print now expects
|
||||
# tray_pre = the slot that was loaded before (e.g. the one that ran out)
|
||||
# For a single regular AMS these equal the global tray ID; for multi-AMS they
|
||||
# are local slot IDs (0-3) that must be resolved against the mapping field, and
|
||||
# for AMS-HT they are already global (128-135). 255 = none/idle, 254 = external.
|
||||
# Surfaced during a runout PAUSE so the UI can name the expected slot (#2587).
|
||||
tray_tar: int = 255
|
||||
tray_pre: int = 255
|
||||
# Last valid tray_now (0-253) — survives unload (255) for usage tracking after print completes
|
||||
last_loaded_tray: int = -1
|
||||
# Pending load target - used to track what tray we're loading for H2D disambiguation
|
||||
@@ -1763,6 +1772,34 @@ class BambuMQTTClient:
|
||||
self.state.ams_status_sub,
|
||||
)
|
||||
|
||||
# Parse tray_tar / tray_pre (RAW). These identify the slot the firmware
|
||||
# now expects (tray_tar) and the slot loaded before (tray_pre) — the key
|
||||
# signal for a runout PAUSE where AMS Filament Backup has advanced to the
|
||||
# next compatible slot (#2587). Stored raw here; globalised at the API
|
||||
# boundary because that resolution needs the AMS layout. On H2D/multi-AMS
|
||||
# these are local slot numbers (0-3), not global IDs.
|
||||
for _tk, _attr in (("tray_tar", "tray_tar"), ("tray_pre", "tray_pre")):
|
||||
if _tk in ams_data:
|
||||
_raw = ams_data[_tk]
|
||||
if isinstance(_raw, str):
|
||||
try:
|
||||
_val = int(_raw)
|
||||
except ValueError:
|
||||
_val = 255
|
||||
else:
|
||||
_val = _raw if _raw is not None else 255
|
||||
prev = getattr(self.state, _attr)
|
||||
setattr(self.state, _attr, _val)
|
||||
# Log changes only while paused — the moment the operator cares —
|
||||
# so a healthy print's normal tar churn doesn't spam the log.
|
||||
if _val != prev and _val not in (255, -1) and self.state.state == "PAUSE":
|
||||
logger.info(
|
||||
"[%s] AMS %s changed to %s while paused (expected/previous slot signal, #2587)",
|
||||
self.serial_number,
|
||||
_tk,
|
||||
_val,
|
||||
)
|
||||
|
||||
# Parse tray_now from AMS dict - this is the currently loaded tray global ID
|
||||
# Note: tray_tar is also available but on H2D it's just slot number (0-3), not global ID
|
||||
if "tray_now" in ams_data:
|
||||
|
||||
@@ -997,6 +997,64 @@ def resolve_plate_id(state) -> int | None:
|
||||
return parse_plate_id(state.gcode_file)
|
||||
|
||||
|
||||
def resolve_expected_tray(
|
||||
raw_slot: int | None,
|
||||
ams_layout: list[tuple[int, bool]],
|
||||
mapping_raw: object,
|
||||
) -> int | None:
|
||||
"""Globalise a raw firmware ``tray_tar``/``tray_pre`` value for the runout UI (#2587).
|
||||
|
||||
The firmware reports the target/previous slot as a bare number whose meaning
|
||||
depends on the AMS layout (see ``PrinterState.tray_tar``). This mirrors the
|
||||
``tray_now`` handling so the resolved ID lines up with what the AMS graphic
|
||||
already highlights via ``ams_id*4 + slot``.
|
||||
|
||||
``ams_layout`` is a list of ``(ams_id, is_ams_ht)`` for the connected units.
|
||||
|
||||
- ``255``/``-1`` (none/idle) -> ``None``
|
||||
- ``254`` (external spool) -> ``254``
|
||||
- ``128``-``135`` (AMS-HT) -> already global, returned as-is
|
||||
- ``0``-``3`` local slot:
|
||||
* exactly one regular AMS -> ``ams_id*4 + slot``
|
||||
* several regular AMS -> resolved via the snow-encoded ``mapping`` field
|
||||
(each entry = ``ams_hw_id*256 + slot``; ``65535`` = unmapped), or
|
||||
``None`` when it stays ambiguous (honest "can't determine")
|
||||
* no regular AMS -> ``None``
|
||||
- ``4``-``15`` -> already a global regular-AMS ID, returned as-is
|
||||
|
||||
Returns ``None`` for anything it can't place, so the caller surfaces a
|
||||
"check the printer" message instead of pointing at the wrong slot.
|
||||
"""
|
||||
if raw_slot is None or raw_slot in (255, -1):
|
||||
return None
|
||||
if raw_slot == 254:
|
||||
return 254
|
||||
if 128 <= raw_slot <= 135:
|
||||
return raw_slot
|
||||
if 0 <= raw_slot <= 3:
|
||||
regular = [ams_id for ams_id, is_ht in ams_layout if not is_ht]
|
||||
if len(regular) == 1:
|
||||
return regular[0] * 4 + raw_slot
|
||||
if len(regular) > 1:
|
||||
if not isinstance(mapping_raw, list):
|
||||
return None
|
||||
candidates: set[int] = set()
|
||||
for value in mapping_raw:
|
||||
if not isinstance(value, int) or value >= 65535:
|
||||
continue
|
||||
ams_hw_id = value >> 8
|
||||
slot = value & 0xFF
|
||||
if 0 <= ams_hw_id <= 3 and (slot & 0x03) == raw_slot:
|
||||
candidates.add(ams_hw_id * 4 + raw_slot)
|
||||
elif 128 <= ams_hw_id <= 135 and raw_slot == 0:
|
||||
candidates.add(ams_hw_id)
|
||||
return candidates.pop() if len(candidates) == 1 else None
|
||||
return None
|
||||
if 4 <= raw_slot <= 15:
|
||||
return raw_slot
|
||||
return None
|
||||
|
||||
|
||||
def printer_state_to_dict(
|
||||
state: PrinterState,
|
||||
printer_id: int | None = None,
|
||||
@@ -1239,6 +1297,28 @@ def printer_state_to_dict(
|
||||
"ams_status_main": state.ams_status_main,
|
||||
"ams_status_sub": state.ams_status_sub,
|
||||
"tray_now": state.tray_now,
|
||||
# Runout / filament-replacement guidance (#2587). Only meaningful while
|
||||
# PAUSED — resolve the firmware's target/previous slot to a global tray ID
|
||||
# so the AMS graphic can highlight the slot the print now expects and name
|
||||
# the one that ran out. None when idle, not paused, or unresolvable.
|
||||
"expected_tray": (
|
||||
resolve_expected_tray(
|
||||
state.tray_tar,
|
||||
[(u["id"], u.get("is_ams_ht", False)) for u in ams_units],
|
||||
raw_data.get("mapping"),
|
||||
)
|
||||
if state.state == "PAUSE"
|
||||
else None
|
||||
),
|
||||
"previous_tray": (
|
||||
resolve_expected_tray(
|
||||
state.tray_pre,
|
||||
[(u["id"], u.get("is_ams_ht", False)) for u in ams_units],
|
||||
raw_data.get("mapping"),
|
||||
)
|
||||
if state.state == "PAUSE"
|
||||
else None
|
||||
),
|
||||
# Per-AMS extruder map: {ams_id: extruder_id} where 0=right, 1=left
|
||||
"ams_extruder_map": ams_extruder_map,
|
||||
# WiFi signal strength
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""_handle_ams_data must capture tray_tar / tray_pre for the runout UI (#2587).
|
||||
|
||||
The firmware reports the slot a paused print now expects (``tray_tar``) and the
|
||||
slot loaded before (``tray_pre``) alongside ``tray_now``. Bambuddy historically
|
||||
parsed only ``tray_now`` and dropped the other two, so "which slot does the
|
||||
print now expect" never reached the API. These tests lock in that the raw values
|
||||
are stored on PrinterState (globalisation happens later, at the API boundary).
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from backend.app.services.bambu_mqtt import BambuMQTTClient
|
||||
|
||||
|
||||
def _client() -> BambuMQTTClient:
|
||||
return BambuMQTTClient(ip_address="10.0.0.1", serial_number="SERIAL", access_code="code", model="P1S")
|
||||
|
||||
|
||||
def _ams_frame(**extra):
|
||||
frame = {
|
||||
"ams": [
|
||||
{"id": 0, "tray": [{"id": 0}, {"id": 1}, {"id": 2}, {"id": 3}]},
|
||||
],
|
||||
}
|
||||
frame.update(extra)
|
||||
return frame
|
||||
|
||||
|
||||
class TestTrayTarPreCapture:
|
||||
def test_reporter_pause_values_are_stored(self):
|
||||
client = _client()
|
||||
client.state.state = "PAUSE"
|
||||
# @Jostxxl's capture: ran out in slot 2 (tray_pre=1), expects slot 3 (tray_tar=2).
|
||||
client._handle_ams_data(_ams_frame(tray_now=255, tray_tar=2, tray_pre=1))
|
||||
assert client.state.tray_tar == 2
|
||||
assert client.state.tray_pre == 1
|
||||
|
||||
def test_string_values_are_coerced(self):
|
||||
client = _client()
|
||||
client._handle_ams_data(_ams_frame(tray_tar="2", tray_pre="1"))
|
||||
assert client.state.tray_tar == 2
|
||||
assert client.state.tray_pre == 1
|
||||
|
||||
def test_defaults_untouched_when_absent(self):
|
||||
client = _client()
|
||||
# A frame without tray_tar/tray_pre leaves the 255 sentinel in place.
|
||||
client._handle_ams_data(_ams_frame(tray_now=3))
|
||||
assert client.state.tray_tar == 255
|
||||
assert client.state.tray_pre == 255
|
||||
|
||||
def test_unparseable_value_falls_back_to_sentinel(self):
|
||||
client = _client()
|
||||
client._handle_ams_data(_ams_frame(tray_tar="not-a-number"))
|
||||
assert client.state.tray_tar == 255
|
||||
|
||||
def test_change_while_paused_is_logged(self):
|
||||
client = _client()
|
||||
client.state.state = "PAUSE"
|
||||
client.state.tray_tar = 255
|
||||
with patch("backend.app.services.bambu_mqtt.logger") as log:
|
||||
client._handle_ams_data(_ams_frame(tray_tar=2, tray_pre=1))
|
||||
logged = " ".join(str(c) for c in log.info.call_args_list)
|
||||
assert "tray_tar" in logged and "#2587" in logged
|
||||
|
||||
def test_no_log_when_not_paused(self):
|
||||
client = _client()
|
||||
client.state.state = "RUNNING"
|
||||
client.state.tray_tar = 255
|
||||
with patch("backend.app.services.bambu_mqtt.logger") as log:
|
||||
client._handle_ams_data(_ams_frame(tray_tar=2))
|
||||
# A healthy print's tar churn must not spam the log.
|
||||
for call in log.info.call_args_list:
|
||||
assert "#2587" not in " ".join(str(a) for a in call.args)
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Unit tests for runout expected-slot resolution (#2587).
|
||||
|
||||
When a print pauses on an AMS filament runout, the firmware reports the slot it
|
||||
now expects (``tray_tar``) and the slot that ran out (``tray_pre``) as bare
|
||||
numbers whose meaning depends on the AMS layout. ``resolve_expected_tray``
|
||||
globalises them to the same numbering the AMS graphic already highlights, so the
|
||||
UI can point the operator at the right physical slot — critical with AMS
|
||||
Filament Backup, which advances to the next compatible slot rather than
|
||||
re-accepting the depleted one (reporter @Jostxxl saw the print wait on Slot 3
|
||||
after Slot 2 ran out).
|
||||
"""
|
||||
|
||||
from backend.app.services.printer_manager import resolve_expected_tray
|
||||
|
||||
|
||||
def _single_regular():
|
||||
return [(0, False)]
|
||||
|
||||
|
||||
def _dual_regular():
|
||||
return [(0, False), (1, False)]
|
||||
|
||||
|
||||
class TestSingleRegularAms:
|
||||
"""One 4-slot AMS: the local slot IS the global tray ID (matches tray_now)."""
|
||||
|
||||
def test_reporter_scenario_tray_tar_2_is_slot_3(self):
|
||||
# @Jostxxl: tray_tar=2 (zero-based) -> physical Slot 3.
|
||||
assert resolve_expected_tray(2, _single_regular(), None) == 2
|
||||
|
||||
def test_reporter_scenario_tray_pre_1_is_slot_2(self):
|
||||
# @Jostxxl: tray_pre=1 (zero-based) -> physical Slot 2 (the one that ran out).
|
||||
assert resolve_expected_tray(1, _single_regular(), None) == 1
|
||||
|
||||
def test_all_four_slots_pass_through(self):
|
||||
for slot in range(4):
|
||||
assert resolve_expected_tray(slot, _single_regular(), None) == slot
|
||||
|
||||
def test_single_ams_with_nonzero_id(self):
|
||||
# A lone AMS reporting id=1 globalises to 4+slot, not the bare slot.
|
||||
assert resolve_expected_tray(2, [(1, False)], None) == 6
|
||||
|
||||
|
||||
class TestSentinels:
|
||||
"""255 = none/idle, 254 = external spool, -1 = never-set."""
|
||||
|
||||
def test_none_input(self):
|
||||
assert resolve_expected_tray(None, _single_regular(), None) is None
|
||||
|
||||
def test_255_is_none(self):
|
||||
assert resolve_expected_tray(255, _single_regular(), None) is None
|
||||
|
||||
def test_minus_one_is_none(self):
|
||||
assert resolve_expected_tray(-1, _single_regular(), None) is None
|
||||
|
||||
def test_254_external_passes_through(self):
|
||||
assert resolve_expected_tray(254, _single_regular(), None) == 254
|
||||
|
||||
|
||||
class TestAmsHt:
|
||||
"""AMS-HT reports a global ID (128-135) directly — return it unchanged."""
|
||||
|
||||
def test_ht_global_id_passthrough(self):
|
||||
assert resolve_expected_tray(129, [(129, True)], None) == 129
|
||||
|
||||
def test_ht_alongside_regular(self):
|
||||
layout = [(0, False), (128, True)]
|
||||
assert resolve_expected_tray(128, layout, None) == 128
|
||||
# A 0-3 target still resolves against the single regular unit.
|
||||
assert resolve_expected_tray(3, layout, None) == 3
|
||||
|
||||
|
||||
class TestMultiRegularAms:
|
||||
"""Several 4-slot AMS: local slot is ambiguous; resolve via the mapping field.
|
||||
|
||||
The snow-encoded mapping is ``ams_hw_id*256 + slot`` per entry.
|
||||
"""
|
||||
|
||||
def test_resolves_unambiguous_slot_via_mapping(self):
|
||||
# Mapping says slot 2 lives on AMS 1 -> global 1*4+2 = 6.
|
||||
mapping = [1 * 256 + 2]
|
||||
assert resolve_expected_tray(2, _dual_regular(), mapping) == 6
|
||||
|
||||
def test_resolves_slot_on_ams0(self):
|
||||
mapping = [0 * 256 + 3]
|
||||
assert resolve_expected_tray(3, _dual_regular(), mapping) == 3
|
||||
|
||||
def test_ambiguous_when_two_units_match_returns_none(self):
|
||||
# Both AMS 0 and AMS 1 have slot 1 mapped -> can't disambiguate.
|
||||
mapping = [0 * 256 + 1, 1 * 256 + 1]
|
||||
assert resolve_expected_tray(1, _dual_regular(), mapping) is None
|
||||
|
||||
def test_no_mapping_returns_none(self):
|
||||
# Honest "can't determine" rather than guessing AMS 0.
|
||||
assert resolve_expected_tray(2, _dual_regular(), None) is None
|
||||
|
||||
def test_unmapped_sentinel_ignored(self):
|
||||
# 65535 = unmapped; only the real entry counts.
|
||||
mapping = [65535, 1 * 256 + 0]
|
||||
assert resolve_expected_tray(0, _dual_regular(), mapping) == 4
|
||||
|
||||
def test_ht_in_multi_layout_via_mapping(self):
|
||||
# A slot-0 target that maps to an AMS-HT hw id resolves to that global ID.
|
||||
layout = [(0, False), (1, False), (128, True)]
|
||||
mapping = [128 * 256 + 0]
|
||||
assert resolve_expected_tray(0, layout, mapping) == 128
|
||||
|
||||
|
||||
class TestGlobalAndOutOfRange:
|
||||
def test_already_global_regular_id_passthrough(self):
|
||||
# 4-15 is already a global regular-AMS ID.
|
||||
assert resolve_expected_tray(6, _dual_regular(), None) == 6
|
||||
|
||||
def test_out_of_range_returns_none(self):
|
||||
assert resolve_expected_tray(200, _single_regular(), None) is None
|
||||
|
||||
def test_zero_slot_no_regular_ams_returns_none(self):
|
||||
# Only an AMS-HT present but a 0-3 target — can't place it.
|
||||
assert resolve_expected_tray(1, [(128, True)], None) is None
|
||||
@@ -141,6 +141,7 @@ function isAlwaysAllowedIdentical(value) {
|
||||
// German loanwords / cognates from English are extensive. Most short technical
|
||||
// UI labels are identical in DE. List below curates the legitimate ones.
|
||||
const DE_COGNATES = [
|
||||
'{{ams}} · Slot {{slot}}', // #2587 runout slot label — "Slot" is the DE term too
|
||||
'Name', 'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Modus',
|
||||
'Stop', 'Reset', 'Test', 'Code', 'Token', 'Server', 'Port', 'Bug', 'Job',
|
||||
'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale
|
||||
@@ -220,6 +221,7 @@ const FR_COGNATES = [
|
||||
|
||||
// Italian cognates.
|
||||
const IT_COGNATES = [
|
||||
'{{ams}} · Slot {{slot}}', // #2587 runout slot label — "Slot" is the IT term too
|
||||
'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale
|
||||
'AMS Filament Backup', // Bambu Lab product/firmware feature name
|
||||
'Email', // common loanword in Italian, used verbatim in UI labels
|
||||
@@ -267,6 +269,7 @@ const JA_COGNATES = [
|
||||
|
||||
// Portuguese (BR) cognates.
|
||||
const PT_BR_COGNATES = [
|
||||
'{{ams}} · Slot {{slot}}', // #2587 runout slot label — "Slot" is the PT-BR term too
|
||||
'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale
|
||||
'AMS Filament Backup', // Bambu Lab product/firmware feature name
|
||||
'Pipeline', 'Pipelines', // #1425 — Slicer Pipelines (PT-BR)
|
||||
@@ -341,6 +344,7 @@ const KO_COGNATES = [
|
||||
|
||||
// Spanish cognates — words/phrases that are genuinely identical in Spanish.
|
||||
const ES_COGNATES = [
|
||||
'{{ams}} · Slot {{slot}}', // #2587 runout slot label — "Slot" is the ES term too
|
||||
'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale
|
||||
'AMS Filament Backup', // Bambu Lab product/firmware feature name
|
||||
'Pipeline', 'Pipelines', // #1425 — Slicer Pipelines (ES)
|
||||
@@ -363,6 +367,7 @@ const ES_COGNATES = [
|
||||
// Turkish cognates — technical UI labels that Turkish speakers use verbatim
|
||||
// from English (loanwords + acronyms + format strings). Curated, not a shortcut.
|
||||
const TR_COGNATES = [
|
||||
'{{ams}} · Slot {{slot}}', // #2587 runout slot label — "Slot" is the TR term too
|
||||
'Filament', 'Firmware', 'Disk', 'Hex', 'Test', 'Port', 'Model', 'Metal',
|
||||
'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale
|
||||
'AMS Filament Backup', // Bambu Lab product/firmware feature name
|
||||
|
||||
@@ -25,6 +25,13 @@ const unknownError: HMSError = {
|
||||
severity: 1,
|
||||
};
|
||||
|
||||
// Error code 0700_8011 = AMS filament runout (#2587).
|
||||
const runoutError: HMSError = {
|
||||
attr: 0x0700,
|
||||
code: '0x8011',
|
||||
severity: 2,
|
||||
};
|
||||
|
||||
describe('HMSErrorModal', () => {
|
||||
const defaultProps = {
|
||||
printerName: 'Test Printer',
|
||||
@@ -122,6 +129,66 @@ describe('HMSErrorModal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('runout guidance (#2587)', () => {
|
||||
it('shows the generic runout text when no guidance is provided', () => {
|
||||
render(<HMSErrorModal {...defaultProps} errors={[runoutError]} />);
|
||||
expect(
|
||||
screen.getByText('AMS filament ran out. Please insert a new filament into the same AMS slot.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names both the expected and ran-out slot when both are resolved', () => {
|
||||
render(
|
||||
<HMSErrorModal
|
||||
{...defaultProps}
|
||||
errors={[runoutError]}
|
||||
runoutGuidance={{ expectedSlotLabel: 'AMS-A · Slot 3', ranOutSlotLabel: 'AMS-A · Slot 2' }}
|
||||
/>
|
||||
);
|
||||
const p = screen.getByText(/waiting for compatible filament/i);
|
||||
expect(p.textContent).toContain('AMS-A · Slot 3');
|
||||
expect(p.textContent).toContain('AMS-A · Slot 2');
|
||||
// The misleading "same slot" text must be gone.
|
||||
expect(screen.queryByText(/into the same AMS slot/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names only the expected slot when the ran-out slot is unknown', () => {
|
||||
render(
|
||||
<HMSErrorModal
|
||||
{...defaultProps}
|
||||
errors={[runoutError]}
|
||||
runoutGuidance={{ expectedSlotLabel: 'AMS-A · Slot 3', ranOutSlotLabel: null }}
|
||||
/>
|
||||
);
|
||||
const p = screen.getByText(/waiting for compatible filament/i);
|
||||
expect(p.textContent).toContain('AMS-A · Slot 3');
|
||||
});
|
||||
|
||||
it('shows an honest fallback when the slot cannot be resolved', () => {
|
||||
render(
|
||||
<HMSErrorModal
|
||||
{...defaultProps}
|
||||
errors={[runoutError]}
|
||||
runoutGuidance={{ expectedSlotLabel: null, ranOutSlotLabel: null }}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/could not determine which slot/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not apply runout guidance to non-runout errors', () => {
|
||||
render(
|
||||
<HMSErrorModal
|
||||
{...defaultProps}
|
||||
errors={[knownError]}
|
||||
runoutGuidance={{ expectedSlotLabel: 'AMS-A · Slot 3', ranOutSlotLabel: 'AMS-A · Slot 2' }}
|
||||
/>
|
||||
);
|
||||
// 0300_400C keeps its own description; no slot injection.
|
||||
expect(screen.getByText('The task was canceled.')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/waiting for compatible filament/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('interactions', () => {
|
||||
it('calls onClose when X button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -522,6 +522,14 @@ export interface PrinterStatus {
|
||||
fila_switch: FilaSwitchState | null;
|
||||
// Currently loaded tray (global tray ID, 255 = no filament loaded, 254 = external spool)
|
||||
tray_now: number;
|
||||
// Runout / filament-replacement guidance (#2587). Populated only while PAUSED.
|
||||
// Global tray IDs (ams_id*4+slot, 128-135 = AMS-HT, 254 = external), matching
|
||||
// the same numbering as tray_now so the AMS graphic can highlight them.
|
||||
// expected_tray = the slot the firmware now expects filament in (null = idle,
|
||||
// not paused, or unresolvable → "check the printer").
|
||||
// previous_tray = the slot that ran out (null = unknown).
|
||||
expected_tray: number | null;
|
||||
previous_tray: number | null;
|
||||
// AMS status for filament change tracking (0=idle, 1=filament_change, 2=rfid_identifying, 3=assist, 4=calibration)
|
||||
ams_status_main: number;
|
||||
// AMS sub-status for filament change step (when main=1): 4=retraction, 6=load verification, 7=purge
|
||||
|
||||
@@ -14,8 +14,25 @@ interface HMSErrorModalProps {
|
||||
onClose: () => void;
|
||||
printerId: number;
|
||||
hasPermission: (permission: Permission) => boolean;
|
||||
// Runout guidance for a PAUSED print (#2587). When set, AMS-runout errors are
|
||||
// re-described to name the physical slot the firmware now expects, instead of
|
||||
// the generic "insert into the same slot" text (wrong under AMS Filament Backup).
|
||||
// Slot labels are pre-formatted (e.g. "AMS-A · Slot 3"); null when the slot
|
||||
// could not be resolved → an honest "check the printer" message is shown.
|
||||
runoutGuidance?: {
|
||||
expectedSlotLabel: string | null;
|
||||
ranOutSlotLabel: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
// AMS per-slot filament-runout short codes (module 0x07). These pause the print
|
||||
// waiting for a specific slot — the ones #2587 re-describes. Printer-side /
|
||||
// external runout (0300_8004) has no AMS slot and is deliberately excluded.
|
||||
const AMS_RUNOUT_SHORT_CODES = new Set([
|
||||
'0700_8011', '0701_8011', '0702_8011', '0703_8011', '0704_8011',
|
||||
'0705_8011', '0706_8011', '0707_8011', '07FF_8011',
|
||||
]);
|
||||
|
||||
// Comprehensive error code database (short format: XXXX_YYYY)
|
||||
// Auto-generated from ha-bambulab - 853 codes
|
||||
const ERROR_DESCRIPTIONS: Record<string, string> = {
|
||||
@@ -917,7 +934,7 @@ function getHMSHomeUrl(): string {
|
||||
return `https://wiki.bambulab.com/en/hms/home`;
|
||||
}
|
||||
|
||||
export function HMSErrorModal({ printerName, errors, onClose, printerId, hasPermission }: HMSErrorModalProps) {
|
||||
export function HMSErrorModal({ printerName, errors, onClose, printerId, hasPermission, runoutGuidance }: HMSErrorModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -1003,7 +1020,24 @@ export function HMSErrorModal({ printerName, errors, onClose, printerId, hasPerm
|
||||
const { label, color, bgColor, Icon } = getSeverityInfo(error.severity);
|
||||
const codeNum = parseInt(error.code.replace('0x', ''), 16) || 0;
|
||||
const shortCode = getShortCode(error.attr, codeNum);
|
||||
const description = ERROR_DESCRIPTIONS[shortCode] ?? t('hmsErrors.unknownCode');
|
||||
// Runout guidance (#2587): for an AMS per-slot runout on a paused
|
||||
// print, name the slot the firmware now expects rather than the
|
||||
// misleading generic "insert into the same slot" text.
|
||||
let description = ERROR_DESCRIPTIONS[shortCode] ?? t('hmsErrors.unknownCode');
|
||||
if (runoutGuidance && AMS_RUNOUT_SHORT_CODES.has(shortCode)) {
|
||||
if (runoutGuidance.expectedSlotLabel && runoutGuidance.ranOutSlotLabel) {
|
||||
description = t('hmsErrors.runoutExpectedSlot', {
|
||||
expected: runoutGuidance.expectedSlotLabel,
|
||||
ranOut: runoutGuidance.ranOutSlotLabel,
|
||||
});
|
||||
} else if (runoutGuidance.expectedSlotLabel) {
|
||||
description = t('hmsErrors.runoutExpectedSlotOnly', {
|
||||
expected: runoutGuidance.expectedSlotLabel,
|
||||
});
|
||||
} else {
|
||||
description = t('hmsErrors.runoutSlotUnknown');
|
||||
}
|
||||
}
|
||||
const hmsHomeUrl = getHMSHomeUrl();
|
||||
const displayCode = shortCode.replace('_', '-');
|
||||
|
||||
|
||||
@@ -604,6 +604,12 @@ export default {
|
||||
title: 'Dieser Slot ist Filament {{n}} im aktiven Druck',
|
||||
ariaLabel: 'Aktiver Druck-Slot {{n}}',
|
||||
},
|
||||
expectedSlot: {
|
||||
title: 'Der Drucker wartet auf Filament in diesem Slot',
|
||||
ariaLabel: 'Erwarteter Filament-Slot {{n}}',
|
||||
label: '{{ams}} · Slot {{slot}}',
|
||||
external: 'Externe Spule',
|
||||
},
|
||||
// Filaments section
|
||||
filaments: 'Filamente',
|
||||
// Camera
|
||||
@@ -2805,6 +2811,9 @@ export default {
|
||||
clearFailed: 'HMS-Fehler konnten nicht gelöscht werden',
|
||||
actionSuccess: 'Aktion an Drucker gesendet',
|
||||
actionFailed: 'Aktion konnte nicht gesendet werden',
|
||||
runoutExpectedSlot: 'Das Filament in {{ranOut}} ist aufgebraucht. Der Drucker wartet jetzt auf kompatibles Filament in {{expected}}. Legen Sie eine Spule in {{expected}} ein und wählen Sie dann Wiederholen.',
|
||||
runoutExpectedSlotOnly: 'Der Drucker wartet auf kompatibles Filament in {{expected}}. Legen Sie dort eine Spule ein und wählen Sie dann Wiederholen.',
|
||||
runoutSlotUnknown: 'Das Filament ist aufgebraucht und der Druck ist pausiert. Bambuddy konnte nicht ermitteln, welchen Slot der Drucker jetzt erwartet — prüfen Sie am Druckerdisplay, welcher Slot angefordert wird.',
|
||||
actions: {
|
||||
RESUME_PRINTING: 'Druck fortsetzen',
|
||||
RESUME_PRINTING_DEFECTS: 'Fortsetzen (Mängel akzeptabel)',
|
||||
|
||||
@@ -608,6 +608,12 @@ export default {
|
||||
title: 'This slot is filament {{n}} in the active print',
|
||||
ariaLabel: 'Active print slot {{n}}',
|
||||
},
|
||||
expectedSlot: {
|
||||
title: 'The printer is waiting for filament in this slot',
|
||||
ariaLabel: 'Expected filament slot {{n}}',
|
||||
label: '{{ams}} · Slot {{slot}}',
|
||||
external: 'External spool',
|
||||
},
|
||||
// Filaments section
|
||||
filaments: 'Filaments',
|
||||
// Camera
|
||||
@@ -2834,6 +2840,9 @@ export default {
|
||||
clearFailed: 'Failed to clear HMS errors',
|
||||
actionSuccess: 'Action sent to printer',
|
||||
actionFailed: 'Failed to send action',
|
||||
runoutExpectedSlot: 'Filament ran out in {{ranOut}}. The printer is now waiting for compatible filament in {{expected}}. Insert a spool into {{expected}}, then select Retry.',
|
||||
runoutExpectedSlotOnly: 'The printer is waiting for compatible filament in {{expected}}. Insert a spool there, then select Retry.',
|
||||
runoutSlotUnknown: 'Filament ran out and the print is paused. Bambuddy could not determine which slot the printer now expects — check the printer screen for the requested slot.',
|
||||
actions: {
|
||||
RESUME_PRINTING: "Resume Printing",
|
||||
RESUME_PRINTING_DEFECTS: "Resume (defects acceptable)",
|
||||
|
||||
@@ -604,6 +604,12 @@ export default {
|
||||
title: 'Este slot es el filamento {{n}} en la impresión activa',
|
||||
ariaLabel: 'Slot de impresión activa {{n}}',
|
||||
},
|
||||
expectedSlot: {
|
||||
title: 'La impresora está esperando filamento en este slot',
|
||||
ariaLabel: 'Slot de filamento esperado {{n}}',
|
||||
label: '{{ams}} · Slot {{slot}}',
|
||||
external: 'Bobina externa',
|
||||
},
|
||||
// Filaments section
|
||||
filaments: 'Filamentos',
|
||||
// Camera
|
||||
@@ -2808,6 +2814,9 @@ export default {
|
||||
clearFailed: 'Error al borrar los errores HMS',
|
||||
actionSuccess: 'Acción enviada a la impresora',
|
||||
actionFailed: 'No se pudo enviar la acción',
|
||||
runoutExpectedSlot: 'El filamento se agotó en {{ranOut}}. La impresora ahora espera filamento compatible en {{expected}}. Inserta una bobina en {{expected}} y luego selecciona Reintentar.',
|
||||
runoutExpectedSlotOnly: 'La impresora está esperando filamento compatible en {{expected}}. Inserta una bobina ahí y luego selecciona Reintentar.',
|
||||
runoutSlotUnknown: 'El filamento se agotó y la impresión está en pausa. Bambuddy no pudo determinar qué slot espera ahora la impresora — revisa la pantalla de la impresora para ver el slot solicitado.',
|
||||
actions: {
|
||||
RESUME_PRINTING: 'Reanudar impresión',
|
||||
RESUME_PRINTING_DEFECTS: 'Reanudar (defectos aceptables)',
|
||||
|
||||
@@ -604,6 +604,12 @@ export default {
|
||||
title: 'Cet emplacement est le filament {{n}} dans l\'impression active',
|
||||
ariaLabel: 'Emplacement d\'impression active {{n}}',
|
||||
},
|
||||
expectedSlot: {
|
||||
title: 'L\'imprimante attend du filament dans cet emplacement',
|
||||
ariaLabel: 'Emplacement de filament attendu {{n}}',
|
||||
label: '{{ams}} · Emplacement {{slot}}',
|
||||
external: 'Bobine externe',
|
||||
},
|
||||
// Filaments section
|
||||
filaments: 'Filaments',
|
||||
// Camera
|
||||
@@ -2794,6 +2800,9 @@ export default {
|
||||
clearFailed: 'Échec de l\'effacement des erreurs HMS',
|
||||
actionSuccess: 'Action envoyée à l\'imprimante',
|
||||
actionFailed: 'Échec de l\'envoi de l\'action',
|
||||
runoutExpectedSlot: 'Le filament est épuisé dans {{ranOut}}. L\'imprimante attend maintenant du filament compatible dans {{expected}}. Insérez une bobine dans {{expected}}, puis sélectionnez Réessayer.',
|
||||
runoutExpectedSlotOnly: 'L\'imprimante attend du filament compatible dans {{expected}}. Insérez-y une bobine, puis sélectionnez Réessayer.',
|
||||
runoutSlotUnknown: 'Le filament est épuisé et l\'impression est en pause. Bambuddy n\'a pas pu déterminer quel emplacement l\'imprimante attend désormais — vérifiez l\'écran de l\'imprimante pour l\'emplacement demandé.',
|
||||
actions: {
|
||||
RESUME_PRINTING: 'Reprendre l\'impression',
|
||||
RESUME_PRINTING_DEFECTS: 'Reprendre (défauts acceptables)',
|
||||
|
||||
@@ -604,6 +604,12 @@ export default {
|
||||
title: 'Questo slot è il filamento {{n}} nella stampa attiva',
|
||||
ariaLabel: 'Slot stampa attiva {{n}}',
|
||||
},
|
||||
expectedSlot: {
|
||||
title: 'La stampante è in attesa di filamento in questo slot',
|
||||
ariaLabel: 'Slot filamento previsto {{n}}',
|
||||
label: '{{ams}} · Slot {{slot}}',
|
||||
external: 'Bobina esterna',
|
||||
},
|
||||
// Filaments section
|
||||
filaments: 'Filamenti',
|
||||
// Camera
|
||||
@@ -2793,6 +2799,9 @@ export default {
|
||||
clearFailed: 'Impossibile cancellare gli errori HMS',
|
||||
actionSuccess: 'Azione inviata alla stampante',
|
||||
actionFailed: 'Impossibile inviare l\'azione',
|
||||
runoutExpectedSlot: 'Il filamento in {{ranOut}} è esaurito. La stampante ora attende filamento compatibile in {{expected}}. Inserisci una bobina in {{expected}}, quindi seleziona Riprova.',
|
||||
runoutExpectedSlotOnly: 'La stampante attende filamento compatibile in {{expected}}. Inserisci lì una bobina, quindi seleziona Riprova.',
|
||||
runoutSlotUnknown: 'Il filamento è esaurito e la stampa è in pausa. Bambuddy non è riuscito a determinare quale slot la stampante attende ora — controlla lo schermo della stampante per lo slot richiesto.',
|
||||
actions: {
|
||||
RESUME_PRINTING: 'Riprendi stampa',
|
||||
RESUME_PRINTING_DEFECTS: 'Riprendi (difetti accettabili)',
|
||||
|
||||
@@ -603,6 +603,12 @@ export default {
|
||||
title: 'このスロットはアクティブな印刷のフィラメント {{n}} です',
|
||||
ariaLabel: 'アクティブ印刷スロット {{n}}',
|
||||
},
|
||||
expectedSlot: {
|
||||
title: 'プリンターはこのスロットのフィラメントを待っています',
|
||||
ariaLabel: '要求スロット {{n}}',
|
||||
label: '{{ams}} · スロット {{slot}}',
|
||||
external: '外部スプール',
|
||||
},
|
||||
// Filaments section
|
||||
filaments: 'フィラメント',
|
||||
// Camera
|
||||
@@ -2805,6 +2811,9 @@ export default {
|
||||
clearFailed: 'HMSエラーのクリアに失敗しました',
|
||||
actionSuccess: 'アクションをプリンターに送信しました',
|
||||
actionFailed: 'アクションの送信に失敗しました',
|
||||
runoutExpectedSlot: '{{ranOut}} のフィラメントが切れました。プリンターは現在 {{expected}} に対応するフィラメントを待っています。{{expected}} にスプールをセットして、「再試行」を選択してください。',
|
||||
runoutExpectedSlotOnly: 'プリンターは {{expected}} に対応するフィラメントを待っています。そこにスプールをセットして、「再試行」を選択してください。',
|
||||
runoutSlotUnknown: 'フィラメントが切れて印刷が一時停止しています。プリンターが現在要求しているスロットを Bambuddy が特定できませんでした。プリンターの画面で要求されているスロットを確認してください。',
|
||||
actions: {
|
||||
RESUME_PRINTING: '印刷を再開',
|
||||
RESUME_PRINTING_DEFECTS: '再開(不具合を許容)',
|
||||
|
||||
@@ -567,6 +567,12 @@ export default {
|
||||
title: '이 슬롯은 활성 인쇄의 필라멘트 {{n}}입니다',
|
||||
ariaLabel: '활성 인쇄 슬롯 {{n}}'
|
||||
},
|
||||
expectedSlot: {
|
||||
title: '프린터가 이 슬롯의 필라멘트를 기다리고 있습니다',
|
||||
ariaLabel: '요청된 필라멘트 슬롯 {{n}}',
|
||||
label: '{{ams}} · 슬롯 {{slot}}',
|
||||
external: '외부 스풀',
|
||||
},
|
||||
filaments: '필라멘트',
|
||||
openCameraOverlay: '카메라 오버레이 열기',
|
||||
openCameraWindow: '새 창에서 카메라 열기',
|
||||
@@ -2655,6 +2661,9 @@ export default {
|
||||
clearFailed: 'HMS 오류 지우기 실패',
|
||||
actionSuccess: '프린터에 작업을 전송함',
|
||||
actionFailed: '작업 전송 실패',
|
||||
runoutExpectedSlot: '{{ranOut}}의 필라멘트가 소진되었습니다. 프린터가 이제 {{expected}}에 호환 필라멘트를 기다리고 있습니다. {{expected}}에 스풀을 넣은 다음 다시 시도를 선택하세요.',
|
||||
runoutExpectedSlotOnly: '프린터가 {{expected}}에 호환 필라멘트를 기다리고 있습니다. 거기에 스풀을 넣은 다음 다시 시도를 선택하세요.',
|
||||
runoutSlotUnknown: '필라멘트가 소진되어 인쇄가 일시정지되었습니다. Bambuddy가 프린터가 현재 요청하는 슬롯을 확인할 수 없습니다 — 프린터 화면에서 요청된 슬롯을 확인하세요.',
|
||||
actions: {
|
||||
RESUME_PRINTING: '인쇄 재개',
|
||||
RESUME_PRINTING_DEFECTS: '재개 (결함 허용)',
|
||||
|
||||
@@ -604,6 +604,12 @@ export default {
|
||||
title: 'Este slot é o filamento {{n}} na impressão ativa',
|
||||
ariaLabel: 'Slot de impressão ativa {{n}}',
|
||||
},
|
||||
expectedSlot: {
|
||||
title: 'A impressora está aguardando filamento neste slot',
|
||||
ariaLabel: 'Slot de filamento esperado {{n}}',
|
||||
label: '{{ams}} · Slot {{slot}}',
|
||||
external: 'Bobina externa',
|
||||
},
|
||||
// Filaments section
|
||||
filaments: 'Filamentos',
|
||||
// Camera
|
||||
@@ -2793,6 +2799,9 @@ export default {
|
||||
clearFailed: 'Falha ao limpar erros HMS',
|
||||
actionSuccess: 'Ação enviada à impressora',
|
||||
actionFailed: 'Falha ao enviar ação',
|
||||
runoutExpectedSlot: 'O filamento acabou em {{ranOut}}. A impressora agora aguarda filamento compatível em {{expected}}. Insira uma bobina em {{expected}} e selecione Tentar novamente.',
|
||||
runoutExpectedSlotOnly: 'A impressora está aguardando filamento compatível em {{expected}}. Insira uma bobina ali e selecione Tentar novamente.',
|
||||
runoutSlotUnknown: 'O filamento acabou e a impressão está pausada. O Bambuddy não conseguiu determinar qual slot a impressora agora espera — verifique a tela da impressora para o slot solicitado.',
|
||||
actions: {
|
||||
RESUME_PRINTING: 'Retomar impressão',
|
||||
RESUME_PRINTING_DEFECTS: 'Retomar (defeitos aceitáveis)',
|
||||
|
||||
@@ -604,6 +604,12 @@ export default {
|
||||
title: 'Bu slot, aktif baskıdaki {{n}} numaralı filament',
|
||||
ariaLabel: 'Aktif baskı slotu {{n}}',
|
||||
},
|
||||
expectedSlot: {
|
||||
title: 'Yazıcı bu slotta filament bekliyor',
|
||||
ariaLabel: 'Beklenen filament slotu {{n}}',
|
||||
label: '{{ams}} · Slot {{slot}}',
|
||||
external: 'Harici makara',
|
||||
},
|
||||
// Filamentler bölümü
|
||||
filaments: 'Filamentler',
|
||||
// Kamera
|
||||
@@ -2809,6 +2815,9 @@ export default {
|
||||
clearFailed: 'HMS hataları temizlenemedi',
|
||||
actionSuccess: 'Eylem yazıcıya gönderildi',
|
||||
actionFailed: 'Eylem gönderilemedi',
|
||||
runoutExpectedSlot: '{{ranOut}} slotundaki filament bitti. Yazıcı şimdi {{expected}} slotunda uyumlu filament bekliyor. {{expected}} slotuna bir makara takın ve ardından Yeniden Dene\'yi seçin.',
|
||||
runoutExpectedSlotOnly: 'Yazıcı {{expected}} slotunda uyumlu filament bekliyor. Oraya bir makara takın ve ardından Yeniden Dene\'yi seçin.',
|
||||
runoutSlotUnknown: 'Filament bitti ve baskı duraklatıldı. Bambuddy yazıcının şu anda hangi slotu beklediğini belirleyemedi — istenen slot için yazıcının ekranını kontrol edin.',
|
||||
actions: {
|
||||
RESUME_PRINTING: 'Baskıyı sürdür',
|
||||
RESUME_PRINTING_DEFECTS: 'Sürdür (kusurlar kabul edilebilir)',
|
||||
|
||||
@@ -604,6 +604,12 @@ export default {
|
||||
title: '此料槽在当前打印中是耗材 {{n}}',
|
||||
ariaLabel: '当前打印料槽 {{n}}',
|
||||
},
|
||||
expectedSlot: {
|
||||
title: '打印机正在等待此料槽装入耗材',
|
||||
ariaLabel: '需要装料的料槽 {{n}}',
|
||||
label: '{{ams}} · 料槽 {{slot}}',
|
||||
external: '外部料卷',
|
||||
},
|
||||
// Filaments section
|
||||
filaments: '耗材',
|
||||
// Camera
|
||||
@@ -2793,6 +2799,9 @@ export default {
|
||||
clearFailed: '清除 HMS 错误失败',
|
||||
actionSuccess: '已向打印机发送操作',
|
||||
actionFailed: '操作发送失败',
|
||||
runoutExpectedSlot: '{{ranOut}} 的耗材已用尽。打印机现在正在等待 {{expected}} 装入兼容耗材。请将料卷装入 {{expected}},然后选择"重试"。',
|
||||
runoutExpectedSlotOnly: '打印机正在等待 {{expected}} 装入兼容耗材。请在该料槽装入料卷,然后选择"重试"。',
|
||||
runoutSlotUnknown: '耗材已用尽,打印已暂停。Bambuddy 无法确定打印机现在需要哪个料槽——请在打印机屏幕上查看所请求的料槽。',
|
||||
actions: {
|
||||
RESUME_PRINTING: '恢复打印',
|
||||
RESUME_PRINTING_DEFECTS: '恢复 (缺陷可接受)',
|
||||
|
||||
@@ -604,6 +604,12 @@ export default {
|
||||
title: '此料槽在目前列印中是耗材 {{n}}',
|
||||
ariaLabel: '目前列印料槽 {{n}}',
|
||||
},
|
||||
expectedSlot: {
|
||||
title: '印表機正在等待此料槽裝入耗材',
|
||||
ariaLabel: '需要裝料的料槽 {{n}}',
|
||||
label: '{{ams}} · 料槽 {{slot}}',
|
||||
external: '外部料卷',
|
||||
},
|
||||
// Filaments section
|
||||
filaments: '耗材',
|
||||
// Camera
|
||||
@@ -2793,6 +2799,9 @@ export default {
|
||||
clearFailed: '清除 HMS 錯誤失敗',
|
||||
actionSuccess: '已向印表機傳送動作',
|
||||
actionFailed: '動作傳送失敗',
|
||||
runoutExpectedSlot: '{{ranOut}} 的耗材已用盡。印表機現在正在等待 {{expected}} 裝入相容耗材。請將料卷裝入 {{expected}},然後選擇「重試」。',
|
||||
runoutExpectedSlotOnly: '印表機正在等待 {{expected}} 裝入相容耗材。請在該料槽裝入料卷,然後選擇「重試」。',
|
||||
runoutSlotUnknown: '耗材已用盡,列印已暫停。Bambuddy 無法確定印表機現在需要哪個料槽——請在印表機螢幕上查看所請求的料槽。',
|
||||
actions: {
|
||||
RESUME_PRINTING: '恢復列印',
|
||||
RESUME_PRINTING_DEFECTS: '恢復 (瑕疵可接受)',
|
||||
|
||||
@@ -2105,6 +2105,34 @@ function PrinterCard({
|
||||
? currentTrayNow
|
||||
: cachedTrayNow.current;
|
||||
|
||||
// Runout / filament-replacement guidance (#2587). The backend fills these only
|
||||
// while the print is PAUSED (global tray IDs, or null when idle/unresolvable):
|
||||
// expectedTray = the slot the firmware now expects filament in
|
||||
// previousTray = the slot that ran out
|
||||
// With AMS Filament Backup the firmware advances to the next compatible slot,
|
||||
// so these are often different — the graphic highlights the expected one.
|
||||
const expectedTray = status?.expected_tray ?? null;
|
||||
const previousTray = status?.previous_tray ?? null;
|
||||
// Pre-format the runout slot labels (honoring user AMS friendly names) for the
|
||||
// HMS modal (#2587). null when the slot can't be placed → honest fallback copy.
|
||||
const formatRunoutSlotLabel = (globalId: number | null): string | null => {
|
||||
if (globalId === null) return null;
|
||||
if (globalId === 254) return t('printers.expectedSlot.external');
|
||||
if (globalId >= 128 && globalId <= 135) {
|
||||
return amsLabels?.[globalId] || getAmsLabel(globalId, 1);
|
||||
}
|
||||
const amsId = Math.floor(globalId / 4);
|
||||
const slot = globalId % 4;
|
||||
const amsName = amsLabels?.[amsId] || getAmsLabel(amsId, 4);
|
||||
return t('printers.expectedSlot.label', { ams: amsName, slot: slot + 1 });
|
||||
};
|
||||
const runoutGuidance = status?.state === 'PAUSE'
|
||||
? {
|
||||
expectedSlotLabel: formatRunoutSlotLabel(expectedTray),
|
||||
ranOutSlotLabel: formatRunoutSlotLabel(previousTray),
|
||||
}
|
||||
: null;
|
||||
|
||||
// Fetch smart plug for this printer
|
||||
const { data: smartPlug } = useQuery({
|
||||
queryKey: ['smartPlugByPrinter', printer.id],
|
||||
@@ -4633,6 +4661,10 @@ function PrinterCard({
|
||||
// Global tray ID = ams.id * 4 + slot index (for standard AMS)
|
||||
const globalTrayId = ams.id * 4 + slotIdx;
|
||||
const isActive = effectiveTrayNow === globalTrayId;
|
||||
// Runout guidance (#2587): the slot the paused print now
|
||||
// expects filament in, and the slot that ran out.
|
||||
const isExpectedSlot = expectedTray !== null && expectedTray === globalTrayId;
|
||||
const isRanOutSlot = previousTray !== null && previousTray === globalTrayId;
|
||||
// Get cloud preset info if available
|
||||
const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;
|
||||
// Get saved slot preset mapping (for user-configured slots)
|
||||
@@ -4704,8 +4736,25 @@ function PrinterCard({
|
||||
// Slot visual content (goes inside hover card)
|
||||
const slotVisual = (
|
||||
<div
|
||||
className={`relative w-full bg-bambu-dark-secondary rounded-lg p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${isActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}
|
||||
className={`relative w-full bg-bambu-dark-secondary rounded-lg p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${
|
||||
isExpectedSlot
|
||||
? 'ring-2 ring-amber-400 ring-offset-1 ring-offset-bambu-dark animate-pulse'
|
||||
: isRanOutSlot
|
||||
? 'ring-2 ring-red-500/60 ring-offset-1 ring-offset-bambu-dark'
|
||||
: isActive
|
||||
? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{isExpectedSlot && (
|
||||
<span
|
||||
aria-label={t('printers.expectedSlot.ariaLabel', { n: slotIdx + 1 })}
|
||||
title={t('printers.expectedSlot.title')}
|
||||
className="absolute top-0.5 left-0.5 px-1 py-px text-[8px] font-bold text-bambu-dark bg-amber-400 rounded pointer-events-none leading-none"
|
||||
>
|
||||
↓
|
||||
</span>
|
||||
)}
|
||||
{activePrintSlotLabel && (
|
||||
<span
|
||||
aria-label={t('printers.activeJobSlot.ariaLabel', { n: activePrintSlotIdx + 1 })}
|
||||
@@ -4914,6 +4963,9 @@ function PrinterCard({
|
||||
// Check if this is the currently loaded tray
|
||||
const globalTrayId = getGlobalTrayId(ams.id, tray?.id ?? 0, false);
|
||||
const isActive = effectiveTrayNow === globalTrayId;
|
||||
// Runout guidance (#2587): expected / ran-out slot on this HT unit.
|
||||
const isExpectedSlot = expectedTray !== null && expectedTray === globalTrayId;
|
||||
const isRanOutSlot = previousTray !== null && previousTray === globalTrayId;
|
||||
// Get cloud preset info if available
|
||||
const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;
|
||||
// Get saved slot preset mapping (for user-configured slots)
|
||||
@@ -4976,8 +5028,25 @@ function PrinterCard({
|
||||
// Slot visual content (goes inside hover card)
|
||||
const slotVisual = (
|
||||
<div
|
||||
className={`relative w-full bg-bambu-dark-secondary rounded-lg p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${isActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}
|
||||
className={`relative w-full bg-bambu-dark-secondary rounded-lg p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${
|
||||
isExpectedSlot
|
||||
? 'ring-2 ring-amber-400 ring-offset-1 ring-offset-bambu-dark animate-pulse'
|
||||
: isRanOutSlot
|
||||
? 'ring-2 ring-red-500/60 ring-offset-1 ring-offset-bambu-dark'
|
||||
: isActive
|
||||
? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{isExpectedSlot && (
|
||||
<span
|
||||
aria-label={t('printers.expectedSlot.ariaLabel', { n: 1 })}
|
||||
title={t('printers.expectedSlot.title')}
|
||||
className="absolute top-0.5 left-0.5 px-1 py-px text-[8px] font-bold text-bambu-dark bg-amber-400 rounded pointer-events-none leading-none"
|
||||
>
|
||||
↓
|
||||
</span>
|
||||
)}
|
||||
{htActivePrintSlotLabel && (
|
||||
<span
|
||||
aria-label={t('printers.activeJobSlot.ariaLabel', { n: htActivePrintSlotIdx + 1 })}
|
||||
@@ -6188,6 +6257,7 @@ function PrinterCard({
|
||||
onClose={() => setShowHMSModal(false)}
|
||||
printerId={printer.id}
|
||||
hasPermission={hasPermission}
|
||||
runoutGuidance={runoutGuidance}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -26,8 +26,8 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-z4krdH8i.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BikDm6kr.css">
|
||||
<script type="module" crossorigin src="/assets/index-Byt13KYk.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CZwzTgpo.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user