fix(ams): show "?" not "Empty" for non-RFID spools using tray_exist_bits (#2527)

A spool with no readable RFID was reported by the standard AMS with an empty
tray_type and state=9 — structurally identical to a truly-empty slot at the
tray level — so the AMS card rendered it "Empty" while Bambu Studio correctly
showed "?". The authoritative "a spool is physically here" signal is firmware's
AMS-level tray_exist_bits bitmask (what Studio uses), but Bambuddy inferred
emptiness from the per-tray state/tray_type. Confirmed from the reporter's
bundle: tray_exist_bits=f (all four slots present) with tray_is_bbl_bits=5
(only slots 0,2 Bambu) — the present-but-non-Bambu slots were the ones shown
Empty. Supersedes closed #1838.

apply_tray_exist_bits() already parses the bitmask to clear stale fields on
absent slots; it now also annotates each slot with an authoritative `exists`
bool, gated behind a new annotate_exists flag so only the printer-card path
sets it. The VP bridge leaves it off, so the `exists` key never reaches the
slicer wire format. `exists` flows through the AMSTray schema/serialization to
the frontend, where getEmptySlotKind() uses it: exists===true + no tray_type
-> "?" (present, unconfigured), exists===false -> "Empty", exists absent ->
the previous state=9/10 heuristic (AMS-HT and missing-bitmask paths unchanged).
H2D/X1C already reported present-unknown slots with a non-9 state and took the
"?" path; with the fix they reach it via `exists` and are unaffected.
This commit is contained in:
maziggy
2026-07-09 09:27:13 +02:00
parent 9e7f6cafd9
commit f6c6cfbad3
11 changed files with 91 additions and 8 deletions
+1
View File
@@ -37,6 +37,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Sort File Manager folder tree by recent activity (#1770, requested by @Kingbuzz0)** — Until now the folder tree was always sorted alphabetically by name, both backend (`order_by(LibraryFolder.name)`) and frontend. The reporter — a user with a lot of nested cad / slicer directories — wanted "find folders that just got a new 3MF" without scrolling the whole alphabet. **What changed.** The folder sidebar header gains a small dropdown (**By name** / **By recent activity**) plus an asc / desc arrow button, sitting alongside the existing Collapse + Wrap toggles. Choice persists per-browser via `localStorage` (`library-folder-sort-field`, `library-folder-sort-direction`) so the preference survives reloads. **Activity semantics.** `latest_activity_at` per folder = `MAX(folder.updated_at, MAX(immediate-child file.updated_at))`. The DB had the data — `LibraryFile.updated_at` is `onupdate=func.now()` and `LibraryFolder.updated_at` the same — but `LibraryFolder.updated_at` alone only bumps on rename / move, not on file-add inside the folder, which is exactly the wrong signal for "did I just drop a new model in here." The aggregate fixes that. Recursion across subfolders is intentionally **NOT** computed — a deeply nested new 3MF bubbles its immediate parent, not every ancestor up to the root. This keeps the route a single `GROUP BY` rather than a recursive CTE, matching the existing file_counts subquery shape sibling at `library.py:746`. A future Tier 3 follow-up could add the recursive-CTE variant if anyone reports deeply-nested updates not bubbling far enough. **Backend.** New `latest_activity_at: datetime | None` field on `FolderResponse` and `FolderTreeItem` schemas. The `/folders` tree route picks up a sibling `func.max(LibraryFile.updated_at)` group-by alongside the existing file-count subquery; resolves the field per row. The `/folders/by-project/{id}` and `/folders/by-archive/{id}` routes collapse their per-row file-count subquery to fetch `count + max` in one trip (one extra column, zero extra round-trips). All 5 single-folder constructors (POST `/folders`, GET `/folders/{id}`, PUT `/folders/{id}`, POST `/folders/external`, the create flows) populate the field with `max(folder.updated_at, latest_file)` or fall back to `folder.updated_at` when there are no files, so the API surface is consistent across every route that returns a folder. **External folders.** `LibraryFile` rows are created for scanned external files too (`library.py:526`), so the MAX aggregate works on them — but the timestamp reflects when Bambuddy last *scanned / re-indexed* the file, not the filesystem mtime. For a NAS that gets new files added outside Bambuddy, the activity-sort lags until the next scan. Documented in the file-manager wiki page rather than papered over with `os.stat()` on every list call, which would stall the route on slow mounts. **Frontend.** A new recursive `sortedFolders` `useMemo` applies the comparator uniformly to top-level + every nested `children` level so sort order is consistent at every depth. Comparator falls back to name when activity timestamps tie or are both null, so an empty folder never elbows a recently-used one to a random place — empties go to the end of the activity bucket regardless of direction. Both the desktop sidebar render and the mobile selector dropdown consume `sortedFolders` so the order is identical across breakpoints. The single-folder `findFolder()` traversal and `selectedFolder` memo still operate on the unsorted `folders` because they index by ID — sort-order-independent. **Recursion safety.** The sort creates fresh object refs at every level on every memo invocation; the `FolderTreeItem` keys stay ID-based (`${folder.id}-${collapseFoldersByDefault ? 'c' : 'e'}`) so React reconciliation by ID preserves folder expansion state across sort flips. **i18n.** 3 new keys in `fileManager.*` (`folderSort`, `folderSortByName`, `folderSortByActivity`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallback. Parity 5238 leaves per locale. **Tests.** 2 new backend integration cases in `test_library_api.py` (file-in-folder bubbles `latest_activity_at` to the file's timestamp, empty folder falls back to `folder.updated_at`). All 152 library + folder + trash + slice integration tests still pass; 51/51 FileManagerPage frontend tests still pass; 26/26 QueuePage tests still pass; `npm run build` clean; `ruff` clean; i18n parity green.
### Fixed
- **AMS slot with a non-Bambu (no-RFID) spool showed "Empty" instead of "?" (#2527, reporter @NeighborGeek)** — When a spool without a readable RFID tag was loaded, the AMS card showed the slot as **Empty**, while Bambu Studio correctly showed a `?` for an unidentified filament. The reporter's decisive test — swapping the unknown spool between slots and watching "Empty" follow the spool, not the slot — pinned it to slot *content*, not position. Root cause: the authoritative "a spool is physically here" signal is firmware's AMS-level **`tray_exist_bits`** bitmask (what Studio uses to draw the `?`), but Bambuddy inferred emptiness from the *per-tray* `state`/`tray_type`. On the standard AMS (P1-series here, fw 01.09.00.00), a no-RFID spool is reported with an empty `tray_type` and `state=9` — structurally identical to a truly-empty slot at the tray level — so the frontend's `getEmptySlotKind()` classified it as firmware-confirmed-empty and rendered "Empty" rather than the existing `reset` kind that renders `?` ("Spool loaded — slot not configured", #1694). Confirmed from the support bundle: `tray_exist_bits='f'` (all four slots present) with `tray_is_bbl_bits='5'` (only slots 0,2 are Bambu) — slots 1,3 were present-but-non-Bambu, exactly the ones shown Empty. **Fix.** `apply_tray_exist_bits()` — which already parses the bitmask to clear stale fields on absent slots — now also annotates each slot with an authoritative `exists` bool (gated behind a new `annotate_exists` flag so only the printer-card path sets it; the VP bridge leaves it off and the `exists` key never reaches the slicer wire format). `exists` flows through the `AMSTray` schema/serialization to the frontend, where `getEmptySlotKind()` uses it: `exists === true` + no `tray_type` → `?` (present, unconfigured), `exists === false` → Empty, and `exists` absent → the previous `state=9/10` heuristic (so AMS-HT and missing-bitmask paths are unchanged). This is why the bug never reproduced on H2D or X1C — their firmware already reports present-unknown slots with a non-9 `state`, so they fell through to `reset`/`?`; with the fix they take the same path via `exists` and are unaffected. Supersedes the closed #1838. **Tests.** Backend: 3 helper cases (present/absent slots annotated, a present-no-`tray_type` slot marked `exists=true` and left uncleared, and `annotate_exists` off keeps the wire dict clean). Frontend: 1 `AmsUnitCard` case (a `state=9` slot with `exists=true` renders `?`, while `exists=false` still renders "Empty"). Full `test_bambu_mqtt` + VP-bridge suites 384/384 and the AMS/printer/VP backend selection green; `ruff` clean; `npm run build` + ESLint clean; `AmsUnitCard`/`PrintersPage` vitest green. **Scope.** No DB migration, no new permission, no i18n change; VP slicer-facing wire format unchanged.
- **Postgres→SQLite backup dropped NOT NULL / DEFAULT / FK / UNIQUE, causing NULLs after restore (#2526, reporter @bmorrison9)** — On a PostgreSQL install, `create_backup_zip()` exports a portable SQLite copy of the database so backups can move between engines. It rebuilt each table with only column name + type + primary key — the code's own comment admitted "simplified — just column names and types" — dropping `NOT NULL`, `server_default`/`DEFAULT`, foreign keys, and unique constraints. When such a backup is restored onto a SQLite install, `restore_backup()` page-copies the file straight onto the live database (`sqlite3.Connection.backup()`), so the stripped-down schema becomes the *running* database; the post-restore `init_db()` can't repair it because `create_all()` is `CREATE TABLE IF NOT EXISTS` and never alters existing tables. The reporter root-caused it precisely: `SpoolBuddyDevice.created_at` is `server_default=func.now()`, so SQLAlchemy omits the column on INSERT and relies on the DB default — but with no `DEFAULT` clause the row got a bare `NULL`, which then failed Pydantic validation (`DeviceResponse.created_at`) on the next read and 500'd. Every `server_default` column across the schema was exposed the same way, and the FK/unique loss followed from the same simplified CREATE TABLE. **Fix.** The PostgreSQL branch now builds the portable SQLite schema with `Base.metadata.create_all()` against a SQLite engine — the exact DDL a native SQLite install gets — instead of the hand-rolled loop. That emits `NOT NULL`, `DEFAULT` (`server_default=func.now()` → `DEFAULT (CURRENT_TIMESTAMP)`), foreign keys, unique constraints, and indexes, so a Postgres→SQLite restore reproduces the same effective schema a fresh SQLite install would have. The data-export insert path is unchanged, and the `#1333` OIDC-icon guard is preserved automatically — `LargeBinary` renders as `BLOB` under the real DDL — which let the now-redundant `_sqlalchemy_type_to_sqlite_type()` type-mapping helper be removed. This fixes newly-created backups; a backup taken with an older build still carries the degraded schema, so re-take backups after upgrading. **Tests.** The `#1333` type-mapping unit tests were replaced with three that inspect the *real* backup schema (`metadata.create_all` on SQLite, read back via `sqlite_master`/`PRAGMA table_info`): the OIDC `icon_data` column is `BLOB` (#1333), `spoolbuddy_devices.created_at` keeps its `CURRENT_TIMESTAMP` DEFAULT (#2526), and a NOT NULL non-PK column stays NOT NULL. Full backup/restore suite (`test_settings_api`, `test_security`, Postgres-restore-cascade, SQLite-WAL-safety, OIDC-blob-roundtrip) 136/136 green; `ruff` clean. **Scope.** Backend-only, PostgreSQL-source backups. No DB migration, no new permission, no i18n change.
- **"Store on external storage" diagnostic reported an unresolvable fail on P1S/P1P (#2524, reporter @gregspatrick)** — Install-step-4's `external_storage` check hard-**failed** for a P1S even though there is no reachable UI anywhere — Bambu Studio, OrcaSlicer, Handy, or the printer (P1S has no screen) — that can turn the option on. The reporter root-caused it precisely: `has_external_storage()` returns True for the P1S (it does have a MicroSD slot), so the check proceeds to read `state.store_to_sdcard` (MQTT `home_flag` bit 11), which is stuck `False`. The toggle only renders in Studio when the printer publishes `support_save_remote_print_file_to_storage`, and current P1-series firmware (through 01.10.00.00) never does — Bambu's own storage-cache wiki lists P1 Series as "Not Supported". So the user was shown a red fail they could never clear. **Fix.** New `NO_REMOTE_STORAGE_TOGGLE_MODELS` set (P1S, P1P) + `has_remote_storage_toggle()` helper, distinct from the no-slot `NO_EXTERNAL_STORAGE_MODELS` used for A1/A1 Mini (the P1S genuinely *has* a slot — conflating the two would be wrong). When a model has a slot but no reachable toggle and `store_to_sdcard` is False, the diagnostic now emits `skip` with `params={"reason": "unsupported_model"}` instead of `fail`, and the overall result no longer escalates to "problems" for it. A P1S that somehow reports the option *on* still passes. The gate is model-scoped and default-open, so X1/P2S/H2-class printers — where the toggle is reachable and the fail is actionable — are unaffected; if a future P1 firmware surfaces the capability, drop the model from the set and the check reactivates. The frontend `DiagnosticChecklist` picks a reason-specific message variant (`external_storage.skip_unsupported_model`) when a check carries a `reason`, falling back to the plain per-status text otherwise — so instead of the misleading generic "needs a live MQTT connection" skip line, P1 users see an accurate explanation that the option can't be enabled on current firmware and archived prints may lack thumbnails/metadata until Bambu adds support. **i18n.** 1 new key translated across all 11 locales; parity green at 5580 leaves each. **Tests.** Backend: 3 diagnostic cases (P1S/P1P → skip with the reason param, overall stays "ok"; P1S with the option on still passes) + 3 helper cases in `test_printer_models.py` (P1-series false, other models/unknown/empty true). Frontend: 2 `ConnectionDiagnosticModal` cases (reason variant renders and suppresses the generic text; no-reason falls back). Full diagnostic + model suites green; `ruff` clean; `npm run build` + ESLint clean. **Scope.** No DB migration, no new permission.
- **Finish photo still caught the swapped/empty plate intermittently on A1 Mini + SwapMod (#1867 follow-on, reporter @qoatzelcoat)** — The last-layer edge trigger shipped in 0.2.4.9 fixed most cases but the reporter still saw the wrong (post-swap) plate now and then — "nothing changed, just kept adding files to the queue." Root cause, confirmed from the support bundle: this A1 Mini firmware (01.08.01.00) **never emits `stg_cur=22`** — across the whole 35k-line log (24 completions) the only stages it reports are 0/2/3/4/13/14/54/77/255, so every completion falls through to the `FINISH`-state fallback. Bambu only reports `gcode_state=FINISH` *after* the user End G-code runs (the print's last object layer was laid ~2 min before FINISH), so a live grab there is guaranteed to show the SwapMod-ejected plate. The last-layer edge (`layer_num >= total_layer_num` while RUNNING) is the right window but depends on catching **one transient MQTT packet** — if the firmware coalesces or drops the final `layer_num == total` push and jumps straight to FINISH, the edge is missed and it silently reverts to the post-swap grab. That's the intermittency; queued prints run unattended so the misses accumulate. **Fix — bank a frame instead of chasing an edge.** Bambuddy now keeps a rolling "last in-print camera frame" per printer, refreshed on layer change (throttled to ~25 s, always refreshed on the final object layer) via the same snapshot path the finish photo uses — so it honours the `capture_finish_photo` setting and works for external cameras, buffered RTSP, and fresh RTSP grabs alike. Because banking is **layer-driven it freezes automatically the instant printing ends**: the End G-code (plate swap) emits no further `layer_num` increases, so the last banked frame is always the finished print before the swap. On the `FINISH`-state fallback the finish-photo path now prefers the banked frame over a live grab; the `stage_22` and `last_layer` triggers still live-grab (they fire before the swap and give cleaner parked-toolhead framing), and if no banked frame exists it degrades to the old live grab rather than sending a text-only notification. Correctness no longer depends on *which* signal the firmware emits or on catching the edge — a missed edge just means the photo is one layer-frame stale (a finished print, not an empty plate). The bank is cleared on print start so a queued job can't reuse the prior job's frame. **Tests.** 8 new cases in `test_finish_photo_moment_sync.py`: `finish_state` prefers the banked frame and skips the live grab, falls back to live when no bank exists, and `last_layer` ignores the bank; plus 5 for the banking helper — stores while printing, throttles within the interval, always refreshes on the last layer, skips when not RUNNING (the freeze), and skips during calibration sub-stages. Full finish-photo + MQTT + layer-timelapse suites 355/355 green; `ruff check` clean. **Scope.** Backend-only. No DB migration, no new permission, no i18n change.
+1
View File
@@ -535,6 +535,7 @@ async def get_printer_status(
drying_temp=tray_data.get("drying_temp"),
drying_time=tray_data.get("drying_time"),
state=tray_data.get("state"),
exists=tray_data.get("exists"),
)
)
# Prefer humidity_raw (percentage) over humidity (index 1-5)
+4
View File
@@ -181,6 +181,10 @@ class AMSTray(BaseModel):
drying_temp: int | None = None # RFID-recommended drying temp
drying_time: int | None = None # RFID-recommended drying time (hours)
state: int | None = None # AMS tray state: 9=empty, 10=spool present not loaded, 11=loaded
# Firmware's authoritative "spool physically present" bit (from tray_exist_bits).
# True for a non-RFID spool the firmware can't identify — the UI shows "?" rather
# than "Empty" (#2527). None when the bitmask was unavailable (→ state-based fallback).
exists: bool | None = None
class AMSUnit(BaseModel):
+12
View File
@@ -56,6 +56,7 @@ def apply_tray_exist_bits(
*,
power_on_flag: bool = True,
log_label: str | None = None,
annotate_exists: bool = False,
) -> int:
"""Wipe stale per-tray filament fields on slots whose `tray_exist_bits` bit is 0.
@@ -87,6 +88,14 @@ def apply_tray_exist_bits(
way). Ints are tolerated for defensive symmetry but typically not seen
on the wire. ``None`` / empty / unparseable → no-op.
``annotate_exists`` writes a per-tray ``exists`` bool (from the bitmask) on
every processed slot. This is firmware's authoritative "spool physically
present" signal — the same one BambuStudio uses to draw a ``?`` for a
non-RFID spool in an otherwise-unidentified slot. Bambuddy's AMS card keys
empty-vs-unknown off it so a non-Bambu spool shows ``?`` instead of "Empty"
(#2527). Only the internal (printer-card) caller sets this; the VP bridge
leaves it False so the ``exists`` key never reaches the slicer wire format.
Mutates ``units`` in place. Returns the number of slots cleared.
"""
if not tray_exist_bits_str:
@@ -131,6 +140,8 @@ def apply_tray_exist_bits(
continue
global_bit = ams_id * 4 + tray_id
slot_exists = (tray_exist_bits >> global_bit) & 1
if annotate_exists:
tray["exists"] = bool(slot_exists)
if slot_exists:
continue
tray["state"] = 9
@@ -2009,6 +2020,7 @@ class BambuMQTTClient:
ams_data.get("tray_exist_bits"),
power_on_flag=ams_data.get("power_on_flag", True),
log_label=self.serial_number,
annotate_exists=True,
)
self.state.raw_data["ams"] = merged_ams
@@ -1330,6 +1330,40 @@ class TestApplyTrayExistBitsHelper:
assert cleared == 0
assert units[0]["tray"][0]["state"] == 9
def test_annotate_exists_marks_present_and_absent(self):
"""#2527: annotate_exists writes the tray_exist_bits presence bit onto
every slot so a non-RFID spool (present, no tray_type) is distinguishable
from a truly-empty slot. 0x5 = slots 0,2 present; slots 1,3 absent."""
from backend.app.services.bambu_mqtt import apply_tray_exist_bits
units = [{"id": 0, "tray": [{"id": i} for i in range(4)]}]
apply_tray_exist_bits(units, "5", power_on_flag=True, annotate_exists=True)
exists = [t["exists"] for t in units[0]["tray"]]
assert exists == [True, False, True, False]
def test_annotate_exists_present_unknown_slot_not_cleared(self):
"""A present slot with no tray_type (fresh non-RFID spool) keeps its
state and is marked exists=True — the UI then shows "?" not "Empty"."""
from backend.app.services.bambu_mqtt import apply_tray_exist_bits
# 0x1 = slot 0 present. Slot 0 has no tray_type (unidentified spool).
units = [{"id": 0, "tray": [{"id": 0, "state": 9}]}]
cleared = apply_tray_exist_bits(units, "1", power_on_flag=True, annotate_exists=True)
assert cleared == 0
assert units[0]["tray"][0]["exists"] is True
# Present slot is left untouched (only absent slots get state=9 forced).
assert units[0]["tray"][0]["state"] == 9
def test_annotate_exists_off_by_default_keeps_wire_clean(self):
"""The VP bridge calls this without annotate_exists, so the slicer-facing
tray dict must NOT gain a non-standard `exists` key."""
from backend.app.services.bambu_mqtt import apply_tray_exist_bits
units = [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}, {"id": 1}]}]
apply_tray_exist_bits(units, "1", power_on_flag=True)
assert "exists" not in units[0]["tray"][0]
assert "exists" not in units[0]["tray"][1]
class TestNozzleRackData:
"""Tests for nozzle rack data parsing from H2 series device.nozzle.info."""
@@ -114,6 +114,25 @@ describe('AmsUnitCard', () => {
expect(screen.getByText('Empty')).toBeDefined();
});
it('shows "?" for a non-RFID spool the firmware reports as state 9 (#2527)', () => {
// A non-Bambu spool with no RFID is physically present (tray_exist_bits →
// exists=true) but some firmware (standard AMS on P1-series) reports its
// tray as state=9 with no tray_type — identical to a truly-empty slot at
// the tray level. exists is authoritative, so it must read "?" not "Empty".
const unit = makeUnit({
tray: [
makeTray({ id: 0, tray_type: 'PLA', remain: 80 }),
makeTray({ id: 1, tray_color: null, tray_type: '', remain: 0, state: 9, exists: true }),
makeTray({ id: 2, tray_type: 'ABS', remain: 10 }),
// exists=false with the same state=9 is a genuinely empty slot.
makeTray({ id: 3, tray_color: null, tray_type: '', remain: 0, state: 9, exists: false }),
],
});
render(<AmsUnitCard unit={unit} activeSlot={null} />);
expect(screen.getByText('?')).toBeDefined();
expect(screen.getByText('Empty')).toBeDefined();
});
it('renders fill level bars for slots with filament', () => {
const { container } = render(
<AmsUnitCard unit={makeUnit()} activeSlot={null} />
+1
View File
@@ -365,6 +365,7 @@ export interface AMSTray {
drying_temp: number | null; // RFID-recommended drying temp
drying_time: number | null; // RFID-recommended drying time (hours)
state: number | null; // AMS tray state: 9=empty, 10=spool present not loaded, 11=loaded
exists?: boolean | null; // Firmware tray_exist_bits: spool physically present (non-RFID → "?" not "Empty", #2527)
}
export interface AMSUnit {
@@ -10,12 +10,16 @@ function isTrayEmpty(tray: AMSTray): boolean {
return !tray.tray_type || tray.tray_type === '';
}
// Mirror of PrintersPage.getEmptySlotKind (#1694): 'physical' when firmware
// confirms no spool (state 9/10), 'reset' when tray_type is absent but the
// firmware hasn't confirmed empty (= spool loaded, slot just unconfigured).
// Mirror of PrintersPage.getEmptySlotKind (#1694, #2527): 'physical' when
// firmware confirms no spool, 'reset' when a spool is present but has no
// tray_type (= loaded, slot just unconfigured — e.g. a non-RFID spool).
// tray_exist_bits (exists) is authoritative when present; otherwise fall back
// to the state=9/10 heuristic.
function getEmptySlotKind(tray: AMSTray): 'physical' | 'reset' | null {
if (tray.tray_type) return null;
const state = (tray as { state?: number | null }).state ?? null;
if (tray.exists === true) return 'reset';
if (tray.exists === false) return 'physical';
const state = tray.state ?? null;
return state === 9 || state === 10 ? 'physical' : 'reset';
}
+8 -1
View File
@@ -863,8 +863,15 @@ function TemperatureIndicator({ temp, goodThreshold = 28, fairThreshold = 35, on
*
* Returns null when the slot is loaded (tray_type is present).
*/
function getEmptySlotKind(tray: { tray_type?: string | null; state?: number | null } | null | undefined): 'physical' | 'reset' | null {
function getEmptySlotKind(tray: { tray_type?: string | null; state?: number | null; exists?: boolean | null } | null | undefined): 'physical' | 'reset' | null {
if (tray?.tray_type) return null;
// tray_exist_bits is firmware's authoritative presence signal: a non-RFID
// spool the firmware can't identify is physically present (exists === true)
// but carries no tray_type, so it must read as "?" (loaded, unconfigured),
// never "Empty" (#2527). BambuStudio draws it the same way. Only fall back to
// the state=9/10 heuristic when the bitmask was unavailable (exists == null).
if (tray?.exists === true) return 'reset';
if (tray?.exists === false) return 'physical';
return (tray?.state === 9 || tray?.state === 10) ? 'physical' : 'reset';
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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-CJ6VzpV2.js"></script>
<script type="module" crossorigin src="/assets/index-CWda6lvk.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-blSspT6K.css">
</head>
<body>