Commit Graph
3270 Commits
Author SHA1 Message Date
maziggy c31c8dc4d0 security(fix): Bumped brace-expansion to ^5.0.7 2026-07-22 16:16:41 +02:00
MartinNYHC bf0d0ea6d4 Merge pull request #2633 from maziggy/dependabot/npm_and_yarn/frontend/npm_and_yarn-ddcf56f842
build(deps-dev): bump js-yaml from 4.2.0 to 4.3.0 in /frontend in the npm_and_yarn group across 1 directory
2026-07-22 16:11:54 +02:00
dependabot[bot] 367942018c build(deps-dev): bump js-yaml
Bumps the npm_and_yarn group with 1 update in the /frontend directory: [js-yaml](https://github.com/nodeca/js-yaml).


Updates `js-yaml` from 4.2.0 to 4.3.0
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...4.3.0)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.3.0
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-22 13:47:58 +00:00
maziggy 8afc9b2d19 Housekeeping 2026-07-22 15:45:59 +02:00
maziggy 3372d959ad security(frontend): bump linkify-it and dompurify to patched releases
npm audit flagged both against the production dependency tree, and the
Frontend Security job fails on any fixable high-severity finding there
(FIXABLE HIGH: linkify-it).

linkify-it 5.0.1 -> 5.0.2 (GHSA-v245-v573-v5vm, high, CVSS 7.5) fixes a
quadratic-complexity DoS in the mailto: validator scan loop. It reaches us
only through prosemirror-markdown inside @tiptap/pm; the editor's own
autolinking uses linkifyjs, which is a different package and unaffected.
Nothing under frontend/src/ imports prosemirror-markdown or markdown-it and
neither appears in the production bundle, so the vulnerable code is tree-
shaken out and no running install was exposed.

dompurify 3.4.11 -> 3.4.12 (GHSA-c2j3-45gr-mqc4, low) fixes a
CUSTOM_ELEMENT_HANDLING bypass of afterSanitizeElements for allowed custom
elements. DOMPurify is shipped, but we never set CUSTOM_ELEMENT_HANDLING and
register no afterSanitizeElements hook, so the bypass has no precondition;
ProjectPageModal additionally passes a strict ALLOWED_TAGS/ALLOWED_ATTR
allowlist.

Both patched versions already satisfy the ranges their parents declare, so
this is a lockfile-only change - no overrides entry needed, package.json
untouched. npm audit reports zero vulnerabilities, npm run build is clean,
and all 2423 frontend tests pass.
2026-07-22 15:44:52 +02:00
maziggy 77e4273699 Updated BACKERS 2026-07-22 10:45:54 +02:00
maziggy 8a2608643d Updated BACKERS 2026-07-19 13:30:02 +02:00
maziggy dfdc1c67e6 ci: upgrade setuptools in pip-audit jobs to clear PYSEC-2026-3447
The GitHub runner's Python toolcache ships setuptools 79.0.1, which
pip-audit flags for PYSEC-2026-3447 (fixed in 83.0.0), failing the
blocking Backend Security job. A fix version exists, so upgrade
setuptools in the install step rather than --ignore-vuln. Applied to
both ci.yml (blocking) and security.yml (scheduled scan).
2026-07-17 08:59:30 +02:00
maziggy f4f7356c1b Updated BACKERS 2026-07-17 07:17:08 +02:00
maziggy 6f5d68ec6c Updated BACKERS 2026-07-14 10:39:58 +02:00
maziggy 6fdd701a84 fix(repo-stats): plot ghcr pulls on a linear axis so the trend is visible
The ghcr pulls chart used a symlog y axis with a hardcoded tick list, both
copied from github-repo-stats, which builds the rest of the report. Those
settings suit views and clones - small, spiky, frequently zero - but not
container pulls, which sit in a tight band far above zero.

Consequences on the published report: the series (7,946 to 16,160) lives
entirely inside the top decade of the log scale, so a 2x swing rendered as a
14px wobble on a 200px chart and read as a flat line. Of the nine fixed ticks,
six were squashed against the baseline and 50000 fell outside the domain and
never drew, leaving a single usable gridline.

Switch to a linear scale, drop the fixed ticks so Vega derives them from the
actual domain, and format labels with SI prefixes (5k / 10k / 15k). The zero
baseline and the 10% headroom are unchanged, so the axis stays honest; the same
swing now spans 92px and the growth from ~9k to ~15k pulls/day is legible.
2026-07-13 16:07:31 +02:00
maziggy 259da3f185 Make uvicorn bind address configurable via HOST env (default 0.0.0.0)
Allows binding loopback-only (HOST=127.0.0.1) when a reverse proxy on the
same host fronts the app. Default behaviour unchanged.
2026-07-09 15:47:38 +02:00
maziggy 62cccb8348 Updated BACKERS 2026-07-08 08:03:02 +02:00
maziggy 824d16e209 Updated CHANGELOG 2026-07-07 14:17:24 +02:00
maziggy 3ff0bdc570 Updated CHANGELOG 2026-07-07 13:30:59 +02:00
maziggy 15242fe36e test(updates): isolate _update_status global to fix CI-only flake
POST /updates/apply short-circuits (returning a payload without the per-branch
keys like is_windows_installer) when the module-global _update_status is
downloading/installing. A prior test leaving an apply flow mid-update made
test_apply_update_windows_installer_rejection hit that guard instead of the
Windows branch — an order-dependent flake that passed locally but failed on the
sharded CI run with KeyError: 'is_windows_installer'. Add an autouse fixture
resetting _update_status to idle before each TestUpdatesAPI test.
v0.2.4.9
2026-07-07 12:44:53 +02:00
maziggy 4dc3ea7588 Housekeeping 2026-07-07 12:27:41 +02:00
MartinNYHC b27601db54 Merge pull request #2478 from maziggy/0.2.4.9
**Bambuddy 0.2.4.9**

