mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-04 21:21:54 +02:00
1677efb2c6cc4de7687536dcd168317ae41319fc
608
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
1677efb2c6 |
fix(labels): replace incorrect ams_30x15 preset with correct AMS holder sizes (#1426)
Reporter — the same person who originally requested the labels feature in #809 — discovered that the ams_30x15 preset's 30x15 mm dimension didn't actually fit any variant of the MakerWorld AMS Filament Label Holder (model 752566) it advertised. Two new presets replace it: - ams_holder_74x33 (74 x 33 mm) matches the printable label STL bundled in the MakerWorld project - ams_holder_75x55 (75 x 55 mm) fits the cardstock-insert variant the reporter validated on bench Both cross the 20 mm height threshold so they land in the roomy layout branch — swatch on the left, QR on the right, multi-line text (brand, material, hex code, spool ID) in the middle. The old 30x15 mm preset couldn't fit a QR code; the new ones do. No DB migration: the preset name was never persisted. Callers scripting the old ams_30x15 value get a clean 422 at the route's Literal validator with the new valid values listed. i18n: replaced inventory.labels.templates.ams.{label,hint} with amsHolderSmall and amsHolderLarge across all 8 locales with real translations; parity guard cleaned of the stale English-fallback cognate entries. Parity holds at 4856 leaves per locale. Tests: backend label renderer + integration tests cover both new presets; LabelTemplatePickerModal test updated for the 6-button grid and the new template value in the API-call assertion. |
||
|
|
9c934c905d |
fix(ftp): tolerate transient 426 when file is intact on the printer (#1417 follow-up)
Previous daily build (
|
||
|
|
e2df0fc601 |
fix(ams): physically-empty slots report state=9 and render distinctly from reset slots (#1322 follow-up)
Two-part fix for the #1322 follow-up by @RosdasHH. Data layer. The previous narrow heuristic in printer_manager.py only caught the bare {"id": N} payload firmware sends right after a printer restart. In steady-state operation — and on the more common post-Reset-Slot path on P1S and A1 Mini BMCU — firmware sends a populated payload and signals emptiness via the tray_exist_bits bitmask. We already parse that bitmask and use it to wipe stale tray_type / tray_color / tag_uid fields, but never touched the state field, so downstream readers (printers.py API serializer, inventory.py's tray_state in {9, 10} short-circuit, AMS card) saw state: null and had to guess from absent payload fields. Fix lifts tray["state"] = 9 (int — not "9"; inventory.py:1358 uses == not `in {...}` so a string would silently miss and the reporter's deadlock would come back) to the outer `if not slot_exists` branch, so the bitmask path now writes the canonical "no spool" code for every empty slot regardless of stale fields. The narrow heuristic in printer_manager.py:797 stays as belt-and-suspenders for any MQTT path that doesn't flow through _handle_ams_data. UI layer. With the data flow now consistent, the AMS slot card renders physically-empty slots distinctly from reset slots, per reporter's mockup. New helper getEmptySlotKind(tray) returns "physical" (state ∈ {9, 10}), "reset" (any other empty state), or null (loaded). The inline label below the slot circle reads "Empty" for physical and "Reset" for reset; pre-fix both showed an em-dash. FilamentSlotCircle gains an emptyKind prop that picks a quieter dashed border colour for reset slots so the visual hierarchy reads loaded > reset > physically empty. EmptySlotHoverCard gains a kind prop and switches between "Empty slot" and "Slot reset — no spool assigned". |
||
|
|
3b552094a7 |
fix(spoolman): edit-spool patches the linked filament in place when singleton (#1357 follow-up)
Editing a Spoolman spool used to mint a brand-new filament every time
a match-key field (subtype/material/brand/color_hex) changed, orphan
the previous one, and re-link the spool. The reporter ended up with
dozens of duplicate "Amazon Basics / PLA Glow" filament rows.
PATCH /spoolman/inventory/spools/{id} now:
- Reuses the current filament_id when no filament-shaping field
changed (a note/weight_used edit never touches the catalogue).
- PATCHes the existing filament in place when it's a singleton
(only this spool points at it, archived spools included).
- Falls back to find_or_create_filament only when the filament is
genuinely shared with another spool.
Mirrors internal-inventory behaviour where editing a spool updates
the thing the spool points at instead of proliferating new entities.
|
||
|
|
134847a3bd |
feat(camera): in-app diagnostic for "Connection lost" (#1395 follow-up)
Step 2 of the camera architecture overhaul agreed after #1395. When the camera viewer hits its error state OR before a print at any time, a Diagnose button runs a staged check against the printer and renders the result inline: which stage failed, how long it took, and a translated remediation hint. Cuts off the "user opens a 'camera broken' ticket → ask for support bundle → triage" loop at the user's screen. Backend - New `backend/app/services/camera_diagnose.py` orchestrator with CameraDiagnoseResult / CameraDiagnoseStage dataclasses. - New POST /printers/{id}/camera/diagnose route in camera.py. - Stages: tcp_reachable — TCP socket open to 322 (RTSP) / 6000 (chamber) with 3 s timeout. Distinguishes timeout, refused, and host- unreachable into distinct summary codes so the frontend can show a precise remediation (firewall vs LAN-only off vs wrong IP). first_frame — captures one JPEG end-to-end via the existing capture_camera_frame_bytes pipeline. Auth + RTSP handshake + first keyframe collapse into one stage; the user-facing answer is the same regardless of which sub-layer failed. - Live-stream shortcut: when a viewer is currently watching the camera with a buffered frame < 10 s old, the diagnostic skips the real test and returns live_stream_active_healthy. Opening a fresh socket would kick the live viewer off on single-camera- connection firmwares (the #1348 reconnect-storm trigger), so we trust the real-world evidence instead. - Response surfaces protocol, port, and profile name for support triage — lets us ask "what does your modal say?" instead of "send the support bundle". Frontend - New CameraDiagnoseModal renders one row per stage with green- check / red-X / grey-skipped icons, the per-stage duration in ms, a remediation banner styled by overall status, and a Run again button. - Two entry points: 1. The viewer's error overlay grows a Diagnose button next to Retry. Retry stays the primary action; Diagnose is the escape hatch for users who can't see what's wrong. 2. A stethoscope icon in the viewer's always-visible control bar, between Refresh and Fullscreen. Pre-flight testing ("did my firmware update break the camera?", "is the camera up before I send a print?") doesn't require waiting for the stream to fail first. - Also lifted the previously-hard-coded "Camera unavailable" / "Retry" strings into camera.unavailable / camera.retry so the error UI is fully translated alongside the new keys. |
||
|
|
67cb5275d0 |
fix(camera): per-model profile registry; P2S gets relaxed RTSP probe (#1395)
Reporter on a P2S running firmware 01.02.00.00 saw the camera connect
for a few seconds then time out, repeating. P1S on the same install
worked fine — different protocol (chamber-image port 6000 vs RTSP via
ffmpeg).
The P2S RTSP path was running ffmpeg with `-probesize 32
-analyzeduration 0`, tuned for X1/H2 fast startup. The P2S's slower
keyframe pacing means ffmpeg can't lock onto the stream within 32
bytes — its own stderr says "consider increasing probesize" before
giving up after ~2s. Bambuddy reconnects, cycle repeats.
Instead of bumping the globals (which would regress every other RTSP
model's startup latency), this lifts the per-model tuning into a new
`camera_profiles` registry. CameraProfile dataclass holds the
previously-global knobs (probesize, analyzeduration, rtsp_reconnect_max,
rtsp_reconnect_delay, plus an extra_ffmpeg_input_args hook for future
per-model flags). get_camera_profile(model) returns the model's profile
or DEFAULT_PROFILE.
Default profile preserves the historical X1/H2 fast-startup values
verbatim — X1, X1C, X1E, X2D, H2C, H2D, H2D Pro, H2S all see no
behaviour change. P2S is the only override:
P2S: probesize=1_000_000, analyzeduration=500_000
SSDP internal codes (N7→P2S) resolve via an alias map so the camera
path works during the early-connect window before the display name
is settled.
This is the first step of the camera-architecture overhaul agreed
after #1395. Adding the next quirky model is a config entry, not
another module-level constant.
|
||
|
|
173edd9b7c |
● fix(vp-queue): inherit slicer print options instead of always using defaults (#1403)
Reporter sliced in OrcaSlicer with timelapse on, sent the job to a VP queue, started from the queue, and got no timelapse video. Their dispatch chain itself was correct (queue item -> scheduler -> MQTT command honors `timelapse`); the gap was at queue-add time. The VP's `_add_to_print_queue` reads `default_timelapse` (and the four other print-option settings) from the workflow settings card. That was introduced in #1235 to stop column-level defaults from winning. But it also discarded the slicer's actual choice carried on the MQTT `project_file` command, which all the slicers (Studio / Handy / Orca) ship as `timelapse: true|1`. Result: a user with the new-install value `default_timelapse=false` had to either flip the global setting or edit every queue item by hand, even though their slicer's "Print options" UI clearly said "record timelapse". Investigation went wider than #1403 because Martin's hypothesis was "the print options modal isn't respected either." Cross-checking 86 captured P1S `project_file` commands across the support packages shows 46 from the queue scheduler and 33 from background_dispatch emitting `"timelapse": true` correctly to real printers - the modal + re-print path is intact end-to-end. The slicer-side gap was the only real bug. Two unrelated dead-code issues turned up in the same dig and are folded in below. Fix (VP queue inheritance) - `on_print_command` in the VP manager now stashes the slicer's project_file dict keyed by filename, then signals an asyncio.Event. - `_add_to_print_queue` checks the dict first; if empty, creates the event and waits up to 2 s for it before reading the settings fallback. Each option flows through per-field - slicer value wins if present, else the existing settings default (so users who explicitly set `default_timelapse=true` in their VP workflow card still get that on slicers that don't send a print command). - MQTT field naming preserved exactly: `bed_leveling` (single L) on the wire stays mapped to `bed_levelling` (double L) on the Bambuddy column. Integer 0/1 from H-family slicers and bool true/false from P1/X1 slicers both coerce via `bool()`. - Capture is gated on `mode == "print_queue"` so immediate / review / proxy modes keep their pre-fix no-op `on_print_command` and don't accumulate stashed entries over the VP's uptime. - Wait is also skipped when there's no MQTT server attached (`self._mqtt is None`), so unit tests that invoke `_add_to_print_queue` directly don't pay the 2 s tax. - Capture is consumed on use so the dict stays bounded. - `printer_manager.get_status(...).get(...)` against a `PrinterState` dataclass that has no `.get()` method. - Every print option discarded (timelapse, bed_levelling, AMS mapping). The route 500'd before ever reaching the printer. Rewritten to mirror `POST /print-queue/{item_id}/start`: clear `manual_start=False` on the next pending queue item and let the scheduler dispatch with the queue's stored options intact. Response shape preserved. Side-bug b: vibration_cali default drift in background_dispatch - `ReprintRequest.vibration_cali` and `FilePrintRequest.vibration_cali` both default to `True` (matches Bambu Studio behavior for X1/P1). - Both `_process_job` call sites read `job.options.get("vibration_cali", False)`. Cosmetic today because the frontend always sends the field, but a latent landmine for any future caller that bypasses the schema. Both sites flipped to `True`. |
||
|
|
48a7024b96 |
security(github-backup): refuse to save against a non-private repository
While auditing real-world Bambuddy backup repos on GitHub I found
several left public. That's a serious leak: the settings backup only
filters bambu_cloud_token and auth_secret_key, so mqtt_username,
mqtt_password, ha_token, prometheus_token, bambu_cloud_email,
external_url, and the printer access codes (via K-profiles) were going
to whatever visibility the user picked.
Hard guard at every save and re-checked on every push:
- POST /github-backup/config and PATCH /github-backup/config (when URL,
token, or provider changes) run a connection test internally and
return 400 unless is_private comes back True.
- run_backup() re-checks before each scheduled or manual push, so a
repository that flipped from private to public gets a clear
"Backup aborted: the target repository is no longer private" failure.
Each provider's test_connection now returns is_private (GitHub /
Gitea / Forgejo read data.private, GitLab reads visibility=="private";
"internal" is treated as non-private). None means "couldn't determine"
and is also rejected -- safer to fail closed.
Frontend renders visibility inline on Test Connection: green check when
private, red warning panel listing every credential at risk when public,
yellow when unknown.
---
ui(github-backup): show save-failure messages inline on the card
The new "repository is not private" rejection message is ~250 characters
listing every credential the backup carries (MQTT password, HA token,
Prometheus token, Bambu Cloud email, printer access codes), which clips
badly in a toast.
Both the initial-setup save and the debounced autosave now stash the
backend's error message into a saveError state and render it as a red
inline banner above the test-result block, with whitespace-pre-wrap so
the full message stays readable. The banner clears on success, on the
next save attempt, and when the user starts editing URL / token / provider
-- the three fields whose changes invalidate the privacy check -- so it
doesn't linger after the user has already addressed the cause.
Short success toasts (Settings saved, Token updated, Backup enabled) are
unchanged.
|
||
|
|
8b9efd0160 |
fix(inventory): "Reset usage to 0" works in Spoolman mode too (#1390)
First cut of this action only wired the built-in inventory path, so the
eraser buttons vanished when the user switched to Spoolman mode. Mirror
the endpoints on the Spoolman router:
- POST /spoolman/inventory/spools/{id}/reset-usage
- POST /spoolman/inventory/spools/reset-usage-bulk
Both route to a new SpoolmanClient.reset_spool_usage() helper that PATCHes
/spool/{id} with used_weight=0. The bulk variant keeps the same typo-wipe
guard (rejects empty/missing spool_ids), and individual Spoolman failures
are logged + counted out without aborting the batch.
InventoryPage mutations now switch on spoolmanMode to pick the right
client method, and the three "spoolmanMode ? undefined : ..." gates on
the eraser buttons are gone.
|
||
|
|
f645bd2bbe |
fix(stats): per-event data for all widgets, not just Quick Stats (#1390)
After #1378 moved Quick Stats to print_log_entries, six widgets and Failure Analysis still iterated the archive list. That made reprints multiply event-based widgets while leaving archive-based ones unchanged, and made hard-deleted archives drop from archive-based widgets while their orphan events kept feeding Quick Stats. Swap the data source in two places: - GET /archives/slim now reads PrintLogEntry, LEFT JOINs the archive for the sliced print_time_seconds estimate, prefers PrintLogEntry's own duration_seconds as the measured-time field. StatsPage is the only caller -- every widget realigns in one step. - FailureAnalysisService swapped from PrintArchive to PrintLogEntry for every aggregation. project_id filter still resolves through archives but counts matching events. Conftest archive_factory now syncs the synthesized event's created_at with the archive's so backdated test data survives the change. |
||
|
|
1fac027654 |
fix(ftp): raise on ftplib.Error from voidresp instead of proceeding
bambu_ftp.upload_file (and upload_bytes) wrapped the voidresp() call in a
broad "except Exception: log warning and proceed" because H2D printers
can take 30+ seconds to send the 226 and we don't want to fail on that.
But the same handler was swallowing ftplib.error_temp (e.g. 426 "Failure
reading network stream") from buggy printer firmware, which explicitly
means the data stream was cut mid-transfer and the file on the SD card
is partial.
Bambuddy then sent the print command anyway, and the printer surfaced a
generic "unable to parse 3mf file" error 30 seconds into the print
attempt -- with nothing in the log on the user side to suggest the
upload had actually failed.
Split the catch: ftplib.Error subclasses (server-reported failure)
re-raise so the outer handler returns False; everything else (socket
timeout etc.) keeps the existing proceed-with-warning behaviour so the
H2D 226 tolerance survives.
Two regression tests patch _ftp.voidresp to raise error_temp("426 ...")
and assert both upload_file() and upload_bytes() return False.
The underlying P2S firmware / TLS-data-channel issue that triggers the
426 for the reporter is separate -- this change just stops Bambuddy from
hiding it.
|
||
|
|
6f2cec5eb3 |
feat(smart-plugs): auto-off after AMS drying completes (#1349)
Reporter Kyobinoyo asked for the equivalent of the existing print-finish auto-off but triggered when AMS drying ends. Two new SmartPlug columns: auto_off_after_drying (default false), off_delay_after_drying_minutes (default 10 — AMS chamber is hot post-cycle so longer cooldown than the print-finish default of 5). SQLite + Postgres migrations both idempotent. Trigger lives in BambuMQTTClient — per-AMS _previous_dry_times tracks the dry_time > 0 → 0 falling edge and fires a new on_drying_complete(ams_id) callback. Plumbed through PrinterManager.set_drying_complete_callback to SmartPlugManager.on_drying_complete(printer_id, db), which walks linked plugs and respects the per-plug toggle. Catches queue, ambient and manual drying identically because it observes firmware state, not scheduler intent. Frontend: single "Auto Off After Drying" toggle + delay input on the smart plug card, next to the existing print-finish auto-off section. Per-AMS plug routing (separate plug for AMS only, per-AMS targeting on dual-AMS printers) deferred — Bambuddy's plug model is plug→printer, so the trigger fires whenever any AMS on the linked printer finishes a cycle. |
||
|
|
e7045597bc |
fix(archives): bulk and auto purge now honour the soft / hard delete choice from #1343 (#1390 follow-up)
Reporter IndividualGhost1905 followed up after the #1378 / #1343 backfill landed and pointed at the next inconsistency: the per- archive delete dialog has had a "Also remove this print from Quick Stats" checkbox since #1343, but the "Purge Old" button and the scheduled daily auto-purge sweeper both ignored that choice and hard-deleted unconditionally. From the user side this looked like "automatically deleted from statistics without any warning" — half- true, and the inconsistency was real either way. The actual current shape (before this fix): - POST /archives/purge -> archive_purge_service.purge_older_than -> ArchiveService.delete_archive (hard). Archive row dropped. Linked PrintLogEntry rows have ON DELETE SET NULL so they survive as orphans with archive_id=NULL. Quick Stats keeps the filament / cost / energy contribution because the log rows are still there, but the archive-list-iterating widgets (Filament Trends, By Material, Color Distribution, Printer Stats) lose the row, and Time Accuracy loses its join target. Visibly inconsistent. - Scheduled _maybe_run_auto_purge -> same code path, same effect. - Single-archive DELETE /archives/{id} -> already takes purge_stats=true|false (default false=soft) and routes either soft_delete_archive (keeps everything, flips deleted_at) or deletes PrintLogEntry rows first + hard-deletes archive. The fix threads the same purge_stats flag through every bulk surface with soft as the default, matching the single-archive default: Backend: - archive_purge_service.purge_older_than(..., purge_stats=False) -> per-row soft_delete_archive when False, per-row PrintLogEntry deletion + delete_archive when True. Each runs in its own session (same pattern the sweeper already used). - preview_purge gains the same kwarg so the eligible-count matches what an actual run would touch: soft mode excludes already-soft-deleted rows, hard mode counts them as eligible for promotion. - get_settings / set_settings now persist archive_auto_purge_stats (default False). _maybe_run_auto_purge reads it on every tick. - ArchivePurgeRequest / ArchivePurgeResponse / ArchivePurgeSettings schemas extended. - Route /archives/purge accepts the body flag, /purge/preview accepts the query param, /purge/settings GET + PUT echo the setting. Frontend: - "Purge old archives" modal: new "Also remove from statistics" checkbox under the preview, unchecked by default. Plumbed into the preview query key + the execute mutation. - Settings -> Archives auto-purge card: matching toggle next to the days slider, disabled when auto-purge itself is off. - api.previewArchivePurge / api.executeArchivePurge accept the flag; ArchivePurgeSettings type gains purge_stats. - All 8 locales (en, de, fr, it, ja, pt-BR, zh-CN, zh-TW) get new purgeStatsLabel / purgeStatsHint / purgeStatsDescription keys, plus rewritten effect / warning copy in archivePurge and archiveAutoPurge to reflect that the default no longer "permanently removes from the database" but instead hides the row + removes files while keeping Quick Stats intact. i18n parity check clean: 4814 keys across all 8 locales, no fallback. Behaviour change for existing users on auto-purge: the sweeper used to hard-delete by default and now soft-deletes by default. After the upgrade those installs start *preserving* more data in Quick Stats rather than losing it — safer direction of the two, but worth the explicit call-out. Anyone who wants the old behaviour ticks the new toggle once and it persists. |
||
|
|
1bb0d4856d |
fix(vp): deep-merge ams on bridge cache so P1S/A1 partial pushes don't nuke AMS (#1387)
Reporter vmhomelab ran a Print Queue VP against a P1S, opened BambuStudio, and saw only the External Spool. Toggling Auto-Dispatch (which restarts the VP) made AMS briefly appear, then it reverted to defaults. Proxy Mode worked fine. The earlier #1371 sticky-keys fix only handled one of two firmware incremental-push shapes: it preserved cached `ams` when the incoming push OMITTED the key entirely. P1S firmware (01.09.01.00) instead sends incrementals with the `ams` key present but the inner `ams.ams` array stripped — `{ams_status: 1, humidity: 2}` rather than `{ams: [...], ams_status: 1}`. To the existing "key present? leave it" check that read as "no need to preserve," so the bridge cache got overwritten with the stripped blob, the slicer's next 1 Hz read saw `ams` with no unit list, and BambuStudio fell back to its "no AMS" default render. Toggling Auto-Dispatch restarted the VP and got a fresh pushall through; the next P1S incremental stripped it again. H2D rarely trips this because its incrementals typically don't carry `ams` at all, so #1371 alone was enough — which is why H2D users (including the project owner) didn't see the bug while P1S/A1 users do. Fix: deep-merge the `ams` key inside the bridge cache. Mirrors the structure Bambuddy itself already does in `bambu_mqtt.py::_handle_ams_data` — scalar fields take the new value, but the `ams.ams` array is merged unit-by-unit by `id`, each unit's `tray` array is merged tray-by-tray by `id`, and units / trays the incremental doesn't mention survive intact from the cached full state. A tray-targeted incremental during a print (`{ams: [{id: 0, tray: [{id: 0, state: 11}]}]}`) now updates that one tray's state without dropping the other trays' tray_type / tray_color. Helper added as `_merge_ams_dict` next to `_ip_to_uint32_le`, called from the existing sticky-keys block when both prev and new carry the `ams` key as dicts. Other sticky keys (vt_tray, net, ipcam, lights_report, ams_extruder_map, mapping) keep the prior absent-only preservation; only `ams` has the multi-shape partial problem worth the merge complexity. |
||
|
|
4a98914d4a |
fix(spoolman): persist Color Name via spool.extra — Spoolman has no filament.color_name field (#1357)
Reporter pgladel edited a spool's Color Name in Spoolman mode, hit Save, and saw the value snap back to the subtype on the next read. The earlier #1319 fix correctly handled the read/form-prefill half (the color_name_is_synthesized flag, blank-on-synth form init), but the write half assumed Spoolman has a `color_name` field on Filament. It doesn't. Verified against the live FilamentUpdateParameters schema on Spoolman 0.23.1 — the accepted fields are name, vendor_id, material, price, density, diameter, weight, spool_weight, article_number, comment, settings_extruder_temp, settings_bed_temp, color_hex, multi_color_hexes, multi_color_direction, external_id, extra. No color_name. Spoolman's PATCH happily returns 200 for {"color_name": "Red"} and silently discards the unknown key, so find_or_create_filament was either patching a void or creating filament after filament with the same field-that-doesn't-stick (which is what produced the "BB also created a bunch of new filaments" duplicate trail on each save attempt). The fix follows the same pattern as the existing BambuStudio slicer- preset storage: persist color_name on spool.extra.bambu_color_name as a JSON-encoded string, register the extra field via ensure_extra_field before write (Spoolman 400s on unknown extra keys), and read it back in _map_spoolman_spool with priority extra > filament.color_name (forward-compat for any future Spoolman release that adds the field) > subtype synth. Dropped the now-dead color_name passing through find_or_create_filament and create_filament — Spoolman would discard it anyway and keeping the dead pipe risked the same confusion the next time someone reads this code. The previous "match by name then patch color_name" loop is gone; what survives is the name-match resilience that lets an AMS-sync-created filament named "Glow" still match the user-driven edit's composed "PLA Glow", which prevents re-introducing the duplicate-filament trail. The frontend form's color_name_is_synthesized handling is unchanged — that part already worked. |
||
|
|
135b8fd93b |
fix(smart-plugs): HA entity search bypassed the schema's domain whitelist (#1388)
Reporter MartinNYHC opened the Add Smart Plug dialog in HA mode, typed
a search prefix matching a multi-entity device (one switch.* plus
several sensor.*/binary_sensor.* siblings under the same friendly
name), clicked one of the non-switch siblings, and got a 422 on Save:
String should match pattern
'^(switch|light|input_boolean|script)\.[a-z0-9_]+$'
The screenshot confirms the bug shape — the X button next to the
"empty-looking" Select Entity field only renders when haEntityId is
truthy. So haEntityId was set, but selectedEntity (haEntities.find by
that id) returned undefined, so the input rendered the placeholder
text instead of the friendly-name display. That can only happen when
the user had earlier picked an entity whose domain is NOT in the
schema's allowed list, then the search cleared, the entity-list
refetched without a search param, and the refreshed list (filtered to
the default domains) no longer contained the user's pick.
Root cause was in HomeAssistantService.list_entities: when a search
query was present, the function bypassed the domain filter entirely
and returned matches across every HA domain. Offering a clickable
choice the schema can't accept is broken UX, and the cryptic Pydantic
pattern echo on save made it look like a backend/schema problem
rather than a search-permissiveness problem. Confirmed via git diff
that the smart-plug code path is unchanged between v0.2.4 and
0.2.4.1 — this has been latent since the script-domain commit in
February 2026, only noticed now because the reporter hadn't reopened
the modal in months.
Fix: always apply the allowed-domains filter ({switch, light,
input_boolean, script} — kept in sync with the regex in
backend/app/schemas/smart_plug.py:17). Search composes on top as a
substring match against entity_id or friendly_name, instead of
replacing the domain filter. Whitespace-only search strings now
fall back to the no-search behavior.
|
||
|
|
96fd4bb7e3 |
fix(printer): H2S could not start prints without AMS — was misclassified as dual-nozzle (#1386)
H2S is single-nozzle (nozzle_count=1 across 9+ stored support bundles
and the reporter's diagnostic) but had been added to the H-family model
gate in start_print_job. That single flag controlled both the firmware
bool->int format (legitimately needed for the whole H-family, including
H2S) and the dual-nozzle external-spool routing (correct only for actual
dual-extruder printers).
With no AMS attached and an external-spool slot (tray_id=254), the
dual-nozzle branch wrote ams_id=254 into ams_mapping2 instead of the
canonical 255 — exactly the failure the comment six lines above warns
against. Firmware rejected the dispatch with 07FF_8012 "Failed to get
AMS mapping table". The use_ams=False fallback was also being skipped
because the H-family bypass was meant for dual-nozzle routing.
A second site at bambu_mqtt.py:3987 and its sibling at kprofiles.py:119
detected dual-nozzle by serial prefix ("094", "20P9", "31B8B"). H2S
shares prefix "094" with H2D, so prefix detection misclassified it too.
Split the conflated flag into two:
- is_h_family — firmware format (int 0/1 for calibration fields).
Includes H2S. H2S firmware structurally accepted the current command
shape (failure was at AMS routing, not parsing), so the int format
stays for H2S.
- is_dual_nozzle — external-spool routing and use_ams gating. Excludes
H2S. Source-of-truth is the runtime _is_dual_nozzle flag set from
device.extruder.info, with a model-name fallback for the brief window
after connect before push data arrives.
The K-profile delete site and the kprofiles route now use the same
runtime+model check instead of serial prefix.
|
||
|
|
84ed28d5fa |
fix(queue): cancel pending items and hide stale archive surface when archive soft-deleted (#1348)
Opening the Print Queue page fired 404s on /archives/{id}/thumbnail,
/archives/{id}/plates, and /archives/{id}/plate-thumbnail/{n} for any
row pointing at a soft-deleted archive. Two underlying problems wearing
one mask:
1. Cosmetic: the queue API was copying item.archive.thumbnail_path into
archive_thumbnail without checking deleted_at. Soft-delete leaves the
row (so the relationship resolves) but removes the file from disk, so
the cached path was always stale.
2. Functional: a queue item whose 3MF was removed can never dispatch.
Without an explicit cancel, the item sits in 'pending' forever with
no indication to the user about why nothing is printing.
Fix in three parts:
- New _cancel_pending_queue_items() helper, called from soft_delete_archive
alongside the existing print-log thumbnail cleanup. Sets status='cancelled'
+ waiting_reason='Source archive deleted' on every pending queue item
linked to the archive. Only 'pending' is touched - completed/failed/
cancelled rows are historical and untouched. Hard-delete is already
covered by ON DELETE CASCADE on print_queue.archive_id.
- Queue API serializer now checks item.archive.deleted_at before
populating any archive-derived field. New archive_deleted: bool field
on PrintQueueItemResponse signals the soft-deleted state.
- Frontend's getArchivePlates query in QueuePage was gated on archive_id
only - archive_id is the real FK and stays exposed for dispatch/audit,
so added an explicit && !item.archive_deleted clause to respect the
new flag. Thumbnail render and CompactHistoryRow/QueueTimelineView
already gate on archive_thumbnail so the backend suppression alone
covers them.
Regression tests pin cancel-only-pending behavior, soft-deleted
suppression + archive_deleted=True flag, and the sanity guard that
live-archive fields keep flowing through unchanged.
|
||
|
|
cad63500a8 |
fix(archives): clear stale thumbnail paths on log entries when archive deleted
Archives → Print Log was 404-storming the thumbnail endpoint on every render: PrintLogEntry.thumbnail_path is copied by value from the archive at write-time, but the FK on archive_id is ON DELETE SET NULL (#1378) so log entries survive archive deletion to preserve stats history - and the cached path keeps pointing at a file that was removed when the archive's directory was deleted. Same shape for failed prints whose extractor never wrote the thumbnail. Two-part fix: 1. Route self-heals: get_print_log_thumbnail NULLs thumbnail_path on the entry and commits before returning 404 when the file is missing on disk. The frontend's <img> tag is gated on entry.thumbnail_path being truthy, so the next fetch of the log list skips the request entirely. 2. Eager clear on archive delete: new _null_print_log_thumbnail_paths() helper called from both soft_delete_archive and delete_archive before the on-disk files are removed. Avoids the one-time storm for future deletes; covers both the manual delete route and the auto-purge sweeper at archive_purge.py. Regression tests cover soft delete, hard delete via ArchiveService, and the route's lazy-NULL for failed-print orphans where the file was never written. |
||
|
|
ce5f4e5f1a |
fix(camera): don't open competing socket while a viewer is attached (#1348)
Obico polling could freeze the live camera stream within seconds of opening the viewer. Cause: when the buffer-reuse path in obico_detection._capture_frame saw an empty _last_frames[printer_id] entry (stream startup before the first JPEG lands, or upstream mid-reconnect after a 30s read timeout), it fell through to capture_camera_frame_bytes() and opened a second RTSP socket. On firmwares that allow only one camera connection, that second socket forced the printer to drop the live fan-out connection - the viewer's ffmpeg then hit its own 30s timeout, looped through 30 reconnects at 0.2s, all racing the next Obico poll, and the broadcaster pump exited. Widen the gate from "do we have a buffered frame?" to "is any fan-out stream registered for this printer?". New is_stream_active() helper checks _active_streams / _active_chamber_streams independently of buffer state. _capture_frame consults it first: if a viewer is attached, it returns the buffered frame when available or None (skip this poll cycle) when not. Never opens a competing socket while a viewer is connected. Cost: at most one missed Obico detection cycle per viewer-attach (~10s lag). Benefit: zero competing-socket events while any viewer is connected. try_get_active_buffered_frame() refactored to delegate to is_stream_active() so the two helpers stay in lockstep. The /camera/snapshot caller is unchanged behaviorally (snapshot is a user-initiated single-shot; falling through to fresh capture on an empty buffer is the desired behavior there). |
||
|
|
856b849ffa |
fix(stats): per-event aggregation so reprints add to Quick Stats instead of overwriting (#1378)
Statistics now aggregate over PrintLogEntry (one row per print event,
the same table backing the global Print Log) rather than PrintArchive
(one row per file). A reprint creates a new PrintLogEntry instead of
overwriting the source archive's runtime fields, so:
- a 100 g successful print + a 10 g failed reprint correctly sums to
110 g / 2 prints / 1 successful / 1 failed in Quick Stats and the
Prometheus /metrics endpoint (previously the failed reprint silently
replaced the source archive's data; totals dropped from 100 g to 10 g)
- the archive's card cost/energy_kwh are preserved on reprints (only
the first run writes them); per-run actuals live on PrintLogEntry
- failed/cancelled/stopped reprints record partial-aware filament: sum
of tracked spool deltas when inventory is set up, else estimate
scaled to progress%, else None — prevents the full slicer estimate
from inflating totals on a print that stopped at 10 % progress
PrintLogEntry gains six columns: archive_id (nullable FK, ON DELETE
SET NULL so log entries survive archive deletion preserving #1343
soft-delete-vs-stats decoupling), cost, energy_kwh, energy_cost,
failure_reason, created_by_id. Idempotent SQLite + Postgres migrations.
New per-archive surface:
- archive list response carries run_count / last_run_at /
total_filament_actual_grams / successful_run_count / failed_run_count
via a single batch JOIN, no N+1
- new GET /archives/{id}/runs endpoint returns every PrintLogEntry for
the archive (ARCHIVES_READ permission, newest-first ordering)
- archive cards render an orange "N prints" badge for archives with
more than one run; clicking the badge opens a dedicated PrintLogModal
with date/status/duration/filament/cost columns plus failure_reason
under failed runs. Also reachable via the context menu's new "Print
Log" entry (works for single-run archives too), and embedded at the
top of the Edit Archive modal for context.
The purge_stats=true delete path now hard-deletes linked PrintLogEntry
rows up front so the archive's contribution truly leaves the totals;
without it, ON DELETE SET NULL would orphan the runs and leave them
counting toward stats.
|
||
|
|
072b8c8ce1 |
fix(vp): preserve AMS/vt_tray/net across incremental push_status updates (#1371)
The bridge cache replaced _latest_print_state wholesale on every push_status arrival. Bambu firmware sends full pushall responses (with AMS/vt_tray/net.info/lights_report) on reconnect / pushall requests, but ~1 Hz incremental updates with only the fields that changed. The first incremental push after a pushall therefore wiped AMS info from the bridge cache, and slicers reading the cache (via the VP's 1 Hz status push) saw a stripped-down state with no AMS visible until the next pushall — typically only on a manual printer power-cycle. Preserve a small set of slicer-visible sticky keys from the previous cache when the incoming push doesn't carry them: ams, vt_tray, ams_extruder_map, mapping, net, ipcam, lights_report. Mirrors the same pattern Bambuddy uses for its own internal state.raw_data. |
||
|
|
5680f5d34b |
fix(scheduler): watchdogs no longer falsely treat FINISH->IDLE as "print landed" (#1370)
Both the queue-side _watchdog_print_start and the direct-dispatch _verify_print_response used `status.state != pre_state` to decide whether a project_file command had been accepted. When a printer was in FINISH at dispatch time (un-dismissed post-print prompt from a prior job), the firmware silently rejected the new command; if the user then dismissed the screen prompt, the printer moved FINISH -> IDLE and the watchdog returned early as "command landed" — leaving the queue row stuck at status='printing' indefinitely and the scheduler permanently marking the printer as busy. Narrow the "command landed" check in both verifiers to an allow-list of active-print states (PREPARE / SLICING / RUNNING / PAUSE). Inactive transitions (FINISH -> IDLE, etc.) no longer short-circuit the revert. The subtask_id-advance signal stays in place for H2D's slow FINISH -> PREPARE transition (#1078). Also wrap _watchdog_print_start's revert commit and printer_manager._persist_awaiting_plate_clear in run_with_retry so SQLite single-writer contention can't silently drop these writes. The revert path returns a tristate sentinel so the post-revert MQTT session-recovery logic only runs when we actually reverted (or the commit failed) — not when on_print_complete had already cleared the row, where a forced reconnect could break a healthy concurrent print. |
||
|
|
7aa5ff0156 |
fix(ams): detect spool removal on X1C firmware that reports power_on_flag=False (#1365)
The #765 guard against shutdown-time data wipes skipped any AMS update with power_on_flag=False, but some X1C firmware emits power_on_flag=False while idle with tray_exist_bits still reflecting the real slot inventory. Older firmware (01.08.02.00) doesn't emit per-tray state=9/10 events, so the bitfield path is the only signal — muting it left spool removals undetected until a manual reconnect. Narrow the skip to the exact shutdown pattern: zero bits AND power_on_flag=False. Non-zero bits with power_on_flag=False are now applied. The #765 shutdown protection is preserved (its regression test uses tray_exist_bits='0' and still passes); newer firmwares are unaffected because their per-tray state path catches the removal first. |
||
|
|
5e88ce13f0 |
fix(notifications): accept discordapp.com webhook URLs (#1363)
Discord's "Copy Webhook URL" button emits discordapp.com URLs; both hostnames serve the same webhooks. The validation now accepts either prefix while keeping the check itself in place to catch the paste-the-wrong-thing error. |
||
|
|
b5a83924eb |
fix(cost): top-up untracked filament at default rate so multi-color
archives stop reporting near-zero cost (#1344)
Reporter @nicktags hit $0.01 on a 110.3g multi-color print with the
global default filament cost set to $10/kg. archive.py initial cost
calc was correct (~$1.10), then usage_tracker.on_print_complete
overwrote archive.cost with sum(r.cost for r in results) -- where
results only includes AMS trays mapped to a spool in Bambuddy's
inventory. On a multi-color print where 3 of 4 used trays had no
inventory spool, only the one tracked slot's tiny share (~1g) survived
and the archive recorded $0.01.
The overwrite logic dates to #505 (Feb 2026) and is correct for
fully-tracked single-color prints, but the multi-color slicer feature
in 0.2.4 (
|
||
|
|
7d3af9834c |
fix(inventory): emulate state=9 for bare-tray empty-slot signal on P1S/A1 (#1322)
Follow-up to the #1322 root fix. Reporter @RosdasHH traced the raw MQTT payload and found that P1S and A1 Mini send only {"id": N} for a physically empty slot — no state, no tray_type, no other fields. Without that signal, the assign-spool path was firing one wasted MQTT publish per click on a truly-empty slot (firmware dropped it silently, but still). The AMS parser in printer_manager.py now detects the bare-tray shape and promotes it to state=9 — the firmware's explicit "no spool" code — which lets the existing state in {9, 10} short-circuit in the inventory route apply automatically. The detection is intentionally narrow: len(tray) == 1 and "id" in tray and state is None so the post-Reset-Slot A1 Mini BMCU case (populated payload with state=3 and tray_type="") has more than one key and stays unaffected — the #1322 root fix is preserved. |
||
|
|
d6364646f8 |
feat(auth): manual LDAP user provisioning from the UI (#1298)
Reporter @Fuechslein flagged that disabling LDAP auto-provision left admins
with no UI path to onboard new users — the create-user form had zero LDAP
awareness and the only workaround was hand-editing the database.
Add a Local / LDAP tab toggle to the create-user modal (hidden when LDAP is
disabled). The LDAP tab is a debounced directory search (≥2 chars, 300ms)
that returns up to 25 matches via the service-account bind, annotated with
already_provisioned so existing usernames render disabled. Clicking
"Provision user" re-resolves via the service bind and creates the user
through the same _provision_ldap_user helper the auto-provision login path
uses, so group mapping, default-group fallback, and email sync are identical
regardless of which path created the user.
The picker component is shared across all four create-user modal paths
(UsersPage basic + advanced, SettingsPage basic + advanced).
Two ldap3 schema-check workarounds were needed for OpenLDAP installs:
- Open the search connection with check_names=False so ldap3 doesn't reject
the cross-schema OR filter (sAMAccountName/displayName are AD-only)
- Request attributes=["*"] because ldap3's build_attribute_selection
validates each named attribute against the server schema regardless of
check_names, and only the * wildcard is in its hard-coded exclusion list
Login/lookup paths keep check_names=True so typos in user_filter still fail
loudly.
Backend
- New routes: GET /auth/ldap/search, POST /auth/ldap/provision (both gated
by USERS_CREATE; 503 details include ldap3 exception class + message)
- Extract _open_service_connection + _extract_user_info helpers so
authenticate_ldap_user, lookup_ldap_user, and search_ldap_users share the
bind and attribute-extraction logic
Frontend
- New LdapUserPicker component (debounced search, result list, provision
mutation, already-provisioned guard, error surface)
- Tab toggle wired into UsersPage and SettingsPage modals, plus
CreateUserAdvancedAuthModal props
- 14 i18n keys added to en.ts (other locales fall back to English)
|
||
|
|
405dd1525b |
fix(firmware): keep download-URL resolution working when bambulab.com 403s (#1350)
The firmware update dialog showed "01.11.02.00 newer · Unavailable" with the
misleading error "Firmware file is not available from Bambu Lab" while the
logs spammed "Failed to get Bambu Lab page: 403". The wiki scrape was fine —
only the Next.js buildId fetch on bambulab.com was being blocked by Cloudflare
on the reporter's network, and the buildId was cached in memory only, so a
single 403 broke download-URL resolution for the rest of the session.
- Send Accept + Accept-Language headers alongside the honest Bambuddy/1.0 UA
so the request stops tripping Cloudflare's "bare scraper" signal.
- Persist the buildId to <data_dir>/firmware/build_id.json so a transient
403 or a backend restart can't wipe a previously-valid buildId.
- Add a download_page_unreachable flag and use it in the prepare-update flow
to render an honest error ("page unreachable from this network — try later
or download manually from bambulab.com") instead of implying Bambu doesn't
have the file.
- Retry the per-model JSON once when a cached buildId returns 404 (page
rebuild), give up gracefully on 403 without churning.
|
||
|
|
9ba1e34729 |
fix(archives): keep Quick Stats contribution when deleting a print (#1343)
Reported by @IndividualGhost1905: printing the same model ten times and
then deleting nine archive entries (to keep the file list tidy) silently
rewound the totals on the Statistics page — total prints, filament,
cost, and per-print energy all dropped back to whatever the surviving
row contributed, as if the other nine prints had never happened.
Root cause: every metric in get_archive_stats is recomputed live from
PrintArchive rows via COUNT / SUM, so removing a row removes its
contribution. Energy in the default "Total" mode already survived
deletion because it reads the smart-plug lifetime counters — that's
the architectural shape we now generalise to the rest.
Fix: soft delete with opt-in hard purge.
Backend:
- New nullable, indexed deleted_at column on print_archives, dialect-
conditional migration (DATETIME on SQLite, TIMESTAMP on PostgreSQL).
- ArchiveService.soft_delete_archive flips deleted_at and removes the
files from disk (still reclaims storage); the path-safety checks were
extracted into _resolve_archive_dir_for_delete so soft and hard delete
share the rules.
- DELETE /archives/{id} accepts ?purge_stats=true; default is soft.
- Listings filter deleted_at IS NULL: list_archives, search FTS + LIKE
fallback, GET /{id} (404 on soft-deleted), tag listing, duplicate
detection (so a 1-live + 9-soft-deleted group no longer marks the
survivor as a duplicate), and ArchiveComparisonService's "similar"
suggestions. GET /stats and GET /slim deliberately do NOT filter so
Quick Stats and the dashboard widgets keep counting deleted prints.
Frontend:
- ConfirmModal gained an optional children slot.
- ArchivesPage (both card and detail views) own a per-instance
deletePurgeStats boolean and render an opt-in checkbox in the delete
dialog; resets to off on every close so the destructive option is
never sticky.
- api.deleteArchive(id, purgeStats?) appends ?purge_stats=true only
when the box is ticked.
- One new i18n key archives.modal.deletePurgeStats added across all 8
locales (full German, English fallbacks elsewhere).
|
||
|
|
8a7598f6b5 |
feat(auth): proxy OIDC provider icons server-side (#1333) (#1342)
* feat(auth): proxy OIDC provider icons server-side (#1333) Strict img-src CSP blocked external OIDC icon hosts on the login page. Loosening CSP was rejected via the MakerWorld precedent, so icons are proxied: admin sets icon_url, backend fetches and caches the bytes in a deferred BLOB column, the SPA renders from a same-origin /api/v1/auth/oidc/providers/{id}/icon endpoint. |
||
|
|
ccf985abfc |
feat(slicing): build-plate override in the SliceModal (#1337)
Slicing an STL via the integrated slicer always defaulted to whatever
curr_bed_type lived in the chosen process preset (typically "Cool
Plate"), which the slicer CLI rejected for high-temp filaments with
"Plate 1: Cool Plate does not support filament 1". The user had no
way to switch plates without cloning the preset in BambuStudio.
The Slice modal now exposes a Build plate dropdown with the six
canonical BambuStudio / OrcaSlicer plates (Cool Plate, Cool Plate
SuperTack, Engineering Plate, High Temp Plate, Textured PEI Plate,
Smooth PEI Plate) plus an "Auto (use process preset)" option that
preserves the previous behavior. Positioned between Process profile
and Filament rows so a long filament list never pushes it off the
modal's scrolled viewport, and always enabled regardless of whether
the user picked a Printer Preset Bundle.
A new bed_type field on SliceRequest flows through both dispatch
paths:
- Resolved-preset path: _patch_process_bed_type overwrites
curr_bed_type on the process JSON before forwarding to the sidecar.
Works end-to-end today, no sidecar change needed.
- Bundle dispatch path: slice_with_bundle adds a bedType form field
to the sidecar multipart. The sidecar (maziggy/orca-slicer-api
fork) needs a matching change to honor it as --curr_bed_type on
the CLI invocation; until then the field is silently ignored and
the slice runs with the bundle's default plate.
|
||
|
|
a1d6fb22ae |
fix(spoolman): filter external library lookup by Bambu Lab manufacturer (#1330)
Bambuddy's external SpoolmanDB lookup in `_find_or_create_filament` matched
on material+color only, with no manufacturer filter. Because SpoolmanDB is a
multi-vendor catalog and entries are roughly ID-sorted, the first hit for
any common combination is almost always a competitor — `bambulab_pla_black_1000_175_n`
is the 15th entry for PLA + `#000000`. Bambu Lab RFID spools were being
labeled with competitor product names (`3DJAKE Black`, `3DXTECH™ Black`, etc).
Restrict the external-library loop to entries whose manufacturer is
`"Bambu Lab"` (with `id.startswith("bambulab_")` as a defensive fallback
for schema drift). When multiple Bambu Lab candidates exist, prefer the
entry whose `name` equals the AMS `tray_sub_brands` so `"PLA Basic"` wins
over generic `"Black"` when both are present. Forward `density` from the
chosen external entry so it is no longer overwritten by the PLA-default
1.24 in `create_filament`.
Six unit tests added: internal short-circuit preserved, non-Bambu external
entries skipped, PLA Basic > generic PLA tiebreaker, no-match fallback,
id-prefix defensive fallback, density propagation.
Fixes #1309
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: MartinNYHC <mz@v8w.de>
|
||
|
|
a2c9eef87c |
fix(safety): invert bed-jog Z direction on A1 / A1 Mini bed-slingers (#1334)
On A1 / A1 Mini, clicking the "Up" arrow on the printer-card bed-jog
control sent the nozzle straight into the build plate. Reporter
triggered it with the 50 mm step and crashed their nozzle.
Root cause: the bed-jog UI was designed against the X1 / P1 / H2 family
where the bed is the Z-axis and Bambu's firmware homes Z=0 at the top,
so G1 Z- raises the bed toward the toolhead (decreases the nozzle-bed
gap). The frontend maps "Up" to negative distance with that convention
in mind.
A1 / A1 Mini are bed-slingers: bed moves on Y, toolhead moves on X+Z,
firmware uses standard cartesian Z (Z+ = toolhead up). On those models
G1 Z-10 drives the toolhead DOWN 10 mm. There was no model
classification at the bed-jog code path, so every printer got the same
X1-convention G-code.
Fix: new is_bed_slinger(model) helper in printer_manager (sibling to
existing supports_chamber_temp / has_stg_cur_idle_bug, reuses the
already-defined A1_MODELS frozenset which covers display names and
internal codes N1 / N2S). The bed-jog route now inverts the signed
distance before emitting G-code when the printer model is in that set,
so UI "Up" semantics ("decrease nozzle-bed gap") stay consistent
regardless of which physical part moves. Frontend untouched, single
source of truth lives in the backend, keyed off the Printer.model
column. Route Query description and docstring updated to spell out the
new contract: distance is the gap adjustment, not the raw Z value.
|
||
|
|
b8e350c3bf |
fix(spoolman): persist color_name edits and stop form round-tripping the subtype synth fallback (#1319)
Editing a spool's color name on Spoolman-backed inventory appeared to
accept the new value but the inventory list column and the next edit
showed it back to the subtype. Three layers stacked to produce this:
1. find_or_create_filament matches by material/name/color_hex/vendor —
color_name is intentionally not part of the match key, but on a
match it returned the existing filament's id unchanged, silently
dropping the new value.
2. The read helper falls back to subtype when filament.color_name is
empty (kept on purpose: without it Spoolman installs that don't
fill the field render every spool as "Unknown color").
3. The edit form prefilled color_name from spool.color_name — which
on those installs was the synth value. Changing subtype but not
color_name silently round-tripped the OLD subtype back to Spoolman
as if it were a real user-set color_name.
Fixes:
- find_or_create_filament now patches the matched filament's
color_name via the existing patch_filament wrapper when the request
differs. Parameter convention: None = don't touch, "" = explicit
clear, any other string = set/update. A patch failure is logged but
does not block the match.
- The PATCH route uses model_fields_set to distinguish "field omitted"
from "field explicitly set to null" (mirrors the existing
storage_location pattern at the same site).
- The map helper returns color_name_is_synthesized: bool. The edit
form leaves the input blank when true, so the user sees the real
stored state and can't accidentally round-trip the synth value back.
|
||
|
|
82c90c6387 |
fix(mqtt): skip print-start fire on first RUNNING after Bambuddy startup (#1304)
Restarting Bambuddy mid-print misfired the plate-check + archive flow. The is_new_print guard treated _previous_gcode_state=None → RUNNING as a transition, but None just means we haven't seen any prior state yet — catch-up from a printer that was already running, not a fresh start. Add `_previous_gcode_state is not None` to the guard. _was_running still flips on unconditionally, so completion detection is unchanged. 3 tests that asserted the buggy behavior now seed an explicit prior state; new regression test pins the contract for the reporter's exact scenario. |
||
|
|
db308aa80b |
fix(library): defer external-scan STL thumbnails + Path coerce (#1299)
External scan hung on a 1200-subdir NAS because (1) every STL crashed
with TypeError ('str / str') inside generate_stl_thumbnail and (2)
thumbnail generation ran synchronously per file, so the FE timed out
before db.commit() and nothing was persisted.
stl_thumbnail.py now coerces inputs to Path defensively, and
scan_external_folder defers STL thumbnail generation to a background
asyncio task that opens its own session and processes each file
post-commit. Subdirs appear in the sidebar immediately; thumbnails
backfill over the next seconds/minutes.
|
||
|
|
0c92a4d326 |
fix(vp): broadcast archive_created so Archives page refreshes live (#1282)
Real-printer prints broadcast archive_created from the MQTT print_start handler, which the Archives page listens for to invalidate its query cache. The VP file-receive paths created the archive in the DB but never emitted the event, so the new card only appeared after a tab switch triggered refetch-on-focus. Added a small _broadcast_archive_created helper on VirtualPrinterInstance and called it from _archive_file (immediate mode) and _add_to_print_queue (queue mode). Review mode is unaffected — it creates a PendingUpload, not a PrintArchive. Broadcast errors are swallowed at debug level so a transient WebSocket issue can't break the file-receive flow. |
||
|
|
59f7d736e3 | Added BACKERS.md | ||
|
|
0d6171dc9a |
fix(vp): emit FINISH after FTP upload so Print-flow slicers unwedge (#1280)
Bambuddy's VP supports two slicer flows: Send (file upload only — what queue/immediate/review modes are designed for) and Print (file upload + start-print, intended for proxy mode). When a user clicks Print against a non-proxy mode the VP must still respond gracefully — the file is fine to receive, just the start-print never happens. Instead the slicer wedged at "Downloading...(0%)" and blocked the next dispatch with "The printer is busy with another print job". Cause: on_file_received transitioned gcode_state PREPARE -> IDLE directly. Print-flow slicers watch the state cycle and only release their in-flight-job lock on PREPARE -> ... -> FINISH (or FAILED). PREPARE -> IDLE looks like "printer abandoned my job" and keeps the prior job pinned in the slicer's memory. Fix: transition PREPARE -> FINISH with prepare_percent=100. The 1-Hz periodic status push broadcasts the new state to every connected slicer within a second. Send-flow slicers don't watch this state so the change is a no-op for them; Print-flow slicers see the FINISH they were waiting for and unwedge. |
||
|
|
7596725550 |
fix(mqtt): external-spool ams_filament_setting must use global tray_id (#1279)
ams_set_filament_setting and reset_ams_slot encoded the single-external
case as {ams_id: 255, tray_id: 0, slot_id: 0}. The "LOCAL tray_id = 0"
comment was a misread of the printer's response (which echoes the local
slot position), not the request semantics.
Captured BambuStudio -> X1C exchange shows the request encoding is
{ams_id: 255, tray_id: 254, slot_id: 0} (global tray index in tray_id).
The previous code's tray_id: 0 is what the P1S in #1279 rejects with
result: "fail", which silently broke external-spool filament selection
on every Bambu printer with no AMS or external spool in active use.
Dual-external (H2D) branch was not in the captured exchange and is
explicitly pinned at the legacy encoding pending a Studio -> H2D capture.
|
||
|
|
6fe00adb23 |
fix(spoolman): resolve -1 in ams_mapping to external spool (#1276)
BambuStudio encodes virtual tray IDs (254/255) as -1 in the flat ams_mapping array — a convention already documented in bambu_mqtt.py:start_print(). The spoolman tracking helper was treating -1 as "unmapped, use position-based default", which mapped slot_id=1 to AMS tray 0 and credited external-spool prints to whatever Spoolman spool happened to be linked to AMS slot 0. The reporter's TPU prints on an H2S were credited to a PLA spool for ~49g over 4 prints before being noticed (regression of #853). When slot_to_tray[slot_id-1] == -1 and ams_trays contains 254/255, return the external tray ID directly. Prefers 254 over 255 (matches single-nozzle tray_now reporting + the vir_slot id=255->254 remap in bambu_mqtt.py:864). Legacy fall-through preserved for callers that don't pass ams_trays. Root cause investigation and patch by @ojimpo. |
||
|
|
ae43ced0ef |
fix(vp): honor workflow default print options in queue-mode receive (#1235)
Prints sent from a slicer to a VP in print_queue mode arrived in the queue with bed_levelling / flow_cali / vibration_cali / layer_inspect / timelapse set to the SQLAlchemy column defaults, ignoring the user's workflow page settings entirely. The manual POST /print-queue endpoint reads these from the request body (frontend pulls them from settings before submitting), but manager._add_to_print_queue constructed the PrintQueueItem without touching any of those fields. Read default_bed_levelling and the other four settings via get_setting and pass them explicitly. _bool_setting helper handles the None -> AppSettings default fallback. |
||
|
|
c097140e4c |
fix(camera): share broadcaster buffered frame with Obico + /camera/snapshot (#1271)
The MJPEG fan-out broadcaster from #1089 only solved viewer-side concurrency. Obico polling (every 5s) and the manual /camera/snapshot endpoint kept opening their own fresh RTSP sockets, which X1/H2/P2 firmwares tolerated but X2D firmware 01.01.00.00 enforces strict single-connection on — every poll kicked the live stream. Add try_get_active_buffered_frame(printer_id): returns the broadcaster's last buffered frame when a viewer is connected, None otherwise. Obico and /camera/snapshot consult it before opening a fresh socket. When no viewer is active they fall through to the existing fresh-capture path. plate_detection and layer_timelapse intentionally not converted. |
||
|
|
b334d7edc9 |
fix(spoolman): per-print 3MF tracking is the only weight writer (#1119)
Spoolman had two mutually-exclusive weight paths gated on the
`disable_weight_sync` flag. The default (False) used AMS remain%
x tray_weight auto-sync, which silently dropped non-BL spools
because the AMS doesn't report tray_weight without RFID. The
inventory_remaining fallback would have covered it, but the
spool_assignment table it reads from is wiped on Spoolman
activation, so non-BL spools got no weight updates at all.
Match the internal Filament Inventory: per-print tracking always
runs, AMS auto-sync no longer writes remaining_weight (it still
maintains spool metadata and slot assignments). The setting
becomes a no-op; left in the schema and UI for backwards compat.
- store_print_data: drop the disable_weight_sync early return
- sync_ams_tray callsites in main.py + routes/spoolman.py: force
disable_weight_sync=True so weight is never written by AMS sync
- new regression test confirming tracking runs with flag=false
|
||
|
|
4b7df9f30b |
fix(usage-tracker): skip remain% fallback for trays not used by print (#1269)
The AMS remain% delta path charged every tray with a delta, not just trays involved in the print. Swapping a spool in an UNUSED slot mid- print made the slot report remain=0 (fresh spool, no tag), versus a print-start snapshot of 100%, so the originally-assigned spool got charged the full 1000g. Build print_used_keys from ams_mapping, tray_change_log, and tray_now_at_start, and skip fallback for trays not in that set. Legacy "scan every tray" behavior preserved when none of the three signals are present. |
||
|
|
7afb303ffd |
feat(#1239): Update Gitea and Forgejo due to API changes from initial cut (#1255)
feat(#1239): first cut at Gitea backups silently failing after 1st run feat(#1239): Added Token Scope for Forgejo edge case. Also included: test coverage for fixes |
||
|
|
79d54a8d53 |
feat(archives): build-plate icon on cards + uniform printer/model line (#1253)
Show an OrcaSlicer-style bed icon in the archive card's printer-name row indicating which build plate the print was sliced for (Cool / Cool SuperTack / Engineering / High Temp / Textured PEI / Smooth PEI), with the full plate name in the hover tooltip. Closes the gap where users had to remember which plate matched a re-print or open the source 3MF in a slicer just to read the bed setting. Card row also unified: archives with a real Bambuddy-printer association used to render "H2D-1 GCODE ..." while slicer-only uploads rendered "Sliced for X1C GCODE ..." -- same line, two different shapes. Drop the "Sliced for " prefix so both render as a uniform "<name-or-model> [bed-icon] GCODE <hash>" row, scanning identically regardless of provenance. Backend: new bed_type column on print_archives (idempotent ALTER TABLE migration; SQLite + Postgres safe). Populated from curr_bed_type in Metadata/slice_info.config (per-plate, authoritative -- that's what got sent to the printer for the exported plate) with a fallback to project_settings.config for older 3MF shapes. Wired through both archive_to_response() (the hand-rolled dict converter that bypasses from_attributes -- easy to miss) and the /rescan endpoint, so old archives can be re-parsed via the existing per-archive Rescan button. Backfill script (scripts/backfill_archive_bed_type.py, --dry-run supported) re-opens every NULL archive's 3MF on disk to populate the column. Auto-loads .env from project root before importing backend modules (config.py reads DATABASE_URL from os.environ at import time, not from pydantic-settings at Settings() time) and prints the resolved DB URL with credentials redacted, so operators can confirm they're hitting the intended database -- Postgres or SQLite. Frontend: 6 OrcaSlicer-style PNGs ship in frontend/public/img/bed/ -- under /img/ because that path is already statically mounted; a toplevel /bed-icons/ tried first hit the SPA catch-all and returned index.html as text/html. New utils/bedType.ts maps slicer strings case-insensitively, covering both Bambu Studio and OrcaSlicer naming variants for the same physical plate. Unmapped or NULL bed_type simply omits the icon, so cards stay clean for pre-feature archives. |
||
|
|
83a83ed724 |
feat(labels): add 40x30 mm template, hex colour code, bolder brand (issue #809 follow-up)
Three enhancements requested by @oliboehm after the V1 label-printing ship in #809: - New box_40x30 single-label template (common DK/Brother roll size, good for filament-bag and storage-bin labels). Routes through the existing roomy layout since height >= 20 mm. - Colour hex code (#RRGGBB, alpha-stripped, uppercase) rendered on every label - useful when several near-identical material/colour spools sit next to each other and the swatch alone isn't enough to tell them apart. Skipped silently when rgba is None or malformed. - Brand line bumped to Helvetica-Bold (was regular) and a couple of points larger on both layouts so it reads cleanly at arm's length. Wired through the SpoolLabelTemplate union, the modal's TEMPLATE_OPTIONS, and the inventory.labels.templates.box40x30 i18n key in all 8 locales (native translations for de/fr/it/ja/pt-BR/ zh-CN/zh-TW). Modal regression test widened from 4 to 5 template buttons. Three new renderer tests pin the hex-code render, the hex-code skip on invalid rgba, and the bold-brand font reference. |
||
|
|
f5ecc61cda |
fix(spoolbuddy): lower /update permission to INVENTORY_UPDATE so kiosk's own Settings -> Update button works
The kiosk's Settings -> Update Daemon button returned "API keys cannot
be used for administrative operations" because POST /spoolbuddy/devices/
{id}/update was gated on Permission.SETTINGS_UPDATE, and SETTINGS_UPDATE
is in the _APIKEY_DENIED_PERMISSIONS deny-list introduced by PR #1241.
Every kiosk-side request tripped the deny-list before the API key's
scope set (Read / Print Queue / Control / Legacy) was even consulted.
Same root cause as the four QuickMenu System buttons fixed in 0.2.4b3
(Restart Daemon / Restart Browser / Reboot / Shutdown). Missed /update
in that audit on the reasoning "replaces the daemon binary, different
threat surface" — but that's wrong: restart_daemon already replaces
the running daemon process, so daemon-replacement is not a step up in
blast radius. The SSH update is also strictly scoped to the one device
the operator physically controls (git fetch + pip install + systemctl
restart on that host) — same threat profile as the system commands
already running on INVENTORY_UPDATE.
Lower /spoolbuddy/devices/{id}/update from SETTINGS_UPDATE to
INVENTORY_UPDATE so it aligns with the rest of the kiosk-scoped routes
(calibration/tare, display, cancel-write, system/command,
system/command-result, update-status). The main Bambuddy in-app updater
at POST /api/v1/updates/apply keeps SETTINGS_UPDATE — that one runs on
the Bambuddy host and is correctly fenced behind the deny-list.
|