mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-01 03:31:25 +02:00
71b0575ff9e9508f3ac58fb3b59cd58adc6fec09
1412
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
71b0575ff9 |
fix(vp): close #1780 race — bump slicer-MQTT wait to 5s + retroactive stamp
Round 2 (
|
||
|
|
9f9c17752f |
feat(inventory): toggle to disable auto-add of unknown RFID spools + global confirmation modal (issue #1764)
New setting "Auto-add unknown RFID spools" under Settings -> Filament -> Filament Tracking,
default ON for back-compat. When turned off, the backend stops auto-creating an inventory
record for an unknown RFID tag and instead broadcasts an unknown_tag WS event that pops
a global confirmation modal in the Bambuddy UI showing the printer / AMS-X label / slot /
material / colour. Add or Cancel; no nag on every MQTT push.
Backend
- Module-level _unknown_tag_last_broadcast dict dedupes per (printer, slot, tag). Set is
committed AFTER ws_manager.broadcast() returns so a crashed broadcast doesn't poison
the dedup and permanently silence the slot.
- Empty-slot MQTT push clears that slot's entry, so remove+reinsert reliably re-prompts.
- Successful matches via get_spool_by_tag / find_matching_untagged_spool / create_spool
also clear the entry so a future tag swap re-prompts.
- Tray data (tray_type, tray_color, tray_sub_brands, tray_count) shipped in the WS payload
directly so the modal renders the real material / colour instead of relying on the
React Query cache that lags the WS event by several seconds.
- Two new endpoints back the modal's confirm action:
POST /api/v1/inventory/spools/from-slot (INVENTORY_UPDATE)
POST /api/v1/spoolman/spools/from-slot (FILAMENTS_UPDATE)
Both look up the slot's tray data server-side and create + auto-assign atomically.
- Spoolman /from-slot now raises HTTP 500 when the slot-assignment INSERT fails instead
of returning success while the DB rolled back the binding.
- sync_ams_tray gained an optional auto_add_unknown_rfid kwarg (default True so existing
callers are unaffected); auto-sync and both manual sync routes thread the setting.
Frontend
- useUnknownTagPrompt hook listens for the unknown-tag CustomEvent, reads the tray fields
out of the event detail, and feeds a single-modal queue. No long-lived dismissed set;
the backend dedup handles spam suppression.
- UnknownSpoolModal wraps the existing ConfirmModal with a material + colour-swatch
preview block.
- Mounted in Layout.tsx alongside useSponsorPrompt so SpoolBuddy kiosk / login / setup
routes are excluded.
- getAmsLabel moved to utils/amsHelpers.ts; ConfigureAmsSlotModal.tsx and PrintersPage.tsx
both import the shared version (canonical AMS-A / HT-A / External labels).
- AppSettings TS interface gained spoolman_enabled, auto_add_unknown_rfid, spoolman_url
so the runtime cast in the hook is no longer needed.
- SpoolmanSettings.tsx gets a new toggle row in the Filament Tracking card, visible in
both built-in and Spoolman branches; auto-save + toast already wired.
|
||
|
|
8e99b0c86d | Fix camera port diagnostic for A1/P1 printers (#1799) | ||
|
|
29a5abd986 |
feat(deficit): backup-aware filament deficit check, colour-strict (#1762)
When the printer reports ams_filament_backup=True,
compute_deficit_for_queue_item pools remaining_grams across spools
matching (preset, colour) on the same printer (scoped per extruder on
dual-nozzle) before declaring a per-slot shortfall. Identity is strict:
same slicer_filament preset AND same colour (alpha-normalised). Two
PETG HF spools in different colours are NOT pooled — the firmware would
swap correctly but the print would change colour mid-run. Spoolman side
mirrors the rule via filament.id + color_hex. Backup OFF falls back to
the pre-PR per-slot accounting line-for-line.
8 new test cases in TestFilamentDeficitBackupAware pin pool covers,
pool insufficient, different presets, backup-OFF regression, dual-
extruder side scoping, no-preset never pairs, colour-strict, and
alpha-hex normalisation. The 8 pre-existing test_filament_deficit.py
cases stay green.
feat(printers): AMS Filament Backup modal with BS-style ring per pair
Badge click on the Filaments section header (#1766) now opens a
modal: filament-colour ring per backup pair, material name + rotation
count in the centre, slot labels distributed around the colour band on
contrast-aware pills. Closely modelled on Bambu Studio's Auto Refill
widget. Lone slots are intentionally not listed. R / L badges per ring
when the extruder map carries two distinct values; collapses to no-
badge rendering for single-nozzle printers misflagged as dual.
Esc keypress closes the modal. Theme-aware via CSS variables matching
AMSHistoryModal. computeBackupGroups helper in utils/amsHelpers
defensively dedupes duplicate ams.id entries observed on switch-VP
aggregations.
10 modal render cases pin: Esc closes / unmount nulls the listener /
ring renders for pairs and omits lone slots / R-L badges only when
extruder map has distinct values / empty state / toggle gating.
13 frontend cases pin computeBackupGroups identity rules.
feat(printers): active-print P-N pill on AMS slot tiles during RUNNING
While the printer is mid-print, each AMS slot tile referenced by
status.ams_mapping carries a small "P1 / P2 / P3" pill in the top-
right corner, naming which print-slot is mapped to that AMS slot.
Catches the #1762 comment-2 scenario: a queue job set for "any X1C"
staged to a printer with mismatched filament, no way to verify mid-
print. Same wire data (status.ams_mapping is already on the wire) —
the addition is purely surface.
The existing ring-bambu-green highlight for effectiveTrayNow keeps its
meaning (currently extruding RIGHT NOW); the pill is the per-slot
static assignment for the active print.
chore(scheduler): log Print Anyway short-circuit at INFO
_block_on_filament_deficit logs at INFO when it honours
item.skip_filament_check, so a future "Print Anyway didn't work" report
(third commenter on #1762 hit this shape) has actionable evidence in
the standard support bundle without DEBUG. Bundled because the deficit
fix makes the original symptom disappear for users with backup ON.
|
||
|
|
a9bf6f1f79 |
fix(notifications): sync finish-photo producer→consumer to land the photo on FINISH-state fallback (#1790)
On the FINISH-state fallback path bambu_mqtt.py:3258 dispatches
on_finish_photo_moment and on_print_complete back-to-back, so the
moment-producer's RTSP grab and the print-complete consumer's cache
read race — consumer wins the empty pop, then its own RTSP fallback
times out against the producer's in-flight grab (Bambu printers allow
one RTSP client). 394 KB frame captured, notification went text-only.
Add a per-printer asyncio.Event in _stage22_finish_in_flight: producer
registers before first await, sets it in finally on every exit;
consumer awaits with a 20s timeout (15s producer grab + headroom)
before the cache pop. Closes the race AND the concurrent-RTSP timeout
in one change. Timelapse path skips the event, so its branch is
unchanged.
|
||
|
|
30c2e263dd |
fix(vp): correct #1780 root cause — VP intake key mismatch dropped every slicer field
First-attempt fix ( |
||
|
|
7e5eff14d8 |
feat(humidity): per-filament humidity threshold for auto-drying + alarms (#1605)
Reporter @thenewguy runs an engineering farm with one AMS per material
(PLA, ASA, Nylon, PVB, HIPS) — Bambuddy's single global ams_humidity_fair
threshold (default 60%) was driving both the queue / ambient auto-drying
trigger AND the hourly humidity alarm uniformly, which is wrong for
multi-material setups where Nylon wants <10% and PLA is fine at 60%.
Drying RUN parameters were already per-filament via drying_presets;
this commit adds the missing per-filament TRIGGER.
New setting ams_humidity_thresholds — JSON map of filament-type to
threshold percent with a "default" key for unknown / unmapped types.
Empty / unset → both consumers fall back to ams_humidity_fair so the
upgrade is silent.
Resolver lives in PrintScheduler.resolve_humidity_threshold(trays,
thresholds, fallback) — picks the lowest (most-restrictive) threshold
across all loaded tray types, matching the conservative-params strategy
_get_conservative_drying_params already uses for temp / hours. Empty
tray slots contribute no constraint; all-empty AMS falls through to the
"default" key. Filament names normalized to uppercase base (so
"PLA Basic" / "pla basic" both map to PLA).
Two consumer sites rewired through the same resolver so the scheduler
and the alarm path can never disagree about whether an AMS is "too
humid":
- print_scheduler.py::_check_auto_drying — per-AMS humidity comparison
for start / stop / skip decisions.
- main.py AMS sensor / alarm worker — hourly humidity alarm notifier.
UI: new table in Settings → Workflow → Auto-Drying, below the existing
Drying Presets table. Default row + 8 default filament types
(PLA / PETG / TPU / ABS / ASA / PA / PC / PVA) pre-filled from the
current ams_humidity_fair value so the editor starts sensibly.
Input pattern: draft-on-edit / commit-on-blur (transient humidityDrafts
state per row). onChange only updates the draft; onBlur (and Enter)
parses + clamps to [5, 95] + commits. Empty value on blur clears the
override and falls back to default. Caught mid-PR via a typing test:
the naive per-keystroke clamp snapped "3" → 5 before the user could
type the second digit of "30".
Setting is in the public _UI_PREFERENCE_FIELDS allowlist (same rationale
as drying_presets and ams_humidity_fair — non-sensitive integer map,
no SETTINGS_READ permission required for badge-color rendering).
|
||
|
|
b7ff72d856 |
fix(updates): switch Windows installer installs to release-asset update flow
In-app "Install Update" on Windows installer installs failed with "Could
not find git executable" because (1) _find_executable's fallback paths
are Unix-only, and (2) the installer stages backend/ via shutil.copytree
so there is no .git directory — even with Git for Windows installed, the
fetch would die on "not a git repository". Adding Windows paths would
only have changed which error users saw.
Switches the Windows installer path to a fourth update_method
("windows_installer") that mirrors the existing docker / ha_addon
branches — surface a link to the release .exe and let the user re-run
the installer, matching the Discord / Spotify Windows update model.
Backend:
- New _is_windows_installer_install() — true iff sys.platform == "win32"
AND no .git in app_dir, so Windows devs with a real git clone keep
the git path.
- New _find_windows_installer_asset() picks the matching release asset
(prefers versioned bambuddy-<ver>-windows-x64-setup.exe, falls back
to the unversioned alias on non-daily tags).
- /updates/check now returns is_windows_installer / update_method /
installer_download_url.
- /updates/apply short-circuits with a friendly message after the
existing HA / Docker guards — defense in depth, the frontend swaps
the button so the POST should not fire on Windows.
Frontend:
- UpdateCheckResult extended with the new fields and 'windows_installer'
in the update_method union.
- SettingsPage renders a Bambu-green styled <a target="_blank"
rel="noopener"> between the Docker snippet and the in-app Update
button, with installer_download_url falling back to release_url then
the tag page so the link is never broken.
- applyUpdateMutation onSuccess toast guard extended to treat
is_windows_installer the same as HA / Docker.
|
||
|
|
e761e09297 |
feat(sponsor-prompt): in-app toast at earned milestones
ghcr.io pull baseline (~10k/day rising → ~8-12k active installs) puts
sponsor conversion at 0.08% — roughly an order of magnitude under
industry-benchmark for OSS with visible CTA. The Settings banner from
|
||
|
|
d1d166592c |
feat(heater-history): track nozzle / bed / chamber readings + per-tile chart-icon overlay opens history modal
New PrinterSensorHistory table + 60s recorder + GET/DELETE /printer-sensor-history
route gated behind a new PRINTER_SENSOR_HISTORY_READ scope (separate from AMS). UI
adds a 10x10 LineChart icon on each heater tile - click body opens the existing
target-temp popover unchanged, click icon opens a HeaterHistoryModal mirroring the
AMSHistoryModal shape (kind toggle + 6h/24h/48h/7d range + current/avg/min/max +
recharts line for value + dashed target). Read-only X1C/P2S chamber tile finally
gets an interaction. Retention configurable via printer_sensor_history_retention_days
(default 30, sibling of ams_history_retention_days). 8 new i18n keys translated in
all 11 locales, parity green. 4 backend + 6 frontend tests added; full pytest -n 30
6226/6226, vitest 2176/2176, ruff/eslint/build all clean.
|
||
|
|
a70c2a2dc4 |
fix(usage-tracker): split mid-print AMS-Backup spool switch correctly (#1771)
Reporter forcefully started a print needing ~260 g with 180 g on the
first spool and a backup spool in the AMS. Printer correctly consumed
spool 1, AMS Backup switched, spool 2 finished the print. Bambuddy
attributed all 260 g to spool 2 -- spool 1 untouched in inventory.
Two stacking bugs produced the exact "all to second spool" symptom for
prints without per-layer 3MF gcode data:
1. bambu_mqtt.py:2135 wrote state.total_layers = int(data["total_layer_num"])
unconditionally. P1S firmware pushes total_layer_num=0 at print end
(same reset pattern other models do for layer_num / progress). The
unconditional write clobbered the slicer's actual total to 0 before
the usage tracker read it.
2. usage_tracker.py:1129-1137 linear-fallback dumped EVERYTHING onto the
last segment when total_layers was 0:
if total_layers > 0:
segment_grams = total_weight * (seg_end_layer - seg_start_layer) / total_layers
else:
segment_grams = 0.0 # <- entire print weight ends up on last segment
Path 2 (AMS remain% delta) couldn't recover because (a) the emptied
spool reported remain=-1 and (b) Bug-A had already added the second
spool's key to handled_trays, suppressing the Path 2 lookup.
Fix:
- bambu_mqtt.py: only overwrite state.total_layers when the incoming
value is positive (mirror of the existing _last_valid_layer_num
pattern at line 2127). Explicit reset on new print start at
_handle_print_start so the previous print's total can't bleed in.
- usage_tracker.py: cascade the linear-fallback denominator -
state.total_layers, then last_layer_num (already threaded in for
the last_progress fallback), then equal-split as a bounded fence.
Equal-split is still wrong but never dumps the whole print on the
last segment, which was strictly worse.
|
||
|
|
99c6949b5c |
feat(ams-backup): add status badge + toggle, fix prefer-lowest (#1766)
Two tightly-coupled deliverables in one drop -- a new AMS Filament Backup
status/control surface, and the #1766 fix that depends on it.
Added -- AMS Filament Backup status + control
- Parse bit 18 of top-level print.cfg into PrinterState.ams_filament_backup
on every push_status. Verified against OrcaSlicer source
(DeviceManager.cpp:4961) and a live H2D ON/OFF capture. Tri-state
(None = A1 family / pre-cfg push) preserves today's behaviour.
- Hold-timer guard (3 s) prevents stale frames from flickering the badge
back to the printer's old cfg after a user-initiated toggle.
- POST /printers/{id}/ams-backup toggle, set_ams_filament_backup() client
method calling _set_print_option("auto_switch_filament", enabled).
- GET /printers/{id}/inventory-remain endpoint exposes the same map the
dispatcher uses (internal and Spoolman modes both work uniformly).
- Small icon badge in the printer card's "Filaments" section header
(placement reads as printer-wide because the cfg bit is printer-wide,
not per-AMS). Click to toggle, success toast.
- 5 i18n keys x 11 locales for the badge UI.
Fixed -- #1766: prefer_lowest didn't pick lowest, ignored backup state
- Backend gate in _compute_ams_mapping_for_printer: coerce prefer_lowest
to False when status.ams_filament_backup is False; log the skip.
- New effectivePreferLowest(setting, backup) helper applied at every
frontend sort entry point: single-printer PrintModal, multi-printer
hook per-printer, PrinterSelector InlineMappingEditor, FilamentMapping
standalone editor (the last had NO preferLowest awareness at all
before this change).
- New preferLowestSortKey(f, inventoryByTrayId) mirrors backend's two-tier
key exactly, including the banding tie-break (regular AMS < AMS-HT <
external) so the client-side pre-compute matches the dispatch-time pick.
An earlier draft used a flat `amsId * 4 + trayId` priority which gave
external slots (ams_id = -1) a NEGATIVE priority -- caught in code
review before commit.
- Settings -> Filament -> "Prefer lowest remaining filament" gets an
explanatory note about the printer-side AMS Backup dependency, with
i18n key in all 11 locales.
|
||
|
|
964015deaf |
fix(notifications): scope completion notification to printed plate on multi-plate 3MFs (#1785)
The 3MF parser sums prediction + weight across every plate (#1593) so the archive card can headline the whole project — correct for the card, wrong for the completion notification of a single plate. The queue UI already re-reads the 3MF per-plate at print_queue.py:272-285; mirror that for the notification path so Discord / Pushover / email show the plate's actual duration and grams instead of the project sum. Helper fails open on every error path so a missing or corrupt 3MF can't block the notification. |
||
|
|
b691605509 |
fix(virtual-printer): forward H2C rack-swap nozzle pick from slicer to dispatch (#1780)
BambuStudio's project_file MQTT command for O1C2 (the H2C dual-
nozzle-rack variant) carries nozzle_mapping (per-filament physical
nozzle position IDs) and nozzles_info (per-extruder rack metadata).
The VP intake was dropping both, so the H2C firmware fell back to
"last matching nozzle type" auto-pick and ignored the user's
slicer choice — every HF print landed on R2, every standard print
landed on R4.
Carry both fields through the VP intake → queue item → MQTT
dispatch path. New nullable TEXT columns on print_queue, non-
branched ALTER (matches ams_mapping / filament_overrides
precedent). Dual-nozzle gate at start_print() keeps the fields
off single-nozzle dispatches. Fail-open on malformed JSON —
firmware auto-picks, never worse than pre-fix.
Stamps both fields on every plate in the multi-plate Send All
loop (#1697 / #1188 precedent).
ams_mapping2 still handles H2D/X2D dual-extruder routing
unchanged; this fix is scoped to the O1C2 rack-swap mechanism.
|
||
|
|
580f42c1ec |
chore(deps): backend security floor bumps + 422 constant rename
requirements.txt
- cryptography 46.0.7 -> 48.0.1 floor (GHSA-537c-gmf6-5ccf,
non-contiguous Python buffer handling)
- python-multipart 0.0.27 -> 0.0.31 floor (CVE-2026-53538/53539/53540,
multipart parser hardening)
- starlette 1.1.0 -> 1.3.1 floor (CVE-2026-54282/54283, FormParser
limit enforcement + StaticFiles absolute-path rejection)
- pyopenssl 26.0.0 -> 26.3.0 floor (NOT a security fix; pyOpenSSL
<26.3.0 caps cryptography<47 and would otherwise downgrade out
of the GHSA-537c-gmf6-5ccf fix line)
backend/app/api/routes/mfa.py
- 3x HTTP_422_UNPROCESSABLE_ENTITY -> HTTP_422_UNPROCESSABLE_CONTENT
(the former is deprecated in starlette 1.3.x, same 422 wire status;
the 2 remaining warnings are inside FastAPI itself, upstream's)
Release-notes review done before bump: cryptography 47/48 dropped
binary EC, CFB/OFB/CFB8, Camellia, PUBLIC_KEY_TYPES/PRIVATE_KEY_TYPES,
OpenSSL 1.1.x, Python 3.8 -- grep clean against every removed surface;
starlette's newly-enforced max_part_size=1MB only applies to text form
fields (verified in MultiPartParser.on_part_data), file streams from
UploadFile = File(...) are unaffected; python-multipart 0.0.30 dropped
RFC 2231/5987 filename* parsing, minor cosmetic impact on non-ASCII
filename uploads, plain filename= fallback still works.
|
||
|
|
11227f65b3 | chore(deps): dompurify 3.4.10 -> 3.4.11 (GHSA-cmwh-pvxp-8882, moderate) | ||
|
|
03d092387f |
fix(install): docker installer tries mkdir without sudo, escalates on EACCES (#1774)
install/docker-install.sh::create_install_dir ran `mkdir -p
"$INSTALL_PATH"` without sudo while DEFAULT_INSTALL_PATH was
/opt/bambuddy, root-owned on every Linux distro. set -e then
aborted the whole script before docker compose could pull the
image — anyone running the documented `curl ... | bash` flow as
a normal user hit this on first install.
Fix: try the unprivileged `mkdir -p ... 2>/dev/null` first so
--path ~/bambuddy, /srv/bambuddy and other writable targets don't
trigger a needless password prompt, then fall back to
`sudo mkdir -p` + `sudo chown -R "$USER:$USER"` only when the
first attempt failed. The chown is load-bearing: without it the
script would later try to write docker-compose.yml + .env into a
root-owned dir as the invoking user and cascade further EACCES
failures.
Not changing the default path: install/update.sh and
install/update_macos.sh both default INSTALL_DIR to /opt/bambuddy,
and install/README.md's update flow documents the same — flipping
the install default to ~/bambuddy without coordinating those
would silently break self-service updates for anyone following
the docs verbatim. The default stays /opt/bambuddy; only the
escalation gap closes.
set -e survives the redirected stderr because the `if !` form is
the documented escape hatch for an expected-failure check.
Smoke-tested writable-target, idempotent-rerun, and the
failing-mkdir-then-sudo-fallback branches.
|
||
|
|
d2232e0291 |
fix(archives): render plate thumbnails server-side when sidecar slice skips them (#1759)
Bambuddy's archive cards were blank for every print sliced through the
BS or Orca docker sidecars. The "Some recent prints couldn't be archived
with thumbnails" banner pointed at install step 4 which is unrelated —
that flag only fires on FTP-fetch failures, not on missing-thumb in the
sliced 3MF.
Root cause is upstream of Bambuddy: neither slicer CLI renders
Metadata/plate_N.png when invoked headlessly with --slice --export-3mf.
That render is a separate code path triggered by --export-png, which is
mutually exclusive with --export-3mf and additionally needs a working
display backend (BS 02.07.x's bundled GLFW is hard-locked to Wayland —
even XDG_SESSION_TYPE=x11 + GDK_BACKEND=x11 + QT_QPA_PLATFORM=xcb don't
switch it back). An Xvfb display in the sidecar wouldn't help even if we
wired the second-pass call. The Orca sidecar has been silently shipping
thumbnail-less 3MFs from STL inputs since launch; nobody noticed.
Fill the gap on the Bambuddy side: new plate_thumbnail.py renders the
missing thumbnails after the slice returns. inject_plate_thumbnails_if_missing
parses the sliced zip, finds every Metadata/plate_N.gcode entry that
doesn't have a matching plate_N.png, loads 3D/3dmodel.model via trimesh,
renders an isometric Bambu-green-on-dark view at 512x512 + 128x128 via
the same matplotlib Agg pipeline as stl_thumbnail.py, and re-packs the
zip with the PNGs injected. Visual style matches Bambuddy's existing
library thumbnails — archive cards stay consistent inside Bambuddy rather
than chasing parity with desktop Studio's plate render. Best-effort:
input bytes are returned unchanged on any failure so the slice flow itself
can never fail because of a missing thumbnail. Idempotent: re-running on
an already-injected 3MF returns the input verbatim.
Wired into both library.py slice paths via result._replace; covers the
cross-class merged-multi-plate path automatically (merged bytes flow into
the same write site). No sidecar Dockerfile change required — an earlier
attempt to install Xvfb in Dockerfile.bambu-studio was a false start and
is not part of this drop.
Dependencies: trimesh's 3MF loader uses networkx (scene-graph traversal)
and lxml (model.xml parse) lazily inside the 3MF code path — both added
to requirements.txt because they aren't strict trimesh transitives.
|
||
|
|
b118dd2687 |
test(settings): include preset fields in /ui-preferences pin assertion
Follow-up to the temperature & fan-speed presets feature — the
TestUiPreferencesEndpoint.test_returns_expected_field_set test pins
the exact set of fields the endpoint exposes (so adding a sensitive
field by accident fails the assert). The 4 preset fields were added
to _UI_PREFERENCE_FIELDS without updating the pin, breaking the full
backend test run.
|
||
|
|
6fa74be429 | feat: Update printer card UI for structure and readability (#1661) | ||
|
|
18270b4a69 |
log(scheduler): emit prefer-lowest dispatch decision at INFO so #1766 can be triaged from a bundle
The matcher's tray_info_idx vs color vs type_only bucket choice was
debug-only, so a bug report's bundle never showed which path won. Emit
the sorted candidate trays and the picked bucket per filament req when
prefer_lowest=True. Behaviour-neutral; existing 89 matcher tests pass.
|
||
|
|
8283b175c0 |
Restrict printer secrets to update-authority callers
GET /api/v1/printers/ and /api/v1/printers/{id} return access_code
only when the caller holds PRINTERS_UPDATE. Adds PrinterResponseWithSecret
as the elevated response shape; PrinterResponse no longer carries the
field. Auth-disabled single-trust mode preserved.
|
||
|
|
000af6830b |
chore(frontend): dependency bumps
Runtime:
- dompurify 3.4.0 -> 3.4.10 (package.json floor raised from
^3.4.0 to ^3.4.10 so fresh installs cannot land on the
deprecated 3.4.4 release; release notes 3.4.1 -> 3.4.10
reviewed — the three call sites (MakerworldPage,
ProjectDetailPage, ProjectPageModal) use string-output
sanitisation and are unaffected by 3.4.4's widened default
allow-list)
Build / lint / test tooling (transitive, dev-only):
- @babel/core 7.29.0 -> 7.29.7 (via @vitejs/plugin-react and
eslint-plugin-react-hooks)
- vite 7.3.2 -> 7.3.5
- markdown-it 14.1.1 -> 14.2.0 (via @tiptap/extension-link
-> @tiptap/pm -> prosemirror-markdown; Bambuddy never calls
markdown-it.render directly)
- js-yaml 4.1.1 -> 4.2.0 (via eslint)
- form-data 4.0.5 -> 4.0.6 (via jsdom)
- ws 8.20.1 -> 8.21.0 (via jsdom)
|
||
|
|
37d5dfe25a | Housekeeping | ||
|
|
2cbbd1eed3 |
fix(notifications): wire on_printer_offline dispatch on disconnect edge (#1752)
The provider toggle, schema, template, and NotificationService.on_printer_offline
all shipped, but no caller invoked the dispatcher — the offline event was an
orphan toggle. Edge detection in on_printer_status_change now schedules a
debounced (60s) background task on the connected→disconnected transition;
reconnect before the window elapses cancels it. Covers both upstream paths
(smart-plug power-off via mark_printer_offline, and MQTT staleness via
check_staleness), both of which already route through the status callback.
The "back online" channel is the existing print-failure notification on
firmware FAILED report — no symmetric on_printer_online needed.
|
||
|
|
8faaeb96e0 |
fix(archives): backfill NULL created_at + tolerate NULL in response (#1732)
Older print_archives rows (and rows that landed via the SQLite ↔ Postgres
cross-DB restore path) can have created_at = NULL because the column was
originally created without a DEFAULT clause — server_default=func.now()
only fires at table creation, not for existing rows or raw cross-DB
inserts. The list_archives response model required a datetime, so a
single NULL row 500'd the whole endpoint via Pydantic ResponseValidationError.
- Boot-time backfill: COALESCE(completed_at, started_at, now()) for
any row where created_at IS NULL. Dialect-branched (SQLite datetime('now')
vs Postgres NOW()).
- Schema: created_at is now Optional on ArchiveDuplicate, ArchiveResponse,
and ArchiveSlim so a future NULL-leaking path doesn't break the list
endpoint again.
|
||
|
|
38b8a87c11 |
fix(vp): close #1780 race — bump slicer-MQTT wait to 5s + retroactive stamp
Round 2 (
|
||
|
|
fb3821630f |
feat(inventory): batch / mass edit on the Filament tab (#1795)
Bulk operations on the Inventory page in both built-in and Spoolman modes.
Reporter wanted ten-of-the-same-spool edits without ten round-trips through
the per-spool editor.
Frontend
- New checkbox column on the inventory table (header / row / group). Sticky
toolbar appears when at least one row is selected with Edit / Print labels /
Reset usage / Archive (or Restore in the Archived tab) / Delete / Clear.
Selection clears on any filter / tab / search change so the count can't
drift from what is on screen.
- BulkEditSpoolsModal is a three-state-per-field form. The user opts in per
field by ticking its checkbox or just typing into it; only ticked + non-
empty fields are sent. Clearing fields in bulk is intentionally NOT
supported per the issue discussion.
- A new SearchableSelect renders all categorical fields (material, sub-type,
brand, category, slicer preset name, slicer filament, storage location)
with the same dropdown pattern the per-spool editor uses - text input +
chevron + filtered button list, click-outside / Escape closes. No native
select anywhere in the modal. Options merge the canonical constants from
spool-form/constants.ts with whatever already exists in the user's
inventory. Slicer-preset dropdowns fetch the same sources as the per-spool
form (Bambu Cloud + Orca Cloud + local + built-in) through buildFilament
Options() and three useQuery calls gated on isOpen.
- onSuccess handlers surface three outcomes: all-succeeded (green toast),
partial-success (yellow toast with ok / failed counts), all-failed (red
toast that keeps the selection and modal open so the user can retry).
The first cut silently dropped errors / not_found arrays - audited and
fixed before merge.
- Invalid rgba hex is flagged inline with a red border + helper text and
the Apply button is gated on a hasDroppedTickedField guard, so silently
dropping a ticked field is no longer possible.
- bulkResetConsumedCounterMutation.onSuccess now closes the confirm modal +
clears selection, matching the other three bulk mutations.
Backend
- Four new endpoints per inventory mode (eight total):
POST /api/v1/inventory/spools/bulk-update INVENTORY_UPDATE
POST /api/v1/inventory/spools/bulk-delete INVENTORY_UPDATE
POST /api/v1/inventory/spools/bulk-archive INVENTORY_UPDATE
POST /api/v1/inventory/spools/bulk-restore INVENTORY_UPDATE
POST /api/v1/spoolman/inventory/spools/bulk-* FILAMENTS_UPDATE
- Built-in update runs the same prepare_internal_spool_payload(...) +
weight_used / weight_locked auto-stamp as the per-spool PATCH.
- Spoolman update loops the per-spool update_spool route function so the
filament re-linking / extra-dict / extra-lock / shared-filament rules
stay byte-identical to single-spool edits.
- Per-spool failures inside the batch are collected. Spoolman bulk-delete /
archive / restore now catch non-HTTPException too (matches bulk-update) -
a mid-batch httpx.ConnectError or TimeoutError no longer aborts the route
with a 500 and skips the WS broadcast.
- Both modes broadcast a single inventory_changed WS event at the end of
the batch.
|
||
|
|
7cb905ad0c |
feat(inventory): toggle to disable auto-add of unknown RFID spools + global confirmation modal (issue #1764)
New setting "Auto-add unknown RFID spools" under Settings -> Filament -> Filament Tracking,
default ON for back-compat. When turned off, the backend stops auto-creating an inventory
record for an unknown RFID tag and instead broadcasts an unknown_tag WS event that pops
a global confirmation modal in the Bambuddy UI showing the printer / AMS-X label / slot /
material / colour. Add or Cancel; no nag on every MQTT push.
Backend
- Module-level _unknown_tag_last_broadcast dict dedupes per (printer, slot, tag). Set is
committed AFTER ws_manager.broadcast() returns so a crashed broadcast doesn't poison
the dedup and permanently silence the slot.
- Empty-slot MQTT push clears that slot's entry, so remove+reinsert reliably re-prompts.
- Successful matches via get_spool_by_tag / find_matching_untagged_spool / create_spool
also clear the entry so a future tag swap re-prompts.
- Tray data (tray_type, tray_color, tray_sub_brands, tray_count) shipped in the WS payload
directly so the modal renders the real material / colour instead of relying on the
React Query cache that lags the WS event by several seconds.
- Two new endpoints back the modal's confirm action:
POST /api/v1/inventory/spools/from-slot (INVENTORY_UPDATE)
POST /api/v1/spoolman/spools/from-slot (FILAMENTS_UPDATE)
Both look up the slot's tray data server-side and create + auto-assign atomically.
- Spoolman /from-slot now raises HTTP 500 when the slot-assignment INSERT fails instead
of returning success while the DB rolled back the binding.
- sync_ams_tray gained an optional auto_add_unknown_rfid kwarg (default True so existing
callers are unaffected); auto-sync and both manual sync routes thread the setting.
Frontend
- useUnknownTagPrompt hook listens for the unknown-tag CustomEvent, reads the tray fields
out of the event detail, and feeds a single-modal queue. No long-lived dismissed set;
the backend dedup handles spam suppression.
- UnknownSpoolModal wraps the existing ConfirmModal with a material + colour-swatch
preview block.
- Mounted in Layout.tsx alongside useSponsorPrompt so SpoolBuddy kiosk / login / setup
routes are excluded.
- getAmsLabel moved to utils/amsHelpers.ts; ConfigureAmsSlotModal.tsx and PrintersPage.tsx
both import the shared version (canonical AMS-A / HT-A / External labels).
- AppSettings TS interface gained spoolman_enabled, auto_add_unknown_rfid, spoolman_url
so the runtime cast in the hook is no longer needed.
- SpoolmanSettings.tsx gets a new toggle row in the Filament Tracking card, visible in
both built-in and Spoolman branches; auto-save + toast already wired.
|
||
|
|
271560f7cb | Fix camera port diagnostic for A1/P1 printers (#1799) | ||
|
|
4dcd37bc87 |
feat(system): NTP-gate state on /api/v1/system/appliance
Extends the appliance endpoint that landed in the previous commit with a
time_synced field, sourced from /run/bambuddy/time-synced (the appliance's
ntp-gate.sh writes this once chronyd reports sync, or with a "warning"
marker after the 3-minute timeout). The RPi 5 has no battery-backed RTC,
so on a fresh boot the system clock is wrong until NTP catches up -- JWT
expiries and TLS certificate validity windows depend on this being right.
Exposing the gate lets the SPA render a "time not synced" indicator while
that's still true and clear it once "ok" comes through.
backend/app/core/local_config.py
New read_ntp_gate(path) function alongside read_local_toml. Three states:
"ok" chrony reported sync within the 3-minute window
"warning" 3-minute timeout elapsed without sync; user already waited
and the wizard proceeded with a degraded clock
None file absent (non-appliance install), OSError, empty content,
unknown marker, or binary garbage -- "unknown / don't gate"
Defensive read mode (errors="replace") survives non-utf8 content without
crashing. Module docstring broadened from "local.toml reader" to "small
readers for appliance-set state files".
backend/app/api/routes/system.py
/system/appliance now returns:
{hostname, timezone, locale, time_synced}
with the same no-auth posture: bootstrap surfaces (i18n init, time-sync
banner) read this before auth might be set up, and the contents are
non-secret (user-set defaults + a public sync flag). The endpoint
docstring expands to explain the RTC motivation -- otherwise the
time_synced field reads like a leftover.
|
||
|
|
f4a4d6dceb |
feat(system): appliance locale defaults endpoint + frontend i18n bootstrap
Closes the cross-repo contract started in bambuddy-appliance: the firstboot
wizard writes /etc/bambuddy/local.toml with the user's hostname / timezone /
locale, but nothing on the main app side read it. Hostname + timezone are
already applied by the appliance's firstboot.sh via hostnamectl /
timedatectl. This PR closes the loop for the third field — locale — so the
language the user picked in the wizard actually shows up on first SPA load.
backend/app/core/local_config.py
New module. read_local_toml(path) returns a LocalConfig TypedDict
({hostname?, timezone?, locale?}) parsed from /etc/bambuddy/local.toml.
Defensive on every failure mode -- missing file returns {}, invalid TOML
returns {} + log warning, non-string values dropped with warning. The
reader never raises; a malformed config never blocks startup.
backend/app/api/routes/system.py
New endpoint GET /system/appliance. Returns {hostname, timezone, locale}
with null for any field not present in the TOML. No auth required: the
frontend i18n bootstrap reads this before auth might be set up, and the
contents are user-set defaults, not secrets. The function calls
read_local_toml() with no args (default path) so tests can monkeypatch
the module's read_local_toml reference to inject fixtures.
frontend/src/i18n/index.ts
One-shot applyApplianceLocale() runs after i18n.init(). Gated by a
bambuddy_appliance_locale_consumed localStorage flag so it runs at most
once per appliance. Fetches /api/v1/system/appliance, validates the
returned locale against supportedLngs, calls i18n.changeLanguage if
valid. Silent .catch() because the endpoint absent / unreachable means
non-appliance install or dev environment -- we leave the LanguageDetector's
choice in place. The consumed flag is set on success; future loads skip
the fetch entirely. Won't override a user's explicit language pick (the
language picker writes to a separate localStorage key, bambutrack_language).
|
||
|
|
bb42b423af |
feat(file-manager): user-authored tags for cross-cutting filtering (#1268)
Third and final piece of #1268, alongside the recursive-search + README-panel commit that landed earlier in 0.2.5b1. Folders express hierarchy (one home per file); tags are orthogonal labels — "toy", "kid-safe", "petg-only" — and a single file can carry as many as the user wants. Reporter wanted to find "every toy regardless of which folder it lives in"; folders alone can't do that without forcing the file into one bucket. Design decisions locked with maziggy before code: - file-only (folders already express hierarchy) - multi-tag filter = AND - tag filter IGNORES the selected folder (cross-cutting by design) - bulk-tagging from multi-select toolbar in v1 - no auto-tags from 3MF metadata (user-authored only) - label-only chips, no color/icon Backend - LibraryTag (id, name, name_key UNIQUE = LOWER(TRIM(name))) in backend/app/models/library.py. Case-insensitive UNIQUE collapses "Toys"/"toys"/"TOYS " into one row, so the route returns 409 instead of silently fragmenting the catalog. - LibraryFileTag(file_id, tag_id) association, composite PK, ON DELETE CASCADE both directions. Deleting a tag drops every chip; files survive. Deleting a file drops its tag links; the catalog row survives. - Both tables auto-create via Base.metadata.create_all — no explicit run_migrations step needed for new tables. - New router at backend/app/api/routes/library_tags.py with: GET /library/tags (list + per-tag file_count) POST /library/tags (create, 409 on case-insensitive dup) PATCH /library/tags/{id} (rename, 409 on collision, self-rename OK) DELETE /library/tags/{id} (cascade) POST /library/tags/bulk-assign (add | remove | replace) - Bulk-assign add is idempotent; replace with empty tag_ids clears the file's tag set. Per-file ownership enforced — *_OWN callers can only modify their own files; unknown file_ids quietly skipped (matches library_trash bulk shape). - list_files gains tag_ids: list[int] query param. AND semantics via JOIN + GROUP BY + HAVING COUNT(DISTINCT) — portable across SQLite and Postgres. When tag_ids is non-empty, folder_id / project_id / include_root / recursive are all bypassed so the result is cross-cutting. - FileListResponse gains tags: list[{id, name}] via selectinload(LibraryFile.tags) — N+1-free chip render. - Permissions reuse existing constants: LIBRARY_UPDATE_ALL for catalog mutations (global catalog, ownership-aware update isn't meaningful), LIBRARY_UPDATE_ALL/OWN pair for bulk-assign, LIBRARY_READ_ALL/OWN for list — file_count projection narrows for *_OWN callers so chip counts match what they actually see. Frontend - LibraryTagsModal (catalog CRUD) opens from the toolbar's new Tags button. max-w-4xl so multi-language subtitles don't wrap. Delete-with-warning when file_count > 0 ("removes the chip from all of them; files themselves are untouched"). - BulkTagsPickerModal opens from the multi-select toolbar (new Tag button between Move and Delete). Add/Remove radio, checkbox list, inline "create new tag" disabled on dup. Apply disabled until at least one tag is selected. The replace action is exposed in the API but deliberately NOT in this UI — arbitrary multi-file replace is destructive and confusing. - FileManagerPage integration: * selectedTagIds state, sorted into the useQuery key so the cache hits are stable regardless of toggle order * filter rail above the file list lists EVERY catalog tag as a togglable chip — inactive outlined, active filled green with an X. Clear all when 1+ active. Bar hidden entirely when catalog is empty. * useEffect prunes selectedTagIds when a tag is deleted from the catalog so the filter never strands on a phantom id * dedicated Tags column in list view at minmax(0,200px) between Prints and Actions * grid view chips render below the metadata block * chip clicks stop propagation so they don't toggle file selection - libraryTagsQueryKey extracted to frontend/src/utils/ libraryTagsQuery.ts so component files export only components (Vite react-refresh rule). - LibraryFileListItem.tags is OPTIONAL even though the backend always emits an empty array — legacy msw mocks in pre-existing tests construct partial file shapes without the field. Without the ? the FileCard renderer crashed on .length and broke 49 unrelated tests across FileManagerPage + FileManagerExternalFolder. Read sites use file.tags ?? []. |
||
|
|
5cbefca6a0 |
feat(file-manager): recursive subfolder search + per-folder markdown description panel (#1268)
Reporter (@zumik3-del, seconded by @unLieb) asked for three File Manager
improvements: recursive search, tags, and a markdown preview side panel.
This commit ships the two scoped ones; tags is held back gated on the
"give the issue a thumbs up" interest check Martin posted on the issue
because it's a much larger surface (M2M schema, CRUD endpoints, tag UI +
filter + autocomplete + i18n for the management surface) and isn't the
right call without a real demand signal.
1) Recursive search inside the selected folder.
Until now, selecting "Toys" and typing "robot" only found files
directly in Toys/ — anything under Toys/Cars/Race/ stayed invisible.
The page's client-side filter ran over a server-narrowed listing
(/library/files?folder_id=X is strict equality on folder_id), so the
client filter couldn't see what the listing never loaded.
list_files (backend/app/api/routes/library.py:1729+) gains a
recursive=true query param. When combined with folder_id, the route
walks library_folders.parent_id via a recursive CTE rooted at the
requested folder and returns every descendant folder's files in one
query. Recursive CTEs work on both SQLite >=3.8.3 (2014, well below
Bambuddy's runtime floor) and Postgres without dialect branching.
Default off so the existing folder-browsing call sites (Project /
Archive detail, the FE's no-search case) keep their narrow scope.
FE opts in only when both a folder is selected AND searchQuery is
non-empty (FileManagerPage.tsx — derived as searchExpandsSubfolders,
threaded through the useQuery key so the cache invalidates on
toggle). Small "Including subfolders" caption renders under the
search input when active so the user understands why a file from two
levels deep showed up.
2) Per-folder markdown description panel.
New endpoint GET /library/folders/{folder_id}/readme returns the
first .md file in the folder as {filename, content, truncated}.
Selection prefers README.md / readme.md / description.md
(case-insensitive via func.lower(filename) LIKE '%.md' + an
in-Python stem-preference sort), falls back to the
alphabetically-first *.md otherwise. 404 when no markdown is present
so the FE can hide the side panel — non-users pay no UI cost.
Bytes are clipped at 512 KiB (_README_BYTES_CAP) with a truncated
flag so the panel can warn the reader. UTF-8 decode uses
errors="replace" so one bad byte never blanks the panel.
New FolderReadmePanel.tsx fetches on folder-select and renders via
react-markdown@9 + remark-gfm@4 (tables, strikethrough, task lists).
Collapsible (default expanded), max-height 24rem with internal
scroll. react-markdown 9 doesn't render raw HTML by default — no
dompurify needed. Links open in a new tab with rel=noopener
noreferrer. Tailwind has no typography plugin in this project so
per-element components map h1/h2/h3/p/ul/ol/code/blockquote/table
to explicit utility classes that match the rest of the app.
Scope and permissions.
Both endpoints reuse the existing LIBRARY_READ_ALL / LIBRARY_READ_OWN
ownership-aware pair, so a viewer-tier user with read_own only sees
their own files in recursive listings and can only fetch the README of
folders containing their own files. No new permission, no DB migration.
The recursive CTE is a single SQL query — no N+1, no per-folder
round-trip, scales to deeply-nested model libraries.
|
||
|
|
7e6b390d74 |
feat(notifications): template-driven finish-photo email embed + user_print_* rename (#1792)
Reporter (email provider, "Reason: unknown" failures) wanted a camera snapshot in failure emails. The finish-photo capture path shipped in 0.2.5b1 (#1397) already loads JPEG bytes into archive_data["image_data"] for terminal print events, and pushover/telegram/discord/ntfy users have been getting them. Email was the one provider that dropped the bytes on the floor. Reporter separately flagged that the Message Templates list shows "Print Completed" and "User Print Completed" with no visual cue they're different dispatches. Both fixes in one commit because they touch the same UI surface (Message Templates) and both stem from the same reporter conversation. 1) Inline finish-photo embed in email — template-driven, opt-in. _send_email now accepts finish_photo_url alongside image_data. Inline embed fires only when bytes are present AND URL is set AND the rendered body contains that URL — i.e. the user's template referenced the existing {finish_photo_url} variable. The multipart/related shape wraps a multipart/alternative (plain + HTML) plus an inline MIMEImage with Content-ID: <bambuddy-finish-photo>. The HTML part replaces the escaped URL in-place with the cid <img>, so the image appears WHERE the user put the variable in the template, not stapled to the bottom. Plain-text part keeps the URL as a clickable link for non-HTML clients. First draft of this fix unconditionally inlined the photo whenever image_data was present, which bypassed the template system. Reverted to the template-driven contract: default templates unchanged, opt-in by editing the template body to include {finish_photo_url}. 2) user_print_* template name disambiguation. The four user_print_* templates are the per-user SMTP emails sent to the print's submitter (advanced-auth-only path). They shared the "Print Completed" / "Print Failed" / etc. short names with the broadcast provider templates, so the Message Templates list was indistinguishable. The EVENT_NAMES display map in routes/notification_templates.py already used the disambiguated "… Email" labels, but the seed wrote the short name to the DB. DEFAULT_TEMPLATES now seeds the four user_print_* rows with " Email" suffix so fresh installs are correctly labelled. New _migrate_rename_user_print_template_names runs on startup and updates existing rows where the name still matches the old default. Admin-edited names are preserved. Standard SQL UPDATE works on both SQLite and Postgres without dialect branching. |
||
|
|
57e312b38c | feat(inventory): by-tag spool lookup, readable with Manage-Inventory keys (#1663) (#1700) | ||
|
|
9d74f9281b |
feat(deficit): backup-aware filament deficit check, colour-strict (#1762)
When the printer reports ams_filament_backup=True, compute_deficit_for_queue_item pools remaining_grams across spools matching (preset, colour) on the same printer (scoped per extruder on dual-nozzle) before declaring a per-slot shortfall. Identity is strict: same slicer_filament preset AND same colour (alpha-normalised). Two PETG HF spools in different colours are NOT pooled — the firmware would swap correctly but the print would change colour mid-run. Spoolman side mirrors the rule via filament.id + color_hex. Backup OFF falls back to the pre-PR per-slot accounting line-for-line. 8 new test cases in TestFilamentDeficitBackupAware pin pool covers, pool insufficient, different presets, backup-OFF regression, dual- extruder side scoping, no-preset never pairs, colour-strict, and alpha-hex normalisation. The 8 pre-existing test_filament_deficit.py cases stay green. feat(printers): AMS Filament Backup modal with BS-style ring per pair Badge click on the Filaments section header (#1766) now opens a modal: filament-colour ring per backup pair, material name + rotation count in the centre, slot labels distributed around the colour band on contrast-aware pills. Closely modelled on Bambu Studio's Auto Refill widget. Lone slots are intentionally not listed. R / L badges per ring when the extruder map carries two distinct values; collapses to no- badge rendering for single-nozzle printers misflagged as dual. Esc keypress closes the modal. Theme-aware via CSS variables matching AMSHistoryModal. computeBackupGroups helper in utils/amsHelpers defensively dedupes duplicate ams.id entries observed on switch-VP aggregations. 10 modal render cases pin: Esc closes / unmount nulls the listener / ring renders for pairs and omits lone slots / R-L badges only when extruder map has distinct values / empty state / toggle gating. 13 frontend cases pin computeBackupGroups identity rules. feat(printers): active-print P-N pill on AMS slot tiles during RUNNING While the printer is mid-print, each AMS slot tile referenced by status.ams_mapping carries a small "P1 / P2 / P3" pill in the top- right corner, naming which print-slot is mapped to that AMS slot. Catches the #1762 comment-2 scenario: a queue job set for "any X1C" staged to a printer with mismatched filament, no way to verify mid- print. Same wire data (status.ams_mapping is already on the wire) — the addition is purely surface. The existing ring-bambu-green highlight for effectiveTrayNow keeps its meaning (currently extruding RIGHT NOW); the pill is the per-slot static assignment for the active print. chore(scheduler): log Print Anyway short-circuit at INFO _block_on_filament_deficit logs at INFO when it honours item.skip_filament_check, so a future "Print Anyway didn't work" report (third commenter on #1762 hit this shape) has actionable evidence in the standard support bundle without DEBUG. Bundled because the deficit fix makes the original symptom disappear for users with backup ON. |
||
|
|
4206d675eb |
feat(notifications): dedicate AI Failure Detection notification event (#1794)
Split Obico failure-detection dispatch out of the multiplexed
on_printer_error event onto its own on_ai_failure_detection event so
users can subscribe to AI alerts without also enabling HMS hardware-
error pages, and so the discoverable label "AI Failure Detection" is
what subscribes them rather than the unrelated "Printer Error" toggle.
New column on notification_providers (default False, branched
SQLite/Postgres migration), new notification_service.on_ai_failure_detection
method, new ai_failure_detection template, obico_actions._notify swap.
Frontend gets a summary badge, a toggle row with description, and ntfy
priority surfacing. 14 new tests pin the routing + the regression guard
("Printer Error" alone must NOT receive AI notifications now). 11 locales
covered.
Existing providers keep working: HMS hardware errors continue to ride
on_printer_error unchanged; users who want spaghetti alerts opt in via
the new toggle.
|
||
|
|
12b21ce0df |
fix(notifications): sync finish-photo producer→consumer to land the photo on FINISH-state fallback (#1790)
On the FINISH-state fallback path bambu_mqtt.py:3258 dispatches on_finish_photo_moment and on_print_complete back-to-back, so the moment-producer's RTSP grab and the print-complete consumer's cache read race — consumer wins the empty pop, then its own RTSP fallback times out against the producer's in-flight grab (Bambu printers allow one RTSP client). 394 KB frame captured, notification went text-only. Add a per-printer asyncio.Event in _stage22_finish_in_flight: producer registers before first await, sets it in finally on every exit; consumer awaits with a 20s timeout (15s producer grab + headroom) before the cache pop. Closes the race AND the concurrent-RTSP timeout in one change. Timelapse path skips the event, so its branch is unchanged. |
||
|
|
166e9f9ef2 |
fix(vp): correct #1780 root cause — VP intake key mismatch dropped every slicer field
First-attempt fix ( |
||
|
|
4d16faed76 |
feat(file-manager): sort folder tree by recent activity (#1770)
Reporter has a lot of nested cad / slicer directories and wanted "folders that just got a new 3MF" surfaced without scrolling the alphabet. Tree was always alphabetical; LibraryFolder.updated_at only bumps on rename / move, not on file-add inside the folder. Backend exposes latest_activity_at = max(folder.updated_at, max(immediate-child file.updated_at)) on FolderResponse + FolderTreeItem. The /folders tree route picks up a sibling func.max(updated_at) group-by alongside the existing file-count subquery; the by-project / by-archive / single-folder routes collapse count + max into one trip. Recursion across subfolders is intentionally not computed - bubbles immediate parent only, keeps the query a single GROUP BY rather than a recursive CTE. Frontend adds a folder-sidebar sort dropdown (By name / By recent activity) plus an asc / desc arrow, persisted in localStorage. sortedFolders memo applies the comparator recursively so order is consistent at every depth. Empty folders fall back to name within the activity bucket so they never elbow a recently-used folder to a random position. Both the desktop sidebar and the mobile selector consume the sorted list so order is identical across breakpoints. External folders: LibraryFile rows are created for scanned external files too, so the aggregate works on them - but the timestamp reflects last scan, not filesystem mtime. Documented in the wiki. Same change also fixes File Manager list-view column alignment: header and body were sibling grids with min-content as the trailing column, computed independently. Header empty trailing div resolved to 0; body action strip to ~220px. Different trailing widths gave the 1fr Name column different remaining space, shifting every fixed column to its right. Replaced min-content with fixed 220px in both auth-on / auth-off grid templates. |
||
|
|
68b9d741d9 |
feat(humidity): per-filament humidity threshold for auto-drying + alarms (#1605)
Reporter @thenewguy runs an engineering farm with one AMS per material
(PLA, ASA, Nylon, PVB, HIPS) — Bambuddy's single global ams_humidity_fair
threshold (default 60%) was driving both the queue / ambient auto-drying
trigger AND the hourly humidity alarm uniformly, which is wrong for
multi-material setups where Nylon wants <10% and PLA is fine at 60%.
Drying RUN parameters were already per-filament via drying_presets;
this commit adds the missing per-filament TRIGGER.
New setting ams_humidity_thresholds — JSON map of filament-type to
threshold percent with a "default" key for unknown / unmapped types.
Empty / unset → both consumers fall back to ams_humidity_fair so the
upgrade is silent.
Resolver lives in PrintScheduler.resolve_humidity_threshold(trays,
thresholds, fallback) — picks the lowest (most-restrictive) threshold
across all loaded tray types, matching the conservative-params strategy
_get_conservative_drying_params already uses for temp / hours. Empty
tray slots contribute no constraint; all-empty AMS falls through to the
"default" key. Filament names normalized to uppercase base (so
"PLA Basic" / "pla basic" both map to PLA).
Two consumer sites rewired through the same resolver so the scheduler
and the alarm path can never disagree about whether an AMS is "too
humid":
- print_scheduler.py::_check_auto_drying — per-AMS humidity comparison
for start / stop / skip decisions.
- main.py AMS sensor / alarm worker — hourly humidity alarm notifier.
UI: new table in Settings → Workflow → Auto-Drying, below the existing
Drying Presets table. Default row + 8 default filament types
(PLA / PETG / TPU / ABS / ASA / PA / PC / PVA) pre-filled from the
current ams_humidity_fair value so the editor starts sensibly.
Input pattern: draft-on-edit / commit-on-blur (transient humidityDrafts
state per row). onChange only updates the draft; onBlur (and Enter)
parses + clamps to [5, 95] + commits. Empty value on blur clears the
override and falls back to default. Caught mid-PR via a typing test:
the naive per-keystroke clamp snapped "3" → 5 before the user could
type the second digit of "30".
Setting is in the public _UI_PREFERENCE_FIELDS allowlist (same rationale
as drying_presets and ams_humidity_fair — non-sensitive integer map,
no SETTINGS_READ permission required for badge-color rendering).
|
||
|
|
25a23eadd7 |
fix(updates): switch Windows installer installs to release-asset update flow
In-app "Install Update" on Windows installer installs failed with "Could
not find git executable" because (1) _find_executable's fallback paths
are Unix-only, and (2) the installer stages backend/ via shutil.copytree
so there is no .git directory — even with Git for Windows installed, the
fetch would die on "not a git repository". Adding Windows paths would
only have changed which error users saw.
Switches the Windows installer path to a fourth update_method
("windows_installer") that mirrors the existing docker / ha_addon
branches — surface a link to the release .exe and let the user re-run
the installer, matching the Discord / Spotify Windows update model.
Backend:
- New _is_windows_installer_install() — true iff sys.platform == "win32"
AND no .git in app_dir, so Windows devs with a real git clone keep
the git path.
- New _find_windows_installer_asset() picks the matching release asset
(prefers versioned bambuddy-<ver>-windows-x64-setup.exe, falls back
to the unversioned alias on non-daily tags).
- /updates/check now returns is_windows_installer / update_method /
installer_download_url.
- /updates/apply short-circuits with a friendly message after the
existing HA / Docker guards — defense in depth, the frontend swaps
the button so the POST should not fire on Windows.
Frontend:
- UpdateCheckResult extended with the new fields and 'windows_installer'
in the update_method union.
- SettingsPage renders a Bambu-green styled <a target="_blank"
rel="noopener"> between the Docker snippet and the in-app Update
button, with installer_download_url falling back to release_url then
the tag page so the link is never broken.
- applyUpdateMutation onSuccess toast guard extended to treat
is_windows_installer the same as HA / Docker.
|
||
|
|
a5fe5cb3d4 |
feat(sponsor-prompt): in-app toast at earned milestones
ghcr.io pull baseline (~10k/day rising → ~8-12k active installs) puts
sponsor conversion at 0.08% — roughly an order of magnitude under
industry-benchmark for OSS with visible CTA. The Settings banner from
|
||
|
|
090c180ebf |
feat(heater-history): track nozzle / bed / chamber readings + per-tile chart-icon overlay opens history modal
New PrinterSensorHistory table + 60s recorder + GET/DELETE /printer-sensor-history route gated behind a new PRINTER_SENSOR_HISTORY_READ scope (separate from AMS). UI adds a 10x10 LineChart icon on each heater tile - click body opens the existing target-temp popover unchanged, click icon opens a HeaterHistoryModal mirroring the AMSHistoryModal shape (kind toggle + 6h/24h/48h/7d range + current/avg/min/max + recharts line for value + dashed target). Read-only X1C/P2S chamber tile finally gets an interaction. Retention configurable via printer_sensor_history_retention_days (default 30, sibling of ams_history_retention_days). 8 new i18n keys translated in all 11 locales, parity green. 4 backend + 6 frontend tests added; full pytest -n 30 6226/6226, vitest 2176/2176, ruff/eslint/build all clean. |
||
|
|
a53dc20ca3 |
fix(usage-tracker): split mid-print AMS-Backup spool switch correctly (#1771)
Reporter forcefully started a print needing ~260 g with 180 g on the
first spool and a backup spool in the AMS. Printer correctly consumed
spool 1, AMS Backup switched, spool 2 finished the print. Bambuddy
attributed all 260 g to spool 2 -- spool 1 untouched in inventory.
Two stacking bugs produced the exact "all to second spool" symptom for
prints without per-layer 3MF gcode data:
1. bambu_mqtt.py:2135 wrote state.total_layers = int(data["total_layer_num"])
unconditionally. P1S firmware pushes total_layer_num=0 at print end
(same reset pattern other models do for layer_num / progress). The
unconditional write clobbered the slicer's actual total to 0 before
the usage tracker read it.
2. usage_tracker.py:1129-1137 linear-fallback dumped EVERYTHING onto the
last segment when total_layers was 0:
if total_layers > 0:
segment_grams = total_weight * (seg_end_layer - seg_start_layer) / total_layers
else:
segment_grams = 0.0 # <- entire print weight ends up on last segment
Path 2 (AMS remain% delta) couldn't recover because (a) the emptied
spool reported remain=-1 and (b) Bug-A had already added the second
spool's key to handled_trays, suppressing the Path 2 lookup.
Fix:
- bambu_mqtt.py: only overwrite state.total_layers when the incoming
value is positive (mirror of the existing _last_valid_layer_num
pattern at line 2127). Explicit reset on new print start at
_handle_print_start so the previous print's total can't bleed in.
- usage_tracker.py: cascade the linear-fallback denominator -
state.total_layers, then last_layer_num (already threaded in for
the last_progress fallback), then equal-split as a bounded fence.
Equal-split is still wrong but never dumps the whole print on the
last segment, which was strictly worse.
|
||
|
|
b1cb26f6ee |
feat(ams-backup): add status badge + toggle, fix prefer-lowest (#1766)
Two tightly-coupled deliverables in one drop -- a new AMS Filament Backup status/control surface, and the #1766 fix that depends on it. Added -- AMS Filament Backup status + control - Parse bit 18 of top-level print.cfg into PrinterState.ams_filament_backup on every push_status. Verified against OrcaSlicer source (DeviceManager.cpp:4961) and a live H2D ON/OFF capture. Tri-state (None = A1 family / pre-cfg push) preserves today's behaviour. - Hold-timer guard (3 s) prevents stale frames from flickering the badge back to the printer's old cfg after a user-initiated toggle. - POST /printers/{id}/ams-backup toggle, set_ams_filament_backup() client method calling _set_print_option("auto_switch_filament", enabled). - GET /printers/{id}/inventory-remain endpoint exposes the same map the dispatcher uses (internal and Spoolman modes both work uniformly). - Small icon badge in the printer card's "Filaments" section header (placement reads as printer-wide because the cfg bit is printer-wide, not per-AMS). Click to toggle, success toast. - 5 i18n keys x 11 locales for the badge UI. Fixed -- #1766: prefer_lowest didn't pick lowest, ignored backup state - Backend gate in _compute_ams_mapping_for_printer: coerce prefer_lowest to False when status.ams_filament_backup is False; log the skip. - New effectivePreferLowest(setting, backup) helper applied at every frontend sort entry point: single-printer PrintModal, multi-printer hook per-printer, PrinterSelector InlineMappingEditor, FilamentMapping standalone editor (the last had NO preferLowest awareness at all before this change). - New preferLowestSortKey(f, inventoryByTrayId) mirrors backend's two-tier key exactly, including the banding tie-break (regular AMS < AMS-HT < external) so the client-side pre-compute matches the dispatch-time pick. An earlier draft used a flat `amsId * 4 + trayId` priority which gave external slots (ams_id = -1) a NEGATIVE priority -- caught in code review before commit. - Settings -> Filament -> "Prefer lowest remaining filament" gets an explanatory note about the printer-side AMS Backup dependency, with i18n key in all 11 locales. |
||
|
|
2f6007a148 |
fix(notifications): scope completion notification to printed plate on multi-plate 3MFs (#1785)
The 3MF parser sums prediction + weight across every plate (#1593) so the archive card can headline the whole project — correct for the card, wrong for the completion notification of a single plate. The queue UI already re-reads the 3MF per-plate at print_queue.py:272-285; mirror that for the notification path so Discord / Pushover / email show the plate's actual duration and grams instead of the project sum. Helper fails open on every error path so a missing or corrupt 3MF can't block the notification. |
||
|
|
d196cfc500 |
fix(virtual-printer): forward H2C rack-swap nozzle pick from slicer to dispatch (#1780)
BambuStudio's project_file MQTT command for O1C2 (the H2C dual- nozzle-rack variant) carries nozzle_mapping (per-filament physical nozzle position IDs) and nozzles_info (per-extruder rack metadata). The VP intake was dropping both, so the H2C firmware fell back to "last matching nozzle type" auto-pick and ignored the user's slicer choice — every HF print landed on R2, every standard print landed on R4. Carry both fields through the VP intake → queue item → MQTT dispatch path. New nullable TEXT columns on print_queue, non- branched ALTER (matches ams_mapping / filament_overrides precedent). Dual-nozzle gate at start_print() keeps the fields off single-nozzle dispatches. Fail-open on malformed JSON — firmware auto-picks, never worse than pre-fix. Stamps both fields on every plate in the multi-plate Send All loop (#1697 / #1188 precedent). ams_mapping2 still handles H2D/X2D dual-extruder routing unchanged; this fix is scoped to the O1C2 rack-swap mechanism. |
||
|
|
ceb0616c82 |
chore(deps): backend security floor bumps + 422 constant rename
requirements.txt
- cryptography 46.0.7 -> 48.0.1 floor (GHSA-537c-gmf6-5ccf,
non-contiguous Python buffer handling)
- python-multipart 0.0.27 -> 0.0.31 floor (CVE-2026-53538/53539/53540,
multipart parser hardening)
- starlette 1.1.0 -> 1.3.1 floor (CVE-2026-54282/54283, FormParser
limit enforcement + StaticFiles absolute-path rejection)
- pyopenssl 26.0.0 -> 26.3.0 floor (NOT a security fix; pyOpenSSL
<26.3.0 caps cryptography<47 and would otherwise downgrade out
of the GHSA-537c-gmf6-5ccf fix line)
backend/app/api/routes/mfa.py
- 3x HTTP_422_UNPROCESSABLE_ENTITY -> HTTP_422_UNPROCESSABLE_CONTENT
(the former is deprecated in starlette 1.3.x, same 422 wire status;
the 2 remaining warnings are inside FastAPI itself, upstream's)
Release-notes review done before bump: cryptography 47/48 dropped
binary EC, CFB/OFB/CFB8, Camellia, PUBLIC_KEY_TYPES/PRIVATE_KEY_TYPES,
OpenSSL 1.1.x, Python 3.8 -- grep clean against every removed surface;
starlette's newly-enforced max_part_size=1MB only applies to text form
fields (verified in MultiPartParser.on_part_data), file streams from
UploadFile = File(...) are unaffected; python-multipart 0.0.30 dropped
RFC 2231/5987 filename* parsing, minor cosmetic impact on non-ASCII
filename uploads, plain filename= fallback still works.
|