mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
fix(diagnostic): skip external_storage check on A1 / A1 Mini (#1703)
A1 and A1 Mini ship without a MicroSD slot at all - there is no firmware-side "Store sent files on external storage" toggle and the slicers don't surface a slicer-side equivalent either. The connection diagnostic was reading state.store_to_sdcard (home_flag bit 11), which is never set on these models, so the check fell through to fail for every A1-series user. Combined with the absent slicer UI it left users thinking Bambuddy was wrong about a setting their hardware does not have. New NO_EXTERNAL_STORAGE_MODELS frozenset in utils/printer_models.py enumerates A1, A1 Mini, and their internal codes (N1, N2S, A04, A11, A12). has_external_storage() returns False for those, True for everything else. Unknown models default to True so the check stays active for future Bambu lineup additions - new no-slot models must be added to the set explicitly. The diagnostic now short-circuits to skip before reading store_to_sdcard when printer.model is in the set. X1, P1, P2S, H2, and X2D are unchanged - the bit-off -> fail signal is still the right read for them. The companion FTP-upload-timeout symptom in the same bug report (ftp code 28 from BambuStudio when sending to the proxy VP) is a separate Docker-bridge-mode networking constraint, not addressed here.
This commit is contained in:
@@ -20,6 +20,8 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **Print Log page: per-row delete (#1687 part 1, reported by @IndividualGhost1905)** — Reporter noted that the existing "Also remove this print from Quick Stats" toggle on archive delete is one-shot: if you tick "keep stats" at delete time, there was no later way to drop the row from /stats; and rows that aren't tied to an archive (errors, aborts, manual entries) had no delete affordance at all. **Fix:** every row in the Archives → Print Log table now has a trash icon next to the filament cell, gated on `archives:delete_own` (own rows) or `archives:delete_all` (any row), matching the archive-delete permission shape. Click → confirm modal → row is gone, and because /archives/stats aggregates over `PrintLogEntry` the filament / time / cost contribution drops out of Quick Stats in the same response cycle. The matching archive (if any) is untouched — the log row is a sibling, not a child. **Backend:** new `DELETE /print-log/{entry_id}` mirrors `delete_archive`'s ownership flow via `require_ownership_permission(ARCHIVES_DELETE_ALL, ARCHIVES_DELETE_OWN)`; owners can drop their own rows, admins can drop any row, missing IDs return 404 rather than 200-silently. **Frontend:** new `deletePrintLogEntry` API helper, per-row mutation that invalidates both `print-log` and `archives-stats` query keys so the totals re-render without a manual refresh. **i18n:** 4 new keys (`deleteEntryTitle`, `deleteEntryConfirm`, `entryDeleted`, `entryDeleteFailed`) translated across all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). **Tests:** 3 backend integration cases — delete drops the row from /stats while keeping the linked archive listed, missing ID returns 404, delete-one does not touch siblings (regression guard against an accidental `delete(PrintLogEntry)` without a `where`). Frontend ArchivesPage / PrintLogModal vitests stay green (31 / 31). i18n parity green (5099 leaves × 11 locales). Issue #1687 also asks for per-row tagging (already covered by `EditArchiveModal`'s tags field) and per-row filament-usage-history edits (deferred — see the issue thread for the reasoning).
|
||||
|
||||
### Fixed
|
||||
- **Connection diagnostic no longer flags `external_storage: fail` on A1 / A1 Mini, which physically have no MicroSD slot (#1703, reported by @MartinNYHC)** — Bug report from an A1 user complained that BambuStudio and OrcaSlicer don't have an "external storage" tick box (correct — there's nothing to toggle, the A1 series ships without a SD card slot at all) while the Bambuddy support bundle simultaneously reported `external_storage: fail` in the printer's connection diagnostic. The two together left the user thinking Bambuddy was wrong about a setting their hardware doesn't have. **Root cause:** the `external_storage` check at `services/printer_diagnostic.py:179-189` reads `state.store_to_sdcard`, which is parsed from MQTT `home_flag` bit 11. On A1 and A1 Mini that bit is never set (no hardware slot, no firmware-side toggle, no slicer-side equivalent), so the value pushes as `False` and the check fell through to `fail` instead of `skip`. **Fix:** new `NO_EXTERNAL_STORAGE_MODELS` frozenset in `utils/printer_models.py` enumerating A1, A1 Mini, and their internal codes (N1, N2S, A04, A11, A12), plus a `has_external_storage(model)` helper that returns False for those and True for everything else (unknown models default to True so the check stays active for any future Bambu model that ships *with* a slot — new no-slot models must be added to the set explicitly). The diagnostic now short-circuits to `skip` before reading `store_to_sdcard` when `printer.model` is in the set. **What this does NOT change:** X1 / X1E / P1S / P1P / P2S / H2D / H2D Pro / H2C / H2S / X2D continue to evaluate `store_to_sdcard` exactly as before — the home-flag-bit-off → `fail` path is still the right signal for them. **The companion FTP-upload-timeout symptom in the same bug report (ftp code 28 from BambuStudio when sending to the proxy VP) is a separate Docker-bridge-mode networking constraint, not addressed by this change.** **Tests:** 8 new cases — `TestHasExternalStorage` (5 cases) pins the model list, internal-code aliasing, case/whitespace normalisation, unknown-defaults-true, and null/empty-defaults-true; `TestExternalStorageCheck` gains `test_skips_on_a1_no_external_storage_slot`, `test_skips_on_a1_mini_no_external_storage_slot`, and `test_still_fails_on_x1c_when_toggle_off` (regression guard that the model-aware skip doesn't accidentally silence the genuine signal on slotted models). Full `test_printer_models.py` + `test_printer_diagnostic.py` + archives integration suite green (172/172); ruff clean.
|
||||
|
||||
- **AMS slot card surfaced the previous spool's preset name after RFID auto-assigned a new spool (reported with H2D-1 / AMS-B3 / PLA-CF showing as "Bambu PLA Silk+")** — Reporter inserted a fresh Bambu PLA-CF spool into AMS-B3, RFID identified it correctly, but the slot card kept showing "Bambu PLA Silk+" (the name from a PLA Silk+ spool that had occupied the slot back in March). Confirmed in the live data: `slot_preset_mappings` row for `(printer_id=1, ams_id=1, tray_id=2)` was `preset_id=GFSA06_09, preset_name='Bambu PLA Silk+', updated_at=2026-03-15` — three months stale. **Root cause:** `slot_preset_mappings.preset_name` is first in the PrintersPage display chain (`PrintersPage.tsx:3624`) and overrides the spool's own `slicer_filament_name` plus the cloud catalog `cloudInfo.name`. The internal-mode manual-assign path (`inventory.apply_spool_to_slot_via_mqtt`) kept this row in sync, but the internal-mode RFID auto-assign path (`spool_tag_matcher.auto_assign_spool`) skipped it entirely. The Spoolman-mode sync path (`main.auto_sync_spoolman_ams_trays`) also skipped it — same bug shape, latent for Spoolman users who'd never manually configured a slot preset, active for those who had. **Fix — three writers in lockstep via one shared helper.** New `backend/app/services/slot_preset_writer.py` exposes a primitive `upsert_slot_preset` plus two convenience wrappers: `upsert_slot_preset_for_spool` for internal `Spool` ORM objects (local-preset numeric ids → `local_{n}`, cloud ids run through `filament_id_to_setting_id`) and `upsert_slot_preset_for_spoolman_spool` for Spoolman dicts (filament.name → preset_name, tray_info_idx → preset_id). All three call sites — the manual-assign block in `inventory.py:396-438`, the RFID auto-assign tail in `spool_tag_matcher.py:auto_assign_spool`, and the per-tray-sync branch in `main.py:auto_sync_spoolman_ams_trays` — now go through the helper. **Self-heal:** existing stale rows from past spool swaps get rewritten the next time a fresh spool is detected on the same slot. No migration script needed. **What this also covers per `feedback_inventory_modes_parity`:** the bug shape exists in both internal and Spoolman modes, so the patch ships fixes for both inventory paths in the same drop — a Spoolman user with a manually-configured slot preset would have seen the same stale-name behavior after every RFID swap until the row was overwritten through Configure Slot. **Tests:** new `test_slot_preset_writer.py` (6 cases) pins the helper contracts — no-op on empty preset_id, upsert idempotency, Spoolman filament.name → preset_name, fallback to material → tray_sub_brands → tray_type, stale-row overwrite from the Spoolman path, skip when tray_info_idx is unknown. New `test_spool_tag_matcher.py` cases (3) pin the internal RFID-auto-assign path — stale-row overwrite (the exact reporter shape: PLA Silk+ → PLA-CF), fresh insert when no row exists, `local_{n}` formatting for numeric local-preset ids. Total touched-area suite 69/69 green; broader related suite (inventory + spoolman + spool_tag + auto_sync) 767/767 green; ruff clean.
|
||||
|
||||
- **Stats page Failure Analysis widget rendered raw camelCase keys instead of translated reasons (#1687 follow-up, reported by @IndividualGhost1905)** — After #1687 part 4 shipped the per-row Print Log editor, the reporter classified a couple of failed runs and saw "filamentRunout" / "cloggedNozzle" (the literal camelCase keys) appear under Statistics → Failure Analysis → Top Failure Reasons, while the same rows rendered correctly as "Filament runout" / "Clogged nozzle" on the Print Log table. Surfaced an inconsistency I introduced when shipping the new editor: the new Print Log row editor saves the camelCase key (`filamentRunout`) which is what the new backend PATCH validates against, but the older `EditArchiveModal` was still saving the localised label (`"Filament runout"`) as the value — two formats landing in the same `PrintLogEntry.failure_reason` column from two different UI surfaces. The Failure Analysis widget at `frontend/src/pages/StatsPage.tsx:817` and the per-archive run history sub-table at `frontend/src/components/PrintLogTable.tsx:81` both rendered the raw column value without running it through i18n, so the new key-form values surfaced as literal keys. **Fix — three sites in one drop:** (1) `StatsPage.tsx` and (2) `PrintLogTable.tsx` now wrap the value in `t('editArchive.failureReasons.${reason}', { defaultValue: reason })` — same pattern already used at `ArchivesPage.tsx:3874` for the Print Log table. The `defaultValue` fallback keeps legacy translated-text rows rendering as-is, no regression. (3) `EditArchiveModal.tsx` now saves the camelCase key (`<option value={reasonKey}>`) instead of the localised label, matching the new editor's wire format. On modal open, a reverse-lookup against the current locale resolves any legacy translated-text value back to its key so the dropdown pre-selects the right option — every save thereafter converts that row forward to the key format, so the data set self-heals over time without a migration script. Added `htmlFor`/`id` linkage to the failure-reason `<label>`/`<select>` pair as a side benefit (lets `getByLabelText` in tests reach the control, plus a small a11y improvement). **What this also fixes invisibly:** German / Japanese / Turkish users who classified rows under one UI language and then switched languages would have seen their historical buckets fragment in the Failure Analysis widget (each translation = its own group). With keys as the storage format, language switch no longer reclassifies anything. **Tests:** 5 new vitest cases — StatsPage `translates camelCase failure-reason keys` and `renders legacy translated-text failure reasons unchanged`; EditArchiveModal `preselects the option when the stored value is already a camelCase key`, `reverse-looks-up a legacy translated value back to its key`, and `sends the camelCase key on save, not the translated label`; PrintLogModal `translates camelCase failure_reason keys`. The existing `shows failure_reason under failed runs` case (which checks legacy text path) keeps passing under the defaultValue fallback. Full vitest 58 / 58 across touched files. ESLint clean; frontend build clean (vite 9.61s); i18n parity 5118 leaves × 11 locales green (no new keys — reuses `editArchive.failureReasons.*`).
|
||||
|
||||
@@ -18,6 +18,7 @@ from backend.app.models.printer import Printer
|
||||
from backend.app.schemas.printer import DiagnosticCheck, PrinterDiagnosticResult
|
||||
from backend.app.services.discovery import is_running_in_docker
|
||||
from backend.app.services.printer_manager import printer_manager
|
||||
from backend.app.utils.printer_models import has_external_storage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -176,8 +177,14 @@ async def run_connection_diagnostic(
|
||||
# banner. An FTP upload-and-verify probe was tried and rejected — the
|
||||
# /cache directory is always writable from Bambuddy regardless of
|
||||
# either toggle, so the probe always passes and detects nothing.
|
||||
#
|
||||
# Skip entirely on models with no external-storage slot at all (A1
|
||||
# and A1 Mini). They never set home_flag bit 11, so a naive read of
|
||||
# `store_to_sdcard` would fall through to a false `fail` for every
|
||||
# A1-series user (#1703).
|
||||
state = printer_manager.get_status(printer.id) if printer else None
|
||||
if state is None or not state.connected:
|
||||
model_has_slot = has_external_storage(getattr(printer, "model", None)) if printer else True
|
||||
if not model_has_slot or state is None or not state.connected:
|
||||
checks.append(DiagnosticCheck(id="external_storage", status="skip"))
|
||||
elif getattr(state, "store_to_sdcard", None) is True:
|
||||
checks.append(DiagnosticCheck(id="external_storage", status="pass"))
|
||||
|
||||
@@ -111,6 +111,27 @@ LINEAR_RAIL_MODELS = frozenset(
|
||||
)
|
||||
|
||||
|
||||
# Models without any external storage (MicroSD / SD card slot).
|
||||
# The A1 and A1 Mini ship with internal storage only — there is no
|
||||
# firmware-side "Store sent files on external storage" toggle and no
|
||||
# slicer-side equivalent surfaces one. The connection diagnostic's
|
||||
# external_storage check (printer_diagnostic.py) must skip on these
|
||||
# models instead of reporting fail from a 0-valued home_flag bit.
|
||||
NO_EXTERNAL_STORAGE_MODELS = frozenset(
|
||||
[
|
||||
# Display names (uppercase, no spaces)
|
||||
"A1",
|
||||
"A1MINI",
|
||||
# Internal codes
|
||||
"N1", # A1 Mini
|
||||
"N2S", # A1
|
||||
"A04", # A1 Mini (alternate)
|
||||
"A11", # A1
|
||||
"A12", # A1 Mini
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# Models with an ethernet port.
|
||||
# X1, P1P, A1, A1 Mini do NOT have ethernet.
|
||||
ETHERNET_MODELS = frozenset(
|
||||
@@ -173,6 +194,21 @@ def has_ethernet(model: str | None) -> bool:
|
||||
return normalized in ETHERNET_MODELS
|
||||
|
||||
|
||||
def has_external_storage(model: str | None) -> bool:
|
||||
"""Return True if the printer model can have a MicroSD / external storage slot.
|
||||
|
||||
Defaults to True when the model is unknown — the diagnostic only flips
|
||||
its check on for the explicit no-storage list. New models added to the
|
||||
Bambu lineup without a slot must be added to ``NO_EXTERNAL_STORAGE_MODELS``
|
||||
or the diagnostic will continue to evaluate ``store_to_sdcard`` against
|
||||
a hardware feature the printer doesn't have.
|
||||
"""
|
||||
if not model:
|
||||
return True
|
||||
normalized = model.strip().upper().replace(" ", "").replace("-", "")
|
||||
return normalized not in NO_EXTERNAL_STORAGE_MODELS
|
||||
|
||||
|
||||
def is_dual_nozzle_model(model: str | None) -> bool:
|
||||
"""Return True if the printer model has two nozzles (H2D family / X2D)."""
|
||||
if not model:
|
||||
|
||||
@@ -85,8 +85,8 @@ class _Env:
|
||||
return False
|
||||
|
||||
|
||||
def _printer(ip="192.168.1.50"):
|
||||
return types.SimpleNamespace(id=1, ip_address=ip)
|
||||
def _printer(ip="192.168.1.50", model=None):
|
||||
return types.SimpleNamespace(id=1, ip_address=ip, model=model)
|
||||
|
||||
|
||||
class TestSameSubnet:
|
||||
@@ -295,3 +295,27 @@ class TestExternalStorageCheck:
|
||||
with _Env(state=bare):
|
||||
result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
|
||||
assert _statuses(result)["external_storage"] == "skip"
|
||||
|
||||
async def test_skips_on_a1_no_external_storage_slot(self):
|
||||
# Regression for #1703: A1 and A1 Mini ship without a MicroSD slot
|
||||
# at all, so home_flag bit 11 is never set and a naive read would
|
||||
# report `fail` for every A1-series user. The model-aware skip
|
||||
# branch suppresses that — and the overall result must NOT escalate
|
||||
# to "problems" purely because of this check.
|
||||
with _Env(state=_state(store_to_sdcard=False)):
|
||||
result = await run_connection_diagnostic("192.168.1.50", printer=_printer(model="A1"))
|
||||
assert _statuses(result)["external_storage"] == "skip"
|
||||
assert result.overall == "ok"
|
||||
|
||||
async def test_skips_on_a1_mini_no_external_storage_slot(self):
|
||||
with _Env(state=_state(store_to_sdcard=False)):
|
||||
result = await run_connection_diagnostic("192.168.1.50", printer=_printer(model="A1 Mini"))
|
||||
assert _statuses(result)["external_storage"] == "skip"
|
||||
|
||||
async def test_still_fails_on_x1c_when_toggle_off(self):
|
||||
# Sanity: the model-aware skip MUST NOT silently let X1C-class
|
||||
# printers off the hook. The store_to_sdcard=False path is the
|
||||
# one real bit of value this check provides for those models.
|
||||
with _Env(state=_state(store_to_sdcard=False)):
|
||||
result = await run_connection_diagnostic("192.168.1.50", printer=_printer(model="X1C"))
|
||||
assert _statuses(result)["external_storage"] == "fail"
|
||||
|
||||
@@ -8,6 +8,7 @@ from backend.app.utils.printer_models import (
|
||||
STEEL_ROD_MODELS,
|
||||
get_rod_type,
|
||||
has_ethernet,
|
||||
has_external_storage,
|
||||
is_dual_nozzle_model,
|
||||
normalize_printer_model,
|
||||
normalize_printer_model_id,
|
||||
@@ -146,3 +147,35 @@ class TestDualNozzleModel:
|
||||
def test_none_and_empty_are_not_dual(self):
|
||||
assert is_dual_nozzle_model(None) is False
|
||||
assert is_dual_nozzle_model("") is False
|
||||
|
||||
|
||||
class TestHasExternalStorage:
|
||||
"""Pins which Bambu models have a MicroSD slot. The connection
|
||||
diagnostic flips its ``external_storage`` check from ``fail`` to
|
||||
``skip`` based on this — a false add (X1C marked as no-storage) would
|
||||
silently disable a genuine fail signal for X1/P1/P2S/H2 users."""
|
||||
|
||||
@pytest.mark.parametrize("model", ["A1", "A1 Mini", "A1MINI", "A1-Mini", "a1"])
|
||||
def test_a1_series_has_no_external_storage(self, model: str):
|
||||
assert has_external_storage(model) is False
|
||||
|
||||
@pytest.mark.parametrize("model", ["N1", "N2S", "A04", "A11", "A12"])
|
||||
def test_a1_internal_codes_have_no_external_storage(self, model: str):
|
||||
assert has_external_storage(model) is False
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model",
|
||||
["X1C", "X1E", "X1", "P1S", "P1P", "P2S", "H2D", "H2D Pro", "H2C", "H2S", "X2D"],
|
||||
)
|
||||
def test_other_models_have_external_storage(self, model: str):
|
||||
assert has_external_storage(model) is True
|
||||
|
||||
def test_unknown_model_defaults_to_true(self):
|
||||
# Default-true keeps the diagnostic active for new Bambu models;
|
||||
# add them to NO_EXTERNAL_STORAGE_MODELS explicitly when they ship
|
||||
# without a slot.
|
||||
assert has_external_storage("BrandNewModel2027") is True
|
||||
|
||||
def test_none_and_empty_default_to_true(self):
|
||||
assert has_external_storage(None) is True
|
||||
assert has_external_storage("") is True
|
||||
|
||||
Reference in New Issue
Block a user