Reporter on an A1 Mini saw the AMS slot Configure dropdown render no
Bambu / Generic filament profiles, and saw the Profiles tab strip
A1 Mini results when filtering by that model. Bambu rolled out a
profile rename mid-2026: the @BBL <code> suffix on 106 cloud profiles
shifted from the long display form to a terse model code -- e.g.
"Bambu PLA Basic @BBL A1 Mini ..." is now
"Bambu PLA Basic @BBL A1M ...". User-authored profiles still use the
long form. Bambuddy's filters did a verbatim uppercase compare
("A1M" vs "A1 MINI"), so every renamed cloud profile silently
disappeared from the picker.
Centralize the alias check in slicerPrinterMatch.ts. New
PRINTER_MODEL_SUFFIX_ALIASES table maps "A1 Mini" <-> "A1M"
bidirectionally; exported matchesPrinterModelSuffix() does the
case-insensitive compare with the alias fallback. Two consumer
sites swap to the helper:
* ConfigureAmsSlotModal.tsx (Orca cloud and Bambu cloud filter
branches) -- the AMS slot picker, hit directly and reached from
SpoolBuddy's AMS page via mapModelCode(printer?.model)
* slicerPrinterMatch.ts:classifyByBambuName -- the SliceModal
Process / Filament compatibility check
Backend printer_models.py also gets a "Bambu Lab A1M" -> "A1 Mini"
entry so server-side 3MF model normalization stays consistent if a
3MF ever embeds the short form.
Kept the alias table narrow on purpose. Wide-net aliasing (e.g.
"X1" <-> "X1C") would silently collapse physically distinct
printers. When Bambu introduces the next rename, it is one new row
in the table -- /api/v1/cloud/settings is the place to grep, called
out in the source comment.
Non-proxy VPs (Archive / Review / Queue) with a target printer set up
a live-mirror bridge that forwards the slicer's MQTT and RTSPS auth
bytes through to the real printer. The slicer holds one code in its
profile (the one it bound the VP with), and that code has to satisfy
both the VP listener and the real printer at the far end of the
bridge. If the codes diverge the bridge silently fails at the second
hop — slicer reaches .49:8883, FINs before sending a ClientHello,
retries identically. The wiki framed the code-match requirement as a
camera-only concern; it isn't, all bridged protocols inherit.
Fix removes the foot-gun instead of re-documenting it. When a target
is selected on a non-proxy VP the access-code field switches to a
read-only display showing the target's code with an Eye-toggle
reveal; the backend auto-inherits on every create / update (any
explicit access_code submitted alongside a target is silently
overridden as belt-and-braces for non-UI clients). The required-when-
enabling check now treats target-set as satisfying the access-code
requirement. Standalone (no-target) non-proxy VPs still get the
editable input + Save button.
One-shot startup migration corrects any pre-existing mismatched
rows: SELECTs diverged VPs and logs one INFO line per row for the
audit trail, then UPDATEs via correlated subquery. Idempotent and
portable between SQLite and Postgres.
Bambu's end-gcode lowers the bed at gcode_state=FINISH. Bambuddy's
live-camera grab captured the bed already dropped, ruining the photo
framing. Source the photo from a brief Bambu timelapse instead —
firmware stops timelapse recording AFTER toolhead parks but BEFORE
bed-drop runs, so the last frame frames the finished print correctly.
When capture_finish_photo is on AND the user did not opt in to
timelapse for this print, force timelapse=True at dispatch + mark the
new PrintArchive.bambuddy_forced_timelapse column. After extraction
(success or failure), cleanup deletes the locally-attached file,
clears archive.timelapse_path, and walks the four scanner directories
(/timelapse, /timelapse/video, /record, /recording) trying FTP DELE
against the original filename. User-opted-in timelapses pass through
unchanged.
Resolver lives at services/background_dispatch.py::resolve_effective_timelapse
(module-level so the print queue can reuse it). Both dispatch paths
wired: background_dispatch.py (Print Now / Reprint) AND
print_scheduler.py:_start_print (the queue). Field testing caught the
scheduler gap on the first round — AST regression test now asserts
start_print(timelapse=...) references effective_timelapse, not the raw
item.timelapse, so a future refactor can't silently drop it.
Extractor: ffmpeg -i input.mp4 -update 1 -q:v 2 out.jpg. Decoded
frames overwrite the same output file, so the file left on disk is the
literal last frame regardless of duration. Bambu records one frame per
layer-change, so a 16-layer cube produces a 0.6 s timelapse — the
original -sseof -1.0 approach seeked before the start of the file and
returned frame 0 (empty bed). Decoding every frame is fine; Bambu
timelapses are short by construction even on hours-long prints.
Migration adds bambuddy_forced_timelapse branched on is_sqlite()
(DEFAULT 0 / DEFAULT FALSE — PG rejects DEFAULT 0 for BOOLEAN).
Verified live on postgres:16-alpine.
Photo-task wait_for budget extends 45s -> 75s when timelapse_was_active
so the notification carries the bed-up photo instead of falling back
to the live-cam grab on slow links.
Scope limit, documented in the camera wiki: prints started directly
on the printer touchscreen / Bambu Handy / Bambu Studio Send bypass
both dispatch paths, so the override doesn't fire there. Future
option: mid-print M981 S1 P20000 MQTT toggle in on_print_start.
Setting description rewritten in all 11 locales to drop the "only
works when timelapse enabled" caveat (Bambuddy now forces it) and
explain the kept-or-deleted behaviour.
SSDP multicast (239.255.255.250:2021) doesn't traverse routers, so a
printer behind a router on a different L3 segment was invisible to
"Discover Printers on Network". Docker mode had a CIDR text input but
only as a fallback when zero interface subnets were detected; native
mode had no subnet field at all.
AddPrinterModal now surfaces an always-visible subnet picker. Detected
interface subnets stay as dropdown options, plus a "Custom subnet..."
sentinel reveals a CIDR text input. When custom is picked, discovery
routes through POST /discovery/scan with the typed CIDR instead of
SSDP (which would no-op against a foreign subnet anyway). Last custom
CIDR persisted to localStorage so VLAN users don't retype every time.
Scan button label and scanning / no-printers-found strings all key off
(isDocker || useCustomSubnet) so wording stays "Scan Subnet..." /
"Scanning subnet..." whether the user is on Docker or just picked
Custom.
Backend unchanged: SubnetScanner.scan_subnet() already accepts any
CIDR, already caps at /22 (1024 hosts), POST /discovery/scan already
takes user-supplied input.
Reporter on a 1508x831 Pi display couldn't mark a project as Completed
because the edit modal's height exceeded the viewport: the outer wrapper
centers vertically and the inner card had no max-h and no overflow, so
the top half scrolled above and the bottom half (Status dropdown +
Save/Cancel) scrolled below. Workaround was a full page reload.
Standard flex-modal-scroll fix: max-h-[calc(100vh-2rem)] + flex flex-col
on the card; a flex-1 overflow-y-auto min-h-0 wrapper around the form
fields; Cancel/Save moved into a flex-shrink-0 sibling with a border-t
separator so they're always visible regardless of scroll position.
Buttons stay inside <form> so type="submit" still works.
Reporter linked a NAS and the auto-imported files drowned their own
Bambuddy uploads in the "All Files" sidebar listing. There was no filter
to escape it — only per-folder clicks. Restore the pre-external semantics:
"All Files" now lists managed-storage files only. The combined
across-every-external view moves to a new sibling sidebar entry,
"External", that only appears when at least one external folder is linked.
Backend: GET /api/v1/library/files gains internal_only and external_only
query flags. Filter is on LibraryFile.is_external. Both flags set is a
400, not a silent pick-one.
Frontend: new topLevelView state on FileManagerPage (default internal);
the query passes the scope only when selectedFolderId is null. Mobile
selector dropdown uses __top:internal / __top:external sentinels so the
same state round-trips through option values. Empty-state copy
distinguishes internal-empty from external-empty.
The old endpoint name implied that calling it would drop weight_used to
0. In practice it only stamps weight_used_baseline = weight_used so the
Inventory page's "Total Consumed" widget (weight_used - baseline) reads
0 going forward, while remaining (label_weight - weight_used) is
preserved. Calling the endpoint via curl and seeing weight_used
unchanged in the JSON response is confusing.
New paths:
- internal: /api/v1/inventory/spools/{id}/reset-consumed-counter
/api/v1/inventory/spools/reset-consumed-counter-bulk
- spoolman: /api/v1/spoolman/inventory/spools/{id}/reset-consumed-counter
/api/v1/spoolman/inventory/spools/reset-consumed-counter-bulk
Behaviour is unchanged in both modes; internal stamps the baseline
directly, Spoolman-mode PATCHes upstream used_weight=0 and the
_map_spoolman_spool read mapping reconstructs the same "displayed
consumed = 0, remaining unchanged" Bambuddy-visible shape. Parity
between modes was already in place and is preserved.
The Spoolman-client method reset_spool_usage keeps its name because it
describes what is sent upstream to Spoolman, not what Bambuddy's
endpoint promises to callers.
Frontend:
- api.resetSpoolUsage / bulkResetSpoolUsage (and Spoolman variants)
renamed to resetSpoolConsumedCounter / bulkResetSpoolConsumedCounter.
- Button labels: "Reset usage to 0" -> "Reset counter" / "Reset all
counters" (short, unambiguous); tooltips and confirm-modal bodies
still spell out the full semantics.
window.open(url, '_blank', 'noopener,noreferrer') returns null even on
success per the WindowFeatures spec — `noopener` deliberately suppresses
the return reference. The label-print modal treated null as "popup
blocked → fall back to <a download> click", so the fallback fired on
every click. Result: window.open opened the blob tab (downloading a
random-named PDF on systems without an inline viewer) AND the fallback
downloaded bambuddy-labels.pdf — two identical PDFs per click.
Drop noopener,noreferrer. The blob is same-origin, the destination is a
passive PDF preview tab with no script context, and noreferrer is no-op
for blob URLs. window.open now returns a real window reference on
success and the if (!win) fallback only fires on genuine popup-block.
The existing connection diagnostic proved TCP + TLS + auth + SUBSCRIBE but
not that the printer was actually publishing reports. A wrong-cased serial
passes mqtt_auth because the broker accepts the subscription regardless;
the user-visible symptom is empty AMS / no K-profiles / no custom filaments
in the slicer Device tab because the VP cached state is empty. Bambuddy
already logged the actionable hint at bambu_mqtt.py:498 but only to
container logs.
New printer_publishing check turns that warning into a structured
diagnostic result. Pass = bridge has seen at least one report since the
last (re)connect; fail = zero reports across the wait window with fix-text
pointing at the case-sensitive serial. Bounded 10s poll on the on-demand
UI route, no wait on the support-package gathering path so bundling stays
fast. Exits the moment a message arrives — typical wall-clock is 1-2s.
Frontend renders an elapsed-seconds counter plus a "Listening for status
report — up to 10s" hint during the pending state so the wait doesn't look
hung. PUBLISH_WAIT_DEFAULT_SECONDS pinned on both sides.
report_messages_since_connect exposed as a public property on
BambuMQTTClient so the diagnostic doesn't reach into private state.
The Scheduled Local Backups time-of-day picker was interpreted as UTC by
_calculate_next_run, so a UTC+3 user had to enter 18:00 to get a 21:00
local backup. The UI labeled the field "UTC" but it was still surprising.
Picker is now interpreted in the container's local timezone, resolved
from the TZ env var via zoneinfo.ZoneInfo (same source the Support page's
environment.timezone shows). UTC fallback when TZ is unset or
unrecognised. The /local-backup/status endpoint exposes the resolved
zone, and the UI renders it next to the field via a new
backup.localTimeHint i18n key with real translations in all 10
non-English locales.
One-time behaviour change for users who entered a UTC time as a
workaround: the first scheduled cycle after upgrade will run at their
local TZ offset earlier than expected. Re-enter the time as local once
and it is correct from then on. No migration is shipped; migrating
around a DST boundary would be ambiguous.
ESLint no-useless-escape flagged 156 errors (153 in ko.ts, 3 in tr.ts):
\" inside single-quoted Korean strings and \' inside double-quoted
Turkish strings. The escapes weren't needed because the surrounding
quote style differs from the escaped quote. Visible-text unchanged;
i18n parity green at 5007 leaves × 10 locales.
Four frontend formatDate / formatDateTime helpers called new Date(iso)
directly on backend timestamps that have no timezone indicator. Per
ECMAScript, a bare "2026-06-02T07:50:00" is parsed as local time, so
a UTC-stored value got displayed as if its numeric components were
already local — visually identical to UTC. Same shape as the #504
fix from Feb 2026, which patched 13 sites but missed these four:
PrintLogTable and SpoolUsageHistory hadn't been written yet;
CameraTokensPage and SpoolBuddySettingsPage existed but were
overlooked.
Reporter #2 (@IndividualGhost1905) confirmed with a UTC+3 host: print
log shows UTC clock value, system date shows local. PrintLogTable is
the "logs/completion time" they called out; the other three are the
same pattern in nearby surfaces.
Replaced the bare new Date(iso) calls with parseUTCDate(iso) from
utils/date.ts — the same helper every other date formatter in the
codebase already uses. It appends "Z" to naive ISO strings and
parses TZ-tagged strings as-is. CameraTokensPage.isExpired got the
same fix because comparing a misparsed Date against Date.now() would
produce false "not expired" / "expired" results around the TZ-offset
boundary.
Reporter #1's printer-card ETA complaint (10:50 + 57m showing 09:48)
is NOT addressed by this fix. That ETA comes from
formatETA(remainingMinutes) which is purely client-side (new Date()
plus minutes from the WebSocket payload, then toLocaleTimeString)
— for it to render UTC the browser timezone itself would need to
be UTC, which is a browser / OS config issue.
Audit confirmed no other regressions: grepped every new Date( call
in frontend/src/. Remaining sites either pass an epoch-ms number,
use the result only for .getTime() arithmetic where the offset
cancels, already wrap in parseUTCDate fallback, or consume a
backend timestamp that includes "+00:00" (FailureDetectionSettings
reads obico_detection.py's tz-aware isoformat).
inline mutation type
POST /api/v1/maintenance/types hard-coded the MaintenanceType
constructor and silently dropped `wiki_url`, so the Documentation URL
field disappeared after save. PATCH worked because it uses
`data.model_dump(exclude_unset=True) + setattr`, which is why editing
a freshly-created type DID save the URL — masking the bug under any
"save then immediately fix it" retest. Reporter @BurntOutHylian
pre-triaged the issue to the exact constructor call at
routes/maintenance.py:206-213; fix is the missing `wiki_url=data.wiki_url`
argument.
Frontend nit from the same report: MaintenancePage.tsx:1131's
`updateTypeMutation` declared `data: Partial<{ name; default_interval_hours;
interval_type; icon }>` — omitting `wiki_url`. The value reached the
API correctly at runtime because `api.updateMaintenanceType` accepts
`Partial<MaintenanceTypeCreate>` (which has wiki_url), but the inline
type lied about the payload shape. Extended the inline `Partial<{...}>`
to include `wiki_url?: string | null`. Pure type fix — no runtime change.
#1429 (reported by @TrickShotMLG02, confirmed by @Mape6 on a flat single-LAN
that rules out subnet / mDNS-reflector theories): with the physical printer
off the slicer's "Send" landed in Bambuddy's archive; once the printer
powered on every subsequent "Send" went straight to the printer's SD card
and bypassed Bambuddy. Bundle analysis: mape6-before showed clean FTP
receive + archive lines, mape6-after had zero FTP attempts to Bambuddy
once the printer was online.
Cause: mqtt_bridge.py::_resolve_client encoded _target_ip_uint32_le /
_vp_ip_uint32_le ONLY on client-identity change and early-returned on
every refresh tick when the same client object was still bound. If
target_client.ip_address was empty at first bind (DB row stale, or client
constructed before SSDP refresh filled it in), the encoding stayed None,
the net.info[*].ip rewrite block was skipped, the cache filled with the
real printer IP, sticky-key preservation kept the poisoned net value
alive across every subsequent incremental push, and the slicer followed
the leaked IP. Only Bambuddy-restart-with-printer-off cleared it — the
workaround both reporters independently arrived at. Same shape on
multi-NIC printers (X1C, H2D Pro): the rewrite only matched entries
whose ip equalled _target_ip_uint32_le, so a secondary interface IP
Bambuddy never saw would leak through unchanged.
Bridge fix:
- _resolve_client calls a new _refresh_ip_encoding() on every refresh
tick, even when client identity is unchanged; self-heals once
ip_address becomes valid.
- _refresh_ip_encoding() sweeps the existing _latest_print_state when
encoding becomes valid for the first time. Without the sweep,
sticky-key preservation keeps the pre-arm poisoned cache alive
forever — incremental pushes that don't include net carry the bad
value forward.
- _rewrite_net_info_ips() rewrites EVERY non-zero net.info[].ip entry
that doesn't already equal the VP IP, not just entries matching
_target_ip_uint32_le. Multi-NIC printers stop leaking secondary
interfaces. Zero-IP placeholders are left alone so "active interface"
detection still works.
- INFO logging on encoding arm/update and on cache sweep so future
bundles directly answer "did the rewrite fire?".
Mode wire-value rename (#1429 follow-up, separate confusion source):
- Both reporters' support bundles showed mode: immediate while the UI
said "Archive"; @TrickShotMLG02 quoted: "I have no idea why it says
immediate in the support-info.json file. In the webui the printer is
set to archive". UI button "Archive" had always saved immediate, and
"Queue" had always saved print_queue. Canonical wire values are now
archive / review / queue / proxy, matching the button labels 1:1.
- New normalize_vp_mode() + VP_MODE_* constants in
models/virtual_printer.py; manager.py normalises on construction so
a legacy row read pre-migration still dispatches correctly.
- core/database.py::run_migrations rewrites existing virtual_printers
and settings rows; idempotent (re-runs are no-ops); identical SQL
under SQLite and Postgres.
- API routes accept both legacy and canonical on input, normalise
before storage. GET /settings/virtual-printer normalises on read so
the frontend's mode-button highlight works for stale legacy values.
- Three frontend VP components (VirtualPrinterSettings,
VirtualPrinterCard, VirtualPrinterAddDialog) switched click handlers
and type aliases to canonical; each got its own normalizeMode()
helper so a stale-cached settings payload still highlights the right
button. Two pre-existing `printer.mode === 'queue' ? 'review'`
legacy mappings in VirtualPrinterCard were the source of a test
failure caught mid-implementation where the new canonical 'queue'
was being mis-aliased back to 'review' and hiding the auto-dispatch
+ force-color-match toggles.
mode handler is NOT the dispatch bug: manager.py::_archive_file (the
handler for archive mode) doesn't dispatch to the physical printer.
The "files end up on the printer's SD card" symptom was the IP-leak
from the bridge cache. The mode rename is purely clarity / support-
bundle accuracy.
Two-part fix for the reporter's "removed profiles still show on the slice
menu" symptom.
Local half (real bug). LocalProfilesView's import and delete mutations
invalidated ['localPresets'] (the management view's own query) but not
['slicerPresets'] (the SliceModal's unified preset query, staleTime 60s).
A freshly-deleted preset kept rendering in the slice dropdown until that
staleTime elapsed plus a refocus/remount. Both mutations now also call
queryClient.invalidateQueries({queryKey: ['slicerPresets']}).
Cloud half (opt-in cache bypass). _fetch_cloud_presets keeps a 5-minute
per-(user, token) in-process cache (slicer_presets.py:69, balances
"users see freshly-saved presets quickly" against "busy install doesn't
hit Bambu Cloud once per modal open"). Users delete cloud presets in
Bambu Studio / Bambu Handy, not in Bambuddy, so there's no event hook
to invalidate on. Rather than shorten the TTL globally, the listing
endpoint gains an opt-in ?refresh=true query param that bypasses both
the cloud cache AND the 1-hour bundled-preset cache for that one call;
the fresh result is still written back so subsequent normal callers
keep hitting the cache.
New SliceModal "Refresh" button. Lives in the preset section header
next to the cloud-status banner. Calls getSlicerPresets({refresh: true})
and writes the fresh slots into the ['slicerPresets'] cache via
queryClient.setQueryData so the spinner stops immediately rather than
triggering a second refetch. RefreshCw icon spins while in-flight;
disabled during slice enqueue to prevent double-fire.
The Vitest UI server's /__vitest_attachment__ handler bypasses
isFileServingAllowed via a path-traversal payload, allowing arbitrary file
read/execute on the host. Dev-scope only and not exploitable in
Bambuddy's CI/CLI usage (we don't start the Vitest UI server and
@vitest/ui is not installed), but bumping clears the Dependabot alert
and brings us onto the supported 4.x line.
Bumped:
vitest 3.2.4 → 4.1.8
@vitest/coverage-v8 3.2.4 → 4.1.8
Migration-required fix:
StreamOverlayPage.test.tsx mocked `WebSocket` via
vi.stubGlobal('WebSocket', vi.fn().mockImplementation(() => ({...})))
and the page does `new WebSocket(url)`. Vitest 4 dropped support for
arrow-function constructor mocks ("is not a constructor"). Rewrote
with a plain `function` so `new` resolves correctly.
All 2043 frontend tests pass; npm run build clean; npm audit shows 0
vulnerabilities.
API-key permission gates went from a 17-entry admin denylist with the three
documented scope flags (can_read_status / can_queue / can_control_printer)
enforced only inside /api/v1/webhook/* to an explicit per-Permission
allowlist consulted by every dependency:
- core/auth.py: _APIKEY_SCOPE_BY_PERMISSION maps every non-admin
Permission to one scope flag on APIKey; unmapped = 403.
_check_apikey_permissions now takes the api_key and checks the flag.
- require_any_permission_if_auth_enabled + require_ownership_permission
were returning None for any valid key with zero scope check; both now
invoke _check_apikey_permissions and fail closed.
- Two new scope flags on api_keys: can_manage_library (LIBRARY_UPLOAD /
UPDATE_OWN / DELETE_OWN / MAKERWORLD_IMPORT) and can_manage_inventory
(INVENTORY_CREATE / UPDATE / DELETE / FORECAST_WRITE — required by
SpoolBuddy kiosks). Default TRUE, backfilled from can_queue so existing
"queue-only" keys keep working and hardened "read-only" keys do not
silently gain writes.
- CLOUD_AUTH now routed through can_access_cloud for defence-in-depth
alongside the existing _cloud_api_key_gate.
- Migration column-existence check (_api_keys_column_exists) gates the
backfill so user-edited values are never overwritten on restart.
Structural drift backstop: test_every_permission_has_a_classification fails
CI on any new Permission added without an explicit scope mapping —
prevents the denylist-shape regression that grew the prior surface.
Backend 5469 tests green; ruff clean. Frontend build green; i18n parity
green across 9 locales (5005 leaves each, +6 new keys). Wiki permissions
table + allowlist callout + upgrade notes updated.
Reporter exported a multi-plate .gcode.3mf from Bambu Studio to the
shared folder Bambuddy watches and the 3D preview tab came up empty;
if he re-uploaded the same file via the file manager, the preview
worked. Root cause: two paths classify file_type differently. The
shared-folder scan at backend/app/api/routes/library.py:1343-1348
does a compound-extension check and tags the file `gcode.3mf`; the
upload path at the same file's :1588 does a single `ext[1:]` and tags
it `3mf`. Then frontend/src/components/ModelViewerModal.tsx:71-73 had
hasModel = normalizedType === '3mf' || 'stl'
hasGcode = normalizedType === 'gcode' || '3mf'
Neither matched `gcode.3mf`, so the capabilities object landed with
both flags false and the modal rendered an empty bed.
FileManagerPage.tsx:858 also gated the Preview-3D context action on
`file_type === '3mf' || 'gcode' || 'stl'`, so for shared-folder files
the menu entry didn't even appear, and the type pill at :765-770 had
no colour case for `gcode.3mf` so it fell through to the generic
gray.
Fix (frontend-only, no backend churn):
- ModelViewerModal.tsx introduces an
`isThreeMfFamily = normalizedType === '3mf' || normalizedType === 'gcode.3mf'`
predicate used in two branches — the capabilities check
(`hasModel = isThreeMfFamily || 'stl'`, `hasGcode = isThreeMfFamily
|| 'gcode'`) and the plates-loading branch that previously hard-
gated on `!== '3mf'` and would have returned setPlatesData(null)
for the shared-folder file.
- FileManagerPage.tsx adds `gcode.3mf` to the Preview-3D action gate
and shares the gcode blue type-pill colour so sliced-output files
are visually distinguishable from source 3MFs.
The compound `gcode.3mf` classification on the backend is intentionally
preserved — it carries useful "this is a sliced output" semantics that
other UI surfaces could use later. The `canOpenInSlicer` and
`sliceableType` checks at ModelViewerModal.tsx:269, 277-280 are
deliberately left alone — a sliced output isn't openable in the slicer,
and `sliceableType` already explicitly excludes `.gcode` and
`.gcode.3mf` per the comment.
Out of scope (separate Bambu-Studio format limitation, not a Bambuddy
bug): Vlado's secondary observation that the upload-path 3D preview
"shows only one plate" even though his project has 5 plates — Bambu
Studio's .gcode.3mf export contains the model data and g-code for the
active plate only, not the entire multi-plate project. The print
picker enumerates plates via gcode_*.gcode entries inside the zip
(a separate code path), which is why the user can still pick the
plate at print time. The empty-bed fix is the data point that closes
the user-visible bug.
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