Earlier patch trimmed the duplicate unit-test re-run from docker-test
to drop a 5-10 min job that wasn't adding coverage. But "wasn't adding
coverage" only holds for pure-logic tests — system-touching tests
(ffmpeg version probes, ftp clients, subprocess shell-outs, locale/
timezone-sensitive assertions, paths) genuinely can pass on the GHA
host and fail in python:3.13-slim. Curation via a `docker_env`
marker is fragile (new tests get forgotten); gating on `main` only
defers the cost without removing it.
Instead, run the full backend suite IN Docker on every PR but make
it fast:
- New docker-backend-tests job runs the same 4-way pytest-split
matrix as the host backend-tests, just inside the test image.
- docker/setup-buildx-action + docker/build-push-action@v5 with
cache-from/cache-to: type=gha,scope=backend-test persist the
BuildKit cache (pip-install layer included) across CI runs and
across the 4 sibling shards. Cold build is ~150s/shard; warm
build drops to ~10s/shard.
- fail-fast: false so a single failing shard surfaces the rest's
output too.
Total CI wall-clock for a PR push is now gated by docker-test (the
image-build + integration HTTP smoke + integration test suite job)
at ~3 min, not by the unit-test re-run anymore.
The earlier ci.yml step that ran `docker compose run --rm
backend-test` synchronously in the docker-test job stays removed —
the new docker-backend-tests matrix covers the same ground and is
much faster.
The "Docker Build" job in ci.yml was running the same 5287 backend
tests + 2022 frontend tests inside the bambuddy-backend-test /
bambuddy-frontend-test images that the host-side backend-tests and
frontend-tests jobs had already run. Same test code, same Python
version (env.PYTHON_VERSION), same requirements.txt the test image
installs. On 2-vCPU GHA runners that re-run added 5-10 min of
wall-clock for zero new coverage — and "frontend tests in Docker"
added another 2-3 min for the same reason.
Drop both steps from the CI job. Keep everything that validates the
Docker IMAGE specifically: production image build, backend module
import verification, static-files-copied check, integration
container bring-up + health/API/static HTTP smoke checks, and the
integration test suite (which IS genuinely Docker-specific — it
runs against the live container via BAMBUDDY_TEST_URL).
test_docker.sh keeps the unit-test reruns because devs running it
locally don't have a separate host-side pytest job to compare
against.
Combined with the earlier 4-way pytest-split shard on the host
backend-tests job, expected PR-push wall-clock drops from
~10-12 min to ~3 min, gated on max(backend-tests shard, frontend
tests, docker-image-build+integration).
Three things were making the Docker test runs noisier and slower than
they needed to be:
1. -v was hardcoded in Dockerfile.test:35 CMD and in docker-compose.
test.yml's integration-test-runner command. The ci.yml change to
drop -v from the bare pytest call missed both — Docker runs use
the image's CMD, not the workflow's.
2. -n 30 was hardcoded as the xdist worker count. On a 2-vCPU CI box
that's 30 Python processes fighting over 2 cores — mostly IPC and
import-thrash overhead. -n auto adapts to the host: 2 on CI, 30
on a 30-core dev box. Same final-result throughput on the dev
box, much better on small runners.
3. pip install had --no-cache-dir and no BuildKit cache mount, so
every Docker build re-fetched ~50 packages from PyPI (~60-90s
on a cold pip cache). Adding `RUN --mount=type=cache,target=
/root/.cache/pip` (with the `# syntax=docker/dockerfile:1.7`
directive that enables it) makes subsequent builds re-use the
download cache so they only do install work, ~5s instead of
~90s. DOCKER_BUILDKIT=1 is already exported in test_docker.sh
and is the GHA default since runner image 2023, so the cache
mount is always honoured.
Verified locally: Docker build is 19s warm (was ~90s cold each
time), test run is 102s with 5287 passed / 1 skipped (the
by-design spoolbuddy importorskip) — clean output, no [gwN]
worker spam, no "created: 30/30 workers" startup line.
GHA-side per-run cold-build slowness still happens because GHA
runners are ephemeral; a follow-up using docker/build-push-action
with type=gha cache backend would persist the BuildKit cache
across CI runs but that's a bigger workflow change.
+ ci: shard backend tests 4-way + drop -v for ~3.5x wall-clock speedup
Root cause of the 4 CI failures on PR #1514 (all in
test_print_start_assigns_printer_id_to_vp_archive.py +
test_timelapse_baseline_restart_recovery.py): test_all_modules_importable
in test_code_quality.py was deleting backend.app.main from sys.modules
and re-importing it via importlib.import_module. That created NEW
module-level dicts (_timelapse_baselines, _expected_prints,
_active_prints, …) and re-ran root_logger.addHandler — hence the
duplicate log lines at the same microsecond in captured stderr.
Any sibling test that bound those names via "from backend.app.main
import _timelapse_baselines" before the reimport now held a reference
to the OLD dict; production code (reached via "from backend.app.main
import on_print_start") resolved the symbol through the NEW module
instance. Production mutated the new dict, the test read the old one,
the assertion saw None / un-mutated mock_archive.
Locally with -n 30, xdist load-balanced test_code_quality.py to a
different worker process so the collision never happened (which is
why the suite was green for me). CI's -n auto = -n 2 on ubuntu-latest
made the collision deterministic.
Fix: drop the "del sys.modules[name]" step. importlib.import_module
already returns the cached module if cached, or runs the import
machinery if not — either way, any import-time error surfaces. The
"fresh import" framing was theatre; in practice every module in the
list is already imported by other tests/fixtures before this test
runs, so we were never actually getting a fresh import anyway — just
destruction.
CI workflow tightening (separate concern, same PR since both touch
the test infrastructure):
- Dropped -v from the pytest invocation. 5300+ "PASSED foo::bar"
lines per worker were eating ~30-60s of stdout I/O on 2-vCPU
runners. --tb=short is sufficient for failure context.
- Sharded backend-tests into a 4-way matrix via pytest-split (new
dev dep). Each shard runs ~1326 tests in ~95s on a 2-vCPU runner;
all 4 run in parallel so wall-clock drops from 362s -> ~100s.
- fail-fast: false on the matrix so a single failing shard doesn't
hide failures in the other three — PRs see the complete failure
picture in one push.
test_running_observed_captures_baseline_on_restart_recovery was reading
_timelapse_baselines.get(1) after the patch() with-block exited.
Locally and under low parallelism this works fine — the dict still
holds what _capture_timelapse_baseline_at_start wrote. CI under
xdist's default load-balancing scheduling intermittently saw the
dict empty by the time the top-level assert ran, even though the
production code logged "Baseline at print start: 3 video files for
printer 1" right before returning. The duplicate log line at the
same microsecond in the captured stderr is the tell — module state
is being re-touched between the handler completing and the test
asserting, almost certainly via the session-scoped event_loop
fixture in conftest.py interacting badly with the per-file
autouse _clear_baselines teardown of a sibling test on the same
worker.
The test is verifying the handler captured the baseline at the
moment it returned, so capture the relevant value at exactly
that point — inside the with-block, immediately after the await.
That's immune to whatever happens to the module-level dict
afterward.
The test_returns_empty_when_3mf_missing test sets a deliberately
non-existent file_path on a PrintArchive to verify
compute_deficit_for_queue_item handles the missing-3MF branch
gracefully. The path just needs to fail an existence check — the
/tmp/ prefix was incidental.
Bandit B108 ("insecure temp file usage") regex-matches /tmp/,
/var/tmp/, and /dev/shm/. Dropping /tmp/ in favour of /nonexistent/
keeps the test behaviour identical (still a guaranteed-missing
path, still triggers the missing-file branch) while clearing the
GitHub Advanced Security finding on PR #1514 without adding a
# nosec annotation.
Dockerfile.test only COPYed backend/ and pyproject.toml, so the
integration test at tests/integration/test_gcode_viewer.py:63
silently pytest.skip'd in every Docker run with "gcode_viewer/
index.html not present at /app/gcode_viewer/index.html".
That was deliberate fallback behaviour for unit-test environments
where the assets are intentionally absent, but in CI it meant the
#1218 packaging regression (3D Preview returning {"detail":"Not
Found"} because the embedded PrettyGCode viewer wasn't bundled into
the prod image) had no test guarding against a recurrence — the
test that was supposed to catch it was the one being skipped.
Add COPY gcode_viewer/ ./gcode_viewer/ to the backend-test stage,
matching the path the production Dockerfile uses (static_dir.parent
/ "gcode_viewer" = /app/gcode_viewer/) so the assertion runs against
the same layout the app sees at runtime. Path-anchored comment in
the Dockerfile so a future maintainer doesn't strip the COPY as
unused.
pip-audit reported starlette 1.0.0 in the dev venv. starlette is
transitive via fastapi, whose range still admits 1.0.0, so the
resolver was silently picking the vulnerable build.
Same floor-pin strategy as the existing idna/urllib3 entries —
direct pin in requirements.txt with a why-comment so it isn't
mistaken for an unused line and dropped later.
Verified clean: pip-audit reports "No known vulnerabilities found"
after the upgrade (starlette 1.0.0 → 1.1.0 locally).
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)
Reporter has a P1S with an inventory spool cloned to slot 1 and the
original (much further used) in slot 4, the preference enabled, and
the dispatch picked slot 1 every time. The sort's been blind to
Bambuddy inventory weights — it reads MQTT `tray.remain`, the
printer firmware's RFID-decremented value, which has two limitations:
- Bambu RFID only. Non-RFID spools report -1 and get clamped to a
sentinel; multiple non-RFID trays then tie in the sort and Python's
stable sort collapses to AMS-slot insertion order, so slot 1 wins.
- Even when set, it's the printer's counter, not Bambuddy's
`label_weight - weight_used` (internal) or Spoolman's
`remaining_weight`. The two diverge whenever the user re-spools,
swaps cardboard, or runs a print outside Bambuddy.
The reporter is on internal-inventory mode with non-RFID spools — both
failure modes apply, hence slot 1 every time.
Fix: when a slot is bound to an inventory spool the inventory record's
remaining weight becomes the sort signal. New async helper
`_build_inventory_remain_overrides(db, printer_id, loaded)` returns
`{global_tray_id: remaining_grams}` for bound slots — internal mode
joins SpoolAssignment → Spool once per dispatch; Spoolman mode joins
SpoolmanSlotAssignment then reuses `_spoolman_remaining_grams` from
filament_deficit.py for parity.
New `_prefer_lowest_sort_key` does a two-tier comparison: inventory-
tracked spools sort BEFORE MQTT-only spools, then ascending by
remaining within each tier, then ascending by ams_id*4+tray_id as the
deterministic slot tie-breaker. The tier flag dominates so grams
(inventory) and percent (MQTT) never get cross-compared — no unit
conversion needed.
MQTT-only behaviour is preserved exactly: remain=-1 still maps to the
101 sentinel and slot order still decides on ties. Users who haven't
bound any inventory spool see no change. The DB lookup runs only when
prefer_lowest_filament is enabled.
External / VT slots are skipped (tracked separately from AMS bindings).
A previous attempt swapped `-timeout` → `-stimeout` unconditionally to
fix EADDRINUSE on the reporter's transitional ffmpeg. That broke every
install on a modern ffmpeg (5+/6+/7+) — current Debian/Ubuntu/Homebrew
— where `-stimeout` was removed and `-timeout` is back to meaning
socket I/O. Verified locally: `ffmpeg -stimeout ...` errors
"Unrecognized option 'stimeout'" on ffmpeg 7.1.
install on a modern ffmpeg (5+/6+/7+) — current Debian/Ubuntu/Homebrew
— where `-stimeout` was removed and `-timeout` is back to meaning
socket I/O. Verified locally: `ffmpeg -stimeout ...` errors
"Unrecognized option 'stimeout'" on ffmpeg 7.1.
ffmpeg has shipped THREE arrangements of this option over time and
Bambuddy supports the full range:
- Pre-deprecation (early 4.x and earlier): `-timeout` is socket I/O.
- Transitional (~late-4.x, Jammy-era): `-timeout` is deprecated and
repurposed to RTSP listen-mode timeout; any non-zero value implies
`-listen`, which makes ffmpeg bind the TLS-proxy port and fail with
EADDRINUSE. `-stimeout` is the replacement socket I/O option.
- Modern (5.x / 6.x / 7.x): `-stimeout` REMOVED. `-timeout` is back to
socket I/O — the original meaning.
So no single literal is correct on all installs.
Fix: `rtsp_socket_timeout_flag()` in services/camera.py probes
`ffmpeg -h demuxer=rtsp` once and picks `-stimeout` when ffmpeg
advertises it (covers transitional + older builds that kept it as an
alias), else `-timeout` (modern + pre-deprecation). Cached at module
level for the process lifetime — ffmpeg doesn't swap mid-run.
The function returns the option name without a leading dash; callers
prepend it themselves so a formatting bug can't pass an empty flag.
Wired into both RTSP ffmpeg call sites in lockstep: routes/camera.py
(printer camera) and services/external_camera.py (external RTSP),
which use the same TLS-proxy + ffmpeg pattern and would hit the same
regression on either ffmpeg cohort.
Tests: 8 in test_ffmpeg_rtsp_timeout_flag.py — 6 probe unit tests
(prefers stimeout when advertised, falls back to timeout on modern,
defaults to timeout when ffmpeg missing or probe raises, caches across
calls, trailing-space substring guard against `-listen_timeout`
false-positives), 2 parametrised guards against either RTSP ffmpeg
argv re-hard-coding a literal instead of consuming the probe. 37
probe + existing external-camera tests green.
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.
When Bambuddy is restarted mid-print, the first MQTT push from the
printer carries `_previous_gcode_state = None`. The #1304 guard
deliberately suppresses on_print_start on that first push to prevent
duplicate archive creation — but on_print_start is also where
_capture_timelapse_baseline_at_start runs, so the in-memory
_timelapse_baselines dict stays empty for the resumed session.
At PRINT COMPLETE, _scan_for_timelapse_with_retries finds no baseline
and falls into its "take baseline now" fallback. By that point the
printer has already uploaded the in-flight MP4, so the snapshot
includes the new file. Every "Found N files / no new files since
baseline" retry then fails to detect a diff, and the archive ends up
with no timelapse attached — pwostran's report (#1485 follow-up): card
shows the finish snapshot but no video.
Add a sibling callback on_print_running_observed that bambu_mqtt fires
in the "Now tracking RUNNING state" branch when on_print_start was
suppressed. main.py wires it to a thin handler that looks up the
printer row and calls the existing _capture_timelapse_baseline_at_start.
Idempotent — skips if a baseline already exists (handles the rare
same-session race where on_print_start also fires for some reason).
The printer doesn't upload the timelapse until after PRINT COMPLETE,
so a baseline captured any time during the print is still pre-upload —
no narrow window to hit.
Verified against the in-the-field logs in #1485 (pwostran's 2026-05-23
support bundle):
pre-reboot: baseline = 7 files
reboot
post-reboot completion: fallback baseline = 8 files (includes new MP4)
-> all 4 retry attempts report "no new files since baseline"
10 new tests cover both the MQTT-side fire decision (fires when
suppressed, doesn't fire when on_print_start handles it, once per
session, payload shape mirrors on_print_start) and the main.py
handler (snapshot capture, double-capture guard, missing-printer-row
guard).
When the user re-sliced an H2D archive for X1C, the new archive card and
reprint modal both showed the source printer's name (e.g. "Workshop H2C")
even though sliced_for_model on the same row correctly read "X1C".
slice_and_persist_as_archive copied source_archive.printer_id verbatim
onto the new PrintArchive row. Both the archive card
(ArchivesPage.tsx:3571) and the reprint modal read printer_id first and
only fall back to sliced_for_model when it's None. With printer_id set
to the source's H2D printer, neither could see that the new file is for
a different model.
Same shape as the sliced_for_model fix already in the same function — a
cross-model re-slice means the source's physical printer isn't where
this output prints anymore. When the slicer-baked target model differs
from source_archive.sliced_for_model, drop printer_id so both surfaces
fall back to the sliced_for_model badge ("X1C"). Same-model re-slices
keep printer_id so the reprint modal still pre-selects the source
printer.
Edge case: when source_archive.sliced_for_model is None (older archives
that predate that column being populated), we can't tell whether this
is a cross-model re-slice. Fail open and preserve printer_id rather
than spuriously nulling it.
slice_and_persist (the library-file path) doesn't have this bug —
LibraryFile has no printer_id column.
Tests in TestSliceArchiveResliceModel cover all three branches: cross-
model nulls printer_id; same-model keeps it; unknown source model
preserves it.
Surfaced via the bambuddy demo doing H2D -> X1C re-slices after the
.bbscfg System-tier slicing fix landed.
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.
Behind Cloudflare, the bot-detection script CF injects into every HTML
response carries a hash that rotates per request, so it can never be
allowlisted by hash. Reporters with CF in front had to relax their NPM
CSP to 'unsafe-inline' as a workaround.
Per Cloudflare's documented behaviour, when a nonce is present in the
page's script-src, CF clones it onto its injected <script>. The SPA CSP
now stamps a fresh per-request nonce via secrets.token_urlsafe(16),
keeping 'self' for our own scripts (index.html has had no inline scripts
since the SW registration moved to /sw-register.js in the original
#1460 PR), so no HTML body rewriting is needed.
Also folded in: /manifest.json, /sw.js and /sw-register.js now accept
HEAD as well as GET, so `curl -I` and uptime scanners stop returning
405 on those routes - a separate red herring during this issue's
debugging.
Tests: 3 new in test_security_headers.py - 'nonce-' token stamped into
SPA script-src while 'self' remains and 'unsafe-inline' does not; nonce
is fresh per request across 5 sequential calls; HEAD on the three PWA
routes never returns 405. 22/22 security-header tests green; backend
ruff 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.
An archive's filament_color was parsed verbatim from the print job's
3MF (filament_colour in project_settings.config) — the slicer's
filament-slot colour, which a user picks independently of the exact
hex they curate on the Bambuddy inventory spool. So a print from a
#000000 inventory spool showed #161616 (the slicer's near-black) in
the archive card and the Color Distribution graph, even though usage
tracking correctly decremented the right spool.
Once usage tracking has resolved the print's filament slots to
inventory spools, the spool colours are authoritative. _track_from_3mf
(built-in inventory) and report_usage (Spoolman mode) now overwrite
the archive's filament_color with the slot-ordered, de-duplicated
colours of the matched spools.
The rewrite is all-or-nothing: it only applies when every used slot
resolved to a spool carrying a colour, so a partially-mapped
multi-colour print keeps the 3MF colour rather than silently dropping
the unmatched slots.
Shipped for both inventory modes: built-in spools read Spool.rgba,
Spoolman spools read the spool's filament.color_hex (fetched via
get_spool for tag-less slot-assignment matches). New helpers
_spool_color_to_hex / _archive_colors_from_spools in usage_tracker.py,
reused by spoolman_tracking.py via _apply_spool_colors_to_archive.
Tests: 12 new in test_usage_tracker.py (hex normalisation, the
all-or-nothing rule across single/multi/partial/no-colour/AMS-fallback
cases, end-to-end rewrite), 4 in test_spoolman_tracking.py (Spoolman
rewrite + empty/partial/missing-archive no-ops). 70 tracking tests
green; backend ruff clean.
A fresh P2S support bundle showed ffmpeg's reason for the stall:
`frame=1 time=00:00:00.06 dup=0 drop=526 speed=0.0037x`. ffmpeg
connects and frames arrive (drop counter climbs ~15/s), but it emits
one output frame and the output clock freezes.
The streaming command ends with `-r 15`, putting ffmpeg in CFR mode:
it drops/dupes input frames to hit 15 fps based on the source's
timestamps. P2S firmware 01.02.00.00 sends an RTSP stream whose RTP
timestamps don't advance, so CFR treats every frame after the first
as a same-timestamp duplicate and drops it. Snapshot capture works on
the same printer because that path has no `-r` (no CFR conversion);
X1/H2 are unaffected because their firmware timestamps are correct.
The earlier probesize fix was masking this second bug.
Add `-use_wallclock_as_timestamps 1` to the P2S camera profile via
the existing extra_ffmpeg_input_args hook. ffmpeg rebuilds each
packet's PTS from arrival wall-clock time, the output clock advances,
and CFR conversion works. No dataclass change, no other model touched.
Tests: 2 new in test_camera_profiles.py (P2S splices the flag+value
pair; default profile keeps extra_ffmpeg_input_args empty so the
override never leaks to X1/H2).
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.
File Manager cards, search and sort keyed off file_metadata.print_name,
which ThreeMFParser lifts from the 3MF's <metadata name="Title">. That
title is the in-app project title — generic "Exported 3D Model" for any
Bambu Studio "Save As", a marketing title for a MakerWorld download —
and almost never the filename the user saved as. A card for
Whatever.3mf showed "Exported 3D Model"; correcting it needed a rename
round-trip, since the Rename dialog disables Save while the name is
unchanged.
The slicer-output write path already dropped print_name for this exact
reason; the four other paths that store parsed 3MF metadata onto a
LibraryFile did not — external-folder scan, managed multipart upload,
the multi-file ZIP-upload branch, and MakerWorld import.
Add a shared _without_print_name() helper and apply it at all four
import paths; switch the slicer path to it so there is one rule. A
LibraryFile's display name is its filename — only PrintArchive carries
a real print_name, which is untouched. Remove the now-redundant
filename->print_name mirroring in the rename route.
Add a one-time idempotent data migration (_migrate_drop_library_print_name,
SQLite json_remove / PostgreSQL jsonb key-removal branched on
is_sqlite()) so libraries imported before the fix correct themselves
without the rename workaround. No frontend change: print_name || filename
yields the filename once print_name is gone.
Tests: 6 new in test_library_print_name.py cover _without_print_name and
the migration (incl. idempotency, siblings preserved, null metadata).
SQLite migration branch verified by test; PostgreSQL branch verified
against a real Postgres instance.
A P2S support bundle on 0.2.5b1 — per-model probesize fix already
applied — showed the camera still failing: ffmpeg connects, stays alive
30+ seconds, emits zero JPEG bytes, the 30s stdout.read times out,
reconnect loop repeats. No ffmpeg stderr appeared anywhere in the log to
explain why.
The cause was a diagnostic bug, not the camera path. _read_ffmpeg_stderr
called process.stderr.read() — read-to-EOF. A stalled-but-still-alive
ffmpeg (the P2S RTSP failure mode) never closes stderr, so the read
blocked until the 2s wait_for timeout and returned None, discarding the
banner + stream-analysis lines ffmpeg had already printed. ffmpeg stderr
was captured only when it fully exited; once the probesize bump turned
the earlier crash into a hang, the diagnostic went dark.
Drain stderr incrementally in bounded 8KB chunks (64KB cap), returning
whatever ffmpeg printed so far whether or not it has exited. Also log
the resolved per-model probesize/analyzeduration on the info-level
"Starting RTSP camera stream" line, and log the full ffmpeg argv at
debug level with only the credential-bearing camera URL redacted
instead of hiding the entire command.
No behaviour change to streaming — this makes the unresolved P2S RTSP
stall diagnosable in the next support bundle.
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.
Several "container hangs after adding a printer" reports (#1486) share a
signature with nothing to act on: HTTP goes silent, /health hangs, the
process may ignore SIGTERM, and the log just stops mid-stream - a frozen
asyncio loop cannot log a thing.
loop_watchdog re-arms faulthandler.dump_traceback_later() from an async
heartbeat. While the loop ticks the timer is cancelled and re-armed before
it can fire; if the loop stalls for 30s the heartbeat can't re-arm and
faulthandler's C-level timer thread dumps every thread's stack to stderr,
so the blocked frame shows up in `docker compose logs`.
A restart during an active print duplicated the running job in the
archive, and every further restart spawned another. on_print_start
re-attaches by subtask_id, falling back to a name match plus a 4-hour
staleness cutoff that cancelled + recreated any name-matched 'printing'
archive older than 4h - destroying the live archive of every long print.
Two fixes:
- start_print records the minted subtask_id (last_dispatch_subtask_id);
on_print_start falls back to it when the printer hasn't echoed one
yet, so queue/scheduled archives persist a restart-stable id.
- Replace the 4h cutoff with a progress-aware check: a name-matched
'printing' archive resumes whenever the printer reports real (or
unknown) progress; it is stale only when the printer shows a
freshly-started print (<1%) on an archive over 2h old.
The Color Catalog sync built its httpx.AsyncClient with no User-Agent, so
it leaked httpx's default python-httpx/x.y string - the only outbound
client that did; bambu_cloud, makerworld and firmware_check all send
Bambuddy/1.0 (+https://github.com/maziggy/bambuddy). It now sends the
same honest UA.
Found while investigating an issue - a Cloudflare 403 on the sync that
turned out to be the reporter's network/IP reputation, not Bambuddy. The
UA leak was a separate inconsistency found in passing; this change does
not by itself resolve a Cloudflare IP block.
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.
The H2S never ran flow-dynamics calibration even with the print option
enabled, because start_print built the project_file command wrong:
- extrude_cali_flag was hardcoded to 0. A BambuStudio request-topic
capture from a real H2D (plus X1C/P2S captures) shows it is always 1
(run calibration) or 2 (skip, reuse stored PA), paired with flow_cali,
never 0 — so the printer skipped calibration regardless of the toggle.
- flow_cali and the other calibration/leveling fields were integer-
encoded for the H2 family on a mistaken belief that H2 firmware
requires 0/1. The same capture sends plain JSON booleans for every
model; the belief conflated these fields with use_ams (which does
need to stay boolean — the actual #1386 cause).
Fix: extrude_cali_flag = 1 if flow_cali else 2; send timelapse,
bed_leveling, flow_cali, vibration_cali and layer_inspect as booleans
for all models; drop the is_h_family integer-conversion branch.
use_ams is unchanged.
Corrected the two tests that asserted the integer format and renamed
them; all three model tests now also assert extrude_cali_flag.
With "Group similar" enabled, the collapsed group row showed a single
member's values — a group of five 1 kg spools displayed 1000 g instead
of 5 kg (#1368).
The group header now aggregates across all members: the table view's
Label / Net / Gross / Used / Remaining columns and the grid card's
weight figure show group totals. Identity columns (Material, Brand,
Colour) and the Cost/kg rate stay per-spool-correct since they are the
group key; per-spool-only fields with no meaningful total (dates,
location, note, tag ID) keep the representative member's value. The
expanded per-spool rows are unchanged.
New aggregateGroupSpool helper with 4 unit tests; the table group
component's header prop is renamed representative -> headerSpool.
Frontend-only — all data was already in the spool list.
The Slice dialog listed every process / filament preset regardless of
the chosen printer, and always defaulted to the first listed preset
rather than what the 3MF was prepared with (#1325).
Matching uses the slicer's own compatible_printers list for imported
(local) presets and falls back to the "@BBL <model>" name suffix for
cloud / standard presets. Compatibility-unknown presets are never
hidden.
Defaults: the printer and process dropdowns default to the preset
names embedded in the source 3MF's project_settings.config when those
presets are available; the per-slot filament and process pre-picks
prefer a printer-compatible preset, and switching the printer re-picks
any selection left incompatible.
- UnifiedPreset gains compatible_printers, exposed for the local tier
- plates endpoints return embedded_printer / embedded_process
- new frontend util slicerPrinterMatch.ts; extract_embedded_presets_from_3mf
- slice.otherPrinters added across all 9 locales
The Slice dialog listed every process / filament preset regardless of
the chosen printer (#1325). Picking a printer profile now filters both
dropdowns to compatible presets; presets resolving to a different Bambu
model move into a trailing "Other printers" group.
Matching uses the slicer's own compatible_printers list for imported
(local) presets, and falls back to the "@BBL <model>" name suffix for
cloud / standard presets where no compatibility metadata is available,
so all three tiers are covered. Compatibility-unknown presets (custom
or untagged) are never hidden. The pre-pick and printer-switch paths
follow the same rule.
- UnifiedPreset gains compatible_printers, exposed for the local tier
- new frontend util slicerPrinterMatch.ts with the matching logic
- slice.otherPrinters added across all 9 locales
Full European Spanish translation — frontend/src/i18n/locales/es.ts
covers all 4899 keys with placeholders, plural forms, and inline
markup preserved. Registered in i18n/index.ts (resources,
supportedLngs, availableLanguages) and selectable as "Español".
check-i18n-parity.mjs auto-discovers the new file; added an
ES_COGNATES allow-list for genuine Spanish cognates and brand/format
tokens so Check 4 does not flag them as untranslated leaks. Brings the
supported-language count to 9.
A triage review of the last 200 closed issues found ~1/3 were
user-side setup errors — printer not in LAN developer mode, blocked
ports, Docker bridge networking, wrong access code, cross-subnet —
each costing a multi-round-trip support exchange.
Add a Connection Diagnostic that runs those checks automatically:
- backend/app/services/printer_diagnostic.py: TCP probes of MQTT
8883 / FTPS 990 / RTSPS 322, LAN developer mode, Docker network
mode, printer/host subnet match, MQTT credential class; each
check returns pass/fail/warn/skip with a localized fix.
- Routes: GET /printers/{id}/diagnostic (saved printer) and
POST /printers/diagnostic (pre-save Add-Printer flow).
- ConnectionDiagnostic.tsx: modal + shared checklist, surfaced from
the printer card actions menu, an offline-printer quick button,
the Add-Printer dialog, and a new System-page section.
- The in-app bug reporter scans configured printers when the form
opens and always shows the result inline — a healthy confirmation,
or the detected problem and its fix.
- config.yml troubleshooting link repointed to the rendered wiki
page; bug_report.yml gains a diagnostic checkbox.
Diagnostic strings translated across all 8 locales. Backend service
unit tests (15) + frontend modal tests (3). Ruff clean, frontend
build clean, i18n parity green.
GET /spoolman/spools/unlinked hid any spool with a non-empty
extra.tag from the AMS-slot assignment picker. extra.tag is only an
RFID/NFC matching key -- OpenSpoolman writes its own NFC tag value
into that same Spoolman field -- so every OpenSpoolman-tagged spool
became un-assignable in Bambuddy even when it occupied no slot.
get_unlinked_spools now determines assignability from the
spoolman_slot_assignments table (the documented source of truth for
slot assignments) and ignores extra.tag entirely. Both link_spool and
the AMS auto-sync upsert a row there for every occupied slot, so the
ledger is complete. get_linked_spools and find_spool_by_tag still use
extra.tag -- they are genuine tag-match maps and are unaffected.
Internal-inventory mode needs no parallel change: it stores tags in
its own DB with no Spoolman extra collision.
Updates test_get_unlinked_spools_success and adds
test_get_unlinked_spools_excludes_slot_assigned.
notify_missing_spool_assignments_on_print_start queried only the legacy
SpoolAssignment table. In Spoolman mode that table is empty -- bindings
live in spoolman_slot_assignments -- so every used tray was flagged
missing, firing a false-positive notification on every print.
- spool_assignment_notifications.py: the assigned-tray set is now the
union of SpoolAssignment + SpoolmanSlotAssignment rows. Union-only,
so legacy-mode behavior cannot regress.
- settings.py: the Spoolman toggle cleared SpoolAssignment on switch-on
but never cleared SpoolmanSlotAssignment on switch-off. Added the
symmetric clear so stale Spoolman rows can't leak into a later
internal-mode session and mask a real missing-assignment warning.
Adds 3 notification tests + 1 mode-switch integration test. An audit
of the remaining SpoolAssignment consumers confirmed usage_tracker,
spool_tag_matcher and routes/inventory are correctly internal-mode-only.
notify_missing_spool_assignments_on_print_start queried only the legacy
SpoolAssignment table. In Spoolman mode that table is empty -- bindings
live in spoolman_slot_assignments -- so assigned_global_trays came back
empty and every used tray was flagged missing, firing a false-positive
notification on every print.
Union SpoolAssignment + SpoolmanSlotAssignment rows for the printer
before computing the missing set. Both tables expose printer_id /
ams_id / tray_id identically, so _global_tray_from_assignment is
unchanged. Union-only, so legacy-mode behavior cannot regress.
The search bar was rendered only when totalCount > 0, but totalCount is
the sum of the post-filter preset lists. A query that matched nothing
dropped it to 0 and unmounted the search bar along with the columns,
so the user couldn't clear the query without a page refresh.
Gate the search bar on hasAnyPresets (pre-filter count) instead, so it
stays visible as long as any preset is imported. Split the empty state:
"No local presets yet" when nothing is imported, vs a new "No presets
match your search" message when a query filtered everything out.