Round-2 cmd.jsonl from shaddowlink proves the bridge forwards both commands and
responses correctly: ams_filament_setting round-trips with result=success on P1S,
the cached push_status carries tray_info_idx=GFA11/tray_type=PLA-AERO/K-n/cali_idx
intact, and the visible "unload" symptom comes from the slicer's choice of
extrusion_cali_set (push K direct, P1S firmware rejects) vs extrusion_cali_sel
(select by id, both H2D and P1S accept). The open question is what makes the
slicer pick _set vs _sel — likely the info.get_version response Bambuddy
synthesises or the first cached pushall reply the slicer reads at connect.
Round 2 captured neither; the JSONL had slicer_to_bridge and printer_to_slicer
but no direction for the bridge's own synthesised replies.
Same BAMBUDDY_VP_DUMP_WIRE=1 flag now also appends a bridge_to_slicer line for
every bridge-synthesised reply (info.get_version answer, project_file ack,
on-demand pushall response). Capture is in _publish_to_report — the single
chokepoint — gated on a new log_event param; the 1Hz periodic push threads
log_event=False so the JSONL isn't flooded (~60 lines/min/VP) because
dump_wire already covers cache shape per tick.
Diagnostic-only, no data-path change. Default param preserves every existing
call site's behaviour.
Round 2 fixed the model-mode FilamentOverride: tray_info_idx →
sub-brand, plus a material-disambiguated colour name from a new
/inventory/colors/by-material endpoint. The printer-mode panel that
renders the same 3MF (FilamentMapping) was reading the same raw
fields — item.type for the required label, getColorName(item.color)
for the swatch tooltip — and was not touched, so picking "Specific
Printer" still showed "Required: PLA - Black" for a slice the
"Any H2D" branch already labelled "Bambu PLA Matte - Charcoal".
Extract the three-query resolution machinery from FilamentOverride
into a shared hook useFilamentLabels (returns positional
{resolvedName, colorLabel} per slot). Both panels call it; both
read the same labels. The hook also owns extractMaterialHint so the
"strip leading brand token" rule has one source of truth.
FilamentMapping required-side now reads {resolvedName} instead of
{item.type}; swatch tooltip reads `Required: {resolvedName} -
{colorLabel}` instead of `Required: {item.type} -
getColorName(item.color)`.
The Print Queue's schedule dialog hid the per-slot "Force color match"
checkbox when a specific printer was picked, even though the scheduler
in print_scheduler.py:535 honours force_color_match regardless of how
the queue item was created. Model-mode ("Any A1") rendered
FilamentOverride which carries the checkbox; printer-mode rendered
FilamentMapping which had no force-match UI at all. Pure UI gap.
Extend FilamentMapping to accept optional forceColorMatch +
onForceColorMatchChange props, mirroring FilamentOverride's signature,
and render the same <Palette>-iconed checkbox under each filament row
when a handler is wired. PrintModal/index.tsx passes the existing
forceColorMatch state through — same object both modes write into so
toggling between modes preserves the user's selection. No new i18n
keys (printModal.forceColorMatch already ships in all 11 locales).
The shape-of-payload dump shipped earlier rules out cache wipes —
shaddowlink's round-1 captures show AMS data reaches the slicer
byte-identical to what the printer sent. The remaining symptom
(picking a generic filament in archive mode "unloads" the slot) lives
on the command path, which the snapshot dump doesn't see: it writes
only the cached _latest_print_state and the periodic 1Hz push.
Add append_event() in _debug.py — same env flag, separate file at
<log_dir>/vp_wire/<vp>_cmd.jsonl. One JSONL line per event with UTC
iso timestamp, direction (slicer_to_bridge / printer_to_slicer), MQTT
topic, <channel>.<command> grep handle, and parsed payload. Wired at
two points: mqtt_server._handle_publish for slicer publishes (after
JSON decode so the trace matches what the bridge actually parsed) and
mqtt_bridge._on_printer_raw "everything else" branch for printer
responses (after serial rewrite so the trace matches what the slicer
sees on the wire). Pushall / get_version stay out — both are handled
locally and never round-trip through the bridge.
Bytes payloads get the same \x00-tolerance fix from #927 so
OrcaSlicer's C-string-null publishes parse cleanly; un-parseable
bytes fall back to {"raw": "..."} so every line stays valid JSON.
Native installs that follow the systemd template
WorkingDirectory=/opt/bambuddy
Environment="DATA_DIR=/srv/bambuddy/data"
(or any layout where DATA_DIR is not a subdirectory of the install)
could not apply in-app updates. Every git subprocess in _perform_update
used cwd=settings.base_dir and safe.directory={base_dir}. On standard
installs (DATA_DIR=INSTALL_PATH/data) this happened to work by accident
because git walks up from a subdirectory of the repo to find .git; on
separate-mount layouts the walk has nowhere to go and every call
returns "fatal: not a git repository." safe.directory was also wrong
even on the standard install -- it must equal the repo root git
discovers, not the data dir.
Resolve app_dir = settings.app_dir at the top of _perform_update and
route all four git subprocesses (remote get-url, remote set-url, fetch,
reset --hard) and the embedded safe.directory through it. Rename the
base_dir parameter on _origin_points_at_repo to app_dir so the
signature documents the contract.
Four #1712 issues from the 2026-06-04 Orca Cloud integration:
(1) Tier order put Orca Cloud above everything across SliceModal,
auto-pick scoring, dropdown groups, the AMS slot picker, and the
backend precedence. Bambu-Cloud-only users saw their profiles
deprioritised behind an empty Orca tier.
(2) Cross-tier dedup hid a same-named preset in all but the highest-
priority tier. A user with both a local-imported and an Orca-synced
"Bambu PLA Basic" couldn't see the Orca copy as a picker option.
(3) CloudStatusBanner nagged signed-out users with a permanent
"Sign in to Orca Cloud" line at the top of every slice -- even after
explicit logout. Bambu Cloud had the symmetric problem.
(4) ConfigureAmsSlotModal source badges were inconsistent: Orca rows
showed only "Custom" (no source identity), Bambu Cloud built-in rows
had no badge at all, and the orthogonal isUser-driven "Custom" badge
collided with the source badge for cloud user presets.
Order is local > orca_cloud > cloud > standard everywhere it lives
(SliceModal SLICE_MODAL_TIER_ORDER + TIER_BONUS + dropdown tier list,
ConfigureAmsSlotModal sourceOrder, and backend precedence). The order
drives auto-pick + visual group rendering; it does NOT hide profiles.
_dedupe_by_name replaced with _enrich_cloud_metadata: every tier
returns its full list across all three slots (printer / process /
filament). The function still backfills Bambu Cloud filament metadata
from same-named local / orca_cloud / standard entries so cloud
filaments score in pickFilamentForSlot.
CloudStatusBanner silently no-ops on not_authenticated for both clouds;
expired / unreachable still surface. The not_authenticated i18n keys
stay in the locale files dormant.
ConfigureAmsSlotModal: one source badge per row, one colour per source
(green Local / purple Orca Cloud / bambu-blue Bambu Cloud / amber
Built-in). The legacy isUser-driven "Custom" badge is gone; every row
identifies its tier consistently.
Add a BAMBUDDY_VP_DUMP_WIRE=1 escape hatch that writes the bridge's
cached push_status (in) and the 1Hz slicer-facing copy (out) to
<log_dir>/vp_wire/<vp_name>_<direction>.json, overwritten each tick.
#1622's symptom — empty filament dropdown in slicer's AMS slot details
for P1S/A1 but not H2D in non-proxy VP modes — needs visibility into
the actual wire bytes flowing through the bridge to bisect between
"cache is missing fields" and "_send_status_report strips them on copy."
The existing logs prove the bridge is bound and pushing at 1Hz, but
not what's in the payload.
Off by default, single env flag, single file per VP per direction
(bounded disk footprint), failures swallowed at debug so a broken
dump can never break the 1Hz loop. 21 tests pin the helper contract:
disabled-by-default, atomic writes, sanitized vp_name (no path
escape), per-call env check so toggling without restart works.
Reprints reuse the source archive row via the expected-print promotion
branch, but that branch never reset archive.timelapse_path. The stale
path made _scan_for_timelapse_with_retries early-return (so the new
run's MP4 was never downloaded), and _capture_finish_photo_from_timelapse
then extracted the *original* run's last frame and shipped it to Telegram
as the new run's finish photo. Surface was specific to the
timelapse-prefer path (data.timelapse_was_active=true and no external
camera) — fallback live-camera paths were unaffected, which is why this
took a P2S reporter to surface.
Clear archive.timelapse_path at promotion and unlink the stale on-disk
MP4 (best-effort; missing files are logged and skipped). The orphan
unlink also kills a long-standing file-leak: every reprint used to leave
its predecessor's timelapse on disk forever. archive.photos is
deliberately left alone — accumulating one finish photo per run is
correct.
Adds bambuddy-windows-x64-setup.exe (unversioned) alongside the
date-stamped bambuddy-<version>-windows-x64-setup.exe for stable
and beta tag releases. Lets external surfaces (website, wiki,
newsletters) link to a stable URL that survives version bumps:
https://github.com/maziggy/bambuddy/releases/latest/download/
bambuddy-windows-x64-setup.exe
Daily prereleases are excluded — GitHub's `latest` redirect skips
prereleases so the alias would add no value there, and an
unversioned name next to a date-stamped versioned one on a daily
release page is semantically confusing.
When the Windows build is triggered by a tag push (the path
docker-publish.sh and docker-publish-daily-beta.sh both take), use
the tag as the installer version instead of APP_VERSION. So a daily
tag v0.2.5b1-daily.20260610 produces
bambuddy-0.2.5b1-daily.20260610-windows-x64-setup.exe
matching the docker `daily` image and the GitHub prerelease. The
stable docker-publish.sh path is unchanged (tag v0.2.5b1 →
bambuddy-0.2.5b1-windows-x64-setup.exe). Manual workflow_dispatch
and local builds fall back to APP_VERSION as before.
Silences Inno Setup's "missing RunOnceId" warning. Both commands
(stop service, remove firewall rule) are already idempotent, so the
behavioural effect is nil — this is purely a cleanliness fix to keep
the build log warning-free.
nssm.cc returned 503 mid-CI-run, breaking the staging step. NSSM 2.24
hasn't shipped a new release since 2014, so the binary is effectively
static — vendoring under installers/windows/vendor/nssm.exe makes
builds reproducible and removes the only single-source download in
the pipeline. SHA-256 pinned in the comment in build.py for audit.
ffmpeg stays fetched from BtbN's GitHub mirror — it's actively
maintained and large (~80MB) so vendoring it would be unreasonable;
GitHub Releases are also far more reliable than nssm.cc.
Upgrading over a running install was failing with permission-denied
errors on python.exe / .pyd / nssm.exe — the service held file locks
during the [Files] copy phase. Add a PrepareToInstall hook that
detects an existing install ({app}\bin\nssm.exe present) and stops
the service before the overwrite. FileExists guards a no-op on
first-time installs; the post-install [Run] step re-registers and
starts the service fresh either way.
- build.py now reads APP_VERSION from backend/app/core/config.py
(the canonical source used everywhere else — /system, support
bundles, FastAPI title) instead of pyproject.toml's stale 0.1.5.
Next installer is bambuddy-0.2.5b1-windows-x64-setup.exe.
- installers/windows/bambuddy.ico is a multi-resolution .ico
(16/32/48/64/128/256) generated from frontend/public/img/
favicon.png. Wired into SetupIconFile (installer .exe icon),
UninstallDisplayIcon (Add/Remove Programs), and the Start Menu /
desktop shortcuts — replaces the NSSM placeholder everywhere a
user sees Bambuddy on Windows.
get_network_interfaces() used fcntl ioctls and get_all_interface_ips()
shelled out to `ip -j addr show` — both Linux-only. On Windows the
fcntl path raised ImportError and returned [], so the VP "bind IP"
dropdown showed no interfaces.
Add a Windows branch using psutil.net_if_addrs() + net_if_stats()
(psutil is already a dep). Same dict shape as the Linux path, filters
loopback / link-local / down interfaces by address class instead of
by name prefix. No name-based exclusion — users may legitimately
bind a VP to a Hyper-V / WSL / Tailscale virtual adapter.
The Linux fcntl path is untouched.
Address CodeQL actions/missing-workflow-permissions finding. Least-
privilege at workflow level: contents: write is required by
softprops/action-gh-release to attach the installer .exe to a tag
release; all other steps are read-only.
vite.config.ts sets outDir to '../static' so the bundle lands at
<repo>/static/ — not frontend/dist/. Matches the runtime expectation
in config.py where static_dir = _app_dir / "static".
Also stage gcode_viewer/ next to static/ so the 3D preview iframe
routes in main.py (resolved via static_dir.parent / "gcode_viewer")
find their assets.
Strip macOS metadata files (.DS_Store, ._.*) from both copies — they
leak in from dev boxes and would only bloat the installer.
get-pip.py installs only pip itself; the embedded Python distribution
ships without setuptools or wheel. pip needs setuptools.build_meta as
the PEP 517 build backend for any sdist-only package — Bambuddy's
requirements.txt hits this on pyftpdlib 2.2.0 (sdist-only on PyPI).
Install both right after the pip bootstrap so requirements.txt installs
cleanly.
windows-latest runners ship Inno Setup 6.7.1 pre-installed under the
same path we already hardcode for ISCC.exe; the choco install was trying
to downgrade to 6.2.2 and failing on the version mismatch.
Lays down the Inno Setup + embedded Python pipeline for producing a
self-contained Bambuddy Windows installer .exe. The installer ships
an embedded Python 3.13, the pre-built React bundle, NSSM (service
supervisor) and ffmpeg — no host Python or Node required on the
target machine.
Architecture:
- Install: C:\Program Files\Bambuddy (admin install, one-time UAC)
- Data: C:\ProgramData\Bambuddy\data (preserved on uninstall)
- Service: registered via NSSM, runs as LocalSystem, autostart on boot
- UI: browser at http://localhost:8000 (Start Menu shortcut)
Files:
- installers/windows/build.py stages embedded Python + deps,
frontend bundle, NSSM, ffmpeg
- installers/windows/bambuddy.iss Inno Setup compiler script
- installers/windows/service/*.bat NSSM register/deregister
- .github/workflows/windows-installer.yml CI build on tag push + manual
dispatch, uploads .exe artifact
build.py hard-fails on non-Windows hosts; Wine cross-build is an
unsupported escape hatch behind --allow-non-windows. v1 ships unsigned
(SmartScreen warns on first run) — production signing will be wired up
via SignPath OSS once the application is approved.
See installers/windows/README.md for build prerequisites and the
embedded-Python ._pth gotchas.
Drop the off-white background on the A2L marketing render so it composites
cleanly on the dark theme (every other printer image in public/img/printers/
is RGBA with transparent corners; A2L shipped as opaque #F7F7F7). Resize to
320x320 to match the rest of the artwork.
Also wire A2L into getPrinterImage so the printer card actually shows the new
artwork -- without this the resolver fell through to default.png for both
the A2L display name and the N9 internal SSDP code.
- frontend/public/img/printers/a2l.png: new, 320x320 RGBA, transparent
- frontend/src/utils/printer.ts: A2L / N9 -> a2l.png, placed above the a1mini
branch
- frontend/src/__tests__/utils/printer.test.ts: 4 cases mirroring the X2D
shape -- display name, case-insensitive variants, N9 internal code,
regression guard against accidentally matching A2M / A1 / A1 Mini
Internal code N9 (from BambuStudio resources/profiles/BBL/machine/Bambu Lab A2L.json),
serial prefix 26A19 (5-char, same shape as H2C's late 31B8B). Capabilities from
Bambu's official A2L specs page: linear rail, single FDM extruder + integrated
cutter/plotter, no Ethernet (2.4 GHz Wi-Fi only), low-rate chamber camera on
port 6000.
The BambuStudio profile's use_double_extruder_default_texture: true flag
describes two TOOL HEADS (FDM + cutter), not dual filament extrusion — A2L
must NOT be classified as dual-nozzle or AMS routing will target the deputy
slot and firmware rejects with 07FF_8012.
Registry updates: printer_models.py, firmware_check.py, virtual_printer/manager.py,
virtual_printer/mqtt_server.py, PrintersPage.tsx, SpoolBuddyAmsPage.tsx.
12 new test cases in TestA2LModel pin every dimension.
A1 and A1 Mini ship without a MicroSD slot at all - there is no
firmware-side "Store sent files on external storage" toggle and the
slicers don't surface a slicer-side equivalent either. The connection
diagnostic was reading state.store_to_sdcard (home_flag bit 11), which
is never set on these models, so the check fell through to fail for
every A1-series user. Combined with the absent slicer UI it left users
thinking Bambuddy was wrong about a setting their hardware does not
have.
New NO_EXTERNAL_STORAGE_MODELS frozenset in utils/printer_models.py
enumerates A1, A1 Mini, and their internal codes (N1, N2S, A04, A11,
A12). has_external_storage() returns False for those, True for
everything else. Unknown models default to True so the check stays
active for future Bambu lineup additions - new no-slot models must be
added to the set explicitly.
The diagnostic now short-circuits to skip before reading
store_to_sdcard when printer.model is in the set. X1, P1, P2S, H2,
and X2D are unchanged - the bit-off -> fail signal is still the right
read for them.
The companion FTP-upload-timeout symptom in the same bug report (ftp
code 28 from BambuStudio when sending to the proxy VP) is a separate
Docker-bridge-mode networking constraint, not addressed here.
The slot card on PrintersPage shows slot_preset_mappings.preset_name
first in its display fallback chain. Three write paths swap which
spool occupies a given slot:
- internal manual assign (inventory.apply_spool_to_slot_via_mqtt)
- internal RFID auto-assign (spool_tag_matcher.auto_assign_spool)
- Spoolman RFID sync (main.auto_sync_spoolman_ams_trays)
Only the first one was reconciling the row. After an RFID-driven
spool change, the card kept surfacing the previous spool's preset
name until the user opened Configure Slot manually.
Reporter saw H2D-1 / AMS-B3 displaying "Bambu PLA Silk+" for a
freshly-inserted Bambu PLA-CF spool. The matching row in
slot_preset_mappings was last written in March when a PLA Silk+
spool had been in that slot - confirmed live in the database.
New backend/app/services/slot_preset_writer.py exposes a primitive
upsert_slot_preset plus two derivation wrappers: one for the
internal Spool ORM object, one for the Spoolman API dict shape.
All three call sites now go through the helper, so the row stays
in lockstep with the assigned spool regardless of inventory mode.
Bug shape exists in both inventory modes and the patch fixes both
per feedback_inventory_modes_parity. The Spoolman path was latent
for users who'd never manually picked a slot preset; the same
"stale row overrides correct catalog name" symptom appeared for
those who had.
Existing stale rows self-heal on the next RFID-driven swap.
The Stats page's Failure Analysis widget and the per-archive run
sub-table rendered the raw PrintLogEntry.failure_reason value
without translating, so the camelCase keys saved by the new
Print Log row editor (#1687 part 4) surfaced as literal
"filamentRunout" / "cloggedNozzle" text. The Print Log table did
translate the value, so the inconsistency was visible from one
surface to the next.
EditArchiveModal was also still saving the localised label as the
column value while the new editor saved the key - same column,
two formats, two failure modes (group fragmentation on language
switch, new PATCH validation rejection on round-trip).
Three surfaces fixed in one drop:
1. StatsPage.tsx and PrintLogTable.tsx wrap the value in
t('editArchive.failureReasons.${reason}', { defaultValue: reason }) -
the defaultValue path keeps legacy translated-text rows rendering
unchanged.
2. EditArchiveModal stores the camelCase key on save and reverse-
looks up any legacy translated-text value against the current
locale on open. Every save thereafter converts that row forward
to the key format, so the column self-heals over time.
3. Added htmlFor/id to the failure-reason label/select pair (a11y
plus testability).
datetime.fromtimestamp(ts) and datetime.now() return naive local
datetimes; .isoformat() then emits no tz marker. The frontend's
parseUTCDate helper appends 'Z' to bare strings, treats the value
as UTC, then converts to local for display — applying the local
offset twice. Reporter on UTC+3 saw boot_time +3h ahead while
uptime was correct (uptime is a backend-side delta of two
naive-local values, so the missing tz info cancels out).
Fix: pass tz=timezone.utc to datetime.fromtimestamp and
datetime.now in system.py's boot_time / uptime path, plus the two
adjacent generated_at sites in system.py and support.py.
Logs device.dev_model_name / dev_product_name / dev_id / project_name
at INFO level once per client session, falling back to device.keys()
if none of the known fields are present.
The MQTT push_status carries the model code in device.dev_model_name
on every message, but nothing in bambu_mqtt.py reads or logs that
field — so adding a new printer model meant chasing the code through
either Bambu cloud or a manual mosquitto_sub. A2L (#1684) was the
case that surfaced this: get_version also failed because the firmware
disconnected right after request topic subscription, so the support
bundle had no way to disclose the model.
INFO level so the line lands in support bundles without enabling
debug. One-shot via _device_id_logged, mirroring the existing
_nozzle_fields_logged flag at line 2095, so push_status spam is
avoided.
Future-proofs against Bambu renaming the field (the fallback dumps
device.keys() so a rename like model_name without the dev_ prefix
is still observable). 3 unit tests in TestDeviceIdentificationProbe
pin all three branches.
N1 and N2S were flipped relative to every other registry that names
them - firmware_check.py (N2S -> "a1"), virtual_printer/manager.py
(both the model map and the serial-prefix map: N2S -> 039 = A1,
N1 -> 030 = A1 Mini), and printer_manager.py A1_MODELS all agree on
N2S = A1, N1 = A1 Mini. Only printer_models.py had it backwards, so
any path that resolved an A1-family printer by internal code rather
than serial prefix would silently misclassify.
Also fixes the matching comments in LINEAR_RAIL_MODELS - cosmetic only
(both codes were already in the frozenset) but kept the file
self-consistent.
New TestA1SeriesModelIds regression test pins both directions so a
future re-flip fails loudly.
close_all_connections() only disposes the engine's connection pool —
asyncio tasks like print_scheduler.run() and the smart-plug snapshot
loop wake on their 30 s cadence and lazily reopen pool connections
holding RowExclusiveLock on print_queue / smart_plug_energy_snapshots.
The restore's DROP TABLE ... CASCADE pass needs AccessExclusiveLock on
every public table, producing an AB/BA deadlock that rolls back the
entire restore transaction.
Reproduced 2026-06-09 restoring a native install's backup into a fresh
Docker+Postgres deploy:
asyncpg.exceptions.DeadlockDetectedError: deadlock detected
Process X waits for AccessExclusiveLock on relation 109940
Process Y waits for RowExclusiveLock on relation 110182
Fix:
- Layer 1: pause print_scheduler / smart_plug_manager /
notification_service / background_dispatch via their existing stop
affordances before close_all_connections(), with a 1.0 s sleep for
in-flight loop iterations to release sessions. Restore handler
already requires a container restart on success, so the paused
services come back via the next lifespan startup.
- Layer 2: prepend SET LOCAL lock_timeout = '10s' to the begin-block
in _import_sqlite_to_postgres so any reactive writer (per-printer
MQTT, hourly AMS history recorder) that slips through the pause
window fails fast and visibly instead of producing a new deadlock.
pywebpush brings aiohttp in transitively with no version bound, so the
resolver kept installing 3.13.5. Both CVEs are fixed in 3.14.0; direct
floor pin here, same shape as the existing idna / urllib3 / starlette
transitive pins. Our usage in services/external_camera.py is unaffected
by 3.14.0 (ClientSession, ClientTimeout, ClientError, iter_chunked all
unchanged); 29 external_camera tests pass on 3.14.1; pip-audit clean.
Configure Slot dropped to "default 0.020" on reopen for slots that were
physically loaded but unconfigured (tray_type="", no slot_preset_mappings
row). The #1689 cali_idx safety net was unreachable from that path —
matchingKProfiles early-returned [] on !selectedPresetInfo before the
safety net ran.
Split the early return so the cali_idx fallback survives the no-preset
case: when selectedPresetInfo is null but slotInfo.caliIdx > 0, return
the active profile as a single-item list (extruder-matched when known).
Strictly additive; caliIdx == 0/null still returns [], existing matcher
unchanged when a preset is resolvable.
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.
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
(#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.
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.
Two related bugs in K-profile matching, same root cause.
#1688 — spool form's PA-profile suggester (PAProfileSection via
isMatchingCalibration in spool-form/utils.ts) matched K-profiles by
parsing the profile NAME for material/brand/variant. Spools already
store slicer_filament (the slicer preset id) and K-profiles already
carry filament_id, but both were ignored — so a user's custom
K-profile whose name doesn't agree with the slicer preset got silently
dropped from suggestions even when the underlying filament_id was
identical.
#1689 — ConfigureAmsSlotModal's matchingKProfiles ran the same
name-only logic on the slot's selected preset. A spool assigned under
"Generic PLA" with a custom K-profile actively bound on the printer
landed in the modal as "K profile not assigned, default 0.020 will
be used", while the printer-card hover-card correctly showed the
active profile. Two paths, only one was filtering by name.
Shared root: spool preset ids and K-profile filament_ids look
different but are equivalent after normalising. Spools store
slicer_filament as the cloud setting_id form ("GFSG98_09" — _09 is
the variant suffix, the S infix marks setting_id form); K-profiles
store filament_id as the bare form ("GFG98"). Plain === doesn't
work; both need normalising. This conversion already existed in the
other direction at buildFilamentOptions (filament_id → "GFS" +
filament_id.slice(2)), so the inverse toFilamentId helper is just
the matching reverse, not new ground.
Fix — one shared helper, two surfaces:
- spool-form/utils.ts: new exports toFilamentId(id) (drops "_NN"
variant suffix and strips the "S" in "GFS", so GFSG98_09 → GFG98)
and isGenericFilamentId(id) (flags Bambu's generic GFx99 ids
which are shared across many filaments and must NOT id-match —
the name fallback handles those correctly).
- isMatchingCalibration: gains slicer_filament?: string in formData,
tries id-match (with generic exclusion) before the existing name
parse. PAProfileSection already passes the full formData so no
caller edit needed. Strictly additive precedence.
- ConfigureAmsSlotModal.selectedPresetInfo: resolves a filamentId
field (toFilamentId(cp.setting_id) for cloud presets,
toFilamentId(builtinFilamentId) for builtin; empty for local /
orca paths which fall through to name match).
- ConfigureAmsSlotModal.matchingKProfiles: id-match check at the top
of the per-profile predicate (preferred when both sides agree
after normalisation), then the existing name-parse logic, then
ALWAYS unshifts the slot's currently-active K-profile by
slot_id === slotInfo.caliIdx — gated on activeIdx > 0 (so caliIdx
0/null doesn't leak unrelated profiles in), extruder-matched when
slotInfo.extruderId is known. This is Spionkiller01's #1689 patch
verbatim with the activeIdx > 0 guard added.
SpoolBuddy: both kiosk K-profile surfaces reuse the shared
components. SpoolBuddyWriteTagPage renders PAProfileSection;
SpoolBuddyAmsPage renders ConfigureAmsSlotModal. Verified — fixes
propagate automatically, no kiosk-specific edits.
What this does NOT change: spools without slicer_filament, K-profiles
without filament_id, and generic GFx99 ids all fall through to the
existing name-based matching path. Strictly additive precedence; no
input shape that matched under the old logic fails to match under the
new. The #1053 cloud-preset PFUS* path is preserved because the
toFilamentId regex /^GFS/ doesn't match a "PFU" prefix.
When the JWT expired on an open tab, the next API request hit a 401 with
"Token has expired"; client.ts cleared the token from storage but
AuthContext.user stayed populated from the original mount. ProtectedRoute
only redirects when user === null, so the protected tree kept rendering
and every subsequent request silently failed with no Authorization
header — the UI looked like every list was empty until a manual refresh
remounted AuthProvider.
The 3 other setAuthToken(null) sites live inside AuthContext itself and
already pair with setUser(null), so only the client.ts cross-module
site needed a React-tree signal.
- client.ts: after setAuthToken(null) on a token-invalidating 401,
window.dispatchEvent(new CustomEvent('auth:expired')). Guarded on
`typeof window !== 'undefined'` for SSR / test safety. Generic
"Authentication required" 401s still don't clear the token or fire
the event — treated as transient timing issues per the pre-existing
comment at client.ts:155.
- AuthContext.tsx: mount useEffect adds a window listener that calls
setUser(null) under the mountedRef guard; cleanup removes the
listener so unmount → remount doesn't double-bind.
Mirrors the patch the reporter shipped on their fork (deec96d1).
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.
Reporter on a 3-AMS P1S saw slots labelled "Empty" even though spools
were physically loaded - OrcaSlicer's Device view showed the same
slots as loaded.
Root cause: the compact label below the AMS slot circle rendered
tray.tray_type || t('ams.slotEmpty'), falling back to "Empty" whenever
the printer firmware hadn't been told which material is in the slot.
getEmptySlotKind already distinguishes 'physical' (firmware confirmed
empty via state 9/10) from 'reset' (tray_type absent but firmware
hasn't confirmed empty - spool loaded, just unassigned). The hover
card and circle border already used that distinction; the compact
label did not.
Fix: label branches on emptyKind - 'physical' keeps "Empty", 'reset'
shows "?" matching the slicer's own convention. External / VT tray
label is unchanged (external trays have no "configured/unconfigured"
distinction - they're either loaded or not). SpoolBuddy AmsUnitCard
carried the same bug and got the same fix plus a tooltip.
Round 1 (b6636053 + 4ffefa60) shipped the keepalive parser, 1.5x idle
disconnect per MQTT spec section 4.4, and a per-minute status-push
diagnostic. Reporter's follow-up pcap showed the round-1 logic was
correct as designed, but the actual root cause sits one layer down:
the same OrcaSlicer install that stays connected to a real Bambu P1S
indefinitely sends zero MQTT packets after the initial CONNECT /
SUBSCRIBE / pushall / get_version burst - no PINGREQ at all - so any
spec-compliant server disconnects it at keep_alive x 1.5.
Real Bambu firmware does not enforce section 4.4. The reporter's
identical Orca install holds idle sessions against real hardware on
the same network. Spec compliance was itself the regression.
Fix: after CONNECT/auth, drop the application-level read timeout
entirely (read_timeout = None) and set SO_KEEPALIVE on the underlying
socket so the OS TCP stack reaps dead connections within a few
minutes. The 60s pre-CONNECT cap is preserved - a client that opens
TCP but never sends CONNECT still gets reaped. Negotiated keepalive
is still parsed and now logged at INFO ("MQTT client X authenticated
(negotiated keepalive=Ys, idle disconnect disabled)") for support-
bundle visibility.
After this ships, OrcaSlicer should stay connected to the VP
indefinitely while idle and reconnect cleanly on real network drops.
The publish_json code -4 and -6010 errors reported in the original
thread were downstream of this disconnect and should also clear.
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).
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).
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).
bambuddy.service shipped with ProtectHome=true, which makes /home/* invisible
to the service namespace. Installing into /home/bambuddy/ (instead of the
default /opt/bambuddy/) made ExecStart=/home/bambuddy/venv/bin/uvicorn fail
with status=203/EXEC because systemd couldn't resolve the binary path.
ReadWritePaths=$INSTALL_PATH does not reliably re-expose /home/* subpaths for
exec resolution.
install/install.sh now detects /home/* INSTALL_PATH and emits ProtectHome=read-only;
default /opt/bambuddy installs keep ProtectHome=true. The manual deploy template
defaults to read-only with a comment on when to tighten it.
read-only keeps /home immutable to the service - no security regression, since
ReadWritePaths still gates writes to the install/data/log dirs only.
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.