**⚠ Upgrade Notes — Read Before Updating**

0.2.4.9 is a fix-and-polish release on the same 0.2.4 code base — no schema breaks beyond auto-migrated column additions (dialect-branched for SQLite and Postgres), no Docker entrypoint changes. The in-app Apply Update button in Settings → System → Updates works for Docker and for any native install already on 0.2.4.x.

**Four behaviour-change callouts to know about before you upgrade:**

- Plate-clear confirmation now correctly defaults to OFF (#1865, reported by @PurseChicken). On installs that never explicitly saved the "Require plate-clear confirmation" setting, the scheduler was still enforcing the plate-clear gate even though the toggle and the printer card showed it disabled — so a queued print could sit "Waiting" forever on a FINISH-state printer with no UI control to release it. The scheduler now reads the setting with the same default the schema and UI use, so a finished printer dispatches the next queued job automatically. If you were relying on that gate to hold the queue until you physically clear the bed, explicitly turn on Require plate-clear confirmation after upgrading — the "Mark plate as cleared" button and plate badge reappear on the card when it's on.
- File Manager "All Files" now scopes to your own uploads. The sidebar's All Files entry now shows the files you uploaded; a new "External" entry holds the combined linked-folder view that used to be mixed in. If you were used to seeing linked-folder content under All Files, it's now one entry down under External — no data moved, just the sidebar grouping.
- Slicer sidecar now ships as pre-built images (#1657). The slicer-API sidecar is now published on GHCR and Docker Hub, so it runs on QNAP / Synology / Container Station out of the box without a local build. If you run the sidecar, pull the published image instead of building; the wiki sidecar instructions are updated.
- Light theme contrast overhaul (#1909, reported by @AntonPalmqvist). If you run the light theme, a large number of previously washed-out status banners, badges, and form fields are now readable. Dark theme is pixel-identical.

Make a backup before upgrading via Settings → Backup → Create Backup. Native install with update.sh snapshots the database automatically and rolls back on failure. Docker and fully-manual paths don't.

Docker

docker compose pull
docker compose up -d

docker-compose.yml doesn't need refreshing for 0.2.4.9.

Native install — recommended path

sudo BRANCH=main /opt/bambuddy/install/update.sh

Snapshots the database first and rolls back on failure.

Native install — manual path

sudo systemctl stop bambuddy
cd /opt/bambuddy
sudo -u bambuddy git fetch --prune --tags --force origin
sudo -u bambuddy git checkout main
sudo -u bambuddy git reset --hard origin/main
sudo /opt/bambuddy/venv/bin/pip install -r requirements.txt
cd frontend && sudo npm i
sudo systemctl start bambuddy

Windows install

Download bambuddy-0.2.4.9-windows-x64-setup.exe from this release page (or the unversioned bambuddy-windows-x64-setup.exe alias for an always-latest link). This release fixes a fresh-install failure where the service reported "running" but nothing listened on port 8000 (#2474) — a missing C++ runtime DLL that greenlet needs is now bundled. Existing Windows installs upgrade in place via the in-app "Install Update" flow.

---
**Highlights**

0.2.4.9 is a stabilisation release — the headline is install robustness on Windows and macOS, a light-theme contrast pass, and a scheduler fix that could silently freeze queues.

The plate-clear scheduler fix (#1865, reported by @PurseChicken) is the one to know about: on installs that never explicitly saved the setting, a FINISH-state printer would never dispatch the next queued job even though the "Require plate-clear confirmation" toggle showed as off — see Upgrade Notes.

Install paths got hardened on both new platforms: the Windows .exe no longer starts a dead service on a clean Win10 box (#2474, @fangme), and the macOS native install is now fully rootless (no more Homebrew/venv permission failures) with a fixed Virtual Printer bind-interface list.

Smaller-but-useful: native CSV import / export on the inventory page (#1576, PR by @samedyuksel), custom-subnet printer scan for printers behind a different L3 segment (#1564, @MartinNYHC), thermal-printer-friendly spool-label QR codes with a black-and-white monochrome option (#1870, @fireboyff / @Geoff-S), and a finish photo that no longer catches the bed already dropping away (#1397).

---
**New Features**

- Inventory page now supports native CSV import / export (#1576, PR #1659 by @samedyuksel).
- Spool labels: scannable QR on 203 dpi thermal printers + monochrome mode for black-and-white printers (#1870, reported by @fireboyff, monochrome requested by @Geoff-S).
- Add Printer: scan a custom subnet for printers behind a router on a different L3 segment (#1564, reported by @MartinNYHC).
- Orca Cloud profile sync — end-to-end integration with the slicer + SpoolBuddy surfaces.
- Finish photo no longer shows the bed already dropped (#1397, reported by @rtadams89, @Jeff-GebhartCA, @MA2ZAK).
- Connection diagnostic now verifies the printer is actually publishing on its report topic (#1622).
- Virtual Printer MQTT bridge now surfaces why a net.info[].ip rewrite didn't arm (#1429, defensive diagnostics).

---
**Changes**

- Slicer sidecar now ships as pre-built images on GHCR + Docker Hub — install works on QNAP / Synology / Container Station without a local build (#1657). See Upgrade Notes.
- File Manager sidebar: "All Files" now scopes to your own uploaded files; a new "External" entry holds the combined linked-folder view. See Upgrade Notes.
- Empty AMS units no longer trigger hourly humidity / temperature notifications (#1619).
- AMS drying now enabled for H2C starting at firmware 01.02.00.00.
- Virtual Printer access code is now auto-derived from the target printer in non-proxy modes.
- Inventory: /reset-usage renamed to /reset-consumed-counter; the UI label is now "Reset counter".
- docker-compose.yml: added a bridge-mode warning about the 1001-port FTP passive range + docker-proxy RAM footprint (#1646).

---
**Fixed**

Print queue + scheduler

- Queued prints never dispatched to FINISH-state printers when "Require plate-clear confirmation" was disabled (#1865, reported by @PurseChicken). See Upgrade Notes.

Connection / camera

- External camera "connection lost" when the snapshot URL serves a non-JPEG image — snapshots are now transcoded to JPEG (#1902).
- Virtual Printer "bind interface" dropdown was empty on macOS — interface enumeration now routes non-Linux platforms through psutil.

Install / backup

- Windows installer: fresh install failed to start ("connection refused", nothing on port 8000) — the C++ runtime DLL greenlet needs is now bundled (#2474, reported by @fangme).
- Native install script failed on macOS with Homebrew/venv permission errors — the macOS path is now fully rootless.
- PostgreSQL restore from a SQLite backup no longer deadlocks against the print scheduler.

UI / rendering

- Light theme: low-contrast washed-out text on status/warning banners, badges, and form fields, app-wide (#1909, reported by @AntonPalmqvist).
- Sponsor toast ignored its 14-day cooldown and re-fired on every fresh browser session (#2477, reported by @pchulpjoost).

---
**Security**

- idna: bumped to >=3.15 to clear CVE-2026-45409 (ReDoS in idna.encode() with crafted Unicode input).

---
**Contributors**

External code contributors with merged PRs in this release: @samedyuksel (#1659 — native CSV import / export on the inventory page). Thank you!

The reporters who drove the fixes in this release are credited inline next to each entry above.

---
**Sponsors**

Bambuddy is sustainable thanks to people who put their money where their use is. If this release saved you time or kept your farm running, the project runs on recurring contributions — there's no paid tier, no telemetry, no upsell, just sustainable maintenance.

- GitHub Sponsors (recurring, 5 tiers from $5/mo to $300/mo) — https://github.com/sponsors/maziggy
- Ko-fi (one-time or recurring) — https://ko-fi.com/maziggy
2026-07-07 12:26:37 +02:00
maziggy 70312b0a11 fix(scheduler): skip per-nozzle filter under FTS so dispatch feeds the right spool (#2186)
On a dual-nozzle H2C with a Filament Track Switch, a queued print targeting
one nozzle fed a same-type wrong-colour spool: the backend mapping
(_match_filaments_to_slots) hard-filtered candidate trays to the requested
extruder, excluding the correct spool loaded in the other nozzle's AMS — which
the FTS can route across. Confirmed from the reporter's captures: same model
mapped to AMS-A slot 2 (black) on the left nozzle but AMS-B slot 3 (red) on the
right. The #1162 FTS-skip existed only in the frontend mapping, never the
queue-dispatch path.

Read fila_switch.installed in _compute_ams_mapping_for_printer and skip the
per-nozzle filter when an FTS is present. Single-nozzle printers are unaffected
(no nozzle_id in the 3MF, no FTS). Regression tests in TestFtsNozzleBypass.
2026-07-07 12:17:46 +02:00
maziggy 5d64935658 Housekeeping 2026-07-07 11:38:24 +02:00
maziggy 2d8b0005cb Housekeeping 2026-07-07 11:11:15 +02:00
maziggy 5cf429f696 feat(labels): scannable QR on 203 dpi thermal printers + monochrome mode (#1870)
The 40x30 mm box label rendered its QR too densely for low-res thermal
    printers — the modules bled together and wouldn't scan. Two causes: the QR
    was 20% of inner width (~7.5 mm on the narrowest template, half of the
    others) and used ERROR_CORRECT_M. Fix adaptively so all templates benefit:
    give the roomy-layout QR a 12 mm minimum size (box_40x30 -> 12 mm, ~3.5
    dots/module at 203 dpi) and switch label QRs to ERROR_CORRECT_L (same
    payload, chunkier modules; a label needs no M-level recovery). Keep the
    quiet-zone border at 2 — the size+L gains suffice without risking scans.

    Also add a Monochrome (black & white printer) option to the label dialog:
    drops the colour swatch (a useless grey block on B&W) and widens the text;
    the hex-code line still carries the colour. Threaded through the renderer,
    route, API client, and modal, with translations in all 11 locales.
2026-07-07 11:07:08 +02:00
maziggy d8d3cde830 fix(scheduler): default require_plate_clear to False to match schema/UI (#1865)
check_queue() read the plate-clear setting with _get_bool_setting(default=True),
    but SettingsSchema.require_plate_clear defaults False and the whole frontend
    treats a missing value as off. Since _get_bool_setting returns its default when
    no DB row exists, installs that never saved the setting enforced the plate-clear
    gate the UI showed as disabled — FINISH-state printers never dispatched and no UI
    control existed to clear awaiting_plate_clear. Read the setting with default=False
    so the enforced behavior matches the schema and the toggle. Both defaults shipped
    together in #752; this aligns them.
2026-07-07 11:06:48 +02:00
maziggy 56185d2bac fix(ui): resolve light-theme low-contrast semantic text app-wide (#1909)
The app was built dark-first, so hundreds of hardcoded Tailwind semantic
    text/icon utilities at light shades (text-amber-400, text-blue-300, ...) had
    no dark: variant. With darkMode:'class' they applied in light theme too,
    producing washed-out text on pale tints and white cards — including the three
    reported spots (AMS Drying banner, Archives no-3MF warning, debug-logging
    banner). Give each a theme-aware pair: a darker readable shade in light theme
    with the original pinned to dark:, so dark theme is unchanged. ~100 files.

    The bambu-* CSS-variable palette (self-correcting) and the dark-only SpoolBuddy
    kiosk are left untouched. Plain text-white is already theme-aware via the
    existing index.css .text-white override, so it needed no changes.
2026-07-07 11:06:27 +02:00
maziggy 85bd68b0cc fix(sponsor): anchor 14-day toast cooldown on show, not just on CTA click (#2477)
The sponsor toast re-fired on every fresh browser session. The backend
    owns the 14-day cooldown but only persists the anchor (last_shown_at) and
    the seen-milestone record inside POST /sponsor-prompt/dismiss, and the hook
    only called dismiss from the "View supporters" CTA onClick. A user who saw
    the toast but never clicked the CTA persisted no state; the per-tab
    sessionStorage guard hid the re-fire within one session, but every new
    session re-checked against empty state and re-showed the same milestone.

    Record the toast as shown the moment it renders (POST /dismiss right after
    showPersistentToast) so display is what arms the cooldown. CTA click stays
    optional and just navigates. Frontend-only; backend cooldown logic unchanged.
2026-07-07 11:06:10 +02:00
maziggy daecfe6ca0 fix(windows): bundle vcruntime140_1.dll so greenlet loads on fresh Win10 (#2474)
A clean Windows 10 install crashed on startup: init_db() -> SQLAlchemy async
    engine -> greenlet failed with "DLL load failed while importing _greenlet:
    The specified module could not be found", so uvicorn never bound :8000 and the
    dashboard refused all connections while the NSSM service still showed running.

    greenlet's _greenlet.pyd is C++ and needs vcruntime140_1.dll, which the
    python.org embeddable distribution does not ship (it includes only
    vcruntime140.dll, enough for the pure-C python313.dll). Machines with the VC++
    2015-2022 redistributable already installed have the DLL in System32, which
    masked the bug in testing.

    Stage vcruntime140_1.dll and msvcp140.dll next to python.exe at build time,
    from a vendored copy or the runner's System32, failing loudly if absent. The
    Inno Setup [Files] step already copies staging\python\* recursively.
2026-07-07 11:05:52 +02:00
maziggy 2119ddd4f9 fix(vp): populate bind-interface list on macOS (route non-Linux to psutil)
get_network_interfaces() only sent Windows to the psutil path; macOS fell into
    the Linux ioctl branch, whose SIOCGIFADDR/SIOCGIFNETMASK ioctls are Linux-only.
    macOS/BSD have fcntl but different ioctl numbers, so every call raised OSError
    and the function returned an empty list — the VP bind-interface dropdown showed
    nothing. Route all non-Linux platforms through the cross-platform psutil path.
2026-07-07 11:05:35 +02:00
maziggy ae7674b3c1 fix(install): make macOS native install rootless (brew + venv permission errors)
macOS mixed root-only steps (default /opt path, sudo git clone) with steps
    that must not run as root: brew refuses to run as root, and a root-owned
    venv/node_modules can't be managed by the launchd agent. The installer now
    refuses sudo on macOS, defaults to ~/bambuddy, and drops sudo from the
    download/venv/frontend/env/dir steps. A --path under a root-owned parent still
    works via a single elevate-and-chown. Linux (service user + systemd) unchanged.
2026-07-07 11:05:17 +02:00
maziggy e3fe2971db fix(camera): transcode non-JPEG external snapshots to JPEG (#1902)
External cameras in HTTP-snapshot mode failed to load with a repeating
    "connection lost" when the endpoint served PNG/WebP/BMP stills instead of
    JPEG (common on IP cameras and reverse-proxied snapshot URLs). The URL
    rendered fine directly in a browser, but Bambuddy's MJPEG stream wraps
    every part in a hard-coded Content-Type: image/jpeg boundary, so a
    non-JPEG payload labelled as JPEG made the browser reject the frame and
    tear down the whole multipart/x-mixed-replace stream.

    _capture_snapshot now transcodes non-JPEG stills to JPEG via OpenCV
    (already a dependency). Genuine JPEG snapshots keep a byte-for-byte fast
    path; truly undecodable responses (HTML error pages, auth redirects) fall
    back to the previous raw-return behaviour with a single clear warning
    instead of a per-frame log flood.
2026-07-07 11:04:58 +02:00
maziggy 33554072ed fix(ui): restore missing per-user Notifications nav item (#1901)
The sidebar-ordering refactor in #1673 accidentally dropped the
    `notifications` entry from `defaultNavItems` and its
    `notifications:user_email` permission mapping, but kept the advanced-auth
    visibility gate that references that id. With no nav entry the id never
    enters the render set, so the /notifications page (route, page, and API
    all intact) became reachable only by typing the URL — users could no
    longer opt in/out of their own print email notifications from the menu.

    Restore both the defaultNavItems entry and the permission gate, matching
    the permission the user-email-preferences API actually requires
    (notifications:user_email, held by both default groups). Add comments so
    the entry isn't dropped again in a future sidebar refactor.
2026-07-07 11:04:33 +02:00
maziggy 6e03ecdb8d fix(vp): stop uvloop from silently truncating VP FTP uploads (#1896)
Native (non-Docker) installs launched uvicorn without --loop asyncio, so
    uvicorn[standard] auto-selected uvloop. uvloop's SSL layer drops
    already-received but still-buffered data when the client closes the data
    connection without a TLS close_notify while the reader is flow-control
    paused on slow storage. cmd_STOR writes each chunk to disk inside the read
    loop, so a slow consumer falls behind, the tail is lost, read() returns a
    clean EOF, and the loop exits with no exception -- the server acked 226 for
    a file it truncated itself, then archived, queued, and forwarded the corrupt
    3MF to the real printer.

    Fix in two independent layers:

    1. Remove the trigger: add --loop asyncio to every native launch path,
       matching the Dockerfile -- deploy/bambuddy.service, install/install.sh
       (systemd + launchd), spoolbuddy/install/install.sh, the Windows NSSM
       service, README, CONTRIBUTING dev command.

    2. Defense in depth (loop-independent): cmd_STOR now validates that a
       received .3mf opens as a ZIP (reads the central directory, no
       decompression) before replying 226. A truncated/corrupt file is dropped
       and answered with 426, and on_file_received never runs -- so a broken
       upload surfaces as an immediate slicer-side send error instead of being
       archived and pushed to the printer. Scoped to .3mf; other filetypes pass
       through unchanged.
2026-07-07 11:04:04 +02:00
maziggy c5b02d9473 fix(auth): let API keys manage projects via new can_manage_projects scope (#1893)
PROJECTS_CREATE/UPDATE/DELETE were in _APIKEY_DENIED_PERMISSIONS with no
    entry in _APIKEY_SCOPE_BY_PERMISSION, so every project mutation returned a
    generic 403 for any API key regardless of granted permissions -- the same
    regression class as archives (#1888) and library (#1832).

    Add a per-key can_manage_projects scope. Project routes gate on plain
    PROJECTS_* (no OWN/ALL split), so all three CRUD permissions map to the one
    scope; membership edits (add-archives) gate on PROJECTS_UPDATE and are
    covered. PROJECTS_READ is unchanged (already under can_read_status).

    Column defaults TRUE for new keys; existing rows backfill to FALSE so the
    upgrade never silently widens scope. Migration is BOOLEAN (SQLite + Postgres
    safe), verified on fresh SQLite and Postgres 17. Bundled SpoolBuddy kiosk key
    set to False. Settings API-key UI gets a Manage Projects toggle + Projects
    badge; 11-locale i18n. RBAC scope matrix + drift guards extended.
2026-07-07 11:03:46 +02:00
maziggy 18dbe63fd5 fix(drying): don't stop a running AMS dry on an unreliable humidity re-check (#1892)
Auto-drying stopped manually started (and pre-restart) AMS drying cycles
    after exactly 30 minutes. The already-drying branch in _check_auto_drying()
    applied a humidity-based auto-stop despite its own "track but don't stop"
    comment, and the humidity re-check is unreliable: RH drops steeply in heated
    air, so the sensor reads ~15-20% within minutes of the dryer starting even
    with saturated filament. humidity <= threshold was thus effectively always
    true, and the _min_drying_seconds=1800 floor pinned the stop to the 30-minute
    mark. This also truncated Bambuddy's own preset-duration dries.

    Remove the humidity-based early-stop entirely: a running dry now runs to its
    configured duration (firmware stops it). Scheduling stops (print priority,
    queue no longer needing the dry) are unchanged via _stop_drying(). Drop the
    now-unused _min_drying_seconds.
2026-07-07 11:03:25 +02:00
maziggy c751047ed8 fix(websocket): stop the ws-token reconnect loop on auth failure
After the GHSA-r2qv gate (b7d7c825), /api/v1/ws needs a token from
    POST /api/v1/auth/ws-token (Permission.WEBSOCKET_CONNECT). When the mint
    failed, useWebSocket swallowed the error, opened a tokenless socket, the
    server closed it 4401, and ws.onclose rescheduled connect() every 3s -
    an endless loop that hammered /auth/ws-token. The dominant trigger is a
    validly-logged-in user whose group lacks WEBSOCKET_CONNECT (mint returns
    403). A secondary leak: the unmount-triggered onclose could schedule a
    post-unmount reconnect.

    Classify the mint failure: 401 (JWT expired; request() already clears it
    and dispatches auth:expired) or 403 (valid session, missing permission;
    degrade to REST polling) now stop the hook - no tokenless socket, no
    reconnect. A 4401 close is terminal. Network/5xx still reconnect. A
    disposedRef set in cleanup before close() prevents the unmount-race
    reconnect. Same 401/403 no-open guard applied to StreamOverlayPage.

    Also surface a one-line hint under the WebSocket permission in the group
    editor (all 11 locales) explaining that live updates need it and fall
    back to polling without it - rather than auto-granting the permission,
    which would partly undo the GHSA-r2qv gate.
2026-07-07 11:03:00 +02:00
maziggy 640c7daaaa fix(auth): don't discard a valid stored token on a transient load-time error (#1889)
On mount, AuthContext.checkAuthStatus restores the persisted "Remember Me"
    token from localStorage and validates it via GET /auth/me. The catch around
    that call cleared the token on ANY failure, not just a definitive 401
    invalid-token — so a brief backend-not-ready or reverse-proxy hiccup during
    page load (plausible right after a container restart, e.g. on Unraid) would
    delete a still-valid token. Because the token was deleted, a reload couldn't
    recover it and the user was bounced to the login screen.

    Token validation now retries transient failures (up to 3 attempts with short
    backoff) and only discards the token on a definitive 401 — which request()
    already handles (clears the token and dispatches auth:expired). Transient /
    5xx / network errors leave the persisted token intact so the session survives
    a slow load. "Remember Me" stays client-storage only; it does not extend the
    server-side JWT lifetime (session_max_hours, default 24h).

    Adds AuthContext tests: transient /auth/me failure keeps the token, a
    definitive 401 clears it, and a valid token loads the user. Rebuilt frontend
    bundle.
2026-07-07 11:02:40 +02:00
maziggy 1fd1825b71 fix(smart-plug): don't cut power when a print restarts, honor per-plug cooldown setting (#1890)
The print-queue "auto off after this job" trigger used a second, inline
    auto-off implementation (main.py, print_scheduler.py, print_queue.py)
    that hardcoded wait_for_cooldown(50C, 600s) — ignoring each plug's
    configured off_delay_mode / off_delay_minutes / off_temp_threshold — and
    ignored the return value, powering off on the 600s timeout regardless of
    print state. A print that failed and was reprinted from the touchscreen
    got its power cut mid-print. The inline tasks were also uncancellable, so
    a reprint couldn't abort a pending off.

    Consolidate all three into SmartPlugManager.schedule_off_after_queue_job,
    which schedules via the plug's configured strategy (shared with
    on_print_complete through _schedule_off_per_mode) and is cancellable via
    _pending_off. Add printer_manager.is_print_active() and guard the actual
    power-off in _delayed_off and _temp_based_off so no path cuts power on a
    loaded print. Move the on_print_start cancellation ahead of the auto_on
    gate so a reprint always aborts a pending off.
2026-07-07 11:02:21 +02:00
maziggy 99d06f3cd1 fix(auth): allow API keys to delete/edit archives via new can_manage_archives scope (#1888)
DELETE /api/v1/archives/{id} rejected every API key with 403
    "API keys cannot be used for administrative operations", regardless of
    the print's owner or the key's scopes. ARCHIVES_DELETE_ALL/_OWN (and the
    create/update variants) were on the denylist and absent from the scope
    allowlist, so require_ownership_permission fell through to the generic
    admin-denied 403 — the whole archive-management surface was unreachable
    for API keys. Same regression class as the #1832 library/maintenance
    carve-outs.

    Add a can_manage_archives per-key scope: ARCHIVES_CREATE, ARCHIVES_
    UPDATE_OWN/_ALL and ARCHIVES_DELETE_OWN/_ALL move from the denylist to
    the allowlist under it (OWN and ALL fold into the same scope, matching
    can_manage_library). ARCHIVES_PURGE stays admin-only — it drops the
    print's Quick Stats contribution, mirroring LIBRARY_PURGE. Column
    defaults TRUE for UI-created keys; existing rows backfill to FALSE so the
    upgrade never silently widens scope. Bundled SpoolBuddy kiosk key stays
    minimally scoped (False). Migration is dialect-agnostic and verified on
    fresh SQLite and Postgres 17.

    Adds the Settings API-key toggle + badge (11-locale i18n) and extends the
    RBAC scope matrix to cover all five archive-management permissions.
2026-07-07 11:01:57 +02:00
maziggy 11d73b0a64 fix(slicer): preserve PVA-for-support intent across re-slice of source 3MF (#1881)
Three bugs on the same PLA-model + PVA-support flow, discovered in
    sequence:

    (A) substitute_unused_plate_filaments inspected only object geometry
        (per-object extruder metadata + paint_color triangles) so a support-
        only slot was silently treated as "unused" and the user's PVA profile
        got overwritten with slot 1's PLA.

    (B) _extract_filament_info stripped filament_is_support==1 entries,
        hiding PVA from unsliced source archive cards even when the project
        explicitly configured it.

    (C) --load-settings is authoritative over the source's project_settings.
        config, and Bambu's shipped process presets ship enable_support=0
        (supports are a per-print decision, not per-quality). So even with
        (A) fixed, the sliced output had supports disabled and the PVA slot
        loaded but never consumed. Inverts BambuStudio GUI's semantics where
        the project overrides the preset.

    Fixes:
    - New extract_support_filament_slots_from_3mf reads enable_support +
      support_filament + support_interface_filament from project_settings.
      config; substitute_unused_plate_filaments unions it into the geometry-
      derived set.
    - _extract_filament_info returns all configured filament types + colours.
    - New _patch_process_support_settings overlays four fields (enable_
      support, support_filament, support_interface_filament, support_type)
      from the source 3MF onto the picked process preset JSON before
      --load-settings sees it. Deliberately targeted to what fixes #1881
      without widening to a full project-over-preset merge.
2026-07-07 11:01:25 +02:00
maziggy b6da148890 fix(vp): evict MQTT clients on drain timeout + tighten TCP keepalive (#1872)
Reporter (H2C + macOS 26.5.1 + BS 2.8.0.50): after every Mac sleep/wake
    cycle, Bambu Studio couldn't see the VP or connect to it. Only fix was
    quit BS + reboot Bambuddy. The physical printer's own cloud/LAN link
    recovered in ~5 s from the same sleep — the delta was in VP session
    handling.

    Log evidence (bug-report-assets/logs/ddf1ede75df045cd94ad223d0f08f88a):

    - 14:04:06 healthy `1Hz status push: 60 pushes/min to :54698`
    - 14:04:06 → 14:09:16: five minutes of SSDP-only, no push summary for
      :54698, no OSError, no disconnect line
    - 14:09:16: new source port :54861 connects and authenticates fine —
      the server was not rejecting reconnects
    - 14:10:17 first DEBUG line: `MQTT drain timeout for
      device/…/report — client may be busy` — smoking gun

    Root cause: `_publish_to_report:1149` caught `asyncio.wait_for(drain,
    timeout=5)` TimeoutError at DEBUG and returned silently. TimeoutError
    is not OSError, so the push loop's `except OSError` at :441 never saw
    it — the zombie writer sat in self._clients until the kernel's default
    TCP keepalive detected the dead peer (Linux default: ~2 h 11 min).

    Two hunks:

    1. `_publish_to_report`: on drain TimeoutError, close the writer (best
       effort, catch Exception so an already-broken close() doesn't mask
       the raise) and raise BrokenPipeError, which IS OSError. Push loop
       evicts on the same tick.

    2. `_handle_client`: after SO_KEEPALIVE=1, set TCP_KEEPIDLE=60,
       TCP_KEEPINTVL=15, TCP_KEEPCNT=4 — dead-peer detection in ~2 min
       instead of ~2 h. `getattr(socket, ...)` guards keep it cross-
       platform (macOS uses TCP_KEEPALIVE not TCP_KEEPIDLE, other kernels
       may not expose all three — skip whichever is missing).

    What I got wrong first pass and corrected on log-read: hypothesised
    "missing MQTT session takeover on same client_id". Wrong. _handle_connect
    parses the protocol client_id but discards it (assignment commented out
    at :762), and self._clients is keyed on `f"{addr[0]}:{addr[1]}"` (socket
    peer), so every reconnect gets a distinct key. No takeover race exists.
    The log fixed this: the "not seen" symptom is BS-side (macOS UDP
    receive after sleep + BS holding the pre-sleep socket state), but the
    server-side amplifier was the zombie writer.
2026-07-07 11:00:55 +02:00
maziggy 6d10e3ff89 fix(vp): route non-proxy camera passthrough by target model — 6000 for A1/P1 (#1868)
Non-proxy VP mode hardcoded the camera-passthrough TCPProxy to
    listen_port=322 / target_port=322 regardless of the target printer's
    model. That port is correct for RTSPS models (X1/X2/H2/P2S), but A1 /
    A1 Mini / P1P / P1S use Bambu's proprietary chamber-image protocol on
    port 6000. Result: A1/P1 targets got a 322 listener with no upstream,
    OrcaSlicer Liveview failed with [2:-10061], BambuStudio's camera button
    timed out.

    Reporter confirmed a raw socat forwarder `<VP-IP>:6000 → <P1S-IP>:6000`
    restored the stream — the target camera works, the VP just wasn't
    publishing it.

    Proxy mode was unaffected because SlicerProxyManager already opens 6000
    (nominally file-transfer; Bambu reuses the port for chamber-image), so
    the passthrough coincidentally works there.

    Fix: read the target's model from
    `printer_manager.get_client(target_id).model` at the same point we read
    target_ip, then use `get_camera_port(target_model)` — the same source of
    truth as routes/camera.py — to pick 322 or 6000. Model comes from the
    physical printer, NOT self.model (the VP's spoofed identity has no
    bearing on how the real device serves its camera).

    Renamed the log tag from "RTSP" to f"Camera-{camera_port}" so support
    bundles show which protocol the VP is fronting at a glance. Kept the
    _rtsp_proxy attribute name to keep the diff tight; the block comment
    spells out that it doubles as chamber-image passthrough on A1/P1.
2026-07-07 11:00:32 +02:00
maziggy 0c7480f9ea fix(queue): edit modal shows printer/model selection for model-assigned items
Editing a queue item that was created with "Any of model X" left the
    printer selection area completely blank — the assignmentMode was
    initialised to 'model' from queueItem.target_model, but the three
    model-mode props (onAssignmentModeChange, onTargetModelChange,
    onTargetLocationChange) were gated behind !isEditing. That flipped
    modelAssignmentAvailable to false in PrinterSelector and hid the mode
    toggle, the model dropdown, AND the location filter; combined with the
    assignmentMode === 'printer' gate on the printer list, the whole
    selector rendered empty.

    Users hit this whenever they queued something to "Any of model X" and
    then wanted to change the target model / location — the only workaround
    was delete + re-queue.

    Fix: drop the !isEditing gate on all three PrinterSelector props. The
    submit path already handles both flavours (target_model+target_location
    with printer_id=null vs. printer_id with the target fields nulled), so
    un-gating the UI just surfaces the machinery that was already there.
    Edit is still only offered on pending items, so the mode-flip can't race
    an in-flight dispatch.
2026-07-07 11:00:08 +02:00
maziggy 8b49edf811 fix(mqtt): capture finish photo on last-layer edge, not FINISH state (#1867)
A1 Mini firmware skips stg_cur=22 entirely, so the finish-photo fallback
    fires at gcode_state=FINISH — which runs AFTER Bambu Studio has already
    executed the user's End G-code. Users with SwapMod plate-swap injected
    into End G-code always got a photo of the swapped (empty) plate.

    Add a layer_num >= total_layer_num edge trigger in _parse_print_data so
    the pre-capture fires the moment the last object layer completes, on
    every printer variant. Guarded by the existing _finish_photo_captured
    one-shot so stage-22 and FINISH-state hooks become no-ops for the same
    print — no framing regression on AMS printers without custom end G-code.
2026-07-07 10:59:36 +02:00
maziggy 703dc693d7 feat(currency): add Indonesian Rupiah (IDR) support (#1869)
Adds IDR with Rp symbol to the supported currencies list, available
    in Settings → Cost Tracking.
2026-07-07 10:59:06 +02:00
maziggy 484ea9c2a4 fix(spoolman): split mid-print usage across AMS backup switch (#1793)
usage_tracker's tray-switch split has never had a Spoolman peer.
    An AMS same-material runout switch mid-print charged the whole slot
    to the origin spool via the (via tag) path and double-credited the
    backup via remain-delta — origin exceeded initial_weight.

    Extract the segment-math into utils/tray_split.compute_tray_split_grams
    and call it from both writers so the two inventory backends attribute
    mid-print switches identically. spoolman_tracking gains
    _report_spool_usage_split_by_tray_changes; the Path 2 remain-delta
    fallback now skips trays the split path covered, killing the
    double-count.
2026-07-07 10:58:24 +02:00
maziggy b8b5aaa977 feat(api-keys): can_manage_maintenance scope for HA-style automations (#1832 follow-up)
Carve MAINTENANCE_CREATE/UPDATE/DELETE out of the admin denylist so
    HA automations can log "cleaned nozzle" / reset a counter via API key
    without granting broader printer control. Follows the same shape as
    can_manage_library and can_manage_inventory: new column, allowlist
    entry, UI checkbox, wiki row, RBAC test coverage.

    Distinct backfill: these perms were EXPLICITLY denied for every API
    key before this change (no existing integration relies on them), so
    existing rows migrate to FALSE — no silent scope widening on upgrade.
    New keys default to TRUE, matching the safe-on-by-default pattern.
    Bundled SpoolBuddy kiosk key gets False explicitly (kiosk doesn't need it).
2026-07-07 10:58:09 +02:00
maziggy 7b18bbfc1d fix(printers): drop P1S / P1P from door-sensor badge whitelist (#1866)
P1S has an enclosure door but no hall sensor for it; P1P has no
    enclosure at all. Both models were rendering a permanent green
    "Door Closed" chip driven by bit 23 of the stat field, which stays
    0 forever on that firmware. Whitelist now covers only models that
    actually ship with a door sensor: X1 family, X2D, P2S, and H2 family.
    Corrected the matching stale comments in the PrinterStatus TS
    interface (client.ts) and PrinterState dataclass (bambu_mqtt.py).

    Backend parse left as-is — cheap and future-proof if Bambu ever
    wires the P-series enclosure into a sensor.
2026-07-07 10:57:50 +02:00
maziggy 5d400d2e6e fix(cloud): send required ?version= param on singular GET/DELETE of slicer setting endpoint (#1815)
get_setting_detail and delete_setting were hitting
    /v1/iot-service/api/slicer/setting/{id} without the version query
    parameter Bambu Cloud requires — every call returned HTTP 400
    "field 'version' is not set". The sibling plural GET
    (get_slicer_settings) has always sent it; the comment above
    _SLICER_API_VERSION documents the contract for the endpoint subtree.
    Missed when the placeholder landed in the 2026-05-12 compliance rework.

    Downstream effect: slicer_filament_resolver.resolve_slicer_filament's
    PFUS branch swallowed the 400, fell through to normalize_slicer_filament,
    and caller inventory.py generic-material-fell-back tray_info_idx to
    GFL99/GFG99. BambuStudio's AMS panel reads the printer's tray_info_idx
    echo, so the user saw "Generic PLA" instead of the custom cloud preset.

    Masked for 50 days by two rescue paths in the caller: prior-slot
    tray_info_idx reuse, and stored spool_k_profile → live state.kprofiles
    realign. Reporter's spool 54 → tray 2 assign had neither.

    Adjacent surfaces also fixed by the same two-line change: the delete
    cloud preset UI route, the whole update_setting flow (get_setting_detail
    → delete_setting → POST), preset_resolver's cloud branch, and three
    UI-facing cloud.py routes that fetch setting detail.

    get_setting_detail also includes the truncated response body in the
    raised BambuCloudError so the next contract change is self-diagnostic
    from support-bundle logs.
2026-07-07 10:57:33 +02:00
maziggy ca37d1e200 ci(repo-stats): clone the github-repo-stats data branch, not gh-pages
The opaque "exit code 1" from actions/checkout was actually masking a
    "Remote branch gh-pages not found in upstream origin" — the bambuddy
    repo doesn't have a gh-pages branch. jgehrcke/github-repo-stats writes
    to a branch called `github-repo-stats` by default, and Pages is wired
    to serve from that branch. The path under it (maziggy/bambuddy/latest-
    report/report.html) is unchanged.

    Switching the clone branch and renaming the local path from `gh-pages/`
    to `data/` so the workflow reads correctly. The PAT-in-URL pattern and
    the direct git clone (vs actions/checkout) stay — both still wanted so
    the real git stderr reaches the log if anything goes wrong.

    Verified by cloning `github-repo-stats` locally and running the
    injector against its live report.html: clean patch, 30 days extracted,
    all anchor markers (TOC, section, script) present.
2026-07-07 10:57:16 +02:00
maziggy 259cf7a975 ci(repo-stats): swap actions/checkout for direct git clone on gh-pages
The PAT-token swap didn't fix the gh-pages fetch — same opaque "exit
    code 1" from actions/checkout@v4 on both attempts of the retry loop,
    with no underlying git stderr surfaced. Likely the wildcard-refspec
    + shallow fetch pattern v4 uses combined with something at the runner
    side, but the action's swallowed errors make it untriagable from logs.

    Replacing the gh-pages checkout with `git clone --branch gh-pages
    --depth 1` using the same PAT, embedded in the URL. Direct, explicit,
    and if anything goes wrong the real git error reaches the log instead
    of "exit code 1". The recorded remote keeps the PAT, so the later
    `git push` reuses it — no separate auth setup needed.

    Identity config (user.name / user.email) moved into the clone step so
    it lives on the freshly cloned repo; dropped the now-duplicate config
    calls from commit-and-push.

    Source checkout still uses actions/checkout@v4 since it never had a
    problem (the default ref is the workflow's own commit, no wildcard
    refspec required).
2026-07-07 10:56:29 +02:00
maziggy 007c694674 ci(repo-stats): use jgehrcke's PAT for gh-pages checkout
The default GITHUB_TOKEN failed at `git fetch` for the gh-pages
    checkout step with opaque "exit code 1" and no surfaced git stderr,
    even with permissions: contents: write set at workflow level.
    actions/checkout's retry loop didn't recover.

    Switching the gh-pages checkout to secrets.GHRS_GITHUB_API_TOKEN —
    the same PAT jgehrcke/github-repo-stats already writes the branch
    with one step earlier — keeps the auth chain uniform and avoids the
    mismatch. persist-credentials defaults to true, so the subsequent
    commit-and-push step in the same gh-pages directory picks up the
    PAT automatically; no separate change to the push step needed.

    fetch-depth: 1 left explicit because checkout@v4 defaults to it but
    the value's load-bearing for this workflow (we only need HEAD of
    gh-pages, not history).
2026-07-07 10:55:30 +02:00