Reporter assigned a spool to a slot whose stored slicer profile differed
from the new spool's, got the Cancel / Assign Anyway popup, and was under
the impression that confirming the popup just linked the spool in
Bambuddy's DB without pushing the new profile to the AMS — i.e. that he
then had to manually open Configure AMS Slot to fix it. The auto-push has
actually been in place since the assign route existed:
backend/app/api/routes/inventory.py::assign_spool calls
apply_spool_to_slot_via_mqtt after upserting the SpoolAssignment row,
which publishes ams_filament_setting + extrusion_cali_sel over MQTT, and
backend/app/api/routes/spoolman_inventory.py::assign_spoolman_slot does
the same for the Spoolman backend. The only short-circuit is when
firmware reports the slot explicitly empty (tray_state in {9, 10}), in
which case main.py::on_ams_change deferred-replays the configure once a
spool appears. So the popup was friction without revealing what it did.
Two changes:
- frontend/src/components/AssignSpoolModal.tsx and
spoolbuddy/AssignToAmsModal.tsx: profile-only mismatch no longer
fires the popup. The condition becomes
`if (materialMatchResult !== 'exact')` instead of
`materialMatchResult !== 'exact' || !profileMatches`. The 'profile'
member is dropped from the mismatchType union and its standalone
branch in each popup render body is removed as dead code. Material
mismatch still warns — Bambu firmware can refuse the print when the
type is wrong.
- Every firing warning (material, partial, material+profile,
partial+profile) now appends one line via a new
inventory.assignReconfigureNote i18n key:
"The AMS slot will be reconfigured to use the spool's profile."
Real translations across all 9 locales per
feedback_translate_dont_fallback; parity script clean at 4999
leaves per locale.
Reporter wanted to select a transparent filament colour in the spool
editor; CMW-ISS confirmed on v0.2.5b1 that AMS-detected transparent
spools were silently labelled "Black" in the filament-mapping dropdown
because the colour name resolver dropped the alpha byte and the underlying
RGB 000000 HSL-bucketed to "Black". Spoolman already supported 8-digit
hex; the built-in inventory didn't.
Eight collapsing sites fixed together so transparent reaches the user
intact:
- frontend/src/utils/colors.ts: hexToColorName / getColorName /
resolveSpoolColorName / isLightColor short-circuit to "Clear" for
alpha=00 before HSL bucketing or catalog lookup
- frontend/src/utils/amsHelpers.ts::normalizeColor preserves the alpha
byte when alpha < FF (normalizeColorForCompare unchanged so type/colour
matching is unaffected)
- frontend/src/components/spool-form/constants.ts: new
{ name: 'Clear', hex: '00000000' } preset in QUICK_COLORS
- frontend/src/components/spool-form/ColorSection.tsx: hex draft accepts
0-8 chars, commits at 6 (+FF) or 8 verbatim; blur pads 7-char to 8;
selectColor passes 6-char as +FF / 8-char verbatim; isSelected matches
on full rgba; swatch buttons paint a checkerboard for alpha=00
- backend/app/api/routes/printers.py::get_available_filaments preserves
the full rgba on both AMS and vt_tray branches (6-char dedup key
unchanged)
- backend/app/services/spoolman.py::parse_ams_tray drops the silent
00000000 -> F5E6D3FF cream rewrite — the swatch renderer paints a
checkerboard underlay for alpha < FF already (added in #1154), so the
rewrite was hidden technical debt that made every AMS-detected
transparent spool land in inventory as cream
- backend/app/services/spool_tag_matcher.py::create_spool_from_tray
short-circuits the colour-catalog lookup for alpha=00 and stores
color_name="Clear" directly — otherwise an RFID-tagged transparent
Bambu spool would resolve against the #000000 catalog row (or "Black"
via the HSL fallback) before the frontend's resolver ever saw it
- Two shared helpers in utils/colors.ts — getSwatchStyle(rgba) (style
object: checkerboard for alpha=00) and spoolColorString(rgba)
(8-char hex string for SVG fill) — applied to every simple-swatch
site that would otherwise have rendered Clear spools as solid black:
LabelTemplatePickerModal, SpoolBuddyInventoryPage (SpoolCircle + dot),
SpoolBuddyAmsPage (both branches), SpoolBuddyWriteTagPage (4 sites),
ForecastPanel, AssignToAmsModal, AssignSpoolModal (both branches),
InventorySpoolInfoCard, TagDetectedModal, SpoolInfoCard, LinkSpoolModal,
and the FilamentSwatch tooltip title fallback
Intentionally NOT changed: native <input type="color"> keeps 6-char hex
(can't pick alpha; onChange still emits +FF, correct); Spoolman's
_find_or_create_filament strips alpha (Spoolman catalog is 6-char only);
print_scheduler colour matching strips alpha (auto-mapping treats Clear
as Black for slot compatibility); label_renderer prints "#RRGGBB" on the
physical label (printers can't print transparency, swatch fill via
_color_from_hex still honours alpha).
Bambu printer SD cards are FAT32/exFAT, which forbids < > : " / \ | ? *
plus control chars and trailing dots/spaces. Library rename only blocked
path separators, so a name like L|R.3mf was accepted and only failed
later at FTP upload with 553 Could not create file - far from the rename
action that caused it. Bambu Studio refuses these names in its save
dialog; Bambuddy now does the same.
New backend/app/utils/filename.py centralises validation. Wired into
update_file, upload_file, print_library_file, and queue add. Existing
rows with bad names are left alone (no silent rewrite of user data);
users get an actionable 400 pointing at rename.
Frontend rename modal mirrors the same set client-side with inline error.
New fileManager.invalidFilenameChar i18n key translated across all 9
locales. 26 new tests in test_filename_validation.py.
The TARE button on Settings → Scale set a "Tare command sent. Waiting
for device..." banner with no mechanism to clear it. The daemon writes
back through /calibration/set-tare which stamps last_calibrated_at on
the device row, but handleTare was set-and-forget — the banner stayed
forever. The "Calibration complete!" success banner had the same shape.
Snapshot last_calibrated_at when TARE is pressed, set an awaiting state,
invalidate the device-list query every 1s while waiting (so detection
responds within ~1s, not the 10s background poll), and when the snapshot
advances flip the banner to "Tare complete!" with a 3s auto-dismiss. A
15s timeout falls open to "Tare timed out — is the SpoolBuddy daemon
running?" so a dead daemon doesn't trap the user on the spinner. The
calibration-complete and calibration-failed banners now share the same
auto-dismiss helper.
Reporter flagged that the new Cancelled row's Ban icon rendered colourless
while Successful and Failed used green/red icons in the same widget. Switch
the Cancelled icon to text-status-warning (amber-500) so all three rows now
use semantic status tokens consistently, and the colour matches the orange
Archives + notification badges already use for cancelled status.
Reporter (@IndividualGhost1905) saw Total: 20 / Success: 18 / Failed: 1
and asked where the 20th print went. The Quick Stats endpoint counted
status == "completed" → Successful and status == "failed" → Failed, but
used a raw count(*) for Total Prints, so the four other PrintLogEntry
statuses (aborted, stopped, cancelled, skipped) silently inflated the
total without showing up in any breakdown row. The earlier #1390 round
had committed a test locking in this exact behaviour
("uses total_prints as denominator so cancelled/stopped events count"),
which was wrong: it conflated user intent with print quality.
Three-bucket classification, applied across the whole stats surface
and matching how the rest of the codebase already groups statuses
(main.py:430, 1729; failure_analysis status filter):
successful = completed
failed = failed + aborted (printer-detected quality failures)
cancelled = stopped + cancelled + skipped (user/queue stopped)
Quick Stats endpoint returns the new cancelled_prints field;
ArchiveStats.cancelled_prints defaults to 0 so older fixtures still
parse. SuccessRateWidget gauge now divides by successful + failed
only — a cancelled roll no longer drags the gauge down — and a
Cancelled row appears in the breakdown so the missing prints don't
silently vanish from Total Prints.
Failure Analysis service applies the same denominator fix to both
the headline failure_rate and the per-week trend, so a week with
several cancellations and zero failures reads as 0% rather than
a misleading "failed / total".
i18n: new stats.cancelled key in all 9 locales with real
translations (no English fallback), parity script clean.
Tests: the existing 'uses total_prints as denominator' assertion
is inverted to assert the new behaviour (40 / 20 / 35 → 67% gauge,
Cancelled: 35 visible). The unchanged-display path (140 / 10 / 0
→ 93%) still holds since 140 / (140 + 10) = 93.33% rounds the
same. 33 StatsPage tests + 6 backend stats/failure tests green.
The three diagnostic surfaces shipped earlier this month
(6bc6a1d6 VP setup diagnostic, e222a0ef log-health scanner,
ed31b8f4 connection diagnostic in the bug-report bubble) were
only ever shown to the *user*. A bug report arriving in the
maintainer's inbox carried raw logs but no diagnostic results —
the user-visible "your X1C can't reach MQTT" finding never made
it into the issue body, so the maintainer had to ask the user
to re-run and paste.
New `services/diagnostic_snapshot.collect_diagnostic_snapshot`
runs all three concurrently with a per-probe 15 s wall-clock cap
(so total ≈ max(per-cap), not sum — fleet size doesn't matter)
and is fail-soft per probe: a crash inside one printer's check
emits `{"printer_id": N, "error": "..."}` for that entry rather
than nuking the whole snapshot. The snapshot is then added as a
`diagnostics` top-level key by `_collect_support_info()`, so both
flows (POST /support/bundle and POST /bug-report/submit via
`support_info=...`) pick it up without their own changes.
Private-data sanitization
-------------------------
The diagnostic schemas embed raw IPv4 in five field shapes that
must not land in a submitted GitHub issue or a shared support ZIP:
- PrinterDiagnosticResult.ip_address (top-level)
- DiagnosticCheck.params.printer_ip (network-mode check)
- DiagnosticCheck.params.host_ip (network-mode check)
- VPDiagnosticResult per-check params.bind_ip (VP setup)
- IPs embedded in log-health sample lines
The first two carry the printer's own IP (already in the
existing `collect_sensitive_strings` table via the Printer rows);
host_ip and bind_ip are NOT in the DB so a sensitive_strings-only
pass missed them.
Fix: `_sanitize_recursive` walks the full snapshot tree, masks
DB-known values with the same `[PRINTER]/[IP]/[SERIAL]/[ACCESS_CODE]`
labels the log sanitizer applies (via the shared
`collect_sensitive_strings`), then an IPv4-regex pass catches any
IP the DB didn't cover — most importantly the Bambuddy host IP
returned by `_get_host_ip()` and the VP `bind_ip` the user picked
at setup. Recursive walk so arbitrary nested dicts/lists don't
slip through future schema additions.
Live-DB smoke test against the dev fleet: zero raw IPv4 instances
in the serialized snapshot output; all five field shapes plus the
embedded log samples render as `[IP]`.
Progress indicators
-------------------
The bubble's "submitting" view and the System page's Download
button now render a static four-line checklist showing what's
running (printer connectivity → VP setup → log scan →
submit / build ZIP). Static, not faked phase progress — we can't
actually track server-side phases without SSE and the honest
"here's what's happening" list communicates the longer wait
without lying about percentage complete.
9 new i18n keys, real translations in all 9 locales (no English
fallback). parity script clean at 4993 leaves per locale.
Tests: 6 new in test_diagnostic_snapshot.py
- empty-input shape stable (the three top-level keys always present)
- per-printer / per-VP result coverage (lists match input lengths)
- fail-soft on a single-probe crash (other entries + log-health
still complete)
- timed_out marker when a probe exceeds the per-probe cap
(test patches the cap to 0.05 s)
- end-to-end IP sanitization across all five field shapes plus
log-sample IPs, with a final JSON-serialize-and-regex sweep
asserting zero raw IPv4 escapes anywhere in the result
- concurrent execution proof (4 × 0.2 s probes complete in
< 0.5 s; sequential would be 0.8 s)
After the @BBL name fallback landed, IndividualGhost1905 reported that
an X2D 0.4 selection still mixed 0.2 / 0.6 / 0.8 nozzle process variants
into the main dropdown. The fallback's two extractors —
extractPrinterPresetModel and extractBblToken — both ended their regex
with `\s+[\d.]+\s*nozzle\s*$` and discarded the match, reducing
"Bambu Lab X2D 0.4 nozzle" and "0.40mm Strength @BBL X2D 0.8 nozzle"
to the same "X2D" string. Match was model-only; nozzle ignored.
Bambu's naming convention: 0.4 is the default and DROPS the suffix; 0.2
/ 0.6 / 0.8 carry an explicit "<size> nozzle" segment. So a process
preset with no suffix is implicitly 0.4 — not "any nozzle".
Have both extractors return { model, nozzle }, parsing the suffix out
instead of stripping. classifyByBambuName then requires both model AND
nozzle to compare equal; a null process nozzle counts as "0.4" per the
convention above. Differing nozzles fall into the existing "Other
printers" group — no new group label.
The bundle path was already nozzle-correct: a .bbscfg is scoped to one
printer-preset-name including its nozzle, and the bundle-side exact
match is therefore nozzle-aware. Only the @BBL name fallback needed
fixing. The `compatible_printers` tier is also unaffected (Bambu's
bundled `compatible_printers` lists include the full printer-preset
name with nozzle, so disambiguation already works).
If the selected printer preset name has no parseable nozzle (non-Bambu
/ hand-typed), the matcher degrades to model-only. Bambu printer
presets always carry one in practice; this is defensive.
9 new tests cover the matrix:
- 0.4 printer ↔ no-suffix process: match
- 0.4 printer ↔ 0.6 / 0.8 process: mismatch
- 0.6 printer ↔ 0.6 process: match
- 0.6 printer ↔ no-suffix process (=0.4): mismatch
- same rule applied to filament presets
- explicit "0.4 nozzle" suffix on process still matches 0.4 printer
- wrong-model still mismatches even when nozzles agree
- no-nozzle printer name degrades to model-only match
One existing test ("handles a trailing nozzle-size suffix on the @BBL
tag") had asserted that a 0.6-nozzle process matched a 0.4 printer —
the exact reporter complaint. Reframed: matching 0.4-suffix still
matches, 0.6-suffix now mismatches.
Two small bugs reported in chat:
1. List-view actions column was clipped off the right edge. The grid
template fixed the actions column at 80px - a sliced 3MF row renders
7 icon buttons (Print, Schedule, Slice, 3D Preview, Download, Rename,
Delete) which need ~220px. The wrapper's overflow-hidden (there for
rounded corners) then swallowed everything past column 80px.
Switched the trailing column from 80px to min-content so it sizes to
the widest action strip across all rows, and replaced overflow-hidden
with overflow-x-auto so narrow viewports get a scrollbar instead of
vanished icons. Rounded corners survive because the border-radius is
on the wrapper itself, not on the overflow box.
2. The 3MF preview modal's "Open in Slicer" button always launched the
external slicer (BambuStudio / Orca) regardless of Settings -> Workflow
-> Slicer -> Use Slicer API. With the API enabled, the file-row Cog
already opens the in-app Bambuddy SliceModal - the preview-modal
button should match.
Added an optional onSliceWithBambuddy callback to ModelViewerModal.
When set AND settings.use_slicer_api is true AND we're in library mode
on a sliceable type (.3mf/.stl/.step/.stp), the header button becomes
a Cog labelled "Slice" and calls the in-app handler. Otherwise it
falls back to the existing ExternalLink "Open in Slicer" button.
FileManagerPage wires the callback to close the viewer and open the
SliceModal with the same file, gated on the same isSliceableFilename
+ library:upload permission checks the row's Cog already uses. No new
i18n keys - reuses slice.action ("Slice") that the file row already
ships in all 9 locales.
The first cut of #1325 swapped a stale hardcoded model table for
bundle-based compatibility matching: a cloud / standard process or
filament preset was classified by consulting the user's uploaded
Slicer Bundles (.bbscfg). That works perfectly when bundles cover
every printer in the user's cloud catalogue, and silently no-ops
otherwise - every cloud preset resolves to 'unknown', nothing moves
into "Other printers", and the dropdowns look identical to the
pre-fix state. The reporter saw exactly this on a clean install
with no bundles uploaded.
Restored BambuStudio's `@BBL <token>` name convention as a third
tier below the bundle path, but driven by the canonical backend
PRINTER_MODEL_MAP - exposed via a new GET /slicer/printer-models
route - rather than a manually-maintained frontend table. The
matcher inverts the registry into short-code -> printer-fragment
("X1C" -> "X1 Carbon"), normalises whitespace + case so "A1 mini"
and "A1 Mini" compare equal, and falls back to raw-token compare
for models not yet in the registry (so a future "Q1" matches
without a code change). Adding a new Bambu model still touches
exactly the one backend file already listed in the Bambu Model
Codes registry.
Tests: 2 new in test_slicer_presets.py (route returns the full
PRINTER_MODEL_MAP, route returns a copy not the live dict); 11
new in slicerPrinterMatch.test.ts covering registry-driven X1C
vs X1 Carbon, A1 vs A1 mini, H2D vs H2D Pro, P2S / H2C / H2S /
X2D (which the original hardcoded list was missing), raw-token
fallback, registry-not-loaded-yet degradation, and the
precedence rules between compatible_printers / bundle / @BBL
name. 38 slicer-presets + 36 slicerPrinterMatch + 34 SliceModal
tests green; backend ruff clean; frontend build clean.
Re-slicing a 3MF authored for a single-nozzle printer (X1C, P1S, A1, P2S)
onto a dual-nozzle printer (H2D / H2D Pro) — or vice versa — previously
failed with "G-code in unprintable area of multi-extruder printers" (the
source's bed-coordinate layout lands in the H2D's per-nozzle dead zone)
or, on multi-color projects, a hard SIGSEGV inside the slicer's ZFiller
polygon-clipping. Earlier shipped a fail-fast 400 guard; this drop lifts
it and actually does the conversion by forwarding the sidecar's existing
--arrange flag when the source and target nozzle classes differ. BS
itself reconciles the embedded project_settings.config against the new
printer that way, the same way the GUI's "Switch Printer" operation
does. The guard becomes a kept-for-compat no-op.
Slice-all-plates added to the SliceModal: a checkbox for multi-plate
sources sends plate=0 to the backend, which forwards --slice 0 to the
BS CLI. Same-class slice-all produces one multi-plate output 3MF in a
single sidecar call. Cross-class slice-all loops per plate (BS's
--arrange is project-wide and would otherwise consolidate every plate's
objects onto one bed) and merges the per-plate outputs into one
multi-plate 3MF locally via the new merge_plate_3mfs helper. The toast
shows "Plate 2 of 5 — Generating G-code (47%)" through the loop.
Three side fixes surfaced during testing:
- substitute_unused_plate_filaments overwrites unused-slot filaments
with the slot-1 selection before slicing so BS's loaded-filament
temperature validator doesn't reject a PLA print whose unused slot 2
defaulted to ABS in the dropdown
- re-sliced archive thumbnail now prefers the source's per-plate
render (Metadata/plate_N.png) over the project-wide MakerWorld cover
art, because BS CLI with --arrange skips writing a fresh per-plate
preview
- re-sliced archive bed_type now lifts from the sliced output's
curr_bed_type onto the PrintArchive column the card actually reads
Schema: SliceRequest.plate range relaxed from ge=1 to ge=0 to admit
the "all plates" sentinel; SlicerApiService.slice_with_profiles /
slice_with_bundle take an `arrange` parameter.
Tests: 26 in test_slicer_3mf_convert (count / merge / substitute /
extract), 3 in test_slicer_api (arrange wire format), 9 in
test_library_slice_api (guard no-op, bed_type lift, thumbnail
fallback, new cross-class slice-all loop integration test), 2 in
test_archive_service (Auxiliaries fallback), 4 in SliceModal.test
(plate=0 toggle), 2 in SliceJobTrackerContext.test (multi-plate toast
prefix). 659 backend + 42 frontend green; backend ruff clean,
frontend build clean, i18n parity green at 4984 keys × 9 locales.
The SpoolBuddy daemon already reports load_avg (1/5/15 min) and
cpu_count in its heartbeat, but the Settings -> SpoolBuddy card only
showed CPU temp / memory / disk / system uptime. Adds a fifth tile
rendering the 1-min load with core count and percent-of-cores --
e.g. "1.20 / 4 (30%)" -- next to the existing CPU temp tile. Falls
back to a bare load number when cpu_count is missing and hides the
tile when the daemon doesn't emit load_avg.
settings.spoolbuddy.cpuLoad translated across all 9 locales.
The pre-print deficit warning from #720 only ran inside the PrintModal
submit flow. Both the green ▶ button on a staged queue row (POST
/queue/{id}/start) and the Virtual Printer queue-mode intake bypassed
it — auto_dispatch=True VP intakes would dispatch unsupervised onto
spools that physically can't complete the print.
Extracted the deficit check into backend/app/services/filament_deficit.py
(single source of truth, both internal inventory and Spoolman modes).
POST /queue/{id}/start returns 409 with a structured deficit payload
unless ?skip_filament_check=true. The dispatch scheduler runs the same
check before each _start_print; a deficit promotes the item to
manual_start + sets a new filament_short flag (idempotent migration on
print_queue). The flag clears automatically on the next tick when the
operator swaps a spool to one with enough material.
Frontend ▶ catches the 409 and opens a confirm modal showing each
shorted slot's required vs remaining grams; the row now renders a
yellow "Insufficient filament" badge when filament_short is set.
Translated across all 9 locales.
The File Manager sidebar's "All Files" entry was passing
include_root=true to GET /library/files because the boolean was
derived from `selectedFolderId === null`. The backend's include_root
flag means *root files only* when folder_id is null, so a library
where every file lived in a subfolder rendered as empty.
Pass include_root=false from "All Files" so the backend returns every
active file across folders. Adds a regression test that mounts the
page, mocks the endpoint to distinguish include_root=true vs false,
and asserts both a root file and a nested file appear.
Adds a passive log-health check that complements the active Connection
Diagnostic. Scans Bambuddy's recent app log against a curated allowlist
catalog of known failure signatures (rejected access code, FTPS :990
timeout, FTPS TLS failure, flapping MQTT, unreachable camera, SQLite
"database is locked" contention), dedupes and classifies each finding
as layer8/environment/bug, and deep-links to the troubleshooting wiki.
Sample log lines are sanitized before they leave the process. Exposed
via GET /system/health and surfaced on two surfaces sharing one
SystemHealthPanel component: a System Health section on the System
page, and inline in the bug reporter when the form opens.
The Add-Printer and Edit-Printer dialogs gained a setup-time pre-flight:
saving runs the connection diagnostic and, on a failed check, warns with
a "save anyway" escape hatch instead of silently saving a printer that
will immediately show offline.
Log read/parse/sanitize primitives extracted from routes/support.py into
a shared services/log_reader.py (behaviour-preserving); affected support
tests repointed accordingly.
Tests: test_log_health.py (11), test_system_api.py (2 new),
SystemHealthPanel + BugReportBubble + AddPrinterPreflight +
EditPrinterPreflight (8 frontend). All strings translated across the 9
locales. Backend ruff clean, full unit suite green, frontend build +
eslint clean, i18n parity green.
Two recurring virtual-printer support pains, both on the Virtual Printers
settings page.
Setup check: a stethoscope action on each VP card runs a pass/fail/warn/skip
checklist — VP enabled, services running, bind interface still exists, access
code set, target printer (proxy mode), and a live TCP probe of the FTP / MQTT
/ discovery ports on the bind IP. start_server swallows per-service bind
errors, so a service object can exist while nothing is listening; probing the
bind IP from outside is the only reliable signal and it catches the common
"VP not visible in the slicer" bind-IP-conflict and stale-interface cases.
Slicer certificate: virtual printers present a TLS cert signed by a shared CA
the slicer must trust. Until now users had to docker exec in and cat
bbl_ca.crt. A "Slicer certificate" row on the settings card now offers Copy
and Download (bambuddy-virtual-printer-ca.crt) plus the SHA-256 fingerprint.
GET /virtual-printers/ca-certificate returns only the public certificate; the
CA private key never leaves the backend. The CA is generated on demand so the
button works before the first VP is enabled.
Backend:
- services/virtual_printer/diagnostic.py — run_vp_diagnostic + port probes
- schemas/virtual_printer.py — VPDiagnosticResult
- CertificateService.get_ca_certificate_info() + manager helper
- routes: GET /virtual-printers/ca-certificate, /{vp_id}/diagnostic
Frontend:
- VirtualPrinterDiagnosticModal.tsx; stethoscope button on VirtualPrinterCard
- caCert row on VirtualPrinterList; utils/clipboard.ts (shared copy w/
non-secure-context fallback + downloadTextFile), de-duplicating the
existing FQDN-copy logic
- vpDiagnostic.* + virtualPrinter.caCert.* across all 9 locales
9 backend unit tests + 4 route integration tests + 6 frontend tests.
Backend ruff clean, frontend build clean, i18n parity green.
The "Report a Bug" panel scans every configured printer on open and shows
connection problems inline. The first cut rendered a full ~6-row checklist
per problem printer, stacked — on a large fleet that pushed the description
box and Submit button below the fold in the max-w-md / max-h-80vh panel.
The diagnostic section is now a compact summary: one "N of M printers have
connection issues" line plus the affected printers as collapsed rows
(healthy printers count toward M, render no detail). Each row expands on
demand to that printer's full checklist via the shared Collapsible widget;
a single problem auto-expands since that's the case where inline detail is
wanted without a click. Panel height is now fixed regardless of fleet size.
- BugReportBubble.tsx: diagnostic query pairs each result with the printer
name (new DiagnosticEntry type); render block uses Collapsible rows
- i18n: new bugReport.diagnosticSummary ({{problems}}/{{total}}) replaces
static diagnosticHeading; diagnosticIntro reworded count-neutral — all 9
locales
- 2 new BugReportBubble tests (collapsed-then-expand; single auto-expand)
Five follow-up fixes to cross-printer re-slicing, all surfaced while
testing archive re-slices.
1. Re-sliced archive now records the printer it was sliced FOR.
slice_and_persist_as_archive copied sliced_for_model from the source
archive, so re-slicing X1C->H2D still showed "X1C sliced". Read it
from the freshly-sliced 3MF's parsed metadata instead, falling back
to the source only when absent.
2. Real slicer rejections are surfaced instead of silently masked.
_run_slicer_with_fallback retried with the 3MF's embedded settings on
any sidecar 5xx — including genuine content rejections (object off
the bed, incompatible filament temps), which "succeeded" only by
re-slicing for the source's original printer. A new
_slicer_rejection_message detects the slicer's own error string and
surfaces it as a 400; the embedded-settings fallback is kept only for
true CLI crashes.
3. A failed slice opens an error modal, not a 3s toast. The slicer's
reason is actionable and a toast hides it before it can be read. New
AlertModal (acknowledge-only); SliceJobTrackerContext shows it on a
failed job. New slice.failedTitle key in all 9 locales.
4. Sliced files no longer report "0 g" filament usage. The sidecar
doesn't always populate the X-Filament-Used-* headers;
ThreeMFParser._parse_gcode_header now also reads the slicer's own
"total filament weight/length" from the G-code header, and both
slice-persist paths fall back to it when the sidecar reports 0.
5. Nozzle-class re-slice guard. Re-slicing across the single-nozzle <->
dual-nozzle boundary (e.g. X1C -> H2D) fails BambuStudio's
multi-extruder validation; both slice routes now reject it up front
with a clear 400. The dual-nozzle model classification — previously
an inline tuple duplicated across start_print and the K-profile
routes — is centralized into DUAL_NOZZLE_MODELS / is_dual_nozzle_model
in printer_models.py, consumed by all three sites and the guard.
Full cross-nozzle-class re-slicing (dual-nozzle project_settings
reconciliation) remains separately tracked.
Tests: _slicer_rejection_message, _canonical_printer_model,
guard_nozzle_class_reslice, is_dual_nozzle_model, the G-code-header
filament parse, AlertModal, and end-to-end slice-API coverage including
an X1C-archive-to-H2D 400. Backend ruff + i18n parity clean; frontend
build clean.
The browser console logged "downloadable font: rejected by sanitizer"
for inter-latin.woff2 on every load. The #1460 PWA fix added @font-face
rules pointing at /fonts/inter-latin.woff2 and bundled the woff2 files
into static/fonts/, but main.py only mounts /assets, /img and /icons as
static directories. With no /fonts mount, /fonts/*.woff2 fell through to
the SPA catch-all and returned index.html with 200 OK; the browser's
OpenType sanitizer rejected the HTML-as-a-font.
Add a /fonts StaticFiles mount alongside /img and /icons. The woff2
files themselves are valid (verified — Inter variable, latin and
latin-ext subsets).
Also bump the service worker STATIC_CACHE version (v26 -> v27). sw.js
lists the two font URLs in STATIC_ASSETS, and cache.addAll() treats the
200 OK HTML as a successful fetch — so it had cached index.html under
the font URLs and served it cache-first. The version bump makes the
activate handler purge the poisoned cache and re-fetch the real fonts.
The #1395 camera-diagnostic follow-up (stethoscope control-bar icon,
Diagnose button in the stream-error state, CameraDiagnoseModal) shipped
wired into EmbeddedCameraViewer.tsx only — the embedded camera mode.
CameraPage.tsx, the standalone window opened at /camera/{id} when
camera_view_mode is "window" (the default), never got it.
A reporter on window mode could not see the stethoscope regardless of
container rebuilds or cache clears: the camera.diagnose strings are in
the bundle but come from EmbeddedCameraViewer, which never renders in
window mode. Switching to overlay mode surfaced it instantly.
Port the diagnostic into CameraPage.tsx: Stethoscope control-bar button
between Refresh and Fullscreen, a Diagnose button next to Retry in the
streamError block, and the CameraDiagnoseModal render. No new i18n keys
— camera.diagnose.* already exist in all 9 locales. The backend
per-model camera-profile fix from the same issue is view-mode-agnostic
and unaffected.
FileManagerModal ran its file-listing query with refetchInterval 30000,
opening a fresh FTPS connection (full TLS handshake) to the printer every
30s while the modal was open. On fragile controllers like the P1S that
load tipped MQTT, FTP and the camera into simultaneous timeouts. Drop the
interval; the listing still refreshes on open, directory change, after
upload/delete, and via the manual Refresh button.
Also log STL thumbnail failures with a traceback (exc_info) so a
data-specific "str / str" TypeError can be located from a support bundle.
The process dropdown still mixed @BBL P2S presets into an X1C list:
slicerPrinterMatch matched cloud/standard presets by parsing the
@BBL <model> name suffix against a hardcoded model-code allow-list
that was missing P2S, H2C and X2D — so those presets resolved to
"unknown" and stayed in the main list instead of "Other printers".
Drop both hardcoded model tables. Compatibility now comes from the
user's uploaded Slicer Bundles: a bundle is scoped to one printer and
lists the presets it ships, so a preset matches a printer exactly when
some bundle for that printer contains it. New models are covered the
moment their bundle is uploaded.
Bambuddy installed as a PWA on desktop but not on Android. Two causes:
- Chrome for Android removed the automatic install banner in Chrome 108.
With no beforeinstallprompt handler, Android had no install path. New
InstallAppButton captures the event and re-fires it from the sidebar.
- index.css pulled Inter from fonts.googleapis.com: breaks offline, trips
CSP, and the service worker answered the failed cross-origin request
with cached index.html. Inter is now self-hosted; the SW skips all
cross-origin requests and caches the font; CSP drops the Google hosts.
With "Group similar" enabled, the collapsed group row showed a single
member's values — a group of five 1 kg spools displayed 1000 g instead
of 5 kg (#1368).
The group header now aggregates across all members: the table view's
Label / Net / Gross / Used / Remaining columns and the grid card's
weight figure show group totals. Identity columns (Material, Brand,
Colour) and the Cost/kg rate stay per-spool-correct since they are the
group key; per-spool-only fields with no meaningful total (dates,
location, note, tag ID) keep the representative member's value. The
expanded per-spool rows are unchanged.
New aggregateGroupSpool helper with 4 unit tests; the table group
component's header prop is renamed representative -> headerSpool.
Frontend-only — all data was already in the spool list.
The Slice dialog listed every process / filament preset regardless of
the chosen printer, and always defaulted to the first listed preset
rather than what the 3MF was prepared with (#1325).
Matching uses the slicer's own compatible_printers list for imported
(local) presets and falls back to the "@BBL <model>" name suffix for
cloud / standard presets. Compatibility-unknown presets are never
hidden.
Defaults: the printer and process dropdowns default to the preset
names embedded in the source 3MF's project_settings.config when those
presets are available; the per-slot filament and process pre-picks
prefer a printer-compatible preset, and switching the printer re-picks
any selection left incompatible.
- UnifiedPreset gains compatible_printers, exposed for the local tier
- plates endpoints return embedded_printer / embedded_process
- new frontend util slicerPrinterMatch.ts; extract_embedded_presets_from_3mf
- slice.otherPrinters added across all 9 locales
The Slice dialog listed every process / filament preset regardless of
the chosen printer (#1325). Picking a printer profile now filters both
dropdowns to compatible presets; presets resolving to a different Bambu
model move into a trailing "Other printers" group.
Matching uses the slicer's own compatible_printers list for imported
(local) presets, and falls back to the "@BBL <model>" name suffix for
cloud / standard presets where no compatibility metadata is available,
so all three tiers are covered. Compatibility-unknown presets (custom
or untagged) are never hidden. The pre-pick and printer-switch paths
follow the same rule.
- UnifiedPreset gains compatible_printers, exposed for the local tier
- new frontend util slicerPrinterMatch.ts with the matching logic
- slice.otherPrinters added across all 9 locales
Full European Spanish translation — frontend/src/i18n/locales/es.ts
covers all 4899 keys with placeholders, plural forms, and inline
markup preserved. Registered in i18n/index.ts (resources,
supportedLngs, availableLanguages) and selectable as "Español".
check-i18n-parity.mjs auto-discovers the new file; added an
ES_COGNATES allow-list for genuine Spanish cognates and brand/format
tokens so Check 4 does not flag them as untranslated leaks. Brings the
supported-language count to 9.
A triage review of the last 200 closed issues found ~1/3 were
user-side setup errors — printer not in LAN developer mode, blocked
ports, Docker bridge networking, wrong access code, cross-subnet —
each costing a multi-round-trip support exchange.
Add a Connection Diagnostic that runs those checks automatically:
- backend/app/services/printer_diagnostic.py: TCP probes of MQTT
8883 / FTPS 990 / RTSPS 322, LAN developer mode, Docker network
mode, printer/host subnet match, MQTT credential class; each
check returns pass/fail/warn/skip with a localized fix.
- Routes: GET /printers/{id}/diagnostic (saved printer) and
POST /printers/diagnostic (pre-save Add-Printer flow).
- ConnectionDiagnostic.tsx: modal + shared checklist, surfaced from
the printer card actions menu, an offline-printer quick button,
the Add-Printer dialog, and a new System-page section.
- The in-app bug reporter scans configured printers when the form
opens and always shows the result inline — a healthy confirmation,
or the detected problem and its fix.
- config.yml troubleshooting link repointed to the rendered wiki
page; bug_report.yml gains a diagnostic checkbox.
Diagnostic strings translated across all 8 locales. Backend service
unit tests (15) + frontend modal tests (3). Ruff clean, frontend
build clean, i18n parity green.
The search bar was rendered only when totalCount > 0, but totalCount is
the sum of the post-filter preset lists. A query that matched nothing
dropped it to 0 and unmounted the search bar along with the columns,
so the user couldn't clear the query without a page refresh.
Gate the search bar on hasAnyPresets (pre-filter count) instead, so it
stays visible as long as any preset is imported. Split the empty state:
"No local presets yet" when nothing is imported, vs a new "No presets
match your search" message when a query filtered everything out.
#1447 added computePopoverPosition() to flip the drying popover above
the trigger when below would overflow. Its degraded branch (popover
taller than the viewport) keeps the popover below and its comment
promised "the user can scroll inside it" -- but the popover div was
overflow-hidden with no max-height, so the Start button stayed
unreachable on short viewports.
Make the promise true: the popover is now a flex column capped at
calc(100vh - top - 8px); the body scrolls (overflow-y-auto) and the
header/footer are shrink-0, so the Start button is pinned and always
reachable. Covers the MacBook + iPhone 15 Pro Max PWA cases in #1458.
Step 0 empirical test on 2026-05-20 disproved the "CLI cannot re-slice a
3MF for a different printer" assumption: feeding an 18-color H2D-bound
Trent900.3mf to the X1C bundle via /slice produces valid X1C G-code in
1.8s, with bed (256x256), kinematics, nozzle count, machine_start_gcode,
and bed_exclude_area all coming from the target bundle.
- SliceModal: drop !printerMismatch from isReady; remove the banner and
the sourcePrinterModel / printerProfileName / printerMismatch state
entirely. Cross-printer slicing is now indistinguishable from a normal
slice; the picker already shows the target printer.
- Remove slice.printerMismatch from all 8 locales.
- API cleanup: drop source_printer_model from /library/files/{id}/plates
and /archives/{id}/plates responses, drop the field from
frontend/src/types/plates.ts (PlateMetadata + LibraryFilePlatesResponse),
delete extract_source_printer_model_from_3mf from threemf_tools.py and
its 6 unit tests. Zero remaining consumers.
- i18n discipline cleanup in SliceModal.tsx (same drop): strip every
inline English defaultValue / positional fallback from t() calls (22
sites). Add slice.bundle / slice.bundleNone / slice.bundleAllRequired
to all 8 locales — they had no entry in any locale file and were being
served from the inline English fallback for every non-English user.
- Tests: rewrite the mismatch-warning test to assert "no banner, Slice
enabled" when models differ (regression guard); delete 2 obsolete
tests covering gate states that no longer exist.
Adds BZD with symbol BZ$ to the Settings cost-currency picker so
users in Belize can track filament costs in their local currency
without doing 2:1 USD mental conversions.
Reporter on a P1S with non-RFID spools saw an old, almost-empty spool in
the AMS hover card's "Spulen-ID" block while the "Zugewiesen" block
correctly showed the freshly assigned full spool. Two layers compounded:
(1) Non-RFID slots fall back to a deterministic per-slot tag
(hash(serial) + ams_id + tray_id). The Link / Assign routes wrote
that tag to Spoolman extra.tag but never cleared it from the
previous holder on re-binding.
(2) The frontend's hover-card resolver preferred the (stale) tag-link
over the user's explicit slot-assignment. Same precedence bug in
SpoolBuddy's fill-bar resolver and slot-action picker.
Frontend: swap precedence at 5 sites — slot-assignment outranks tag-link
everywhere. FilamentHoverCard's existing match-dedupe then collapses the
two "Open in Inventory" buttons back into one.
Backend: new _clear_stale_tag_links() in spoolman_inventory.py, called
from POST /spoolman/inventory/slot-assignments (with the slot's
deterministic fallback tag) and POST /spoolman/spools/{id}/link (with
the literal tag being bound — works for RFID and fallback). Best-effort:
Spoolman 5xx and per-spool patch failures log + continue, never wedge
the bind. get_fallback_spool_tag_for_slot promoted to a public helper
mirroring the frontend's signature exactly.
The flame-icon onClick on PrintersPage computed popover position as a
fixed { top: rect.bottom + 4, left: Math.max(8, rect.right - 240) }
with no viewport-overflow check. The flame icon sits at the bottom of
the AMS info section on the printer card, so on most realistic viewports
rect.bottom + 4 + popover_height (~320px) overruns viewport.height and
the popover renders partially or entirely off-screen with the Start
button unreachable. Reporter (kleinweby, P1S + AMS-HT) worked around it
via DevTools to confirm the popover was actually there, just clipped.
Extract a computePopoverPosition() helper in utils/popoverPosition.ts:
- defaults to below + right-aligned to the trigger (preserves the
original visual layout when there's room),
- flips ABOVE the trigger when below would overflow AND above fits,
- stays below in the degraded case (popover taller than the viewport) —
at least the top is visible and the user can scroll inside the
popover; flipping to a top-clipped position would lose the action
buttons too,
- clamps the left coordinate so a trigger near either viewport edge
can't push the popover off-screen horizontally either.
Both PrintersPage callsites (the compact AMS row at :3498 and the
dual-nozzle layout at :4011) route through the helper.
This is part 1 of #1447. The functional drying bug — printer receives
the ams_filament_drying MQTT command, ACKs it, but never starts/stops
drying on Bambuddy's request while the printer's own touchscreen works
— is NOT addressed here. Diagnosing it needs the printer's actual
response payload (whether result: "fail" and the specific reason code),
which bambu_mqtt.py:918 currently doesn't log for ams_filament_drying.
Punted to a follow-up where I'll extend the existing extrusion_cali_* /
ams_filament_setting payload-logging path at :919-920 to cover
ams_filament_drying too, ask the reporter to retry, and fix the
command side based on what the printer actually returns.
PrintCalendar.tsx had three instances of the same UTC-shortcut anti-pattern:
1. Bucketing input dates via `date.split('T')[0]` — gives the UTC day
while the cell tooltip rendered local via `toLocaleDateString`. Same
data, two renderers, only one was tz-correct. Reporter on CDT (UTC-5)
saw evening prints jump to "tomorrow's" cell.
2. Per-cell lookup key built via `day.toISOString().split('T')[0]`. The
`day` Date objects produced by the calendar-generation loop are
local-tz (constructed via `new Date()` + `setDate`), so `toISOString`
shifted them back to UTC before the lookup — would have re-broken the
join even after the bucketing fix.
3. "Today" ring comparison used `new Date().toISOString().split('T')[0]`
too — at 23:00 local the ring would have moved to UTC-tomorrow's cell.
Fix adds a `localDateKey(input: string | Date): string` helper to
utils/date.ts that wraps parseUTCDate and formats via the local-tz
getters (`getFullYear` / `getMonth` / `getDate` with two-digit padding),
returning a stable comparable YYYY-MM-DD. PrintCalendar.tsx uses it in
all three spots so the buckets, the cell join, and the today ring share
the same local-tz axis as the user's tooltip label.
Backend stays UTC. Bucketing is a presentation concern and the browser
already knows the user's tz.
Stragglers flagged for follow-up: StatsPage.computeDateRange builds
dateFrom/dateTo for backend stats queries using getUTC* getters, so a
"this week" picked at 23:00 local on Sunday in CDT sends UTC-Monday-based
ranges to the backend. Fixing it properly also needs the backend to
filter on a tz-shifted UTC range, and Bambuddy has no user-tz setting
model today. localDateKey is in place for reuse when that work lands.
Two bugs surfaced by @flom89 in the same thread:
(1) The Write-Tag page hardcoded api.getSpools and friends regardless of
inventory backend. Users in Spoolman mode saw internal spools they never
created and a successful tag write would have bound the NFC tag to the
wrong backend (the backend write-tag route is mode-aware; the frontend
was driving it with the wrong IDs). Fix follows the InventoryPageRouter
pattern: detect spoolmanMode from getSpoolmanSettings, gate the spool
fetch on spoolmanModeReady to avoid the initial wrong-backend request,
and branch all 6 API call sites (list, autocomplete, untag, K-profile
save, single create, bulk create — the bulk variant returns a different
envelope shape so the duck-typed check from SpoolFormModal is mirrored).
(2) The spool ID was missing from three more SpoolBuddy components
beyond the first round of #1439 work — SpoolInfoCard (the dashboard's
right-side found-tag panel — the "main screen" view the reporter named),
InventorySpoolInfoCard, and SpoolBuddyAmsPage's assigned-spool block.
Same #1385 pattern (#<id> in muted small monospace with shrink-0).
The browser-side 3MF parser in ModelViewer.tsx runs entirely on the main
thread (JSZip extract + DOMParser + vertex/triangle iteration), and Bambu's
external-component shape chains one of these per part. Multi-color parted
statues from MakerWorld can spend tens of seconds in straight-line JS,
during which the modal close button can't fire and the browser shows
"page unresponsive".
Add nextTick() yields at four hot spots:
- every 20 000 vertex iterations inside parseMeshFromDoc
- every 20 000 triangle iterations inside parseMeshFromDoc
- the matching loops in parse3MF's direct-mesh path
- once per top-level <object> iteration in parse3MF
- once per <component> iteration in parse3MF
This doesn't make parsing faster — it surrenders control to the browser
between batches so the modal stays interactive. Proper Web Worker refactor
is a tracked follow-up.
Reporter — the same person who originally requested the labels
feature in #809 — discovered that the ams_30x15 preset's 30x15 mm
dimension didn't actually fit any variant of the MakerWorld AMS
Filament Label Holder (model 752566) it advertised. Two new
presets replace it:
- ams_holder_74x33 (74 x 33 mm) matches the printable label STL
bundled in the MakerWorld project
- ams_holder_75x55 (75 x 55 mm) fits the cardstock-insert variant
the reporter validated on bench
Both cross the 20 mm height threshold so they land in the roomy
layout branch — swatch on the left, QR on the right, multi-line
text (brand, material, hex code, spool ID) in the middle. The
old 30x15 mm preset couldn't fit a QR code; the new ones do.
No DB migration: the preset name was never persisted. Callers
scripting the old ams_30x15 value get a clean 422 at the route's
Literal validator with the new valid values listed.
i18n: replaced inventory.labels.templates.ams.{label,hint} with
amsHolderSmall and amsHolderLarge across all 8 locales with real
translations; parity guard cleaned of the stale English-fallback
cognate entries. Parity holds at 4856 leaves per locale.
Tests: backend label renderer + integration tests cover both new
presets; LabelTemplatePickerModal test updated for the 6-button
grid and the new template value in the API-call assertion.
Reporter (@snozzlebert on A1 mini external slot) saw the
Filament page Location column update correctly after Assign
Spool, but the Printer card kept showing "Empty slot" until
they manually pressed Force-refresh. MQTT command was going
through fine; gap was client-side.
AssignSpoolModal's two onSuccess callbacks invalidated the
inventory / slot-assignment queries but never invalidated
['printerStatus', printerId] and never issued a pushall. For
Bambu RFID-tagged spools the printer echoes the new tray_type
on its own; for non-RFID spools and A1 mini external slots
the firmware doesn't volunteer that state change.
Added nudgePrinterRepublish() helper called from both onSuccess
paths: api.refreshPrinterStatus(printerId) to issue the pushall
(same call the Force-refresh button uses) plus invalidate
printerStatus so the refetch lands. Refresh failures are
swallowed — the assignment itself succeeded; a stale-cache
nudge that didn't go through shouldn't surface as "assign
failed". Same pattern as ConfigureAmsSlotModal since #1235,
with the extra pushall because assign-spool affects firmware-
side state.
Two-part fix for the #1322 follow-up by @RosdasHH.
Data layer.
The previous narrow heuristic in printer_manager.py only caught
the bare {"id": N} payload firmware sends right after a printer
restart. In steady-state operation — and on the more common
post-Reset-Slot path on P1S and A1 Mini BMCU — firmware sends a
populated payload and signals emptiness via the tray_exist_bits
bitmask. We already parse that bitmask and use it to wipe stale
tray_type / tray_color / tag_uid fields, but never touched the
state field, so downstream readers (printers.py API serializer,
inventory.py's tray_state in {9, 10} short-circuit, AMS card)
saw state: null and had to guess from absent payload fields.
Fix lifts tray["state"] = 9 (int — not "9"; inventory.py:1358
uses == not `in {...}` so a string would silently miss and the
reporter's deadlock would come back) to the outer `if not
slot_exists` branch, so the bitmask path now writes the
canonical "no spool" code for every empty slot regardless of
stale fields. The narrow heuristic in printer_manager.py:797
stays as belt-and-suspenders for any MQTT path that doesn't
flow through _handle_ams_data.
UI layer.
With the data flow now consistent, the AMS slot card renders
physically-empty slots distinctly from reset slots, per
reporter's mockup. New helper getEmptySlotKind(tray) returns
"physical" (state ∈ {9, 10}), "reset" (any other empty state),
or null (loaded). The inline label below the slot circle reads
"Empty" for physical and "Reset" for reset; pre-fix both showed
an em-dash. FilamentSlotCircle gains an emptyKind prop that
picks a quieter dashed border colour for reset slots so the
visual hierarchy reads loaded > reset > physically empty.
EmptySlotHoverCard gains a kind prop and switches between
"Empty slot" and "Slot reset — no spool assigned".
Three independent root causes behind the divergences the reporter
flagged after the archived-spool fix shipped — fixed together.
(1) Filament Used vs Total Consumed.
_compute_run_filament_grams returned the slicer estimate for completed
prints even when inventory had measured the actual AMS weight delta.
That made Stats and Inventory two different sources of truth: Stats
showed slicer-estimate grams, Inventory showed AMS-tracked grams, and
the two never agreed. Reordered the helper so the tracked spool delta
(same source that drives weight_used behind Total Consumed) takes
priority for every status. Slicer estimate stays as the fallback when
no inventory was tracked; partial-progress scale stays as the fallback
for failed/cancelled with no tracker. The _run_cost block right next
to it was already tracker-first; only filament_used_grams was
inconsistent.
(2) Printer Stats By Time vs Quick Stats Print Time.
/archives/slim only set actual_time_seconds when status == "completed".
For failed/cancelled rows the frontend fell back to print_time_seconds
(the slicer's full-print estimate — wrong number for a print that
failed at 15%). Quick Stats already summed elapsed duration across
all statuses, so the two halves of the page disagreed by the
(estimate - actual-elapsed) gap on every non-completed event. Dropped
the completed-only gate; failed/cancelled now report measured elapsed.
(3) Success Rate %.
Was successful / (successful + failed), excluding cancelled / stopped
from the denominator. With "Total Prints: N" displayed right above
the gauge that produced confusing numbers — 4 successful, 0 failed,
48 cancelled showed 100% out of an apparent 52 prints. Switched to
successful / total_prints — matches the count the user reads from
the widget header.
Step 2 of the camera architecture overhaul agreed after #1395. When
the camera viewer hits its error state OR before a print at any
time, a Diagnose button runs a staged check against the printer and
renders the result inline: which stage failed, how long it took,
and a translated remediation hint. Cuts off the "user opens a
'camera broken' ticket → ask for support bundle → triage" loop at
the user's screen.
Backend
- New `backend/app/services/camera_diagnose.py` orchestrator with
CameraDiagnoseResult / CameraDiagnoseStage dataclasses.
- New POST /printers/{id}/camera/diagnose route in camera.py.
- Stages:
tcp_reachable — TCP socket open to 322 (RTSP) / 6000 (chamber)
with 3 s timeout. Distinguishes timeout, refused, and host-
unreachable into distinct summary codes so the frontend can
show a precise remediation (firewall vs LAN-only off vs
wrong IP).
first_frame — captures one JPEG end-to-end via the existing
capture_camera_frame_bytes pipeline. Auth + RTSP handshake +
first keyframe collapse into one stage; the user-facing
answer is the same regardless of which sub-layer failed.
- Live-stream shortcut: when a viewer is currently watching the
camera with a buffered frame < 10 s old, the diagnostic skips
the real test and returns live_stream_active_healthy. Opening a
fresh socket would kick the live viewer off on single-camera-
connection firmwares (the #1348 reconnect-storm trigger), so we
trust the real-world evidence instead.
- Response surfaces protocol, port, and profile name for support
triage — lets us ask "what does your modal say?" instead of
"send the support bundle".
Frontend
- New CameraDiagnoseModal renders one row per stage with green-
check / red-X / grey-skipped icons, the per-stage duration in
ms, a remediation banner styled by overall status, and a Run
again button.
- Two entry points:
1. The viewer's error overlay grows a Diagnose button next to
Retry. Retry stays the primary action; Diagnose is the
escape hatch for users who can't see what's wrong.
2. A stethoscope icon in the viewer's always-visible control
bar, between Refresh and Fullscreen. Pre-flight testing
("did my firmware update break the camera?", "is the
camera up before I send a print?") doesn't require waiting
for the stream to fail first.
- Also lifted the previously-hard-coded "Camera unavailable" /
"Retry" strings into camera.unavailable / camera.retry so the
error UI is fully translated alongside the new keys.
Reporter on a 27" monitor saw long filenames truncated even though
the Print Log table had plenty of unused horizontal space. The
print-name `<span>` had a hard `truncate max-w-[200px]` cap that
ignored viewport width entirely.
Replaced with `break-words` + a `title` attribute, dropping the
explicit max-width so the column auto-sizes to content. On wide
screens the full name shows on a single line; on narrow ones it
wraps inside the cell instead of forcing horizontal scroll. The
`title` hover preserves the original tooltip affordance for the
edge case where a really long name still gets wrapped.
Reporter typed into the Add Spool modal's hex colour input and only
the first character stuck - everything after that defaulted to "0"
with no way to override except by pasting the full hex.
Pre-fix, the #1055 fix aggressively normalized the input to a valid
8-char rgba on every keystroke. After typing the first char the
controlled input value snapped to e.g. "A00000", the browser placed
the cursor at the end, and the user's next keystroke landed at
position 7. The #1055 fix's 7-char branch then truncated that byte
away, leaving the form state unchanged - so the user appeared to
type nothing.
Fix splits the typing-state from the backend-state:
- The hex input gets its own `hexDraft` useState holding 0-6 chars
freely. Typing one char at a time works naturally because the
controlled value matches what the user typed.
- `updateField('rgba', ...)` fires only when the draft reaches a
complete 6-char RGB (commits as `<6chars>FF`). Below that, the
form state stays untouched - no mid-keystroke snap.
- On blur, a partial 1-5 char draft is right-padded with `0` and
committed. Keeps the #1055 invariant: anything reaching the
backend is exactly 8 hex chars matching /^[0-9A-F]{8}$/.
- A `useEffect` resyncs the draft when an external action (color
picker, swatch click, edit-mode load) changes the canonical hex.
- Paste of 7-/8-char strings truncates to the leading RGB. Bambu
filaments are opaque; the UI never exposed an alpha affordance,
so dropping the (undocumented) paste-with-alpha case is fine.
Reporter saw a 544 g spool jump to 1000 g after pressing the eraser.
"Spools and remaining weights are not changed" - the dialog promised
this; the implementation did the opposite. Root cause was an
architectural conflation: `weight_used` did double duty as the
resettable "consumed since tracking started" counter AND as the basis
for the displayed remaining (`label_weight - weight_used`), so zeroing
it correctly cleared the stat but unavoidably reset remaining to full.
Spoolman has separate `used_weight` and `remaining_weight` fields, so
the API call there was correct - but Bambuddy's frontend was also
computing remaining as `label_weight - weight_used` for Spoolman
spools (ignoring Spoolman's real `remaining_weight` field), so the
same visual bug bit there too. Inventory-mode parity required fixing
both halves in one drop.
Internal mode
- New `weight_used_baseline` column (Float DEFAULT 0) on `spool`.
- Reset stamps `baseline = weight_used` and leaves `weight_used` alone.
- Displayed consumed = `weight_used - baseline`; remaining =
`label_weight - weight_used` (unchanged).
- Subsequent prints continue to grow `weight_used`, so the resettable
counter naturally tracks post-reset delta and remaining keeps
decrementing across the reset.
Spoolman mode
- `_map_spoolman_spool` now reads Spoolman's `remaining_weight` field
and returns a synthetic `weight_used = label - remaining` so the
frontend's remaining calc matches Spoolman's real stored value;
`weight_used_baseline = synthetic - real_used_weight` so the consumed
counter (`weight_used - baseline`) matches Spoolman's `used_weight`.
- Fallback path (no `remaining_weight` set) preserves the old behavior.
- Related fix: `update_spool` (Spoolman PATCH) was deriving the default
`weight_used` from `used_weight`, so editing unrelated fields AFTER
a reset would patch Spoolman with `remaining_weight = label - 0 =
label`, trampling the real value. Now derives from
`remaining_weight` so non-weight edits preserve physical state.
Frontend
- `InventoryPage` `totalConsumed` aggregate switched to
`Math.max(0, weight_used - (weight_used_baseline ?? 0))`.
- `ForecastPanel` `computeDeltaRate`, `totalUsedG`, and the per-spool
"consumed" table cell got the same treatment so forecast and
inventory aggregates stay coherent across a reset.
- `?? 0` keeps pre-migration installs rendering correctly until
`init_db()` runs the idempotent ALTER TABLE.
Migration
- `ALTER TABLE spool ADD COLUMN weight_used_baseline REAL DEFAULT 0`
via `_safe_execute` - SQLite and Postgres both accept it; verified
end-to-end on Postgres 16.
Several support reports traced back to one root cause: a mistyped access
code in Add Printer left an empty card on the dashboard. POST /printers/
was persisting the row first, then firing connect_printer() fire-and-forget.
Now we test_connection() BEFORE the insert; failure returns HTTP 400 and
the row is never written.
Structured error response -- detail={"code", "message"} -- so the toast
shows the localized message instead of the English fallback. New
ApiError.code field on the frontend; printers.toast.connectionFailedNotAdded
While auditing real-world Bambuddy backup repos on GitHub I found
several left public. That's a serious leak: the settings backup only
filters bambu_cloud_token and auth_secret_key, so mqtt_username,
mqtt_password, ha_token, prometheus_token, bambu_cloud_email,
external_url, and the printer access codes (via K-profiles) were going
to whatever visibility the user picked.
Hard guard at every save and re-checked on every push:
- POST /github-backup/config and PATCH /github-backup/config (when URL,
token, or provider changes) run a connection test internally and
return 400 unless is_private comes back True.
- run_backup() re-checks before each scheduled or manual push, so a
repository that flipped from private to public gets a clear
"Backup aborted: the target repository is no longer private" failure.
Each provider's test_connection now returns is_private (GitHub /
Gitea / Forgejo read data.private, GitLab reads visibility=="private";
"internal" is treated as non-private). None means "couldn't determine"
and is also rejected -- safer to fail closed.
Frontend renders visibility inline on Test Connection: green check when
private, red warning panel listing every credential at risk when public,
yellow when unknown.
---
ui(github-backup): show save-failure messages inline on the card
The new "repository is not private" rejection message is ~250 characters
listing every credential the backup carries (MQTT password, HA token,
Prometheus token, Bambu Cloud email, printer access codes), which clips
badly in a toast.
Both the initial-setup save and the debounced autosave now stash the
backend's error message into a saveError state and render it as a red
inline banner above the test-result block, with whitespace-pre-wrap so
the full message stays readable. The banner clears on success, on the
next save attempt, and when the user starts editing URL / token / provider
-- the three fields whose changes invalidate the privacy check -- so it
doesn't linger after the user has already addressed the cause.
Short success toasts (Settings saved, Token updated, Backup enabled) are
unchanged.
The LabelTemplatePickerModal correctly branches on a spoolmanMode prop
and the /spoolman/labels backend endpoint exists, but InventoryPage was
instantiating the modal with spoolmanMode={false} hard-coded. Every
click in Spoolman mode resolved to /inventory/labels with Spoolman spool
IDs and returned 404 "Spool(s) not found".
The hard-coded value came with a stale comment from the original label
printing PR that said "Spoolman path hands users an iframe to Spoolman
so the per-spool button never shows in that context" -- that assumption
stopped being true when the unified inventory UI shipped.
Pass the actual spoolmanMode value through, drop the stale comment.
The existing LabelTemplatePickerModal.test.tsx already covers both
branches at the component level (line 209: "routes to the Spoolman
endpoint when spoolmanMode is true"). The gap was that no test exercised
the InventoryPage wiring.