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).
compute_time_accuracy in routes/archives.py compares the archive row's
own started_at / completed_at (which reflect the latest run only)
against archive.print_time_seconds (which the #1593 parser fix
correctly stores as the sum across plates). For a 3-plate file printed
plate-by-plate the ratio is ~300%, producing a "+188%" card badge that
means nothing — apples to oranges. The 5-500% sanity band catches
truly broken values but lets this deterministic N×100% shape through.
Reporter's archive #65 was 3 plates over 9 runs.
compute_time_accuracy gains an optional run_aggregate argument and
returns both actual_time_seconds and time_accuracy as null when the
aggregate reports more than one logged run. The frontend already falls
through to print_time_seconds for the time display
(actual_time_seconds || print_time_seconds) and gates the badge on
time_accuracy being truthy, so multi-run archives now show the slicer
estimate with no badge. Single-run archives keep the original
behaviour verbatim.
The fix is applied at every call site that renders an archive card:
archive_to_response now threads run_aggregate through, and the three
endpoints that previously didn't load the aggregate (archives.py
search fast-path and FTS path, single-archive PATCH, and
projects.list_project_archives) now batch-load it via the existing
_load_run_aggregates helper.
The stats endpoint's per-run accuracy aggregation at archives.py:940
already uses PrintLogEntry.duration_seconds with its own 50-200% band
filter and is untouched.
The #620 patch fixed the OpenSSL-3.x-strips-plain-RSA-AES-GCM cipher
mismatch on the printer-facing TLSProxy client context. The same fix
was never applied to the four other slicer-facing TLS contexts. On
hardened distros (Fedora / RHEL with update-crypto-policies, hardened
Alpine builds) where the system narrows DEFAULT to forward-secrecy
only, the slicer's ClientHello finds no overlap with what Bambuddy
offers and the handshake aborts with the slicer reporting code=-1
before any application data flows. The reporter pinpointed the missing
set_ciphers call in bind_server.py against the #620 lineage; the
audit-wide sweep here extends the same fix to mqtt_server.py,
tcp_proxy._create_server_ssl_context (the missing other half of #620),
and ftp_server.py.
For the three new contexts (bind / mqtt / proxy-server) the cipher
string is DEFAULT:AES256-GCM-SHA384:AES128-GCM-SHA256 — verbatim match
with the #620 client-side fix. For FTPS the original HIGH baseline is
kept (HIGH:AES256-GCM-SHA384:AES128-GCM-SHA256:!aNULL:!MD5:!RC4) so the
cipher set stays a strict superset of what shipped before — HIGH
offers ~58 suites DEFAULT doesn't (CCM / ARIA / CAMELLIA / DSS) that
no Bambu slicer is known to pick, but narrowing a compat surface
without proof would violate the existing don't-remove-compat-pinning
rule. TLS version pins (TLSv1_2 minimum across all four, TLSv1_2 max
on FTPS for the BambuStudio PSK-reuse compat) and verify-mode settings
are unchanged — only the cipher list is widened.
When no explicit slot-to-tray mapping is captured (path 5 of 6 in
_track_from_3mf — fires before the request-topic subscription that catches
ams_mapping is accepted), the tracker builds available_trays from
build_ams_tray_lookup and uses position to map the slicer's Nth filament
to the Nth available tray. The helper enumerated every AMS tray by id
regardless of whether a spool was loaded, so AMS slots 0-2 loaded + slot 3
empty + external yielded available_trays = [0, 1, 2, 3, 254]. The slicer
compacts its filament UI to hide empty AMS slots, so its 4th filament is
the external — but position mapping routed it to AMS0-T3 (the empty slot)
instead of 254 (external). No spool assigned there → usage silently
skipped → external never decremented.
Filter the fallback to slots with a non-empty tray_type. build_ams_tray_lookup
stays unchanged for its other callers (spoolman_tracking.store_print_data,
routes/printers, spool_assignment_notifications); the filter is applied at
the usage-tracker call site only. Mirrors the existing vt_tray filter in
build_ams_tray_lookup line 174.
The original #1429 fix's _refresh_ip_encoding early-returned when
mqtt_server.bind_address was "0.0.0.0" or empty (the default for VPs created
without a dedicated bind IP). On a flat-LAN install that's the typical case,
so the encoding never armed, _rewrite_net_info_ips was a no-op on every push,
and the slicer kept following the real printer IP to its SD card. @Mape6
reported this on the 2026-06-02 daily that supposedly fixed the bug.
New helper _resolve_host_interface_for_target() consults the existing
network_utils.find_interface_for_ip() to pick the host interface in the
printer's subnet. _refresh_ip_encoding falls back to it when bind_address
is unspecified; an explicit bind IP still wins. INFO log line distinguishes
the two paths ("armed: ... (bind_address)" vs "(auto-resolved)") so future
bundles directly answer which IP the rewrite picked.
Tests: 4 new under TestBindAddressAutoResolve — rewrite arms via auto-resolved
IP at bind_address=0.0.0.0; stays disabled if no interface matches (no crash);
explicit bind_ip still takes precedence; helper returns None defensively when
find_interface_for_ip does.
Tracks the orca-slicer-api Dockerfile change that switched the sidecar
from the (no-longer-published) Fedora AppImage to the Ubuntu 22.04
AppImage. The default in .env.example and slicer-api/docker-compose.yml
now matches the new build path; users running the sidecar should
`docker compose --profile bambu build --no-cache bambu-studio-api &&
docker compose --profile bambu up -d` after pulling this change.
entries
PR #1529 added the Windows installer but the rendered README sections
had a stray blank line inside the install one-liner's code fence and
the cross-platform Service Management / Updating / Troubleshooting
sections still only covered Linux + macOS + Docker. Fold in Windows
entries (Start-Service, Get-NetTCPConnection, NSSM runtime log path)
and link the README description to the Windows Installer wiki page so
users know where the parameter reference and unattended examples live.
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.
files + unify file_type classification across ingest paths
#1600: external-folder sliced outputs landed
with no thumbnail. Cause: four backend ingest paths classified
LibraryFile.file_type differently for the same .gcode.3mf family.
upload / ZIP-extract / in-process used os.path.splitext()[1] which
returns .3mf for foo.gcode.3mf and stored file_type="3mf", matching
the thumbnail-extraction gate at library.py:1467 (file_type == "3mf").
External-folder scan explicitly detected the compound and stored
file_type="gcode.3mf" — preserving "sliced output" identity — but
then skipped both the "3mf" gate and the "gcode" gate, so the file
landed with thumbnail_path = None. Same compound-extension drift that
bit #1543 in the 3D preview, in a surface that audit didn't trace
back to.
Unified fix:
- New classify_file_type(filename) helper in routes/library.py is the
single source of truth. Returns "gcode.3mf" for sliced outputs and
ext[1:] otherwise.
- Applied to every ingest path: upload (line 1704), ZIP-extract
(1998), external-folder scan (the bug site — the manual compound
check is replaced), and in-process save_3mf_from_bytes (471, used
by MakerWorld import).
- External-scan thumbnail gate widened to
`if file_type in ("3mf", "gcode.3mf"):` — a .gcode.3mf IS a 3MF zip
with Metadata/plate_1.png; ThreeMFParser doesn't care about the
trailing extension.
- gcode-download endpoint at GET /library/files/{id}/gcode had the
same drift in reverse: gate was `elif file.file_type == "3mf":` so
a row stored with file_type="gcode.3mf" (the external-scan path's
pre-unification behaviour, and the canonical going forward) got
rejected with HTTP 400. Widened to the same compound-aware tuple.
One-shot DB migration in core/database.py::run_migrations backfills
existing legacy rows:
UPDATE library_files
SET file_type = 'gcode.3mf'
WHERE file_type = '3mf'
AND LOWER(filename) LIKE '%.gcode.3mf'
Idempotent (post-update rows no longer match the file_type='3mf'
predicate, so re-runs at every boot are no-ops) and dialect-neutral
(LOWER + LIKE are identical under SQLite and Postgres). Without the
backfill, users would have a permanent split state: old uploads at
'3mf', new uploads at 'gcode.3mf' — which would double-bucket sliced
outputs in the dashboard stats query at line 4615 and show two
entries in the file-manager filter dropdown for the same conceptual
type.
Frontend untouched. FileManagerPage.tsx and ProjectDetailPage.tsx
already accept both '3mf' and 'gcode.3mf' per the #1543 fix. After
the migration the DB only contains canonical values, so the legacy
'3mf' branches in the frontend become dead code for sliced files —
they stay as defence-in-depth in case any future ingest path I
missed reverts to the legacy classifier.
#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 stacked causes under-reported multi-plate prints in the project
rollup and the archive card.
Root cause 1 - parser only read plate 1.
ThreeMFParser._parse_slice_info used root.find(".//plate") and pulled
prediction / weight from that one element. Any multi-plate file's
archive-level print_time_seconds / filament_used_grams reflected
plate 1 alone. The /plates endpoint already looped findall and was
correct, which is why the plate carousel showed the right numbers
while the archive card was wrong.
Fix: loop every <plate> and sum prediction + weight. Per-plate
concepts (plate_number, _plate_index, printable_objects) only set
when there's exactly one plate - for multi-plate exports the
archive represents all plates and a single index doesn't apply at
the file level. bed_type keeps the first plate's value as a
best-effort default. Malformed prediction / weight on individual
plates skip cleanly rather than poison the sum.
Root cause 2 - project rollup aggregated PrintArchive, not the
per-run log.
compute_project_stats and list_projects quick-stats summed
PrintArchive.print_time_seconds / filament_used_grams / cost /
energy_* WHERE project_id. A reprint reuses the source archive row
and writes a new PrintLogEntry, so 3 sequential runs collapsed to 1
archive - and that archive's numbers were already plate-1-only from
cause 1. The Archive Print Log path was already correct because it
drove off print_log_entries (archives.py:420 comment).
Fix: both compute_project_stats and the list_projects quick-stats
block inner-join print_log_entries -> print_archives WHERE
archives.project_id. total_archives becomes COUNT(PrintLogEntry.id),
failed_prints counts runs in failed/aborted/cancelled/stopped,
completed_items is SUM(PrintArchive.quantity) for runs where
status='completed', time/filament/cost/energy from PrintLogEntry.
Orphan log rows (archive_id IS NULL post archive deletion) are
excluded by the inner join.
Same-shape fixes carried forward (no follow-ups per project rule):
system.py system-info totals: total_print_time / total_filament
had the same bug shape - summed PrintArchive directly so reprints
collapsed to one row. Now sums PrintLogEntry.duration_seconds /
filament_used_grams. The semantic shift is also a correctness
improvement: the field now reflects time the printer actually spent
printing, not slicer-estimated time.
archives.py time-accuracy metric: estimate / actual per run where
estimate = PrintArchive.print_time_seconds. Post-parser-fix
multi-plate archives have file-level estimate but per-run actual =
one plate, so ratio = N x 100% for an N-plate file. The calc now
clamps each row to the [50%, 200%] plausibility band before
contributing to the printer-level average; single-plate accuracy
(the case the metric is designed for) stays fully included.
Backfill: users with AMS spool tracking - the reporter's case - have
per-run filament_used_grams from the tracked spool delta, so stats
become correct immediately. Users without tracking fall back to the
archive estimate and undercount until they reprint. Archive card
still reads PrintArchive.filament_used_grams directly so old
multi-plate archives keep plate-1-only numbers until reslice -
forward-only as the reporter accepted.
webhook.py treated printer_manager.get_status() return as a dict and
called .get(...) on it. The return is a PrinterState dataclass
(backend/app/services/bambu_mqtt.py), so the call raised AttributeError
and Starlette surfaced it as a generic 500 for every printer with a
status row. Non-existent printers correctly returned 404 because the
early "Printer not found" branch fired before the crash.
Reporter's repro matched exactly: id 1 (existing printer) returned 500,
id 2 and id 3 (no row) returned 404. Verified end-to-end against a live
PG-backed instance with the reporter's key shape — same 500 before the
patch, 200 with the correct payload after.
8 crash sites across 3 routes:
- webhook_get_printer_status GET /printer/{id}/status 5 sites
- webhook_stop_print POST /printer/{id}/stop 2 sites
- webhook_cancel_print POST /printer/{id}/cancel 2 sites
Every status.get("X", default) replaced with status.X if status else
default. Pydantic response schema unchanged; PrinterState's dataclass
defaults cleanly cover the "registered but never connected" branch so
the status route now returns 200 with connected=false, state=null
rather than crashing.
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.
Two attacker-controlled strings were being joined to library_dir with no
resolve + containment check in the project ZIP import endpoint:
- linked_folders[*].name from the request's project.json
- per-entry zf.namelist() paths from the ZIP itself
An absolute path in either field collapsed the join (Path("/lib") / "/etc"
becomes Path("/etc") because pathlib discards the left side when the right
is absolute) and the next write_bytes landed wherever the attacker chose.
Adjacent finding from the routes audit: GET /archives/{id}/photos/{filename}
had NO validation on filename and FileResponse-served arbitrary paths -
the DELETE counterpart at least gated on the photos membership check.
Adjacent finding from the services audit: ArchiveService.attach_timelapse
wrote archive_dir / filename where filename ultimately came from a printer's
FTP listing (compromised-printer threat model) or the /timelapse/select
query param. A malicious printer that exposes a directory entry with ..
segments could write the timelapse outside the archive directory.
New backend/app/utils/safe_path.py::safe_join_under(parent, *parts) is the
single source of truth: rejects empty / null-byte / absolute parts up-front,
joins under parent, resolves both sides, asserts is_relative_to. Returns the
resolved canonical path on success, raises HTTPException(400) on escape, or
PathTraversalError when http=False (for service-layer callers that need to
match a non-HTTP return contract).
Wired into the import vectors, both archive photo handlers, and the
attach_timelapse service. The full audit sweep inspected every Path/Name
join in backend/app/api/routes/ AND backend/app/services/ - 25 route-layer
sites + 8 service-layer sites confirmed safe and tagged with
# SEC-PATH-OK: <reason> so future audits trust the inline guard at a glance.
Fifth CI backstop test_route_path_arithmetic_is_safe_joined_or_marked
AST-walks both layers and fails the build on any <dir-like>/<bare variable>
join that doesn't either route through safe_join_under or carry the marker.
The services layer is in scope because it receives values verbatim from the
routes AND from external sources Bambuddy has no control over (the printer
FTP-listing case above).
SECURITY.md gets a fifth rule + a fifth row in the CI test mapping table;
the rule now names the printer FTP-listing case explicitly so future
services-layer audits set the right expectation.
--------------
fix(library): suppress warning storm when bulk-uploading ZIPs of empty/stub STL files
Uploading a ZIP of stub or empty STL files (e.g. the 24-byte
"solid test\nendsolid test" shape) produced one WARNING per file in
stl_thumbnail.py::generate_stl_thumbnail. The warnings were technically
correct - trimesh returns a valid Mesh with zero vertices, the safeguard
matches, and the function returns None so the library entry is still
created without a thumbnail - but the volume turned a successful upload
into thousands of WARNING lines in the journal.
Two changes:
1. The per-file "Failed to load STL or empty mesh" message in
stl_thumbnail.py is now logger.debug instead of logger.warning. It's
a per-file content observation, not an actionable error; the caller
already handles None correctly. The branch now catches the rare
"large enough but trimesh still can't parse it" case, visible in
debug logs without spamming production.
2. New module constant MIN_USABLE_STL_BYTES = 200 (smallest binary STL
with one triangle is 134B, smallest ASCII ~150B; 200 is a safe floor
below any real STL). The three thumbnail call sites in library.py
(extract_zip_file, single-file upload, _backfill_external_stl_thumbnails)
pre-skip files below this size before calling generate_stl_thumbnail.
Stubs never enter the trimesh pipeline at all.
Behavior is unchanged for real STLs: any file >=200 bytes runs through
the existing pipeline, MAX_VERTICES still triggers simplification at
100k vertices for the 256x256 thumbnail render, large files still get
thumbnails.
------------
fix(stl-thumbnail): silence matplotlib first-import noise (writable cache + font_manager log level)
On first STL upload, three matplotlib-internal log lines surfaced:
WARNING [matplotlib] /opt/claude/.config/matplotlib is not a writable directory
INFO [matplotlib.font_manager] Failed to extract font properties from NotoColorEmoji.ttf
INFO [matplotlib.font_manager] generated new fontManager
The writable-dir warning fired because Bambuddy's $HOME isn't writable for
matplotlib's default config path; matplotlib fell back to /tmp/matplotlib-XXX
which lost the font cache on every host reboot, so font_manager rebuilt it
each cold start - producing another batch of INFO lines.
Fix is two small additions in stl_thumbnail.py before the matplotlib import:
1. New _configure_matplotlib_cache() sets MPLCONFIGDIR to
settings.base_dir/.cache/matplotlib (mkdir if missing) so the cache
persists across container restarts and the writable-dir warning never
fires. Respects an externally-set MPLCONFIGDIR so operators who chose
their own path aren't overridden. Best-effort with a debug fallback if
settings can't be imported or the mkdir fails.
2. logging.getLogger("matplotlib.font_manager").setLevel(WARNING) at module
import demotes the per-font INFO scan that fires when font_manager
builds its cache cold. Real font warnings (>= WARNING) still surface.
3 new tests: font_manager logger at WARNING after module import;
_configure_matplotlib_cache creates the directory under base_dir and sets
MPLCONFIGDIR; an externally-set MPLCONFIGDIR is preserved verbatim.
5516 backend tests green, frontend gates clean.
When Cloudflare in front of bambulab.com returns a "Just a moment..." interstitial
instead of the JSON the API normally produces, the parse error in
verify_totp / verify_code / login_request used to surface as the opaque "Invalid
response from Bambu Cloud" or a generic 401 from BambuCloudAuthError. Reporter
hit this with three back-to-back TOTP attempts; a curl from a different network
with the same honest Bambuddy UA returns clean JSON, so the trigger is CF-side
(per-IP / TLS-fingerprint / rate / transient mitigation), not our code.
Add a small _detect_cloudflare_challenge() helper that inspects the response for
four CF markers (body "Just a moment...", body "challenges.cloudflare.com", 403
with cf-mitigated header, 503 with cf-ray header) and returns a message that
attributes the block to Bambu Lab's Cloudflare protection, suggests waiting a few
minutes, and points the user at a same-network browser sign-in as the standard
workaround. Wired into all three JSON-parse sites; verify_totp previously had a
defensive catch, login_request and verify_code now do too.
No header changes, no impersonation, no retry loop - pure diagnostics. Stays
clearly on the right side of Bambu Lab's "no falsified client identity" line.
When an operator configures `Email Claim = preferred_username` (e.g. Authentik) the
primary `_resolve_provider_email` correctly rejects the identity value as non-email
shaped and returns None, leaving auto-provisioned users with `email=None` even though
the same token carries a valid standard `email` claim.
Add a narrow fallback in the auto-create-users branch only: when
`provider.email_claim != "email"` and the primary returned None, resolve the standard
`email` claim with the same Fall A/B shape + email_verified enforcement and use it for
`User.email` and `UserOIDCLink.provider_email`.
The auto-link-existing-accounts gate is left on the primary `provider_email`, so the
GHSA Fall-B / Fall-C guards remain intact - the fallback never feeds account matching.
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.
The 1 Hz status push was silent at INFO, so support bundles couldn't show
whether the push task was actually reaching a specific slicer connection.
Now ``_periodic_status_push`` emits one line per minute per connected
slicer ("1Hz status push: N pushes/min to <client>") and stays silent when
no slicer is attached. No behaviour change to the push itself — counters
are local to the task and reset every 60 ticks.
Motivated by the open follow-up on #1548: keepalive parser shipped (b6636053)
and the symptom moved 60 s → 90 s, but OrcaSlicer still disconnects on idle.
This adds the missing observability so the reporter's next support bundle
shows whether our outbound 1 Hz push is alive for the disconnecting
connection.
is_auth_enabled() and auth_middleware both caught every exception during
the auth-state probe and returned the "allow" answer instead of denying
the request. Reporter's PoC floods /api/v1/auth/login to exhaust file
descriptors, forcing the next SQLite connect to raise, then hits a
protected endpoint during the fail-open window with no token — granting
unauthenticated access to admin-account creation, API-key creation, DB
backup download, and printer control. CWE-636 / CWE-755. Affects >= 0.1.6.
Fix:
- is_auth_enabled (backend/app/core/auth.py): only returns False for the
legitimate "settings row absent" case; any actual exception propagates
so the caller can deny the request.
- auth_middleware (backend/app/main.py): returns 503 on any probe failure
instead of await call_next(request).
4 new regression tests in test_auth_fail_closed.py pin the contract
(propagates DB exceptions, returns False for no-row, True for "true",
False for "false"). 1 existing security test renamed and updated to
accept either 500 or 503 (both fail-closed) and to verify the
SQLAlchemy detail does not leak in the body.
Codebase grep confirmed no other auth-decision predicate has the same
fail-open shape: _validate_api_key returns None on catch (→ 401 fail-
closed downstream), is_advanced_auth_enabled propagates correctly,
permissions.py has no catch-alls.
Reported by @wondercrash via private advisory.
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 ran a fresh trace after the doubled-extension fix landed and
found a distinct second cause behind his ghost prints, hitting 4-of-4
of his A1s. Timeline:
22:50 PRINT START
...print runs all night...
23:13 / 00:47 / 09:35 MQTT disconnects (A1 keepalives are unstable)
print finishes during one of those disconnect windows → PRINT COMPLETE
is never observed
smart plug detects idle → cuts power
power resumes for the next scheduled print → firmware auto-replays the
leftover .3mf from the SD card
09:46 Bambuddy reconnects to a fresh PRINT START for the ghost
The existing IDLE-after-RUNNING completion check at
backend/app/services/bambu_mqtt.py:3022 was meant to catch the simple
disconnect-then-finish case via `_previous_gcode_state` preserved across
reconnects, but with multiple disconnect/reconnect cycles + a smart-plug
power-off that Bambuddy can't distinguish from any other transient drop,
the IDLE window that branch needs simply never reaches it. The SD .3mf
lingers, the firmware ghost-replays every power cycle, and the loop
repeats.
Fix: new connected-edge reconciliation pass.
* `_is_active_archive_stale(archive, state)` — pure decision function
with three triggers:
(1) printer state is terminal (IDLE / FINISH / FAILED)
(2) printer running with a different `subtask_id` than the archive —
Bambu firmware mints a fresh subtask_id for each print including
the ghost replay, so a mismatch is unambiguous
(3) printer running but `subtask_name` is empty — printer doesn't
know what it's running, archive reference is broken
Conservative on PAUSE / PREPARE / SLICING and on RUNNING with matching
subtask. False-positive cost = one misreported "aborted" status that
the next real PRINT COMPLETE would have overwritten anyway. False-
negative cost = the ghost-print loop.
* `reconcile_stale_active_prints(printer_id)` — queries archives in
`status="printing"` for the printer, runs the decision function, and
synthesises `on_print_complete(status="aborted", _reconciled=True)`
for each stale match. Reuses the existing PRINT COMPLETE chain (SD
cleanup, status update, usage tracker, notifications) — no reimplem-
entation. Per-archive try/except so one failure doesn't block the
rest. Returns 0 when status is None / disconnected — the connected
edge is the only legitimate trigger.
* `on_printer_status_change` now runs a connected-edge check at the
start. New `_printer_reconciled_since_connect: dict[int, bool]`
tracker flips False → True on the first connected status update for
this connection and back to False on disconnect, so reconciliation
fires exactly once per (re)connection. The flag is set BEFORE the
task is spawned so concurrent status updates within the same
connection don't re-trigger it (no await between check and set,
asyncio guarantees atomicity). The reconciliation runs as
`asyncio.create_task` so the hot WebSocket dedup / broadcast path
isn't blocked.
* Single handler covers both startup and reconnect — when the first
MQTT connection completes after startup the printer pushes status,
the connected edge fires, reconciliation runs. No separate startup
hook needed.
Idempotency: when the existing #3022 branch DOES fire on a clean
disconnect-then-IDLE-on-reconnect, it lands `archive.status` to terminal
synchronously. The async-scheduled reconciliation then queries
`WHERE status="printing"` and finds 0 rows → no-op. The narrow race
where reconcile's query lands between a real on_print_complete's
archive update and its archive lookup produces at most a duplicate
notification (no double SD-cleanup since FTP delete on a missing file
is a no-op 550).
Ghost-print collateral worth being explicit about: if the ghost is
already running when reconciliation fires, the synthesised SD-cleanup
hits 550-file-locked (same root cause as the #1542 first case). The
cleanup retries 3× then logs "lingering". The ghost runs to completion,
its own end-of-print cleanup deletes the file, the next power cycle
has nothing to replay, the loop breaks. A perfect cancel would require
a `print_stop` MQTT command to the printer mid-ghost — invasive,
explicitly out of scope.
Reporter updated to 0.2.5b1 expecting the #1533 fix to populate filament
fields on his P2S virtual-printer prints when the .3mf is locked. His
support bundle showed Bambuddy still creating fallback archives with NULL
filament fields even though the print-start log line proved AMS-0-T0 had
PETG loaded at the moment the helper should have read it
(`AMS 0: T0(type=PETG, color=FFFFFFFF, ...)`).
Cause: the #1533 helper `_extract_filament_data_from_mqtt(data)` in
backend/app/main.py only looked at `data["ams"]`, but the dict that
on_print_start actually receives at runtime is the wrapper shape
`{"filename", "subtask_name", "remaining_time", "raw_data": <mqtt>,
"ams_mapping"}` that backend/app/services/bambu_mqtt.py:2971-2980
constructs. So `data["ams"]` was undefined on every real call and the
helper silently returned `{}`, leaving the fallback archive's
filament_type / filament_color NULL — the exact regression the original
fix was meant to close. The 15 unit tests that shipped with #1533 all
passed the bare inner shape directly and never exercised the callback
wiring, so the regression slipped through a green build. Surrounding code
in the same fallback path (main.py:2064, 2674) already reads
`data.get("raw_data")` — the original hunk was the outlier that forgot
the wrapper.
Fix: helper now resolves `data["raw_data"]["ams"]` first (the callback
shape) and only falls back to `data["ams"]` when the wrapper isn't
present, preserving the inner-shape callers from the existing tests.
Defensive against a non-dict `raw_data` (e.g. partial MQTT decode
failure) falling through to the inner lookup instead of crashing.
Tests: 5 new in `TestOnPrintStartCallbackShape` in
test_fallback_archive_mqtt_filament.py — wrapper payload with ams_mapping
resolves to the inner AMS state; wrapper without ams_mapping lists all
loaded slots; the existing inner-shape callers still work after the
additive wrapper lookup; missing raw_data returns `{}` instead of
raising; junk raw_data (string) doesn't shadow a present inner `ams`.
Existing 15 inner-shape tests untouched and green. Full 5378-test
backend suite green; backend ruff clean.
What this does NOT fix: per-filament gram usage still needs the actual
.3mf — the printer locks it during print (P-line firmware behaviour,
not a Bambuddy bug), and the existing 19 FTP candidate paths +
directory probes are expected to 550 in that window. Per-print filament
type and colour are the data point the reporter explicitly called out
as load-bearing for AMS-expansion planning at his maker space, so this
is what moves the needle.
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).
OrcaSlicer connects, exchanges pushall + get_version, then sits idle waiting
for status pushes from the (virtual) printer. The VP MQTT server's read
loop used `asyncio.wait_for(reader.read(1), timeout=60)` regardless of what
the client negotiated, and `_handle_connect` explicitly skipped the
keepalive field in the CONNECT payload, so every idle slicer connection was
torn down at exactly 60s.
- Parse the 2-byte big-endian keepalive from CONNECT; return it from
_handle_connect alongside the auth bool.
- Use 1.5x the negotiated keepalive as the per-packet read timeout per
MQTT spec sec 4.4. Treat keep_alive == 0 as no timeout (spec sec 3.1.2.10).
- Retain the 60s default for the initial read before CONNECT arrives, so
a TCP-connect-without-CONNECT still gets reaped.
- 7 new tests: 4 unit-level for the parser (success, opt-out=0, auth-fail
tuple shape, malformed CONNECT) + 3 integration-style for the read loop
(long keepalive survives the old 60s mark, short keepalive closes idle
in ~3s, PINGREQ resets the window so DISCONNECT decides the exit).
A library row with archive.filename "Cube (1).gcode.3mf.gcode.3mf" uploaded
to /Cube_(1).gcode.3mf.3mf (single-iteration strip + append). Post-print
cleanup looked at /Cube_(1).3mf and /Cube_(1).gcode (subtask_name + ext),
missed the on-card file, and A1 firmware re-ran it on next power-on.
- New derive_remote_filename() helper in backend/app/utils/filename.py:
iterative strip of .gcode.3mf/.3mf suffixes, append single .3mf,
space->underscore. isinstance() guard raises TypeError on non-str
input rather than entering the strip loop with a duck-typed
object that returns truthy sentinels from endswith.
- Three previously-duplicated upload sites (background_dispatch reprint +
library, print_scheduler queue) now share the helper.
- SD cleanup fetches archive.filename and tries the derived path first,
with the legacy subtask_name + ext paths kept as fallbacks.
- 10 new unit tests pin the reproducer + edge cases (doubled .gcode.3mf,
doubled .3mf, raw .gcode preserved, idempotence, Unicode, plus the
type guard against MagicMock / None / int inputs).
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.
GitHub forces Node-20 actions to run on Node 24 starting 2026-06-02 and
removes Node 20 from the runner on 2026-09-16. Bumping each action to
its first Node-24 major now gets us ahead of both deadlines and silences
the deprecation warnings already firing in every CI run.
Bumps (across ci.yml, security.yml, codeql.yml, auto-label-area.yml,
issue-closed.yml, stale.yml):
- actions/checkout v4 -> v6
- actions/setup-python v5 -> v6
- actions/setup-node v4 -> v6
- actions/cache v4 -> v5
- actions/upload-artifact v4 -> v7
- actions/github-script v7 -> v9
- actions/stale v9 -> v10
- docker/setup-buildx v3 -> v4
- docker/build-push v5 -> v7
Verified each major's breaking-change notes against our usage:
- setup-node v6 limits auto-cache to npm only; we already pass
cache: 'npm' explicitly, so nothing changes.
- github-script v9 drops require('@actions/github'); none of our
scripts use it (only require('fs') and the injected github/context
globals).
- setup-buildx v4 removes deprecated inputs; we call it with no
inputs.
- build-push v6 enables build summaries by default; informational,
can disable via DOCKER_BUILD_SUMMARY=false env if it gets noisy.
codeql-action stays on v4 (already runs on Node 24). Trivy and
github-repo-stats are Docker actions and aren't affected by the
Node-20 deprecation.
Amazon Inspector flagged fastapi 0.136.x for shipping an undocumented
`fastar>=0.9.0` dep in its [standard] extras group. `fastar` is a
Rust-tar binding package, no plausible reason for a web framework to
depend on it. Even if `fastar` is benign today, the advisory's
"namespace-abuse vector" framing is valid — whoever controls the
fastar PyPI namespace gains code execution at install time across
every fastapi[standard] install.
Bambuddy doesn't request [standard] so we don't pull fastar in
practice, but pip-audit flags the fastapi package itself and breaks
CI. Hold to 0.135.x (last clean release line) until upstream removes
the dep.
When the source .3mf can't be downloaded at print start (P1S/A1/P2S
firmwares lock the file mid-print), main.py creates a fallback
PrintArchive with file_path="" and every filament field NULL — even
though the MQTT payload already has the AMS state and the slicer's
slot-per-print-filament mapping (data["ams"]["ams"] and
data["ams_mapping"]).
New _extract_filament_data_from_mqtt(data, ams_mapping) builds a
{global_tray_id: (type, color)} map from the AMS units, then narrows
to slots referenced by ams_mapping (slicer order preserved, -1 VT-tray
sentinels skipped) or falls back to every loaded slot when no mapping
is present. Returns comma-separated filament_type and filament_color
matching the 3MF-extraction shape, so the inventory page, Quick Stats
rollup, and len(filament_type.split(",")) per-print count behave
identically for fallback rows.
The constructor at the fallback site now passes the resulting values
into the PrintArchive row.
This does NOT recover per-filament gram usage — that needs the .3mf's
slice_info.config or a deeper layer-delta integration via usage_tracker.
The reporter (maker-space lead evaluating Bambuddy partly for AMS
expansion planning) asked specifically for "the number of filaments
used", which is what this gives them.
15 unit tests cover empty/malformed payloads, the no-mapping path,
mapping filtering and reordering, VT-tray sentinels, dual-AMS global
ids, column-limit truncation, and defensive garbage handling.
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.
The notification service's httpx client was the only outbound client in
the codebase still leaking python-httpx/<version> as User-Agent; all
other clients identify as Bambuddy/1.0 since the May 2026 compliance
pass. Bring it in line.
The reporter's ntfy server was behind a Cloudflare Tunnel and CF returned
its JS challenge page (Just a moment...) to every API request — confirmed
by reproducing the same 403 with curl. Cloudflare can't be solved from a
backend, so add detection for the challenge shape (Server: cloudflare or
cf-mitigated header, or <!DOCTYPE html>...Just a moment... body) and
return an actionable error message that points at the real fix on the
user's CF side instead of dumping the raw HTML.
Normal 403s (auth failures with plain text bodies) still surface the
original body so genuine errors stay debuggable.
Archives created from prints Bambuddy didn't archive (cloud / Handy /
SD-card prints) carry file_path="". The two source-upload routes
computed the destination as (base_dir / archive.file_path).parent /
"source", which collapsed to base_dir.parent / "source" for fallback
rows — sending the file to /app/source/ (outside the data volume,
orphaned on container restart) and raising 500 on the final
relative_to.
Centralise the destination math in _resolve_source_3mf_path. Normal
archives keep the <archive>/source/<filename> layout. Fallback
archives land at <base_dir>/archive/no_source/<id>/<filename>, which
stays inside the data volume and is addressable by every existing
read site. The helper also asserts the resolved directory is under
base_dir.resolve() so a corrupted row fails with a clear message
instead of writing outside the volume.
Both upload routes (upload_source_3mf and upload_source_3mf_by_name)
now route through the helper. Two regression tests in
TestUploadSourceThreeMF pin both branches.
POST /spoolbuddy/scale/update-spool-weight tried the local DB first
and only fell back to Spoolman on a local miss. Combined with
nfc/tag-scanned's post-#1119 always-Spoolman routing, a stale local
Spool row sharing a numeric id with a Spoolman spool would absorb
the sync silently while the Spoolman row stayed unchanged.
Mirror the routing already used by nfc/tag-scanned: pick the branch
via _get_spoolman_client_or_none() and never cross. Local mode now
returns 404 on a local miss instead of falling through.
New TestUpdateSpoolWeightSpoolman.test_stale_local_row_does_not_shadow_spoolman
asserts both directions: Spoolman gets the update, the colliding
local row's weight_used and last_scale_weight are untouched.