mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-03 20:56:18 +02:00
ecdf577cfcb1f3bb8a15af96fa1677d58e6eee38
649
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ecdf577cfc |
test(security-fixtures): nosec B104/B108 on new 0.2.4.6 test code
Two adversarial-input fixtures added on the 0.2.4.6 branch were
missing the # nosec annotation that
|
||
|
|
e7574fa67c | Merge remote-tracking branch 'origin/main' into 0.2.4.6 | ||
|
|
f87b5bb3b8 |
feat(diagnostic, archives): install step 4 — proactive check + reactive banner
Two complementary surfaces for the most-missed install step ("Store sent
files on external storage"):
1. Connection diagnostic check (printer-side variant)
- Reads state.store_to_sdcard, parsed from MQTT home_flag bit 11.
- Pass / fail / skip; instant, no I/O.
- Catches the newer-firmware variant where the toggle moved onto the
printer itself (P2S 01.02 / Studio 2.6+).
An FTP upload-and-verify probe was tried first and rejected. /cache
is always writable from Bambuddy regardless of the slicer setting;
only BambuStudio's own behaviour changes when the toggle flips.
Empirically confirmed against X1C + H2D with the slicer option
toggled off: probe still succeeded, home_flag bit 11 stayed True.
2. Archives-page banner (slicer-side variant)
- The slicer-side toggle is invisible to the printer — older
BambuStudio doesn't push the change to the printer. The diagnostic
can't see it.
- Symptom is deterministic: archiver creates rows with
extra_data.no_3mf_available=True (main.py:2770) when it can't pull
the 3MF from /cache after a slicer-initiated print.
- New endpoint GET /archives/no-3mf-warning returns whether any
archive in the last 30 days has the flag (excluding soft-deleted).
- Amber dismissible banner at the top of /archives; one-shot
localStorage dismissal (matches Layout.tsx update-banner pattern,
but persistent across sessions).
- React-Query disabled after dismissal so the endpoint isn't polled
once the user has been told.
|
||
|
|
60e31634b8 |
feat(diagnostic): add "Store sent files on external storage" check (install step 4)
Detects the printer-side variant of install step 4 — many users (esp. on clean installs) forget to enable this and only notice when their archive cards have no thumbnails. The diagnostic now catches it upfront. Detection: read state.store_to_sdcard, which Bambuddy already parses from MQTT push_status home_flag bit 11 (bambu_mqtt.py:153). Instant, no I/O. An FTP upload-and-verify probe was tried first and rejected. /cache is always writable from Bambuddy regardless of the slicer setting — only BambuStudio's own behaviour changes when the toggle flips, not the printer's acceptance policy. Confirmed empirically against X1C + H2D with the slicer option toggled off: probe succeeded, home_flag bit 11 stayed True. So the only reliable signal is what the printer actually reports about its own state. Limitation: the printer-side variant only exists on newer firmware (P2S 01.02 / Bambu Studio 2.6+). On older versions the toggle lives only in the slicer and the printer never hears about it, so this check will pass even when the user is missing step 4 in BambuStudio. The skip-text and the wiki call this out explicitly. A reactive banner on the no-3MF archive-fallback path is planned as a follow-up to cover that case. Statuses: - pass: state.store_to_sdcard is True - fail: state.store_to_sdcard is False (-> overall escalates to problems) - skip: no live state, disconnected, or field never populated |
||
|
|
bebdb38e41 |
feat(print-log): per-row classification editor + fix silent-drop in GET
(#1687 part 4, reported by @IndividualGhost1905) Reporter clarified after part 1 shipped that point 2 wasn't about archive `tags` (which describe the model — home decor, toys), but about failure-cause classification on the *log* row itself: spaghetti, jam, bed-adhesion, etc. Different surface, different lifetime. The data field he wanted already existed. PrintLogEntry.failure_reason is a String(100); the Failure Analysis widget already groups by it; the Archive Edit modal already mirrors archive.failure_reason into the most recent log entry (archives.py:1421, shipped with #1444). The only gaps were: 1. The GET endpoint silently dropped failure_reason (and archive_id and created_by_id) from PrintLogEntrySchema construction even when set in the DB — so the Print Log table couldn't render what the Failure Analysis widget grouped by. Fixed independently of the editor; regression test added. 2. Orphan log entries (no archive — dispatch errors, aborts before archive creation, manual entries) had no edit path at all because the Archive Edit modal cannot reach them. The new endpoint is the only way to classify those rows. Changes: - Backend: new PATCH /print-log/{entry_id} taking {failure_reason, status}, gated on require_ownership_permission( ARCHIVES_UPDATE_ALL, ARCHIVES_UPDATE_OWN) — same ownership shape as the per-row DELETE. Validates against the same 11-key failure vocabulary and 5-key status set the Archive Edit modal uses; unknown values return 400 rather than getting stored as raw text (the i18n layer maps the value back through the vocabulary, unrecognised values would render as literal strings). Empty-string failure_reason stores back as NULL so the column's nullable=True intent is preserved end-to-end. GET endpoint now surfaces failure_reason, archive_id, created_by_id. - Frontend: FAILURE_REASON_KEYS moved to an export from EditArchiveModal.tsx so the new editor reuses the exact same vocabulary — backend and frontend stay in lockstep. Pencil icon beside the existing trash icon on every Print Log row, opens a compact two-field modal (status + failure reason). Save invalidates print-log and archives-stats query keys so the Failure Analysis widget reflects the re-classification on the same response cycle. Failure reason rendered as a sub-label under the status badge, matching PrintLogTable.tsx's convention. - i18n: 10 new keys (editEntryTitle, editEntryDescription, entryUpdated, entryUpdateFailed, archives.permission.noEdit, plus a 5-key statuses block) translated across all 11 locales. No English fallbacks. - Wiki: features/print-log.md gains per-row actions section, updated permissions table, PATCH/single-DELETE endpoint docs. |
||
|
|
85fbd7fc35 |
fix(queue): persist "Print Anyway" so scheduler stops re-flagging it
When the user clicked Print Anyway on a filament-deficit warning, the
acknowledgement was one-shot. The route cleared manual_start and
filament_short, then the next scheduler tick re-ran
compute_deficit_for_queue_item against identical spool state, found
the same deficit, and re-set both flags. The item bounced between
"user said anyway" and "scheduler re-blocked" — every Play click
returned 409, every confirm got rolled back on the next tick.
Add a persistent acknowledgement flag on the queue item:
- New column `skip_filament_check` on print_queue. SQLite + Postgres
migration branched on is_sqlite() so Postgres doesn't reject
DEFAULT 0 on BOOLEAN.
- PrintQueueItemCreate + PrintQueueItemResponse schemas + the
TypeScript types carry the field.
- POST /print-queue/{id}/start with skip_filament_check=true now
ALSO sets item.skip_filament_check = True (not just clearing
manual_start / filament_short).
- PrintScheduler._block_on_filament_deficit short-circuits to
False — no compute, no flag-setting, no notification — when
item.skip_filament_check is True. We trust the operator's
decision and stop fighting them.
- PrintModal at queue-creation time threads
skip_filament_check=true into the create payload when the user
clicks Print Anyway on the frontend deficit warning, so a print
that was warned-then-acknowledged at add-to-queue time goes in
pre-acknowledged — scheduler never blocks it on first tick.
Flag is not auto-cleared on spool swap by design: if remaining is
now sufficient, the check returns no deficit anyway, so the flag
is moot. Auto-clearing would add lifecycle complexity without
changing behaviour.
AMS Backup awareness (the other half of the discussion) intentionally
NOT included — verified the H2D's bit-26 of print.cfg toggles with
the printer-side AMS Backup setting, but the X1C's cfg has a
different shape entirely and verifying every model family isn't
realistic. Silently under-warning would be worse than always
per-slot. The check stays single-slot for now.
|
||
|
|
72044e3a53 |
fix(usage): scope 3MF filament tracking to dispatched plate (#1697)
When a print targets a single plate from a multi-plate 3MF, both the
internal Filament Inventory tracker and the Spoolman-mode tracker parsed
the 3MF without a plate filter and summed every plate's filament — so a
single lid print debited the spool the entire file's grey + black totals.
The 3MF parser already supports plate_id (queue pre-flight uses it at
print_queue.py:254/:286). Plumbed it through both dispatch paths:
Queue path:
- PrintSession gains a plate_id field; on_print_start queries the
printer's currently-printing queue row and records queue_item.plate_id
onto the session.
- _track_from_3mf accepts plate_id and passes it to the extractor.
- store_print_data moves its existing queue-item lookup above the
extract and uses queue_item.plate_id as the plate filter.
Direct-Print path (reprintArchive / printLibraryFile — never goes
through the queue):
- _print_plate_ids dict added in main.py, parallel to _print_ams_mappings.
- register_expected_print accepts plate_id and stores it; the 2 sites in
background_dispatch.py and the 1 site in print_scheduler.py now pass
it (resolve was already happening, just needed reordering before the
register call so the value is available).
- Expected-print promotion in main.py injects _print_plate_ids[archive_id]
into the session, guarded so a queue capture wins over the dict.
- _get_start_plate_id helper feeds plate_id into all 3
_store_spoolman_print_data call sites; spoolman_tracking.store_print_data
takes the caller value first, falls back to queue_item.plate_id.
PrintArchive.filament_used_grams stays file-level summed by design
(#1593's contract — the archive describes the file, not the run); only
the per-run usage attribution becomes plate-aware. Single-plate direct
prints resolve to plate_id=1 → plate 1 = whole file, identical to the
prior no-filter behaviour.
|
||
|
|
7a9c78c32c |
● fix(vp): stop enforcing MQTT keepalive 1.5x to match real Bambu firmware (#1548)
Round 1 ( |
||
|
|
58d1b1eb74 |
fix(system): use PID 1 create_time for uptime/boot time on containers (#1690)
System -> Uptime / Boot Time read psutil.boot_time(), which on shared-kernel containers (Docker, LXC, Proxmox containers) is /proc/stat:btime - the host kernel's boot time, not the container's. Reporter on Proxmox LXC saw the Proxmox node's uptime instead of the Bambuddy container's. PID 1 is the container's entrypoint (or the host init on bare metal), and its create_time is the POSIX wall-clock timestamp of when it started. Switching to psutil.Process(1).create_time() reports the right value on containers and matches host boot within a sub-second on bare metal. Defensive fallback to psutil.boot_time() on psutil.Error / OSError so the endpoint still returns 200 with the best-available answer if /proc/1/stat is unreadable (locked-down container, custom seccomp policy). |
||
|
|
40729da013 |
feat(print-log): per-row delete on Print Log page (#1687 part 1)
Reporter noted the existing "Also remove this print from Quick Stats"
toggle at archive delete is one-shot: if you kept stats then, there was
no later way to drop the row; and rows without a backing archive
(errors, aborts, manual entries) had no delete affordance at all.
Backend: DELETE /print-log/{entry_id} mirrors delete_archive's
ownership flow via require_ownership_permission(ARCHIVES_DELETE_ALL,
ARCHIVES_DELETE_OWN). Owners drop their own rows; admins drop any row;
missing IDs return 404 rather than 200-silently. /archives/stats
aggregates over PrintLogEntry, so the filament / time / cost / count
contribution drops out of Quick Stats in the same response cycle. The
linked archive (if any) is untouched - the log row is a sibling, not a
child.
Frontend: trash icon next to the filament cell on every row, gated on
the same permission shape as the archive trash. Confirm modal -> row
gone. Mutation invalidates both print-log and archives-stats query
keys so the totals re-render without a manual refresh.
#1687 also asks for per-row tagging (already covered by
EditArchiveModal's tags field) and per-row filament-usage-history
edits (deferred - "restore deducted grams" is only consistent for the
most recent usage row per spool; needs a separate design call).
|
||
|
|
bfbf5d59af |
fix(profiles): add PLA-CF + Bambu CF/GF/specialty variants to filament_type select (#1686)
The Profiles page edit modal (shared by BL Cloud / Orca Cloud / Local Profiles) renders filament_type from backend/app/data/filament_fields.json via GET /cloud/fields/filament. The curated 11-option list pre-dated Bambu's CF/GF lineup expansion, so PLA-CF and most carbon/glass-fiber variants were unselectable — saved presets carried the wrong material code at dispatch. Expanded to 25 BambuStudio-aligned options grouped by family: PLA (+ CF/GF/ AERO), PETG (+ CF), ABS (+ GF), ASA (+ CF/GF), PC, PCTG, PA family (+ CF/ PAHT-CF/PA6-CF/PA6-GF), PET-CF, TPU, PPS family (+ CF/GF for X1E), PVA, HIPS. K-profiles editor unaffected (picks filament_id, not filament_type). |
||
|
|
68877c639e |
feat(settings): split API slicer + Open-in-Slicer preferences (#1329)
Reporter wanted to slice via the Bambu Studio sidecar but open files
locally in OrcaSlicer. preferred_slicer drove both the in-app
SliceModal sidecar selection AND the desktop "Open in Slicer" URI
handoff, so picking one forced the other.
New open_in_slicer setting (str | None) drives only the desktop URI;
null inherits from preferred_slicer so existing installs behave
identically. Storage in the existing app_settings key/value table;
GET normalises the "None" string back to null mirroring the
default_printer_id convention.
Frontend: Settings -> Slicer card adds a second dropdown ("Open in
Slicer" with "Same as API slicer" / Bambu Studio / OrcaSlicer);
ArchivesPage, MakerworldPage, ModelViewerModal switch desktop-URI
call sites to open_in_slicer ?? preferred_slicer. MakerworldPage's
"Slice in {{slicer}}" label additionally branches on useSlicerApi
so the label matches what the button actually dispatches.
|
||
|
|
432080d500 |
feat(queue): show build plate type on queue items + print modal (#1281)
Reporter on a multi-printer farm with 40+-plate runs needed to walk to
the printer with the right physical plate, but the queue and the
scheduling modal didn't surface curr_bed_type the way the archive card
already did.
New utils/threemf_tools.extract_bed_type_from_3mf helper (per-plate) so
both the queue API and the /plates endpoint can return per-plate values.
PrintQueueItemResponse.bed_type populated from archive.bed_type /
library_file.file_metadata['bed_type'] as the default, then overridden
per-plate via the helper when item.plate_id is set. /archives/{id}/plates
(and library equivalent) include bed_type in each plate object.
Per-plate accuracy matters because archive.bed_type is captured at
ingest as only the first plate's value (services/archive.py:235) -
a 40-plate 3MF mixing PEI + Engineering returns PEI at the archive
level for every plate. The helper re-reads the 3MF and returns the
truth.
Frontend: queue card meta row + PlateSelector per-plate row + PrintModal
header all render bed icon + canonical label via the existing
getBedTypeInfo() helper, same as the archive card.
|
||
|
|
7b4c5b3c1f |
fix(vp): show target printer's serial on proxy-mode VP card
The runtime services (SSDP, MQTT bind identity, cert subject) already advertise the target printer's serial via target_printer_serial or self.serial in proxy mode, but the API response that drives the VP settings card always returned the self-generated suffix-based serial. The card therefore displayed a serial that didn't match what slicers see, breaking the "one identity per VP" mental model. _vp_to_dict now resolves vp.target_printer_id -> Printer.serial_number when mode == VP_MODE_PROXY and substitutes the result into the response serial field. Archive / queue / review keep the self-generated serial (those modes never speak the target's identity). Orphaned target falls back to self-generated so the card still renders. |
||
|
|
66c09dff2d | feat(inventory): CSV import/export for the inventory page (#1576) (#1659) | ||
|
|
2c2725cb53 |
fix(print): expose nozzle_offset_cali toggle for dual-nozzle printers (#1682)
Bambuddy's project_file MQTT payload hardcoded "nozzle_offset_cali": 2 (skip), giving users on H2D / H2D Pro / H2C / X2D no way to control the same toggle BambuStudio exposes. Critical for diamond-nozzle setups that must keep the calibration off. start_print() now takes a nozzle_offset_cali kwarg; the value is encoded as 1 (run) or 2 (skip) and gated on is_dual_nozzle so single-nozzle machines always send 2 even if a stale flag arrives. The kwarg threads through printer_manager, both background_dispatch sites, and print_scheduler so every dispatch path respects the per-item setting. print_queue gains a nozzle_offset_cali column (DEFAULT TRUE, is_sqlite() branch for Postgres BOOLEAN). Settings default key default_nozzle_offset_cali defaults to TRUE to match BambuStudio. Schemas updated across print_queue, library FilePrintRequest, archive ReprintRequest, settings. PrintModal renders the new toggle only when the selected printer is dual- nozzle (printer-mode: nozzle_count===2; model-mode: DUAL_NOZZLE_MODELS). SettingsPage default-print-options row + QueuePage bulk-edit tri-state both hide unless any registered printer is dual-nozzle. Labels reuse the existing settings.default* keys so the only new i18n strings are settings.defaultNozzleOffsetCali / Desc and queue.bulkEdit.nozzleOffsetCali - real translations in all 11 locales. |
||
|
|
d0d6a659e9 |
fix(reconcile): don't synthesize aborted PRINT COMPLETE on the bare-connect edge before push_status arrives (#1679)
on_printer_status_change fires from MQTT _on_connect BEFORE the first push_status round-trips, when PrinterState is still on construction defaults (state="unknown", subtask_name=""). The connected-edge reconcile was treating that degenerate state as evidence and synthesising aborted PRINT COMPLETE for every in-flight archive on every Bambuddy restart. The reactive PRINT COMPLETE then created a duplicate archive (lookup misses on cleared _active_prints), so filament got deducted twice. Two-layer guard: gate the reconcile spawn on a real state.state, and make _is_active_archive_stale return not-stale on unknown/empty input as defensive fallback. #1542 behaviour preserved — real stale archives still get caught the moment a real push_status arrives. |
||
|
|
fdaff37975 |
fix(queue): two-phase dispatch watchdog so a printer that accepts project_file but never starts doesn't wedge the queue (#1678)
_watchdog_print_start now treats subtask_id-advance as Phase A "command landed", not as final success. Phase B (180s) keeps watching for the active-state transition; if it never arrives — printer accepted the file but stalled (cloud+LAN re-auth after a power cycle on old firmware was the reported trigger) — revert the queue item to 'pending' instead of leaving it stuck in 'printing' until container restart. Phase B skips force_reconnect because subtask_id-advance proves the project_file landed; a reconnect mid-parse would trigger 0500_4003 (#1150). Phase A's H2D 50s FINISH→PREPARE tolerance (#1078) is preserved by Phase B's 180s headroom. |
||
|
|
96ce403554 |
fix(archive): HTML-unescape 3MF Title metadata; correct VP name tooltip (#1658)
ThreeMFParser._parse_3dmodel left XML-escaped values raw, so a Title of "PCB Vise & Solder Station" landed in the DB as the literal "&" and React re-escaped it on render to "&amp;". Apply the same loop-until-stable html.unescape() the sibling ProjectPageParser already uses, uniformly across all <metadata> values. Same drop: rewrite the VP archive-name-source tooltip in all 11 locales. BambuStudio 2.7.x (PrintJob.cpp:314-325) overwrites the user-typed Send-dialog name with the slugified 3MF Title field whenever one is present, so the previous "handy if you renamed the job in the send dialog" copy was false. New text spells out the actual behavior; both Filename and Metadata modes often produce the same string for that reason. |
||
|
|
d597d36d5b |
fix(vp): slice FTP passive ports per VP, drop bridge-mode RAM by 95% (#1646)
The 50000-51000 docker-compose port range spawned ~2000 docker-proxy host processes (~3.5 GB RSS) under Docker's default userland-proxy. The 1001-port pool was symptom treatment — collisions only matter for multi-VP-on-shared-bind, but the cost was paid by every install. Each VP now gets a non-overlapping 10-port slice computed from its id (VP 1 -> 50000-50009, VP 2 -> 50010-50019, ...). Class constants are gone; VirtualPrinterFTPServer takes passive_port_min/max instance args. Wraps modulo PASSIVE_MAX_SLOTS = 100, with the existing 10-attempt random retry as same-slot collision fallback. Compose default narrowed to 50000-50029 (3 VPs). Proxy-mode VPs forward the real printer's full range and stay on the separate TCPProxy constants. Compose comment rewritten to acknowledge Linux multi-service hosts as a primary bridge-mode audience and drop an over-stated "confirmed by reporter" claim about userland-proxy=false. |
||
|
|
47fe30c5ad |
fix(queue): credit user who clicks /start in print log when auth on (#1670)
The print log's User column came from printer_manager.get_current_print_user,
but only background_dispatch (Archive Print, Library Print) ever populated
that dict. The Queue manual-start path went straight from
POST /queue/{id}/start into PrintScheduler._start_print, neither end
recording the clicker — so any print started from the queue landed in
PrintLogEntry with created_by_username NULL even with auth enabled.
Two-sided fix: /start now writes user.id to item.created_by_id when no
prior owner is set (preserves UI-added items' original uploader), and
PrintScheduler gains _propagate_owner_to_printer_manager called from
_start_print to hand the owner into set_current_print_user before the
print command goes out.
|
||
|
|
edaf7c4559 |
fix(queue): cancelled prints no longer block require_previous_success chain (#1667)
Two bugs in PrintScheduler._check_previous_success: - Lookback excluded 'cancelled' so user cancellations were walked past - Lookback included 'skipped', so one skip cascaded indefinitely Swap to ['completed', 'failed', 'cancelled', 'aborted'] and accept both 'completed' and 'cancelled' as predecessor success. Real 'failed' / 'aborted' still gate. One-shot migration in run_migrations resets only the skipped items whose true predecessor was cancelled — surgical reversal of the exact bug fingerprint, leaves genuine failure-gated skips alone. Portable across SQLite and Postgres, idempotent on re-run. |
||
|
|
4bcab89eee |
fix(firmware-check): bypass Cloudflare TLS-fingerprint gate via curl_cffi (#1666)
Cloudflare on bambulab.com now serves cf-mitigated=challenge to plain Python TLS handshakes. Use curl_cffi.AsyncSession with impersonate="chrome" for the two bambulab.com fetches (index page + per-model JSON); wiki and CDN paths stay on httpx. HTTP User-Agent stays honest "Bambuddy/1.0" — only TLS-handshake bytes match Chrome, per the compliance commitment. Soft dependency — falls back to httpx with a startup warning when curl_cffi isn't importable; wiki-based version detection still works. |
||
|
|
f243e4e598 |
fix(asyncio): track strong refs on orphan create_task sites
asyncio holds only a weak reference to tasks returned by ``create_task``. Fire-and-forget callers that discard the return value let the event loop GC the task before it finishes, logging ``Task was destroyed but it is pending!`` with no traceback. The #1648 support-bundle review surfaced 94 such warnings in 8 days of v0.2.4.5 -- the silently-vanished exceptions reach support bundles as opaque GC notices instead of actionable errors. New backend/app/core/tasks.py::spawn_background_task(coro, *, name=None) is the one place in the codebase that calls asyncio.create_task. It stores the task in a module-level set, attaches a done-callback that auto-removes on completion AND surfaces any uncaught exception via the logger with the originating traceback, and accepts name= so a leak source is traceable through /tracebacks and the log line. Cancelled tasks don't log (a shutting-down service is not an error). Migrated the 16 truly-orphan create_task call sites to the helper: main.py (8): reconcile-stale, cooldown-poweroff, energy calc, smart-plug, maintenance-check, photo-then-notify, layer-timelapse, scan-timelapse, print-scheduler, notify-no-archive (the last one was hand-rolling the same pattern with task + no-op done_callback) printers.py:3123 apply-pa-after-refresh print_queue.py:1034 queue cooldown-poweroff firmware_update.py:261 firmware upload archive.py:1514 timelapse mp4 convert print_scheduler.py:2199 watchdog print-start library.py:1614 STL backfill smart_plugs.py:259 tasmota scan discovery.py:159 subnet scan smart_plug_manager.py x3 plug auto-off-pending background_dispatch.py x2 (lambda-wrapped inside loop.call_soon_threadsafe) upload progress Sites that already kept strong refs are unchanged: self._tasks.append(asyncio.create_task(...)) -- VP manager, tcp_proxy, mqtt_server self._x_task = asyncio.create_task(...) on service instances -- mqtt_bridge, obico_detection, github_backup, archive_purge, local_backup, library_trash, discovery service Locally assigned + awaited/gathered -- tcp_proxy bidirectional pumps, camera_fanout, slice_dispatch, slicer_api progress_task, manager._finish_release_task, main.py module-level cleanup loops |
||
|
|
d82f4e032f |
fix(inventory): handle PFCN cloud preset IDs in assign-via-MQTT (#1648)
Reporter on an H2D + Polymaker PLA Matte spool noticed that assigning
the spool from the Dashboard left the slicer's filament dropdown
showing "unknown", but clicking Configure right after made the
slicer recognize it correctly. "Configure" felt like a mandatory
follow-up step rather than a refinement.
Bambu cloud uses three preset-ID shapes:
GFS… — Bambu official cloud preset
PFUS… — cloud user-created preset
PFCN… — cloud shared / partner preset (Polymaker's "(Custom)"
Bambu Lab H2D variants ship this prefix)
apply_spool_to_slot_via_mqtt only routed GFS and PFUS through the
cloud-detail lookup that extracts the underlying filament_id. PFCN
slipped past the cloud-lookup branch, fell into the local-preset
int() parse path, raised ValueError, dropped into
normalize_slicer_filament which returns any P-prefix unchanged, and
the raw PFCN landed in tray_info_idx. The printer's calibration
table can't index that, so the slicer rendered "unknown". The
Configure modal rescued every assign because it does its own
getCloudSettingDetail and writes the resolved filament_id.
Extend the cloud-detail-lookup branch (inventory.py:129) and the
discard safety net (inventory.py:223) to include PFCN alongside
GFS/PFUS. Three behaviours fall out:
* Cloud-authenticated: the real filament_id from
detail["filament_id"] ships as tray_info_idx (Polymaker PLA
Matte resolves to GFL05).
* Cloud unavailable: raw PFCN discarded, the slot reuses an
existing valid P-prefix preset if material matches.
Source comment now lists all three cloud-ID shapes so the next time
Bambu invents a new prefix the maintainer doesn't have to re-derive
the structure from a bug report.
|
||
|
|
e895d8350a |
fix(vp): re-fire FINISH after project_file ack so slicer releases (#1658)
Reporter on Bambu Studio 2.7.1.57 + X1C saw the Send modal stuck at
"Downloading" after sending to a Queue-mode VP. Delete-from-queue and
even Auto-Dispatch ON + a successful real print didn't release it.
Root cause: BS 2.7.x flipped the Send sequence from
MQTT project_file -> FTP upload -> done
to
FTP verify_job -> FTP .3mf -> MQTT project_file.
The #1280 fix sets gcode_state=FINISH in on_file_received (after the
FTP upload). Under the new order, the synthetic project_file ack in
_send_print_response then runs and overwrites _gcode_state back to
PREPARE. The 1 Hz cached-as-base push stream carries PREPARE forever,
the slicer never sees the FINISH transition it waits for, and the
modal sits stuck. Auto-Dispatch ON shares the cause: the real
printer's PREPARE->RUNNING->FINISH on the bridge gets masked by the
local _gcode_state override in _send_status_report.
Re-fire set_gcode_state("FINISH", filename, prepare_percent="100")
1.5 s after the project_file ack for every non-proxy mode (queue /
archive / review). The 1.5 s window lets the slicer see at least one
PREPARE push on the 1 Hz cycle so the transition reads as
PREPARE -> FINISH, matching what the slicer expects. Proxy mode is
exempt -- there the real printer drives the bridge state and a
synthetic FINISH would clobber a real PREPARE/RUNNING transition.
The scheduler cancels any in-flight timer when a new project_file
arrives so a retrying slicer doesn't end with two competing FINISH
timers. The pending timer is also cancelled on stop_server.
|
||
|
|
19073f5c84 |
fix(vp): auto-derive access code from target printer in non-proxy modes
Non-proxy VPs (Archive / Review / Queue) with a target printer set up a live-mirror bridge that forwards the slicer's MQTT and RTSPS auth bytes through to the real printer. The slicer holds one code in its profile (the one it bound the VP with), and that code has to satisfy both the VP listener and the real printer at the far end of the bridge. If the codes diverge the bridge silently fails at the second hop — slicer reaches .49:8883, FINs before sending a ClientHello, retries identically. The wiki framed the code-match requirement as a camera-only concern; it isn't, all bridged protocols inherit. Fix removes the foot-gun instead of re-documenting it. When a target is selected on a non-proxy VP the access-code field switches to a read-only display showing the target's code with an Eye-toggle reveal; the backend auto-inherits on every create / update (any explicit access_code submitted alongside a target is silently overridden as belt-and-braces for non-UI clients). The required-when- enabling check now treats target-set as satisfying the access-code requirement. Standalone (no-target) non-proxy VPs still get the editable input + Save button. One-shot startup migration corrects any pre-existing mismatched rows: SELECTs diverged VPs and logs one INFO line per row for the audit trail, then UPDATEs via correlated subquery. Idempotent and portable between SQLite and Postgres. |
||
|
|
12d17bfbe7 |
fix(photo): source finish photo from forced timelapse + cleanup (#1397)
Bambu's end-gcode lowers the bed at gcode_state=FINISH. Bambuddy's live-camera grab captured the bed already dropped, ruining the photo framing. Source the photo from a brief Bambu timelapse instead — firmware stops timelapse recording AFTER toolhead parks but BEFORE bed-drop runs, so the last frame frames the finished print correctly. When capture_finish_photo is on AND the user did not opt in to timelapse for this print, force timelapse=True at dispatch + mark the new PrintArchive.bambuddy_forced_timelapse column. After extraction (success or failure), cleanup deletes the locally-attached file, clears archive.timelapse_path, and walks the four scanner directories (/timelapse, /timelapse/video, /record, /recording) trying FTP DELE against the original filename. User-opted-in timelapses pass through unchanged. Resolver lives at services/background_dispatch.py::resolve_effective_timelapse (module-level so the print queue can reuse it). Both dispatch paths wired: background_dispatch.py (Print Now / Reprint) AND print_scheduler.py:_start_print (the queue). Field testing caught the scheduler gap on the first round — AST regression test now asserts start_print(timelapse=...) references effective_timelapse, not the raw item.timelapse, so a future refactor can't silently drop it. Extractor: ffmpeg -i input.mp4 -update 1 -q:v 2 out.jpg. Decoded frames overwrite the same output file, so the file left on disk is the literal last frame regardless of duration. Bambu records one frame per layer-change, so a 16-layer cube produces a 0.6 s timelapse — the original -sseof -1.0 approach seeked before the start of the file and returned frame 0 (empty bed). Decoding every frame is fine; Bambu timelapses are short by construction even on hours-long prints. Migration adds bambuddy_forced_timelapse branched on is_sqlite() (DEFAULT 0 / DEFAULT FALSE — PG rejects DEFAULT 0 for BOOLEAN). Verified live on postgres:16-alpine. Photo-task wait_for budget extends 45s -> 75s when timelapse_was_active so the notification carries the bed-up photo instead of falling back to the live-cam grab on slow links. Scope limit, documented in the camera wiki: prints started directly on the printer touchscreen / Bambu Handy / Bambu Studio Send bypass both dispatch paths, so the override doesn't fire there. Future option: mid-print M981 S1 P20000 MQTT toggle in on_print_start. Setting description rewritten in all 11 locales to drop the "only works when timelapse enabled" caveat (Bambuddy now forces it) and explain the kept-or-deleted behaviour. |
||
|
|
4851f54595 |
feat(file-manager): split "All Files" into internal-only + External views (#1621)
Reporter linked a NAS and the auto-imported files drowned their own Bambuddy uploads in the "All Files" sidebar listing. There was no filter to escape it — only per-folder clicks. Restore the pre-external semantics: "All Files" now lists managed-storage files only. The combined across-every-external view moves to a new sibling sidebar entry, "External", that only appears when at least one external folder is linked. Backend: GET /api/v1/library/files gains internal_only and external_only query flags. Filter is on LibraryFile.is_external. Both flags set is a 400, not a silent pick-one. Frontend: new topLevelView state on FileManagerPage (default internal); the query passes the scope only when selectedFolderId is null. Mobile selector dropdown uses __top:internal / __top:external sentinels so the same state round-trips through option values. Empty-state copy distinguishes internal-empty from external-empty. |
||
|
|
aed01f875a |
fix(vp): resolve hostname/FQDN targets in MQTT bridge IP encoding (#1429 follow-up)
Printers added to Bambuddy by hostname/FQDN (e.g. p1s.fritz.box) hit 'invalid IPv4' in _ip_to_uint32_le, so the net.info[*].ip rewrite never armed and BambuStudio Send went straight to the real printer instead of the Bambuddy archive whenever the printer was powered on. Add _resolve_target_to_ipv4(target): IPv4 pass-through, else socket.getaddrinfo(target, family=AF_INET). AF_INET filter is load-bearing because net.info[*].ip is uint32 LE and IPv6 can't round-trip. OSError returns None so a transient DNS failure recovers on the next 30s refresh tick via the existing not-armed throttle. Apply the resolver to both the encode call and the host-interface picker (which also assumes dotted-quad). Armed log line now carries configured->resolved when they differ, so bad-DNS regressions stay legible in 'docker logs'. The unresolvable not-armed reason now names the configured value rather than parroting 'invalid IPv4', distinguishing 'DNS gave a v6 result' from 'user typed garbage'. Root-caused by @Mape6; @TrickShotMLG02 confirmed the FQDN workaround on the same release. Pre-0.2.4 these setups worked by accident because there was no net.info[].ip rewrite at all. |
||
|
|
54389a54aa |
fix(library): MakerWorld URL import honours external folder destinations (#1645)
Reported and root-caused by @needo37. Importing a model via the MakerWorld URL-download feature into a writable external folder (e.g. SMB/NFS-mounted NAS) saved the 3MF into Bambuddy's internal managed library dir, not the external mount. The file card showed in the File Manager under the external folder, but the bytes never landed on the NAS, and the on-disk copy was UUID-renamed so a find by the original basename matched nothing. Root cause was save_3mf_bytes_to_library at backend/app/api/routes/library.py:422: it accepted folder_id but never loaded the folder, never inspected is_external / external_path, hardcoded the destination to get_library_files_dir() with a UUID name, and left the LibraryFile row with is_external=False. So the row's folder_id pointed at the external folder while its bytes and is_external flag both said "managed/internal". Same class of bug as #1112, which had been fixed for the multipart-upload and move paths but never applied to this byte-import path. Fix mirrors the multipart-upload path directly: - Load target_folder from folder_id when non-None. - Feed it to _resolve_upload_destination(target_folder, filename), which already returns (dest, is_external) and enforces the 403-read-only / 400-unwritable-or-missing / 409-collision rejections. - Write bytes to dest (real filename for external, UUID for managed). - Persist the row with file_path=_stored_file_path(dest, is_external) and is_external=is_external. The route-layer read-only guard at makerworld.py:256-260 is preserved - it returns the friendlier error before the upstream download burns bandwidth - and _resolve_upload_destination's identical check stays as defence-in-depth for any future caller that skips the route gate. Thumbnails continue to live under the managed get_library_thumbnails_dir() regardless of the 3MF's location, matching the upload path. |
||
|
|
9554ebd05d |
refactor(inventory): rename /reset-usage to /reset-consumed-counter to match what it actually does (issue #1644)
The old endpoint name implied that calling it would drop weight_used to
0. In practice it only stamps weight_used_baseline = weight_used so the
Inventory page's "Total Consumed" widget (weight_used - baseline) reads
0 going forward, while remaining (label_weight - weight_used) is
preserved. Calling the endpoint via curl and seeing weight_used
unchanged in the JSON response is confusing.
New paths:
- internal: /api/v1/inventory/spools/{id}/reset-consumed-counter
/api/v1/inventory/spools/reset-consumed-counter-bulk
- spoolman: /api/v1/spoolman/inventory/spools/{id}/reset-consumed-counter
/api/v1/spoolman/inventory/spools/reset-consumed-counter-bulk
Behaviour is unchanged in both modes; internal stamps the baseline
directly, Spoolman-mode PATCHes upstream used_weight=0 and the
_map_spoolman_spool read mapping reconstructs the same "displayed
consumed = 0, remaining unchanged" Bambuddy-visible shape. Parity
between modes was already in place and is preserved.
The Spoolman-client method reset_spool_usage keeps its name because it
describes what is sent upstream to Spoolman, not what Bambuddy's
endpoint promises to callers.
Frontend:
- api.resetSpoolUsage / bulkResetSpoolUsage (and Spoolman variants)
renamed to resetSpoolConsumedCounter / bulkResetSpoolConsumedCounter.
- Button labels: "Reset usage to 0" -> "Reset counter" / "Reset all
counters" (short, unambiguous); tooltips and confirm-modal bodies
still spell out the full semantics.
|
||
|
|
e802bfc806 |
fix(ftp): cap TLS to v1.2 for X2D FTPS to dodge WRONG_VERSION_NUMBER on firmware 01.01.00.00 (#1638)
Reporter @vasmarfas saw X2D archive cards land almost empty - only print time, no filament weight / layers / MakerWorld link / thumbnail - and Spoolman filament-usage tracking went silent on the same printer. Support bundle traces the end-to-end: at print start backend/app/main.py::on_print_start tries the usual FTP-download dance for the 3MF, every implicit-FTPS connect attempt to the X2D fails with `[SSL: WRONG_VERSION_NUMBER] wrong version number (_ssl.c:1032)`, ~2 minutes later "Could not find 3MF file for print" -> "Created fallback archive". Fallback path writes file_path="", file_size=0, content_hash=NULL, no layers / filament / model-link fields. Spoolman tracking degrades from the same root cause - both depend on the 3MF metadata parser. Proximate cause: Python 3.13's default ssl.create_default_context() negotiates TLS 1.3, the X2D's implicit-FTPS server on port 990 rejects the ClientHello. Same family as the P2S 01.02.00.00 bug from #1401 (post-Python-3.13 TLS-1.3 breakage), different wire-level failure mode (P2S completes the handshake and truncates with 426; X2D fails the handshake outright). Same fix shape: add X2D to backend/app/services/ftp_profiles.py with cap_tls_v1_2=True, plus N6 -> X2D SSDP alias. Every other model stays on negotiated TLS 1.3. Honest caveat: hypothesis-driven trial, not a confirmed root-cause fix. WRONG_VERSION_NUMBER could equally describe the X2D switching to explicit FTPS (AUTH TLS on plaintext greeting) or moving FTPS to a different port - either would need a different code path. Reporter has been asked to test this build; if the cap doesn't clear it the registry slot stays useful and the next diagnostic round goes to openssl s_client from a network-adjacent host. |
||
|
|
18d534c945 |
feat(orca-cloud): integrate Orca Cloud profile sync across UI, slicer and SpoolBuddy
Reads, lists, and slices with profiles from a user's Orca Cloud account (OrcaSlicer 2.4.0-alpha's Supabase-backed sync) alongside the existing Bambu Cloud integration. Four sign-in providers (Google / Apple / GitHub / email+password); password defaults. Paste-flow PKCE because Orca's Supabase project only allowlists localhost redirect_to — open feature request at OrcaSlicer/OrcaSlicer#14028. Surfaces: - Profiles tab: new "Orca Cloud" tab next to "Bambu Cloud" with the same rich layout (search + 5 filter dropdowns + 3-column grouped grid + read-only detail modal) - SliceModal: 4-tier preset picker (orca_cloud > local > bambu cloud > standard); separate status banner per cloud; metadata-aware pre-pick scores Orca filaments above local (Orca's sync_pull returns full content inline so filament_type / filament_colour come for free, no per-setting fetch rate-limit dance) - ConfigureAmsSlotModal: orca_cloud as a new preset source (prefixed orca_<UUID> to match local_/builtin_); generic Bambu filament-ID derivation from parsed material (printer firmware can't grok Orca UUIDs); slot mapping persists preset_source='orca_cloud' - SpoolForm / SpoolBuddyWriteTagPage: Orca filaments merge into the cloud preset list via Promise.allSettled (OrcaProfileMeta is structurally identical to SlicerSetting) Backend: - services/orca_cloud.py: OrcaCloudService with PKCE / token exchange / single-use refresh rotation / get_user_info / list_profiles via the bare /sync/pull bootstrap path - routes/orca_cloud.py: 7 endpoints (auth/start, auth/finish, auth/password, status, logout, profiles, profiles/{id}); router-level _cloud_api_key_gate + per-route cloud_caller() so API-keyed callers (SpoolBuddy kiosk) properly resolve their owner User; just-in-time refresh with atomic persist-before-API-call - routes/slicer_presets.py: _fetch_orca_cloud_presets mirrors the Bambu Cloud fetcher (status vocabulary, 5min cache, permission shortcut); _dedupe_by_name extended to 4 tiers; UnifiedPresetsResponse gains orca_cloud + orca_cloud_status - services/preset_resolver.py: PresetRef.source extended with "orca_cloud"; _resolve_orca_cloud walks list + filters - 8 columns on users table for tokens (5 persistent) + transient PKCE handshake state with 10-min TTL (3); dialect-branched DATETIME / TIMESTAMP; auth-disabled mode falls back to Settings table - orca_cloud:auth permission folded into can_access_cloud API-key scope (same trust dimension) |
||
|
|
51730a7bf1 |
feat(ams): gate humidity/temperature alarms on AMS-has-filament (#1619)
The hourly AMS sensor recorder dispatched humidity and temperature alarms for every unit above threshold without checking whether the unit was actually loaded. Empty AMS units still report ambient readings, so users with one loaded + one empty AMS got useful alarms for the loaded one and hourly noise for the empty one. Disabling the whole alarm category killed both — not a real choice. New _ams_has_filament helper inspects tray_exist_bits (hex bitmap, "0" = empty) with fallback to the tray array's tray_type strings for shapes where the bitmap is missing. The recorder gates the alarm dispatch on this check per-AMS-unit, so a multi-AMS printer with one loaded + one empty still alarms on the loaded one. Sensor history still records regardless of the gate so the System page humidity charts stay continuous — only the outbound notification is suppressed. 9 unit tests cover the bitmap-zero case, bitmap-missing fallback, garbage/blank/int bitmap edges, and defensive malformed tray. |
||
|
|
db1c664fce |
feat(vp): surface why MQTT bridge IP encoding didn't arm (#1429 defensive)
_refresh_ip_encoding had 4 silent early-returns. When the rewrite silently no-op'd on a user's setup, the only signal was the absence of the "armed" INFO line, and diagnosing which path was firing meant grepping the source. Each path now emits one INFO line naming the specific reason. A _not_armed_reason dedup field throttles to one line per state change, so an idle unarmed bridge doesn't spam every 30s refresh tick. Cleared on successful arm so regressions re-emit. Not a fix for #1429 itself — the bridge logic is unchanged; this just turns the silent failure into visible signal so the next "fix didn't work for me" report can be triaged in one round-trip. |
||
|
|
51abc4b7a8 |
feat(drying): enable AMS drying for H2C at firmware 01.02.00.00+ (issue #1624)
H2C was in _DRYING_UNSUPPORTED_MODELS. Move to _DRYING_MIN_FIRMWARE with the same 01.02.00.00 floor as H2S / P2S. Both SSDP model codes the H2C advertises (O1C single-nozzle, O1C2 dual-nozzle) get the same gate so supports_drying() fires correctly regardless of which form is stored on the printer record. |
||
|
|
c571ad86dd |
feat(diagnostic): printer_publishing check + countdown UI (#1622)
The existing connection diagnostic proved TCP + TLS + auth + SUBSCRIBE but not that the printer was actually publishing reports. A wrong-cased serial passes mqtt_auth because the broker accepts the subscription regardless; the user-visible symptom is empty AMS / no K-profiles / no custom filaments in the slicer Device tab because the VP cached state is empty. Bambuddy already logged the actionable hint at bambu_mqtt.py:498 but only to container logs. New printer_publishing check turns that warning into a structured diagnostic result. Pass = bridge has seen at least one report since the last (re)connect; fail = zero reports across the wait window with fix-text pointing at the case-sensitive serial. Bounded 10s poll on the on-demand UI route, no wait on the support-package gathering path so bundling stays fast. Exits the moment a message arrives — typical wall-clock is 1-2s. Frontend renders an elapsed-seconds counter plus a "Listening for status report — up to 10s" hint during the pending state so the wait doesn't look hung. PUBLISH_WAIT_DEFAULT_SECONDS pinned on both sides. report_messages_since_connect exposed as a public property on BambuMQTTClient so the diagnostic doesn't reach into private state. |
||
|
|
a1cb5d5b4d |
fix(backup): interpret scheduled-backup HH:MM as local time, not UTC (#1602 follow-up)
The Scheduled Local Backups time-of-day picker was interpreted as UTC by _calculate_next_run, so a UTC+3 user had to enter 18:00 to get a 21:00 local backup. The UI labeled the field "UTC" but it was still surprising. Picker is now interpreted in the container's local timezone, resolved from the TZ env var via zoneinfo.ZoneInfo (same source the Support page's environment.timezone shows). UTC fallback when TZ is unset or unrecognised. The /local-backup/status endpoint exposes the resolved zone, and the UI renders it next to the field via a new backup.localTimeHint i18n key with real translations in all 10 non-English locales. One-time behaviour change for users who entered a UTC time as a workaround: the first scheduled cycle after upgrade will run at their local TZ offset earlier than expected. Re-enter the time as local once and it is correct from then on. No migration is shipped; migrating around a DST boundary would be ambiguous. |
||
|
|
32b3a93e60 |
test(security-fixtures): suppress Bandit B108/B104 on adversarial inputs
The hardcoded /tmp paths and 0.0.0.0 bind in test_archives_api.py / test_attach_timelapse_safe_path.py / test_vp_mqtt_bridge.py are deliberate adversarial-input fixtures for the path-traversal containment tests and the #1429 bind_address=0.0.0.0 auto-resolve path — not insecure temp-file usage by the tests. Same nosec-without-comment pattern as the existing test_virtual_printer.py sites. |
||
|
|
cc25cbe774 |
fix(archives): #1608 suppress card time-accuracy badge for multi-run archives
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. |
||
|
|
9cc4b6aa60 |
fix(virtual-printer): #1610 add Bambu cipher pin to every slicer-facing TLS context
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. |
||
|
|
fee7c722f5 |
fix(usage-tracker): #1607 filter empty AMS slots from position-based fallback
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. |
||
|
|
cdc27eb517 |
fix(virtual-printer): #1429 follow-up — auto-resolve VP IP when bind_address is 0.0.0.0
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. |
||
|
|
673001e3cd |
fix(maintenance): #1596 persist wiki_url on Custom Type create + correct
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.
|
||
|
|
a837a3acbd |
● fix(library): #1600 thumbnail extraction for external-folder .gcode.3mf
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. |
||
|
|
5d6d928b3f |
fix(virtual-printer): #1429 net.info[*].ip cache leak + mode wire-value rename
#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. |
||
|
|
1e08c25a9f |
fix(stats): #1593 multi-plate parser + per-run project rollup + carry-over system totals + accuracy band
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.
|
||
|
|
c9bc5eb4e9 |
fix(webhook): #1584 PrinterState dataclass attribute access in status / stop / cancel
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.
|
||
|
|
171848a0fc |
fix(slicer-presets): #1581 SliceModal refresh + invalidate on local-profile delete/import
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.
|