Compare commits

...

240 commits
v1.2.5 ... main

Author SHA1 Message Date
maziggy
a28bdc5478 Updated .github/workflows/windows-installer.yml
Some checks failed
CI / Backend Lint (push) Failing after 2s
CI / Backend Security (push) Failing after 2s
CI / Frontend Lint (push) Failing after 2s
CI / Frontend Security (push) Failing after 2s
CI / Frontend Type Check (push) Failing after 2s
CI / Docker Backend Tests (shard 1/4) (push) Failing after 3s
CI / Docker Backend Tests (shard 2/4) (push) Failing after 2s
CI / Docker Backend Tests (shard 3/4) (push) Failing after 2s
CI / Docker Backend Tests (shard 4/4) (push) Failing after 2s
CodeQL / Analyze (actions) (push) Failing after 2s
CodeQL / Analyze (javascript-typescript) (push) Failing after 2s
CodeQL / Analyze (python) (push) Failing after 2s
Security Audit / Python Security Analysis (Bandit) (push) Failing after 2s
Security Audit / Container Security Scan (Trivy) (push) Failing after 2s
Security Audit / Backend Security Audit (push) Failing after 1s
Security Audit / Frontend Security Audit (push) Failing after 1s
CI / Backend Tests (shard 1/4) (push) Failing after 0s
CI / Backend Tests (shard 2/4) (push) Failing after 0s
CI / Backend Tests (shard 3/4) (push) Failing after 0s
CI / Backend Tests (shard 4/4) (push) Failing after 0s
CI / Frontend Tests (push) Failing after 0s
CI / Frontend Build (push) Failing after 0s
CI / Docker Build (push) Failing after 0s
2026-08-08 14:12:35 +02:00
maziggy
7f1e249849 chore(deps): clear every npm audit and pip-audit finding
Frontend:
    - react-router/-dom 7.18.1 -> 7.18.2. The RSC-mode CSRF advisory was carried
      as a documented exception in the audit gate because its only fix was the
      8.3.0 major; upstream backported it, so the exemption lapsed on its own --
      an entry only holds while fixAvailable.isSemVerMajor is true. The allowlist
      is now empty; the machinery stays for the next one.
    - dompurify 3.4.12 -> 3.4.13. Ships in the app, but the path is unreachable:
      no hooks registered, IN_PLACE never used.
    - js-yaml override ^4.3.0 -> ^5.2.3 (fix not backported below 5.x, so a
      major) and nanoid override ^3.3.18. Both dev-only, via eslint and postcss.
      eslintrc calls only load(), on the legacy .eslintrc.yml path this repo does
      not use; eslint, vite build and 2861 frontend tests pass on it.

    Backend:
    - cryptography >=48.0.1 -> >=50.0.0, aiohttp >=3.14.0 -> >=3.14.3, pyopenssl
      >=26.3.0 -> >=26.4.0. CI resolves from scratch and was already installing
      the fixed releases; the floors cover the case CI does not, an existing venv
      where >= is satisfied and `pip install -r` upgrades nothing. pyOpenSSL has
      to move with cryptography -- each release caps it to a narrow window, so a
      stale pyOpenSSL pins cryptography below its own fix line.
2026-08-08 13:36:54 +02:00
maziggy
34a72fb7c2 Updated BACKERS 2026-08-08 12:45:59 +02:00
maziggy
8d3abf414b Stop the Bambu Cloud TOTP tests reaching the network
verify_totp fetches a CSRF token from the bambulab.com web origin before
posting the code (#2696) and returns early when it cannot get one. These
tests patch only post, so the pre-flight GET went out for real: it succeeded
wherever bambulab.com was reachable and returned a tokenless 403 on a CI
runner, where six tests then asserted on a post that never happened.

Stub the handshake for the module. It is covered end to end, no-token path
included, in tests/unit/test_cloud_totp_csrf.py.
2026-08-02 11:45:18 +02:00
MartinNYHC
f1130acfcc
Merge pull request #2748 from maziggy/1.2.5.2
**Bambuddy 1.2.5.2**

**What this is**

A maintenance release on top of 1.2.5.1, with a heavy focus on the camera, timelapse and finish-photo pipeline, plus the K-profile / Flow Dynamics screens. It also carries seven smaller features, five of them from outside contributors. No breaking changes. Two column additions (a per-VP AMS-mapping flag and a timelapse baseline on print archives) are applied automatically on both SQLite and PostgreSQL.

If you are coming from 1.2.5 or earlier, read the 1.2.5 release notes first — all of its upgrade callouts apply to you as well.

**Docker**

docker compose pull
docker compose up -d

**Native install — recommended path**

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

**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-1.2.5.2-windows-x64-setup.exe from this release page (or the unversioned bambuddy-windows-x64-setup.exe alias). Existing Windows installs upgrade in place via the in-app Install Update flow.

**New**

- The print queue shows when each job would finish — a queue row carried the print duration but not the clock time it maps to. Rows that could actually start now show an if-started-now completion time, which updates as the clock moves (#2736, contributor @mpl1337).
- Keep the AMS slots the slicer picked — Bambu Studio and OrcaSlicer resolve which physical tray feeds each filament before sending, and Bambuddy threw that away and re-derived it at dispatch. A per-VP toggle now preserves the slicer's choice, and re-prints reuse it (#2700, contributor @Striker72rus).
- P2S / X2D accessory fans — the left auxiliary part cooling fan and the chamber exhaust fan now have tiles and controls (#2691, contributor @gzimbric, requested in #2660).
- Live print progress in the browser tab — enable Print progress in tab under Settings → Appearance and the tab title carries the running percentage (#2693, contributor @Chachigo, requested in #1041).
- Telegram notifications can target a forum topic — groups with Topics enabled no longer force everything into General; set a topic per provider (#1518, reporter @vmhomelab).
- Support bundles record Bambuddy's own memory, threads and child processes — the one thing a bundle never described was the process it came from, which made "memory climbs over days" reports impossible to act on after the fact (#2734).
- File Manager folder rows show last activity — the folder tree was sorting on a timestamp it never displayed (#2680 follow-up, reporter @cadtoolbox).

**Fixes**

**Camera, timelapse and finish photos:**

- External-camera timelapses and finish photos came out empty whenever the live view was open (#2707, reporter @bitbarista).
- A long-running camera stream could stall itself with nothing in the log, because ffmpeg's error output was only ever read after something had already gone wrong — so a full pipe blocked the process indefinitely (#2707).
- Reopening a camera quickly could leave the new stream running but unregistered, invisible to Bambuddy's own bookkeeping (#2707).
- Closing a camera held the printer's single camera connection for four more seconds and then logged an error that wasn't true (#2707).
- Two snapshots taken at the same moment opened two competing connections to firmware that allows exactly one (#2705, reporter @gzimbric); the same collision on external cameras, with no viewer attached, is fixed too (#2707 follow-up, reporter @bitbarista).
- A crash or restart mid-print left layer-timelapse frames behind forever — 38MB accumulated over routine test restarts (#2709, reporter @bitbarista).
- The orphaned-timelapse sweep could delete a timelapse while ffmpeg was still stitching it; the sweep margin and the stitch timeout were both 300 seconds, tied with no headroom (#2722, contributor @bitbarista).
- Camera credentials could reach the log and the support bundle from external-camera capture, and Test connection could report success for a frame it never fetched (#2721, contributor @bitbarista).
- Finish photos came out upside-down when a camera rotation was set — and, once that was fixed, double-rotated on the path that firmware without stg_cur=22 actually uses. The archived timelapse video is the printer's own file and is not re-encoded, so it still plays at the camera's native orientation (#2723, contributor @bitbarista).
- The print-complete photo caught the toolhead still printing, up to three minutes before the print ended (#2547, reporter @anthonyma94).
- Timelapses that never got attached, and a Scan for Timelapse button that could not find them. Measured across 247 support bundles, only 262 of 457 automatic scans ever attached a video (#2704).
- P1-series archives kept the worse finish photo when the timelapse arrived after the 60-second wait (#2704 follow-up).

**K-profiles and calibration:**

- Every K-profile reported 0.4mm and a flow type nobody set, on any printer running a different nozzle, and the same profile disagreed with itself between the list and the edit dialog (#1748, reporters @Liquidmasl and @jmoore-skild).
- Fetching profiles for two nozzle sizes at once made the first request time out, even though the printer answered both correctly (#1748).
- A rejected K-profile write reported success — the command was fire-and-forget, and the printer's refusal was received, matched and discarded (#2718, reporter @jmoore-skild).
- A printer with no K-profiles could not be given its first one: the Filament dropdown was built from profiles already on the printer, so the required field was impossible to satisfy (#2719, reporter @jmoore-skild).
- A slot on Generic PLA offered one K profile however many the printer held — nine, in the reported case (#2710, reporter @tommyboy180).
- The Flow Type field is a real choice again instead of an unsaveable "Not reported by printer".
- The AMS slot and K-Profile dialogs no longer act after they have closed.

**Printers, connection and dispatch:**

- A printer refusing every control command looked healthy: the HMS code carrying "MQTT command verification failed" collapsed to a form that matched no catalog entry and was filtered out, while the Developer Mode probe read a non-answer as confirmation. The queue then failed with advice about SD cards (#2732, reporter @hennischd).
- A printer that lost its MQTT session to a keep-alive timeout could stay offline indefinitely — nine hours, in the reported bundle, with the UI open throughout (#2732, reporter @hennischd).
- A printer refusing Bambuddy's access code now says so, instead of reconnecting silently forever behind a warning indistinguishable from a powered-off printer (#2698, reporter @djepsylon).
- The layer count stayed empty for a whole print and First Layer Complete notifications read 1/0 on P1S jobs started from Archives or the Virtual Printer (#2702, reporter @sn8key).
- A non-numeric layer number from a printer could drop its connection entirely (#2702 follow-up).
- A database hiccup mid-dispatch could leave a queue item stuck and file the next print of that file under the wrong archive.

**Slicing and projects:**

- A heavy model failed after five minutes with "Slicer sidecar unreachable" — the timeout bounded total slicing time rather than silence (#2730, reporter @kpp39).
- A model sliced for PETG printed as PLA, and the print dialog then refused to match PETG (#2712, reporter @kpp39).
- One finished slice produced a dozen "Sliced ..." notifications, one every 1.5 seconds for up to twenty seconds.
- Deleted prints stayed in their project as cards with broken previews, with no way to remove them (#2731, reporter @sroesner).

**Settings, backup and API:**

- The Settings page reverted settings changed anywhere else — a second tab, another user, a backup restore — writing its page-load copy back over all 77 settings it manages, and showing Settings saved while doing it (#2716, reporter @jmoore-skild).
- Git backup with Cloud Profiles enabled wrote nothing at all (#2717, reporter @jmoore-skild).
- PUT /settings/spoolman returned a 500 for the natural JSON form of a switch. Only affected scripted callers and Home Assistant rest_command users; the shipped UI always sends strings.
- Support bundles could contain a printer-status file no tool could open (#2702).

**Cloud, Virtual Printer and interface:**

- Bambu Cloud sign-in with a TOTP account always failed with "Invalid code" — Bambu Lab added CSRF protection to the web origin and the endpoint refused the request before evaluating the code at all (#2696, reporter @cmerkle).
- A2L AMS filament showed as "?" in Bambu Studio through the Virtual Printer, and manual filament picks reverted once a second (#2697, reporter @qoatzelcoat).
- Auto-matched filament showed a green tick when the colour was plainly wrong — dark red matched against Dark Green (#2687, reporter @pchulpjoost).
- Ukrainian is listed after Russian in the language picker.

**-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-08-02 11:21:55 +02:00
maziggy
3da4eee16e Bound the scheme repetition in the log credential-redaction pattern. As an
unbounded repetition the match was quadratic in the subject length: on a run
of scheme-legal characters the engine restarted at every offset and consumed
to the end before failing to find "://". ffmpeg echoes the configured camera
URL into its stderr and the whole blob reaches the pattern before any
truncation, so the subject length is attacker-influenced.
2026-08-02 11:17:24 +02:00
maziggy
3b5d219860 Suppress Bandit B104 false positive in the SSRF guard tests
The parametrize list feeds "0.0.0.0" to TasmotaService._validate_ip and
asserts it is refused. B104 matches the literal wherever it occurs and
cannot distinguish a rejection fixture from a bind address.

Split the list across lines so the token carries its own nosec with the
reason; the single-line form was 117 chars against a 120 limit.
2026-08-02 11:09:08 +02:00
MartinNYHC
81ee7658f5
Merge branch 'main' into 1.2.5.2 2026-08-02 11:04:26 +02:00
maziggy
d3770b5463 Updated CHANGELOG 2026-08-02 11:01:19 +02:00
maziggy
ab4b34e203 Updated CHANGELOG 2026-08-02 10:51:32 +02:00
maziggy
feec283c2a Updated CHANGELOG 2026-08-02 10:46:27 +02:00
maziggy
4cf3235fde Fix unawaited coroutine warning in printer offline notification tests
on_printer_status_change builds reconcile_stale_active_prints(...) as a
call argument, so the coroutine is constructed even when the spawn helper
is mocked out. A bare MagicMock retained it in call_args and it finalised
unawaited during a later test's GC, surfacing as a
PytestUnraisableExceptionWarning attributed to test_printer_sensor_history.

Patch spawn_background_task with a side_effect that closes the coroutine,
and correct the _state() docstring, which claimed state="IDLE" kept the
reconcile-edge branch quiescent when it does the opposite.
2026-08-02 10:22:26 +02:00
maziggy
11184b4fd0 Post work PR #2740 2026-08-02 09:55:07 +02:00
maziggy
6c573589b5 Merge pull request #2740 from mpl1337/feature/queue-item-eta
Add per-job ETA to print queue
2026-08-02 09:54:54 +02:00
maziggy
4ff6377050 Fix per-job queue ETA showing for jobs that cannot start now
The scheduler only writes waiting_reason on the model-based assignment
    path, so a job pinned to a specific printer sits behind a running print
    with no marker at all. Every such job rendered an identical "starts now"
    ETA that was wrong by the length of everything ahead of it.

    Decide eligibility on the page instead: an item gets an ETA only when its
    printer is idle and it is the item the scheduler would dispatch next,
    following the same ordering the scheduler uses. Staged and future-
    scheduled items do not block the item behind them, matching the
    scheduler, and items conditional on a previous print are excluded.

    The value also froze at first render, since react-query's structural
    sharing keeps the queue reference stable and nothing re-rendered the row.
    formatETA now accepts a base instant and the page drives it from a 30s
    clock shared by every visible row.

    Retire the borrowed printers.estimatedCompletion tooltip for a queue key
    that says what the number means, translated into all 13 locales.
2026-08-02 09:54:41 +02:00
maziggy
234809fad1 Fix queued ETA visibility and add rendering tests 2026-08-02 09:54:29 +02:00
maziggy
ab768a469f fix(settings): stop the Settings page reverting changes made elsewhere (issue #2716)
While the Settings page was mounted it held its own copy of every
    setting and synced it from the server exactly once, on first load
    (:887-900). A debounced effect then diffed the live ['settings']
    cache against that copy and PUT all 77 keys it manages on any
    difference, with no way to tell a user edit from a value that had
    changed on the server. Anything written server-side while the page
    sat open was silently reverted ~500ms later (#2716, reporter
    @jmoore-skild).

    No interaction was needed to trigger it. The query inherits a 60s
    staleTime and react-query's default refetchOnWindowFocus, and ~30
    other observers share the key, so a window refocus or a refetch from
    any of them moved the cache and the page wrote its page-load snapshot
    back over all 77 keys -- showing "Settings saved" while doing it.

    The page now tracks the last server snapshot it reconciled with. A
    field still equal to that baseline has not been touched since, so a
    newer server value is adopted; a field the user has edited keeps
    their value and is saved over the top, so the newer of the two writes
    wins either way. Typing into a text field while a refetch lands stays
    safe, which is what the previous behaviour was protecting -- an
    in-progress edit is by definition different from the baseline.

    The baseline is seeded from the raw server row rather than from the
    copy the page patches a browser-detected external_url into, so that
    detection still reads as a local change and is still persisted.

    The payload builder and the comparison key lists are unchanged. The
    diff simply measures against the baseline instead of the live cache,
    so no field can silently stop saving.

    Removing the adoption step was verified to reintroduce the revert,
    and removing the post-save baseline advance to reintroduce a resend
    loop; both are covered by frontend tests asserting on the request
    bodies rather than on rendered values.
2026-08-02 09:54:13 +02:00
maziggy
c765d2f2fb fix(kprofiles): stop reporting rejected K-profile writes as saved
Saving a K-profile was fire-and-forget. set_kprofiles_batch published
    and returned True, and the printer's extrusion_cali_set answer was
    logged at DEBUG and dropped, so a write the printer refused was
    reported to the user as saved (#2718, reporter @jmoore-skild).

    The reason it could not simply be gated on: the answer itself was
    wrong. Single-nozzle firmware returned result:"fail" with
    reason:"invalid tray_id" on writes that demonstrably applied.
    Measured against an X1C and an H2D over MQTT, the cause is the
    tray_id:-1 Bambuddy itself put in the payload. Sending three
    otherwise identical writes isolated it: tray_id:-1 fails, tray_id:0
    succeeds, and cali_idx:-1 is accepted either way, so only that one
    field is at fault. The H2D ignores the value entirely; the X1C
    validates it, complains, and applies the write anyway. BambuStudio
    always sends a real tray_id and defaults it to 0 for a manually
    entered profile.

    With tray_id:0 the acknowledgement is honest, and the printer echoes
    back the sequence_id we sent -- confirmed for extrusion_cali_get,
    _set and _del on both printer classes -- so it can be matched to the
    write that caused it. Writes now return their sequence_id and the
    routes await the verdict, turning a real failure into an error that
    carries the printer's own reason. A printer that stays silent is
    still treated as success: no answer is not evidence of refusal, and
    firmware that never answers must not turn every save into an error.

    Raises the ack to INFO. It sat at DEBUG, so the one line that
    explains a failed save was absent from every support bundle -- the
    same reasoning that put ams_filament_drying at INFO for #1447.

    Also fixes extrusion_cali_set building its payload from
    str(self._sequence_id) without incrementing first, reusing the
    previous command's id. Harmless while nothing correlated on it,
    fatal now that the write path does.

    Adds supports_nozzle_flow_type() for the Standard / High Flow choice,
    which the K-Profiles UI previously showed as "Not reported by
    printer" -- not a value anyone can save. Most printers omit the
    nozzle identity from their calibration table entirely, and the slicer
    treats that as Standard rather than unknown; Bambuddy now does the
    same and keeps the choice editable. The field is hidden only where
    the model ships a single nozzle variant, using the slicer's own rule
    (len(nozzle_volume) // len(nozzle_diameter) > 1 over the machine
    preset) evaluated across every bundled Bambu profile. That puts only
    A1, A1 Mini and A2L on the hidden side -- it is not the single-
    versus-dual-nozzle split, since P1P, P1S, P2S, X1, X1C, X1E and H2S
    are all single-nozzle and all carry two variants. Editing a profile
    also no longer writes back an empty nozzle_id.

    Wiki records that on printers which omit the field the chosen flow
    type is discarded by the firmware and reads back as Standard, in
    Bambu Studio as well, so it does not get filed as a bug again.
2026-08-02 09:53:51 +02:00
maziggy
5bbb6a73cc fix(kprofiles): populate the filament picker from all preset tiers (issue #2719)
Add K-Profile built its Filament dropdown from the profiles already on
    the printer, so on a printer with none the field was empty, required
    and unsatisfiable (#2719, reporter @jmoore-skild). The modal's own
    hint described the dead end: create the profile in Bambu Studio first.

    The dropdown now uses the app-wide lookup order -- local imported,
    Orca Cloud, Bambu Cloud, hardcoded built-in table -- same as the AMS
    slot picker and the SliceModal tier groups. The built-in table is
    compiled into the backend, so the list can never be empty: a new
    printer with no cloud account and nothing imported still gets a first
    profile.

    Not fixed the way the report suggested. Seeding from
    /printers/available-filaments would have offered only what happens to
    be in an AMS right now, which on the reported printer is nothing; its
    tray_info_idx is empty or a cloud user preset rather than a filament
    id; it aggregates across every printer of the same model; and it is
    gated on QUEUE_CREATE, which the K-Profiles page does not hold.

    The printer indexes its calibration table by filament_id, so the
    picked preset is reduced to one before anything is sent. Built-in
    entries and Bambu official cloud presets carry one; a cloud user
    preset needs its detail fetched (never base_id -- that collapses a
    custom preset onto its inherited generic, #1053); imported and Orca
    presets have no Bambu id at all and take the closest generic for
    their material, via the same table the AMS slot configure flow uses
    so the two agree. A filament that resolves to nothing is refused with
    a named error rather than written under a wrong id.

    Collapses duplicates from two separate causes. A cloud account
    carries one copy of each filament per printer model, and with the
    "@BBL <model>" suffix stripped for display those rows are
    indistinguishable -- deduped within each tier by resolved filament id,
    by display name for user presets that have none. Cloud setting_ids
    also carry a "_NN" variant suffix, so the built-in tier's
    already-covered check never matched and listed the same filament
    again; the bare id is now recorded alongside.

    Groups the options by source with an optgroup per tier, styled in
    index.css: browsers render optgroup labels small, grey and italic,
    which buries the one thing distinguishing a "Bambu PLA Basic" you
    imported from the one the built-in table ships.

    Drops the second getKProfiles(printer, "0.4") query that existed only
    to seed the old dropdown. It ran concurrently with the main fetch
    whenever a non-0.4mm nozzle was selected -- the two-requests-in-flight
    case that made K-profile fetches time out.

    ---

    fix(ui): cancel a dialog's deferred close when it unmounts

    The AMS slot configure and K-Profile dialogs hold a success state
    briefly and then close themselves -- 1.5s to 4s after the command
    goes out, so the printer has time to process it before the list
    refetches. Each did that with a bare setTimeout closing over setState
    and the parent's onClose, and nothing cancelled it.

    The timer therefore ran whether or not the dialog was still there.
    Dismissing it inside that window, or the printer card re-rendering
    underneath it, left a pending close that fired later and dismissed
    whatever dialog was open by then. It also threw outright when the
    surrounding environment was gone first: a test tearing down its DOM
    before the 1.5s elapsed produced "ReferenceError: window is not
    defined" out of react-dom's resolveUpdatePriority, reported as an
    unhandled error against a suite that otherwise passed.

    Routes all five through a useCancellableTimeout hook -- two in
    ConfigureAmsSlotModal, three in KProfileModal, the latter with the
    longest windows and so the widest exposure. Scheduling replaces any
    pending timer and unmounting clears it.
2026-08-02 09:53:27 +02:00
maziggy
6b2c888a8b Add per-job ETA to print queue 2026-08-02 09:53:14 +02:00
maziggy
30fcfe117b fix(kprofiles): read the nozzle diameter the printer actually sent (issue #1748)
Every K-profile came back as 0.4mm on printers running any other
    nozzle (#1748, reporters @Liquidmasl and @jmoore-skild). The printer
    puts nozzle_diameter on the extrusion_cali_get envelope only; the
    per-filament entries carry setting_id, filament_id, name, k_value,
    n_coef and cali_idx, and nothing else. The parser read the field per
    entry with a hardcoded "0.4" fallback, so the fallback fired on every
    profile of every response. The envelope value was already in scope,
    read into response_nozzle and used only to match the request.

    This never reproduced on H2D because that firmware does include the
    field per entry. Both construction sites are in the same handler, so
    the code path is shared; what differs is the payload, and every
    single-nozzle model omits it.

    The display was the least of it. Editing is delete-and-re-add on
    single-nozzle printers, and the dialog rebuilt nozzle_id and
    nozzle_diameter from its own greyed-out selects, so saving an
    untouched 0.6mm profile rewrote it on the printer as HH00-0.4.
    Deleting aimed extrusion_cali_del at the wrong nozzle the same way.
    Both now pass through what the printer reported. The cali_idx cascade
    in inventory.py, spoolman_inventory.py and spoolman.py matches on
    nozzle_diameter, so on a 0.6 or 0.8 nozzle it never found the
    printer-side entry and the assignment silently failed to stick --
    that is the "cannot auto-map a K-profile" half of the report, fixed
    at the source without touching those three call sites.

    nozzle_id has no source in the payload at all, and state.nozzles
    carries material (hardened_steel), not flow, so it cannot honestly
    produce HH/HS. Rather than keep inventing one, the UI now says the
    printer did not report it: the card shows the diameter alone, the
    dialog shows "Not reported by printer", and the High Flow / Standard
    filter is hidden instead of being offered as a control that can only
    ever empty the list. Import stops stamping HH00 on profiles whose
    source reported none.

    Also correlates K-profile requests by sequence_id. Responses were
    matched by nozzle diameter through a single shared expectation slot,
    so a second request overwrote the first's and the first's valid
    answer was discarded as a mismatch -- the "Failed to get K-profiles
    after 3 attempts" in the same logs, with the printer having answered
    correctly both times. Pending state is now one entry per request,
    keyed by the id we already send, with the nozzle match kept as a
    fallback for firmware that does not echo it back.

    Fixes the flow-type select naming a new profile with the opposite
    label, which contradicted the identical expression 44 lines above it.
2026-08-02 09:52:52 +02:00
maziggy
847f324cfb Housekeeping 2026-08-02 09:52:38 +02:00
maziggy
8e493318c0 fix(backup): collect cloud profiles from every connected account (#2717)
Enabling Cloud Profiles for a Git backup produced nothing, and said it had
    worked. Two independent faults, either one sufficient.

    The collector looked for a "setting" list. The Bambu Cloud listing endpoint
    is keyed by preset type instead, each key holding private and public arrays,
    so the loop body never executed once — and the entries carry no type of
    their own either, which routes/cloud.py already knew: it takes the type from
    the outer key and maps Bambu's "print" to process. Two bugs on one line.

    It also asked build_authenticated_cloud for the credential store used when
    authentication is disabled. With auth on, tokens live on User rows, so the
    collector returned at "Cloud not authenticated" before ever reaching the bad
    key. Every multi-user install was collecting from zero accounts.

    Neither failure surfaced. backup_metadata.json recorded the configured flag
    rather than the outcome, so it claimed cloud_profiles: true on runs that
    wrote nothing, and the log read "Collected cloud profiles: 0 filament, 0
    printer, 0 process" at INFO — which is exactly what a successful backup of
    an empty account looks like.

    Cloud profiles now come from every connected account across both clouds. The
    toggle predates Orca Cloud entirely, and Orca has the same three preset
    types, so both are collected and grouped the same way:

        cloud_profiles/bambu/user-3/{filament,printer,process}.json
        cloud_profiles/orca/user-3/{filament,printer,process}.json

    Accounts are keyed by Bambuddy user id, "global" when auth is off. Never by
    email: a backup repository can be public, and the Bambu listing's user_id is
    dropped for the same reason. Both credential stores are read on every run,
    because a Settings row survives someone enabling auth later and dropping it
    would silently stop backing that account up.

    Bambu costs one get_setting_detail per private preset. The listing is
    metadata only, and without base_id and setting the backup is a list of names
    that create_setting cannot rebuild from. Public presets are skipped — Bambu's
    bundled catalogue is the same hundreds of entries for everyone, always
    re-downloadable, not recreatable under your account, and would rewrite the
    repository on every run. Orca needs no second call; its sync-pull carries
    each profile's content inline. Where the Orca route drops a profile whose
    content.type it cannot map, the backup writes it to other.json instead:
    silently omitting a profile because Orca added a type is the same class of
    bug as this one.

    Failures are contained per account and per preset, and counted rather than
    swallowed. A partial backup that looks complete is how this stayed invisible.

    The metadata now reports what was collected, per cloud and per account, and a
    run that collects nothing while the category is enabled warns with the reason
    instead of an INFO line that reads like success.

    The checkbox gated on the viewer's own Bambu sign-in, which is not the same
    question as whether there is anything to back up — with auth enabled the
    accounts belong to individual users, and an administrator who never signed
    in personally saw the category disabled with plenty in scope. It now gates
    on the total across both clouds and shows the counts. That comes from its
    own endpoint rather than a field on /config, since /config answers null
    until the first save and would disable the toggle during the very setup it
    belongs to. Counts only, never identities.

    One deliberate restraint. _build_authenticated_service clears stored
    credentials when a refresh is rejected, which is right for a route — the
    user is on the page and can pair again — and wrong for a scheduled job.
    Orca reports every rejection with one composite reason ("unknown, expired,
    revoked, or already used"), so a genuine revocation cannot be told apart
    from a lost token-rotation race, and acting destructively on a signal that
    cannot be disambiguated is the #2562 mistake in a different cloud. It also
    gains nothing: the Profiles route hits the same failure and clears it then,
    with the user present. Background callers now pass clear_on_auth_failure=
    False and skip the account. A successful refresh is still persisted either
    way — by that point the old token is consumed, so dropping the new pair
    would break a working pairing for real.

    Restore is not part of this. Nothing reads cloud_profiles/* yet; the format
    carries base_id/setting for Bambu and content for Orca so that it can.
2026-08-02 09:52:12 +02:00
maziggy
6b601298f8 Merge pull request #2700 from Striker72rus/feature/save-ams-mapping-toggle
Feature/save ams mapping toggle
2026-08-02 09:51:58 +02:00
maziggy
44450a3aa4 fix(vp): gate the slicer's AMS pick behind the toggle and scope its badges (#2700)
Round-3 review of the "Save AMS mapping" PR.

    The queue item's ams_mapping was set unconditionally, on the reasoning that
    honouring the slicer's own pick is a correctness fix rather than a feature.
    It is both. Storing a resolved mapping makes _ensure_ams_mapping return
    early, so _compute_ams_mapping_for_printer never runs — and that function is
    where prefer_lowest_filament lives, along with the AMS-filament-backup gate
    that qualifies it (#1766), the inventory-remain overrides, and the per-slot
    force-colour overrides. Every existing queue-mode VP pointed at a printer
    would have quietly lost all of it on upgrade, without a setting to turn it
    back on.

    So save_ams_mapping now gates the queue item too, not just the archive
    persistence. Off is exactly the old behaviour. The correctness case the PR
    was written for — two spools of the same red PLA, and the slot the user
    picked in the slicer thrown away — is still fixed, for anyone who asks for
    it.

    Force color match wins over it when both are on. Its only effect on a
    fixed-printer item is the filament_overrides written onto the queue item,
    and those are read inside the function a stored mapping skips, so the two
    toggles sitting next to each other on the same card silently cancelled. The
    dispatch now matches strictly, as asked, while the slicer's pick is still
    saved onto the archive — that is what the toggle's name promises, and a
    later reprint is a separate decision from this print. The queue-add fallback
    applies the same rule to a request that carries force-colour overrides.

    A mapping shorter than a plate's highest slot id cannot address that plate's
    own slots, and _ensure_ams_mapping would have kept it anyway, since it only
    rejects an all-unresolved one. Each plate now checks the length it needs and
    falls back to a computed mapping if the array does not reach. Bambu Studio
    sends a file-global array, so this normally never fires; it also means a
    multi-plate Send All degrades safely if that ever stops being true.

    The badges claimed more than they delivered. Both rendered whenever a saved
    mapping existed, ignoring which printer it belonged to, while the tooltips
    promised the reprint would reuse those exact spools — true only on the
    printer the trays were resolved against. The queue row's flag is now
    computed against that row's own printer, which is precisely when dispatch
    reuses the mapping, and the archive card names the printer instead of
    implying any of them will do. It hides itself when that printer no longer
    exists. Retranslated in all 13 locales.

    Frontend tests, which the PR had none of. The printer-scoping rule is now a
    pure function rather than an inline expression, covered for the mismatched
    printer, the no-printer-selected case that would otherwise compare undefined
    against undefined, and malformed extra_data. The toggle's undo bookkeeping
    is covered for unresolved slots, short mappings, and hand-made picks —
    preserved when the toggle never wrote that slot, replaced when it did, which
    is behaviour worth pinning either way.

    Also reverts all three queue-mode switches when a save fails, not just the
    new one; without it the card shows a setting the server rejected.
2026-08-02 09:51:39 +02:00
maziggy
19dcc6d89b feat(support): record process memory, threads and children in bundles (#2734)
A bundle described everything except the process it runs in. So a report of
    memory climbing over days until the OOM killer fires arrives with no way to
    act on it: the numbers that name the mechanism only exist while it is
    happening, and by the time anyone asks, the container has been restarted.

    The new `process` section carries what actually separates the candidates.
    Resident against virtual memory: 650MB RSS with 12.9GB VMS is address
    space — thread stacks or allocator arenas — not a heap full of live data,
    and that reading is the opposite of the one the reporter drew from the same
    figures. Thread count and child-process count then split those two apart,
    and a census of live objects by type names what a growing heap is filling
    up with. Open files, sockets and uptime round it out.

    Three constraints worth keeping:

    The heap census is skipped above 2GB. gc.get_objects() materialises every
    tracked object, so it costs most on exactly the process that can least
    afford it — a bundle generated to diagnose runaway memory must not be the
    allocation that tips the host over. Everything else is still collected, and
    the skip is recorded with its reason rather than silently omitted.

    Children are recorded by executable name only. An ffmpeg command line
    carries the camera URL, and with it the camera's password.

    Collection runs off the event loop and every metric is independently
    best-effort. psutil raises on hardened kernels and in restricted
    containers, and the bundle is how someone reports a problem in the first
    place — it has to be produced even when half the numbers are unavailable.

    This does not fix #2734, and nothing here should be read as having found
    its cause. The bundle's own evidence contradicts both proposed causes: the
    orphan janitor ran 7 times in 26 days over 725 stream-ends and killed no
    orphaned ffmpeg, which is not the #776 signature; and the 5 "database is
    locked" errors all fall between two OOM kills, making them a symptom of the
    memory pressure rather than a source of it.
2026-08-02 09:51:01 +02:00
maziggy
bade12ff49 fix(slicer): bound slices by silence, not by total slicing time (#2730)
A heavy MakerWorld model — one Bambu Studio also takes a long time over —
    failed after five minutes with "Slicer sidecar unreachable". The sidecar
    was reachable the whole time and still slicing when we hung up on it.

    SlicerApiService carried a hardcoded 300s timeout, passed to httpx as a
    bare float so it covered connect, read, write and pool alike. On a single
    long request that is not a health check, it is a cap on how long a model is
    allowed to take. And because httpx.ReadTimeout subclasses RequestError,
    expiry landed in the same handler as a refused connection and was reported
    as an unreachable sidecar — so the reporter went and updated their sidecar
    container, which was never the problem.

    The information to do better was already being collected. _poll_progress
    polls /slice/progress/{id} once a second alongside the blocking POST to
    drive the live progress toast, so at minute five Bambuddy had fresh
    evidence the slicer was working. It killed the request anyway.

    So the read timeout comes off the HTTP call and the poller supervises
    instead: the deadline moves forward on every progress update, and only
    genuine silence ends the wait. A model that keeps reporting runs to
    completion however long it takes. Connect and pool keep short timeouts —
    a sidecar that will not accept a connection is unreachable and should
    still say so quickly.

    Only a *changed* progress payload counts as alive. The sidecar re-serves
    its last snapshot on every poll, so counting repeats would leave the
    watchdog unable to detect a stall at all.

    The window is floored at three poll intervals: liveness can only be
    observed as fast as the poller ticks, so anything shorter would expire in
    the gap between two polls and fail every slice instantly.

    New setting slicer_stall_timeout_minutes (Settings > Workflow > Slicer),
    default 15, range 1-240, alongside the sidecar URL and gated on
    use_slicer_api like its neighbours. Sidecars too old to report progress
    have no liveness signal, so for those the same number bounds total elapsed
    time — the old behaviour, configurable and no longer 300s flat. The
    message says which case applies and where to change it.

    SlicerTimeoutError is its own type and maps to 504, not 502: the sidecar
    answered throughout, we stopped waiting. Connection failures keep
    SlicerApiUnavailableError. The preview slice path gets the same treatment.
2026-08-02 09:50:38 +02:00
maziggy
4cea07a510 fix(projects): drop deleted prints from their project, and refresh the view (#2731)
Deleting a print that belonged to a project left it on the project page as
    a card with a missing thumbnail, and there was no way to remove it.

    Deleting a print is a soft delete by default (#1343): the files go from
    disk, the row stays so global Quick Stats keeps counting its filament,
    time and cost. Every other consumer filters those rows out. The projects
    module filtered none of them — the only deleted_at check in the whole file
    was for LibraryFile — so a deleted print kept its project_id and kept
    being listed, pointing at a thumbnail that no longer existed. The same
    broken previews appeared on the overview cards, and in the timeline, where
    the entry links to an archive that no longer opens. Unassigning was
    impossible because the only UI that can change a print's project lives on
    the Archives page, which correctly hides deleted prints: visible on the
    project, unreachable from anywhere.

    All eight project-scoped archive queries now filter, counts included. That
    last part is a deliberate divergence from #1343, where the whole point of
    the soft delete is that the contribution survives: a project is a piece of
    work with a definite membership, not a lifetime total, so a project that
    lists eleven prints must not claim twelve. The reasoning is recorded at
    the constant so nobody later "fixes" it back.

    remove_archives_from_project keeps working on hidden rows on purpose — it
    is the repair path for links written before this. The BOM print_name
    lookups are left alone; naming a since-deleted print is still correct.

    Two more consumers had the same gap. The CSV/Excel export handed back rows
    the interface says are gone — filtered at the base query, since the export
    is the list you are looking at saved to a file. Per-project failure
    analysis measured a failure rate against prints deleted from the project,
    and disagreed with the project's own numbers; only the project-scoped
    branch filters, global analysis still counts every run including orphans
    as #1390 established.

    Finally, the project page needed a manual reload to catch up. staleTime is
    60s and the delete mutations invalidated only ['archives'], so a project
    visited within the minute served its cached copy, print still there. The
    project-assign mutations had the mirror-image bug: ['projects'] refreshed
    the overview cards but never ['project', id]. Both now go through one
    shared helper covering every project-derived key, as bare prefixes so all
    cached project ids are matched.
2026-08-02 09:50:16 +02:00
maziggy
385bff04bc fix(printers): recover MQTT sessions that stopped reconnecting (#2732)
The reporter's printer lost its session to a keep-alive timeout at 02:19
    and did not come back until 11:24 — nine hours offline, with the web UI
    open throughout.

    check_staleness() was never going to catch it. Its first line is
    `if self.state.connected and self.is_stale()`, so it only ever handles the
    half-broken session that is still connected but has gone quiet. This
    client had connected=False from 02:19:42 (the offline notification fired a
    minute later), so every call returned immediately, and paho's own retry was
    the only thing left watching. When that stopped making progress nothing
    noticed.

    Adds a sweep every 60s that rebuilds a client when all four hold: it is
    disconnected, it had a working session before, it has been silent for five
    minutes, and its MQTT port still answers. The port check is what keeps this
    from becoming a nuisance — a switched-off printer is left to paho, so a
    farm powering down overnight causes no client churn and no log spam. The
    five-minute grace sits well past the 60s stale timeout and the 30s max
    reconnect backoff, so a session recovering on its own is never interrupted.

    The rebuild goes through force_reconnect_stale_session from async context,
    which takes the hard-reset path: fresh client_id and paho's QoS 1 queue
    dropped, so a project_file left unacked on the dead session cannot replay
    into the new one and trip 0500_4003 (#1136). Rate-limited per printer,
    cooldown cleared when the printer returns, and the sweep continues past a
    client that throws rather than abandoning the rest of the farm. The log
    line names how long the printer was gone and the last connect error, so a
    session that dies repeatedly leaves a trail.

    check_port gains a public alias in printer_diagnostic rather than having
    the watchdog reach for the private name.

    Also corrects the Developer Mode path added in the previous commit: the
    wiki documents it under Settings > Network, not Settings > General. The
    menu path is dropped from the translated string entirely, since it varies
    by model and firmware and the wiki carries the detail.
2026-08-02 09:49:52 +02:00
maziggy
6184dbb980 fix(printers): surface the printer's own "command verification failed"
A P1S on firmware 01.10.00.00 rejected every control command and said so:
    HMS 0500-0500-0001-0007, "MQTT command verification failed". Bambuddy
    received that, dropped it, and reported a healthy printer instead.

    The frontend filtered it out. This code's meaning lives in attr's low half
    (0500) and code's high half (0001), both of which the MMMM_EEEE short form
    discards, so it collapsed to "0500_0007" — no catalog entry, no firmware
    actions, and filterKnownHMSErrors drops uncatalogued action-less errors.
    Catalog lookups now try full_code first, in both the description and the
    filter, and errors matched that way display the four-group code the
    printer's own screen shows. The remedy line is ours, not Bambu's: their
    wiki says to update Studio or Handy, which does not apply to a print sent
    from Bambuddy.

    The developer-mode probe made it worse. It read anything that was not an
    explicit refusal as confirmation, and this firmware answers the probe with
    an empty result while refusing everything else — so an inference drawn
    from a non-answer became "developer_mode: pass" in the support bundle of a
    printer that had not accepted a command all day. The probe now has three
    outcomes: explicit success enables, explicit verify-failure disables,
    anything else stays unknown and the diagnostic reports skip.

    The HMS is authoritative over that inference in both directions. It forces
    developer_mode False when present, and clears back to unknown when the
    printer stops reporting it, so enabling Developer Mode and restarting the
    printer is picked up without restarting Bambuddy.

    Dispatch no longer treats a refusal as a wedge. The watchdog latches the
    HMS across both phases and fails the item on the first attempt naming the
    code and the fix, rather than spending three uploads and 270s a lap to
    arrive at a message about SD cards. The check runs after the active-state
    exit in both phases, so a lingering HMS can never abort a print that is
    visibly running.

    Also: the "wrong or mis-cased serial number" hint no longer fires in the
    moment after a reconnect. _report_messages_since_connect is reset by
    _on_connect, so a reconnect landing microseconds before the staleness
    check leaves it at 0 for reasons that have nothing to do with the serial —
    this reporter's healthy printer was told to go check its serial 1 ms after
    reconnecting.
2026-08-02 09:49:34 +02:00
maziggy
180e2acbe8 fix(ams): offer every K profile the printer holds for a generic filament preset (#2710)
The reporter's A1 mini has nine Flow Dynamics calibrations, all of them saved
    under Generic PLA and named after the spool's colour — "Dark Brown", "Glow",
    "Marble". Bambu Studio lists all nine for that slot. Configure AMS Slot offered
    one: the profile already bound to the slot. After a slot reset it offered none,
    leaving the slicer as the only way to assign a K value.

    Two independent faults, both tripped by picking a built-in generic preset.

    The filament-id match discarded Bambu's generic GFx99 ids as too broad. But the
    comparison already requires both sides to carry the same id, so that exclusion
    could only ever fire when the selected preset was itself the generic one —
    precisely the case where the match is right. The printer keeps one calibration
    table per filament id, so a slot on Generic PLA should offer everything
    calibrated under Generic PLA. Equal ids now match, generic or not.

    The name fallback was dead for the same presets: parsePresetName reads the
    leading "Generic" in "Generic PLA" as a manufacturer, which put the matcher into
    brand-gated mode and demanded the word GENERIC appear in the profile name. No
    real profile has it. "Generic" is no longer treated as a brand, so profiles still
    match on material when a printer reports no filament_id with its calibrations.

    The one profile that did appear came from the #1689 safety net that always
    surfaces the slot's active cali_idx — which is also why a reset slot, having no
    active profile, showed an empty list.

    Neither fix can be complete on its own, because profile names are free text and
    nothing ties "Marble" to a material. The picker now also lists every remaining
    profile on the printer under "Other K profiles on this printer", so a profile
    that exists can always be selected. Applying one from that group needs no new
    backend work: configure_ams_slot already realigns the slot's filament context to
    the chosen profile's, which is what makes the cali_idx stick.

    Options are keyed by name+k_value rather than the bare name, so two profiles
    sharing a name are no longer indistinguishable in the select. Both render blocks
    carry the change — the modal duplicates the picker for its full-screen variant.

    isMatchingCalibration gets the same generic-id rule for the spool form's PA
    suggester, with two guards. A new generic-id-to-material table means a PETG spool
    can never claim GFL99 profiles just because both sides stored a generic id
    (Nylon and PA compare as one material). And a spool that names its own brand
    keeps the stricter name path, so its suggestions stay brand-specific rather than
    becoming the printer's whole generic table.
2026-08-02 09:49:11 +02:00
maziggy
35e5d0104e fix(camera): take the finish photo when the print ends, not when its last layer starts (#2547)
The photo fired the moment layer_num reached total_layer_num. That edge is
    where the printer *starts* its final layer, not where it finishes it: the
    reporter's H2C capture shows it arriving at 92% with mc_remaining_time=2,
    three minutes and seventeen seconds and one filament change before the print
    actually ended, so the frame caught the toolhead mid-print over the model.

    The trigger also latched _finish_photo_captured, which locked out both the
    stage-22 and FINISH triggers for the rest of the print — so on firmware that
    never reports an end-of-print filament unload (H2C and A1 Mini confirmed)
    nothing could replace the bad frame.

    Remove the last-layer trigger. The photo is now taken at the FINISH-state
    trigger, which every model sends and which lands after the toolhead parks.

    Since Bambu's end G-code drops the plate ~100mm just before that, restore the
    framing before capturing: absolute G90/G1 Z to max_z_height + 10mm clearance,
    settle, capture, then drop it back so the print is as reachable as the printer
    left it. Absolute is the safety argument — that Z is a height the toolhead
    occupied seconds earlier, so it is inside the travel limits by construction and
    leaves the nozzle above the part, and it is unambiguous across model families
    because Z is the nozzle-to-bed gap whether the bed moves or the toolhead does.
    M211 is never touched (#2579). This is what #1145, #1397 and #1565 asked for.

    The height is only trusted when two independent sources agree: the archive is
    matched by the finished print's subtask_name by equality (not LIKE, so "Cube"
    cannot resolve to "Cube v2"), and its layer count from the 3MF must match the
    layer count the printer reported over MQTT. Matching on "most recent archive
    for this printer" was not safe — on_print_complete pops the _active_prints
    binding concurrently, and a print Bambuddy failed to archive would have
    resolved to its predecessor. A wrong height is the one failure that could drive
    the nozzle into the model.

    The move is additionally skipped when the print height is unknown, when a queue
    item is pending for the printer, when the printer has left FINISH, and when the
    new finish_photo_restore_plate setting is off.

    for every FINISH-state capture — which is what shipped the mid-print photo —
    the bank is used only when the dispatcher recorded that it injected End G-code
    into this print, since a SwapMod snippet may have ejected the plate. The flag is
    handed over in two steps (mark_pending at dispatch, adopt at print start) so it
    can never outlive its print: a job started from the slicer or SD card adopts
    False rather than inheriting its predecessor's answer. Those prints also skip
    the plate move outright, bank or no bank.

    The bank now refreshes on mc_percent advances as well as layer changes, via a
    new on_print_progress callback. Layer changes stop the instant the final layer
    begins, which left the #1867 fallback frame stale by the whole length of that
    layer; progress keeps ticking there and freezes before the End G-code runs, so
    a swapped plate still cannot reach the bank. The last-layer throttle exemption
    is dropped, since it would now fire a grab on every percent tick.

    On the timelapse path the moment producer returns early, so the consumer does
    the restore itself before its live-grab fallback — the documented usual outcome
    on P1-series, where the video has not transferred by the time the notification
    goes out and the shipped photo was of an already-dropped plate. The two waits
    are now derived from the settle window and the video poll timeout rather than
    hardcoded; at the old flat 75s that fallback was guaranteed to be cut off
    mid-settle.

    extract_max_z_height_from_3mf reads only a bounded prefix of the plate G-code,
    since a sliced plate is routinely tens of megabytes and the header is ~40 lines.
    It returns None for missing, unparseable, zero and negative values so callers
    must treat "don't know" as such rather than defaulting.
2026-08-02 09:48:48 +02:00
maziggy
dc6217f4e6 fix(vp): scope saved AMS mapping to the printer it was resolved against
Round-2 review fixes for #2700.

    Blocking: the toggle didn't actually gate the archive write. archive.py's
    promotion fired for any print_data carrying ams_mapping, but bambu_mqtt's
    request-topic interception captures ams_mapping unconditionally for every
    print source (slicer-direct LAN prints included). Since main.py's
    real-printer auto-archive path forwards the full MQTT payload as
    print_data, every archive on any install — VP or not — grew
    extra_data.slicer_ams_mapping. Fixed by replacing the print_data-sniffing
    with an explicit `slicer_ams_mapping` param on archive_print() that only
    the VP-queue path (already gated on save_ams_mapping) ever passes.

    Blocking: a saved mapping could get reused on a printer it was never
    resolved against — tray IDs only mean something relative to one printer's
    AMS layout. extra_data.slicer_ams_mapping is now stored as
    {mapping, printer_id} instead of a bare array:
    - add_to_queue's fallback only fires when the reprint's target printer_id
      matches the mapping's origin printer.
    - The frontend's archiveAmsMapping only surfaces (and the Mapping button
      only appears) when the print modal's selected printer matches too.
    - A model-based VP (target_printer_id=None, no MQTT bridge to any real
      printer) never stamps a mapping in the first place — there's no live AMS
      layout for the slicer to have resolved tray IDs against.

    Also from review:
    - Multi-plate archives now get the Mapping button too (the per-plate
      FilamentMapping loop was missing archiveAmsMapping entirely).
    - Added coverage for the previously-untested late-MQTT archive patch path
      (_restamp_recent_queue_item), including the model-based-VP skip case.
    - usingArchiveMapping now also resets on printer change, not just
      plate/archive (it already worked via the printer-scoping above, but is
      now an explicit dependency too).
    - The Mapping button's revert (OFF) now undoes only the slots it itself
      set, not every manual pick in scope — matches the comment above it.
    - Added a comment on why negative-value slots (external spool) are
      skipped rather than cleared when applying a saved mapping.
2026-08-02 09:48:32 +02:00
maziggy
a7b96ea9d6 feat(vp): per-VP "Save AMS mapping" toggle + reprint auto-apply
Lets a reprint reuse the AMS slot the slicer itself picked, instead of
    re-deriving one from the file's static type/color.

    When a Print Queue VP has "Save AMS mapping" on, the slicer's own
    live-resolved ams_mapping (from the project_file MQTT command) is
    persisted onto the archive as extra_data.slicer_ams_mapping. A later
    reprint can reuse it via a new "Mapping" button in the filament-mapping
    panel — one click snaps every slot to the saved pick, click again
    reverts to auto-match. Archive cards and queue rows get an "AMS mapping
    saved" badge so it's visible beforehand. add_to_queue also falls back
    to the saved mapping automatically when the caller sends no explicit
    ams_mapping (e.g. a plain reprint with no per-slot edits).

    The queue item's own ams_mapping (used for that dispatch) is still
    captured unconditionally whenever the slicer provides it — that part is
    a correctness fix, not gated behind the toggle. Only the archive
    persistence for future reprints is opt-in.

    Split out from the original combined PR per review: this half is
    genuinely opt-in and low-risk (#2684). The dispatch-time validation
    gate that keeps a stored mapping honest (#1308) changes behaviour for
    every existing user and will land as its own PR.

    Review fixes applied:
    - _extract_slicer_ams_mapping_json: dropped the unreachable `v is None`
      arm and rejected bool explicitly (isinstance(v, int) accepts bool).
    - Translated the Russian docstring text to English.
    - save_ams_mapping's model comment moved to a trailing comment on the
      column line, matching the file's convention.
    - usingArchiveMapping now resets when the plate or archive changes, so
      the Mapping button can't read ON against a mapping it never applied.
    - Translated "Click to change slot assignment" and "Re-read".
    - add_to_queue's fallback is now called out explicitly in code comments
      and covered by three new integration tests (fallback fires, explicit
      mapping wins, unrelated extra_data doesn't false-trigger).
2026-08-02 09:48:11 +02:00
maziggy
91ac499e20 Merge pull request #2723 from bitbarista/fix/camera-rotation-finish-photo-timelapse
fix(camera): apply camera_rotation to finish photos and layer-timelapse frames
2026-08-02 09:47:58 +02:00
maziggy
72a8dafde9 y fix(camera): rotate every still exactly once, and cover the sources that let ffmpeg write the file
Review follow-ups on applying camera_rotation to finish photos and
    layer-timelapse frames.

    Rotating the frame popped from _stage22_finish_frames rotated one of its
    sources twice. The cache has two kinds of feeder: live grabs, which are raw,
    and the #1867 in-print bank, whose bytes come from
    _capture_snapshot_for_notification and have already been rotated on the way
    in. The consumer cannot tell them apart, so on the finish_state trigger - the
    path the bank exists to serve, on firmware that never emits stg_cur=22 - a 180
    degree rotation cancelled itself out and the photo was upside-down again,
    which is the reported symptom exactly; 90 and 270 landed 180 out. Rotation now
    happens where each frame is captured, so every entry in the cache carries one
    rotation whatever produced it, and the invariant is stated both where the
    cache is declared and where it is consumed.

    Two finish-photo sources were still writing unrotated files: the built-in
    camera's own capture_finish_photo, and the still extracted from a
    printer-recorded timelapse - which is the *preferred* source for a built-in
    camera print, so a user with a rotation set got a correctly oriented photo or
    not depending on which source happened to win. Neither ever holds the frame as
    bytes; ffmpeg writes the file and they return a filename. apply_camera_rotation_to_file
    handles that case and is best-effort - a failed rotate leaves the unrotated
    file rather than losing a delivered photo. The archived video itself is the
    printer's own file and is not re-encoded, so it still plays at the camera's
    native orientation; the CHANGELOG says so rather than leaving it to be
    discovered.

    apply_camera_rotation logs at debug, not info. It was on a path that runs once
    per layer, where a tall print would have put hundreds of lines in the log for
    something the surrounding capture already reports at debug.

    The moved rotation logic had no test of its own - every existing test patches
    it out and asserts the call, so a flipped sign or a dropped expand=True would
    have shipped green. test_camera_rotation.py drives the real round trip: a
    corner marker pins which way it turns, the dimensions pin that the frame is
    not cropped, and an undecodable frame comes back by identity because a capture
    path must not lose a frame to a failed rotate.

    Tests for the fix itself sit on both sides of the cache. The producer half is
    driven directly; the consumer half is a closure nested inside on_print_complete
    with nothing able to reach it, so it is pinned by an AST guard - checked
    against the source because the alternative is no check at all. Reverting
    main.py to the pre-fix shape fails three of the five, the guard among them.

    The three new tests used Path("/tmp/test") for a patched base_dir, which Bandit
    flagged (B108); they take tmp_path now.
2026-08-02 09:47:29 +02:00
maziggy
cdba011b1e Merge pull request #2722 from bitbarista/fix/timelapse-orphaned-session-cleanup
fix(camera): sweep orphaned timelapse session directories on startup
2026-08-02 09:47:15 +02:00
maziggy
f6b38f3926 fix(camera): protect an in-progress stitch from the orphan sweep, and only sweep this feature's own files
Review follow-ups on the orphaned timelapse session cleanup.

    The sweep's own docstring said min_age_seconds made it safe to call mid-run.
    It did not. on_print_complete drops the session from _active_sessions before
    handing frames_dir to ffmpeg, so for the length of a stitch the directory
    matches no active session, and its mtime is the last layer's frame write -
    which on a tall print's final layer is easily older than the margin. The
    default margin is 300s and the stitch timeout is also 300s, so the two were
    tied with no headroom at all: a sweep landing in that window deleted ffmpeg's
    input from under it. _finalizing_sessions now covers the stitch, set as the
    session leaves _active_sessions and cleared in a finally so a failed stitch
    cannot leak the marker and make that printer's leftovers permanently
    un-sweepable. The docstring names all three guards and which gap each covers,
    including that the margin does have real headroom for the two cases it suits -
    a session mid-creation, and the freshly written .mp4 awaiting attach.

    The file branch now requires the timelapse_<session_id>.mp4 shape its own
    comment describes. It previously deleted any file under
    timelapse_frames/<printer_id>/ past the margin; nothing else writes there
    today, but age alone is not a reason to delete a file this feature did not
    create.

    Dropped ignore_errors=True from the rmtree. It made the surrounding
    except OSError unreachable, so a read-only mount or a permissions problem was
    counted and logged as a successful removal - and that log is the only evidence
    an operator has of what was deleted.

    Tests 5 -> 9: sparing a session mid-stitch, the finalizing marker cleared even
    when the stitch raises, unrelated files left alone, and a failed removal not
    counted. The failure test's rmtree stub honours the real contract and returns
    silently when ignore_errors=True, because that silent no-op is exactly what the
    old call could never observe; a stub that raised unconditionally would have
    passed against both versions and proved nothing.

    main.py is unchanged: it has no module-level logger, and the inline
    logging.getLogger(__name__) the sweep uses is the idiom throughout lifespan.
2026-08-02 09:46:59 +02:00
maziggy
b4ea8e28c5 Merge pull request #2721 from bitbarista/fix/external-camera-capture-coalescing
fix(camera): share one connection between concurrent one-shot external-camera captures
2026-08-02 09:46:42 +02:00
maziggy
53844b46a5 fix(camera): redact credentials, contain failures, and stop the external-camera test claiming a connection it never opened
Review follow-ups on the external-camera capture coalescing.

    The coalescing was transplanted from camera.py, which is keyed by printer IP
    and so has nothing to hide in a log line. These keys carry the camera URL, and
    an RTSP camera URL routinely embeds user:pass@ - so the five new log lines
    printed the password, one of them at warning level, where it reaches support
    bundles. All five now go through _log_key(), which redacts before truncating:
    slicing first can cut the URL short of the @ the pattern anchors on and leave
    the password intact, which is why every other URL log in the module already
    does it in that order.

    _capture_frame_uncoalesced gained the blanket catch its camera.py counterpart
    has. That is load-bearing once captures are shared: the wrapper hands one
    task's outcome to every caller waiting on it and can only give a follower its
    own turn for an outcome it recognises, so an escaping exception reached all of
    them at once and none retried - one caller's failure becoming N. The per-type
    helpers catch narrowly (aiohttp.ClientError / OSError / timeouts), so the
    guarantee belongs here rather than resting on their coverage. CancelledError
    is re-raised ahead of it, since the wrapper distinguishes a cancelled leader
    from a failed one.

    test_connection reports whether it shared a capture. It reaches capture_frame
    like any other consumer, so a test landing while Obico is polling got that
    frame back and answered "connected" for a connection it never made - the one
    answer a connection test must not give silently. It still shares rather than
    forcing its own capture, because forcing one would open the second handle to a
    single-reader device that this whole mechanism exists to prevent. The response
    carries `coalesced`, which also gives capture_in_flight() the consumer its
    camera.py counterpart has in the Diagnose tool, and the Test button says
    "shared with a capture already running" instead of a bare success.

    Tests 12 -> 20: an unexpected error reported as a failed capture, a raising
    leader whose follower still gets a frame, the three coalesced states, and
    redaction on each log line that can carry a URL. The raising-leader test
    patches _capture_rtsp_frame rather than _capture_frame_uncoalesced, since a
    stand-in installed in the latter's place sits above the catch and would test
    the wrapper against a shape it can no longer be handed.
2026-08-02 09:46:25 +02:00
maziggy
51f36a4d24 Security hardening (maziggy/bambuddy-security #8) 2026-08-02 09:46:09 +02:00
maziggy
2e45f77a4b fix(camera): apply camera_rotation to finish photos
_apply_camera_rotation was only ever wired into
    _capture_snapshot_for_notification (print-start photo, in-print frame
    bank) - the finish-photo pipeline (_background_finish_photo's three
    branches: stage-22 pre-captured frame, external-camera fallback,
    built-in buffered-frame fallback) saved frames straight to disk with
    no rotation, so a finish photo came out upside-down whenever
    camera_rotation was configured. Pre-existing gap, unrelated to
    yesterday's collision fixes - just more visible now that a finish
    photo reliably gets captured at all.

    Built-in camera's final capture_finish_photo() fallback (writes its
    own file, returns only a filename) is left unrotated - out of scope
    here since Carl uses an external camera; would need its own change to
    services/camera.py.
2026-08-02 09:43:54 +02:00
maziggy
ed3173eb3f docs: add CHANGELOG entry for #2709 orphaned timelapse session cleanup 2026-08-02 09:43:40 +02:00
maziggy
e762ed296d fix(camera): sweep orphaned timelapse session directories on startup
_active_sessions is in-memory only, so a process restart mid-print
    loses track of any active layer-timelapse session without ever calling
    cancel_session()/cleanup() - the frames directory (and, if stitching
    had already produced output before the restart, a stray
    timelapse_<session_id>.mp4) are then orphaned on disk permanently, with
    no equivalent to the ffmpeg orphan janitor to reap them.

    Confirmed live: 38MB of exactly this leftover on the OrangePi after
    several restarts during this week's testing, including two corrupt
    48-byte .mp4s from stitches that got interrupted mid-write.

    Adds cleanup_orphaned_timelapse_sessions(), run once at startup: for
    each printer_id under timelapse_frames/, remove any frame directory or
    stitched-output file that doesn't match that printer's current active
    session (if any) and is older than a defensive margin (5 min default).
    A restart-recovered print never gets a new timelapse session either
    (#1353's _maybe_start_layer_timelapse only fires on fresh PRINT_START
    events), so nothing orphaned here can ever be resumed - safe to always
    remove once it's old enough not to be a startup race.
2026-08-02 09:43:20 +02:00
maziggy
c9f99b35d1 fix(camera): share one connection between concurrent one-shot external-camera captures
#2705 fixed simultaneous captures colliding on the built-in camera path,
    keyed by printer IP through capture_camera_frame_bytes(). External
    cameras reach the same collision through a different function -
    external_camera.capture_frame() - that #2705 didn't touch, and a V4L2
    USB device allows exactly one open handle just like Bambu's own RTSP
    limit.

    Nothing coalesced two one-shot capturers here either: Obico polling,
    the in-print frame bank, the finish-photo moment, plate detection and
    the notification snapshot could each open their own connection to the
    same USB camera and collide - is_stream_active() only stops a
    capturer from competing with an attached viewer, not with another
    capturer (that's what #2707 fixed).

    capture_frame() is now a single-flight coalescing wrapper (actual
    dispatch moved to _capture_frame_uncoalesced), keyed by (url,
    camera_type, snapshot_url) - snapshot_url is part of the key since
    #1177's override routes to a completely different endpoint. Mirrors
    #2705's shape: coalesces, doesn't cache (a call after the previous one
    finishes always captures fresh); each caller keeps its own timeout via
    wait_for(shield(...)) rather than inheriting the leader's; a follower
    whose leader fails takes its own turn instead of inheriting a failure
    it never had a chance to avoid, bounded at two rounds; cancellation is
    disambiguated via leader.cancelled() so a follower's own cancellation
    still propagates while a cancelled leader is treated as a failed one.

    12 tests mirroring test_camera_capture_coalescing.py's structure.
2026-08-02 09:43:04 +02:00
maziggy
8bf110ee62 Post work PR #2691 2026-08-02 09:42:53 +02:00
maziggy
2c94cc9e53 Merge pull request #2691 from gzimbric/feature/p2s-x2d-accessory-fans
feat(printers): expose P2S/X2D accessory fans (left aux + exhaust)
2026-08-02 09:42:40 +02:00
maziggy
7096787be6 fix(printers): don't retract a fan kit on a partial airduct frame
device.airduct is pushed field by field - the modeCur handler reads it with
    an "in" check for that reason - so a frame can carry parts without carrying
    every fan. Absence in that list is what tells us a kit is not fitted, and
    taken from a truncated frame it made both accessory badges vanish mid-print
    and started rejecting fan=aux2 on a printer that has the fan.

    A parts list now counts as a full inventory only when it carries ids 1 (part
    cooling) and 2 (aux). Neither is optional on a machine that reports an
    airduct at all, and both appear in every layout in the support-package
    archive - P2S base 1,2 / P2S+kit 1,2,3 / X2D 1,2,3,10 / H2C,H2D,H2S 1,2,3,6.
    Anything narrower is a diff frame: its speeds are applied, presence is left
    alone. Presence can still be added from a partial frame; only retraction
    needs the full list, so a kit that really is removed still disappears.

    Also compose showChamberFan from both model lists rather than branching
    between them, so the P2S/X2D entries in MODELS_WITH_CHAMBER_FAN stay
    reachable instead of reading as dead, and note in the fan-speed docstring
    that the aux2 gate also rejects between connect and the first airduct push.
2026-08-02 09:42:26 +02:00
maziggy
3466195d60 fix(slice): give the slice modal one filament row per project slot (#2712)
The filament list is positional from the modal down to the CLI's
    filament_N.json parts, but for a source that already carries slice_info the
    requirements endpoint returns only the slots the plate consumes. A
    MakerWorld model declaring four filaments and painting with slot 4 alone
    therefore showed one dropdown, whose PETG pick the CLI bound to slot 1 —
    slot 4 sliced with the profile baked into the source, and the print came out
    PLA.

    The endpoint now takes full_slots, which widens that answer to every
    project slot with used_in_plate flags, and only the slice modal passes it.
    Print-time AMS matching shares the endpoint and keeps the used-only list, so
    it still asks for exactly the spools the job needs.
2026-08-02 09:42:07 +02:00
maziggy
89804b810d fix(slice): report a finished slice once, not once per queued poll
setInterval does not await an async callback. Slicing a large project
    blocks the backend for seconds, so poll ticks piled up behind one stalled
    request, each holding a snapshot taken while the job was still active.
    They resolved together, and every one of them ran the completion path —
    one toast and two query invalidations each. A 20s stall against the 1.5s
    interval produced 13 "Sliced X" toasts from a single slice.

    Only one poll round is now in flight at a time, which also stops queueing
    requests against a backend that is already saturated. Completion is
    recorded once per job id, and a round still awaiting a response when the
    effect tears down now returns instead of acting.
2026-08-02 09:41:46 +02:00
maziggy
4045ddbd1f fix(queue): withdraw an expected print when the command never goes out
feat(db): warn when the connection pool can outgrow the PostgreSQL server

    fix(mqtt): an unusable layer_num must not drop the printer connection

    test: patch settings.base_dir via monkeypatch so it unwinds on error

    test: restore the config module after reloading it
2026-08-02 09:41:32 +02:00
maziggy
bdcb8a3fc7 fix(mqtt): keep the layer total that arrives with the print-start frame (#2702)
fix(support): redact push_status values, not the serialised JSON (#2702)
2026-08-02 09:41:19 +02:00
maziggy
216a8a3695 Security hardening (maziggy/bambuddy-security #7)
fix(settings): accept JSON booleans on the Spoolman settings endpoint
2026-08-02 09:41:04 +02:00
maziggy
8eb185b239 fix(camera): reuse the live view's frame for external-camera captures (#2707)
On a printer with an external camera, watching the live view while a print
    ran meant the layer timelapse recorded almost nothing and the finish photo
    went out with no image. The reporter measured 0 of 87 layer captures on one
    print and 0 of 105 on another, both watched throughout. A USB camera allows
    one V4L2 handle, so a capture during a live view fails outright.

    The built-in camera has had this rule since #1348 and #1271: reuse the
    viewer's buffered frame rather than opening a second connection. It was
    never extended to the external paths, and it could not have been -- the
    buffer it depends on was only ever populated by the built-in paths.
    generate_mjpeg_stream yields multipart-wrapped chunks, so the route layer
    could not recover the JPEG, and a guarded caller would have found an empty
    buffer and skipped every time.

    So the stream now publishes each raw frame through a new on_frame callback
    (parallel to on_process from #2675), and the six one-shot consumers reuse
    it: layer timelapse, the finish-photo moment and its background fallback,
    the notification snapshot, Obico polling, and the plate check. A viewer
    attached with nothing buffered yet skips that one attempt rather than
    competing -- kicking the viewer off is worse than missing a frame.

    on_frame exceptions are logged and swallowed, like iter_subscriber's
    on_unsubscribe: buffering is a side effect and must never be able to take
    the live stream down with it. The external stream's teardown now releases
    the buffered frame too, ownership-checked so a concurrent viewer of the
    same printer keeps its own.

    Two side effects on paths not touched here, both improvements: the snapshot
    endpoint and the finish-photo fallback chain consult get_buffered_frame and
    can now serve an external camera's live frame. plate_detection's docstring
    already claimed this behaviour while implementing it only for the built-in
    fallback; that drift is resolved.
2026-08-02 09:40:50 +02:00
maziggy
55f4aa61eb fix(camera): drain a streaming ffmpeg's stderr continuously (#2707)
ffmpeg is spawned with stderr=PIPE and it was only read on the error
    paths, so for the life of a working stream nobody read that pipe. ffmpeg
    writes its banner, the input analysis, then a progress line at a steady
    rate; a 64 KiB pipe fills eventually, ffmpeg blocks writing to it, frames
    stop, and the stream's own 30s timeout fires -- logged as "RTSP read
    timeout" with no hint that we starved it ourselves.

    How long that takes is unmeasured and evidently long: one H2D upstream
    ran 21m36s without stalling, and an earlier 512 B/s extrapolation of mine
    was mostly the one-off startup banner. So this is a bounded resource being
    treated as unbounded, not a fault anyone has reported.

    _FfmpegStderrTail drains the pipe continuously and keeps a 16 KiB rolling
    tail. That tail is what the error paths now report, which is better
    material than before: it holds what ffmpeg said as things went wrong,
    where the on-demand read returned whatever was printed first -- usually
    the banner, which the summariser strips anyway.

    Three readers wanted this one pipe, and asyncio rejects concurrent reads
    on a StreamReader, so the collector is authoritative: it registers by pid,
    _read_ffmpeg_stderr returns its tail when present and otherwise reads the
    pipe unchanged, and _terminate_ffmpeg skips its own stderr drain when the
    collector owns it (the collector keeps draining through teardown, which is
    all wait() needs). The generator starts it after the immediate-failure
    check, which reads the pipe directly because the process is already dead,
    and releases it after _terminate_ffmpeg.

    text() goes through _summarize_ffmpeg_stderr like every other stderr log
    here, so the access code ffmpeg echoes in its input URL stays masked.
    aclose() awaits the cancelled pump rather than firing and forgetting, so
    no pending task survives into loop teardown.
2026-08-02 09:40:34 +02:00
maziggy
097e10b344 fix(camera): one registry key per stream, not per printer (issue #2707)
Closing a camera view and reopening it immediately could leave the new
    stream unregistered while it was running and delivering frames. The
    damage was all indirect: is_stream_active() reported no viewer, so Obico
    polling and snapshots opened a second camera connection against the live
    view (the thing #1348 and #1271 exist to prevent); the janitor's /proc
    scan found an ffmpeg missing from _active_streams and killed the live
    stream as an orphan; and /camera/stop reported "Stopped 0" with a stream
    running.

    The fan-out stream id was f"{printer_id}-fanout" -- constant per printer,
    so every successive stream shared one registry key, and the departing
    generator's finally popped whatever was under it, including its
    successor's entry. The same finally also cleared the per-printer frame
    buffer unconditionally, discarding the new stream's frame. It needed the
    two streams to overlap, which the 4s teardown made easy.

    Each stream now gets its own key via _new_fanout_stream_id(), so a
    generator can only clean up after itself -- the external-camera path
    already does this (#2675) and this brings the fan-out path in line. The
    per-printer dicts are released through _release_printer_frame_state(),
    which checks that no other stream for the printer is still running; both
    the RTSP and chamber-image cleanups had the same unconditional pop.

    Also hoisted time and uuid to module level and dropped four
    function-local `import time` statements. A local import shadows the name
    for the whole function, so any use on a branch that doesn't reach the
    import raises UnboundLocalError -- a real hazard in camera_stream, whose
    external-camera branch imported both while the RTSP path needs them too.
    A test pins camera_stream as free of function-local imports.
2026-08-02 09:40:19 +02:00
maziggy
562e325972 fix(camera): drain ffmpeg's pipes during teardown (#NNNN)
Closing a camera view logged "ffmpeg didn't terminate gracefully,
    killing" followed by "ffmpeg did not exit within 2.0s of SIGKILL;
    abandoning wait", on every single close. Both waits expired every time,
    so teardown took a fixed 4.00s -- and since the firmware allows one
    camera connection, that was 4s in which nothing else could use it.

    ffmpeg is spawned with stdout and stderr as pipes and the teardown paths
    have stopped reading them, so it sits blocked in write() on a full 64 KiB
    pipe. SIGTERM cannot be acted on there: the handler only sets a flag that
    the main loop polls, and the loop never gets back to the check. SIGKILL
    does kill it, but asyncio resolves Process.wait()'s waiter through
    _try_finish(), which requires every pipe transport to report
    disconnected; paused, unread pipes never reach EOF, so wait() blocks with
    returncode already set. A negative-control test shows returncode=-9 at
    the instant the abandon fires.

    Draining both pipes while stopping the process fixes both halves: 4.00s
    becomes ~0.15s. The signal ladder and its bounds stay as backstops, so a
    genuinely wedged process still cannot hang a stream, a Stop request or
    the janitor.

    This corrects _FFMPEG_KILL_TIMEOUT's premise and #2580's conclusion. That
    12-hour hang was the unbounded form of this same self-inflicted stall, not
    an ffmpeg stuck in uninterruptible I/O -- the process observed doing it
    was in state S, which cannot survive a delivered SIGKILL. Bounding the
    wait capped the symptom without removing the cause.
2026-08-02 09:40:04 +02:00
maziggy
54908115a2 fix(camera): share one connection between concurrent one-shot captures (#2705)
Bambu firmware allows exactly one camera connection. The existing guards
    (is_stream_active / try_get_active_buffered_frame, #1271 and #1348) only
    stop a one-shot capturer from competing with the fan-out broadcaster.
    Nothing coordinated the capturers with each other, so with no viewer
    attached every consumer correctly concluded it was not competing with a
    viewer and then collided with the others. On the reporter's P2S an Obico
    poll and a snapshot opened two RTSP sockets 207 ms apart, which knocked
    over the fan-out stream feeding the camera wall; it was then reaped for
    having received no frames for 58s.

    capture_camera_frame_bytes() now coalesces: the first caller opens the
    connection, callers arriving while it is in flight await the same result.
    Eight paths reach that function independently - Obico polling, the
    snapshot route, the finish-photo moment and its disk-writing sibling,
    plate detection, the camera test and the diagnose tool - so the
    single-flight sits at the bottom of the stack and no call site changes.

    Keyed by IP, since that is what the firmware's limit applies to and the
    function never sees a printer_id. The key excludes the timeout on
    purpose: the call sites disagree about it, from 10s to 30s, so keying on
    it would mean the Obico-vs-snapshot pair from the report never coalesced
    at all.

    It coalesces, it does not cache. A call arriving after the previous
    capture finished still captures fresh, because plate detection and the
    finish-photo path judge a running print from these frames and a stale one
    there is worse than a slow one - #1397 was a finish photo taken seconds
    late showing the bed already lowered.

    Each caller waits on its own deadline rather than inheriting whichever
    one happened to open the connection, and shield() means giving up leaves
    the capture running for whoever else is still waiting. A follower whose
    leader fails takes a turn of its own instead of inheriting a failure it
    never had a chance to avoid; the leader has finished by then, so there is
    nothing left to compete with. Bounded at two rounds. That also covers the
    follower whose timeout is longer than the leader's, which coalescing
    alone cannot. Cancellation is disambiguated via leader.cancelled(), so a
    follower's own cancellation propagates while a cancelled leader is
    treated as a failed one.

    The leader is deliberately not wrapped in a second wait_for: the
    implementation already enforces the timeout internally, where it can also
    kill the ffmpeg process, and an outer deadline would abandon the
    subprocess instead of killing it.

    The diagnose tool now marks a stage whose frame came from a capture
    already in flight as coalesced_capture. The pass is real evidence the
    camera works, but duration_ms is then mostly time spent queueing, and a
    diagnostic must not report a connection it never opened - the same reason
    that file declares its live_stream_active shortcut instead of quietly
    passing. Failures are not annotated, since a follower whose leader fails
    goes on to capture on its own.
2026-08-02 09:39:40 +02:00
maziggy
9b2b433497 fix(filament): a unique preset match no longer counts as a colour match (#2687)
The Filament Mapping panel reported "(Ready)" with a green tick for a slot
    where the slice wanted dark red and the auto-matched tray held dark green.
    Manually picking that same tray reported the mismatch correctly, which is
    what made it obvious something was inconsistent.

    Auto-match ranks candidates by tray_info_idx first, and a uniquely-matching
    preset was accepted as definitive on the premise "same preset = same spool =
    same colour". The preset names the variant, not the spool: GFA00 is PLA
    Basic, GFA01 PLA Matte, GFA17 PLA Translucent, in every colour Bambu sells.
    The reporter's own bundle has eight GFA00 trays in eight colours. With one
    Matte spool loaded, every Matte requirement idx-matched it and the colour
    comparison was never reached - which is why this surfaced on PLA Matte and
    not on Basic, where several spools are usually loaded and the match falls
    through to the branch that does compare colours.

    The verdict now comes from the tray that was selected rather than from which
    rule selected it, and both branches share one comparison so they cannot
    drift apart again. Selection is unchanged - the right variant still wins per
    mismatch and the slot stays selected.

    A requirement with no colour at all is treated as satisfied rather than
    mismatched; 3MFs that omit it parse to "" and there is nothing to disagree
    with. That also affects the manual branch, which used to flag it.

    No dispatch change: _get_missing_force_color_slots already required an exact
    colour, so force colour match was gated correctly throughout.
2026-08-02 09:39:25 +02:00
maziggy
c79a583f7a feat(file-manager): show last activity on folder rows via the existing date toggle (issue #2680)
Follow-up to #2680: the calendar toggle only put dates on the file pane, so
    the folder tree had no way to show the timestamp it was already sorting on.
    FolderTreeItem now takes showModified and renders latest_activity_at under
    the folder name, threaded through the recursive call so nested folders get it
    too. No backend change - the field was already on the wire from the sort fix.

    Folders are labelled "last activity", not "last modified", and get their own
    i18n key. The value is the newest timestamp among the folder, its files and
    everything below it, so a folder can read as newer than its own directory
    mtime - calling that "modified" would look like a fresh instance of the
    ls -lt mismatch the issue was originally about. Folders with no activity
    render nothing rather than an Invalid Date placeholder.

    The name span moved into a flex column so the second line does not disturb
    the row's link badge, file count or kebab menu. That broke a folder-delete
    test that reached the row via parentElement, now fixed to use closest().

    Separately, the #996 collapse describe left an implementation on the
    module-global localStorage.getItem mock, which silently collapsed the folder
    tree for every describe after it. It resets in afterEach now; without that,
    any later test asserting on nested folders fails for reasons unrelated to
    what it is testing.
2026-08-02 09:39:10 +02:00
maziggy
c655413971 fix(tests): add the new fan fields to the plate-clear status fixture
mqtt_relay reads state.left_aux_fan_speed, but the SimpleNamespace fixture in
    test_plate_clear_mqtt_notification enumerates its fields explicitly, so the two
    status-payload tests raised AttributeError. I updated the equivalent fixture in
    test_printer_manager_status_broadcast and missed this one — running only the
    touched suites is what hid it.

    Also addresses the round-2 review notes:

    - exhaust_fan_present: documented that the H2 series reports part 3 too, so the
      flag is not model-specific despite the name.
    - Mask the part id after shifting, matching get_flag_bits(id, 4, 8), for
      consistency with the state decode. No behaviour change for any observed id.
    - Noted the unmapped H2 id 6 beside the id branches.
    - Reject fan=aux2 when the printer reports no left_aux_fan_speed, so a POST
      against an A1 no longer sends M106 P10 for absent hardware. The UI already
      hid the badge; this closes the same hole on the API.
    - Added a test asserting EXHAUST_FAN_LABEL_MODELS and the frontend's
      MODELS_WITH_EXHAUST_LABEL cannot drift apart.
2026-08-02 09:38:51 +02:00
maziggy
647cda6b9d Post work PR #2693 2026-08-02 09:38:38 +02:00
maziggy
83df15c0a3 Merge pull request #2693 from Chachigo/feature/print-progress-in-title
feat: show live print progress in the browser tab title
2026-08-02 09:38:23 +02:00
maziggy
4c733dfd45 fix(tab-progress): drop the redundant status poll and quieten the test suite
The hook is mounted globally in WebSocketProvider, so refetchInterval on its
    per-printer status queries added one request per printer every 30s on every
    page. The Printers page already runs that fallback on the same query key, and
    useWebSocket writes ['printerStatus', id] straight into the cache, so the poll
    bought nothing outside the Printers page and cost a request per printer per
    tab everywhere else.

    Also captures document.title at mount instead of restoring to a hardcoded
    'Bambuddy', so the default no longer has to be kept in sync with index.html.

    jsdom has no canvas backend, so getContext('2d') logged a "Not implemented"
    jsdomError with a full React stack on every run of the hook's tests, and the
    favicon branch bailed on the null context and went untested. Stubbing
    getContext/toDataURL removes the noise and lets the ring code run, so the
    favicon swap and the restore-on-toggle-off path are now asserted.
2026-08-02 09:38:08 +02:00
maziggy
3e326adce8 feat(notifications): optional Telegram forum topic via message_thread_id (#1518)
Telegram groups with Topics enabled always received notifications in the
    General topic, since only Bot Token and Chat ID were configurable. Splitting
    notifications per printer meant running a separate chat for each one.

    The Telegram provider now takes an optional Forum Topic ID - the last number
    in a topic's link, t.me/c/1234567890/25 - and routes its messages there. Left
    empty, nothing changes.

    The value is coerced to an int once in _send_telegram and attached to both the
    sendMessage JSON body and the sendPhoto form data. That ordering matters:
    Telegram rejects a string message_thread_id in the JSON body while accepting
    one in the multipart call, so passing the raw form value through would have
    worked for thumbnail notifications and 400'd for plain-text ones. A
    non-numeric value is rejected in the form and again server-side before any
    request goes out.

    No migration - provider config is a JSON blob.

    Adds Forum Topic ID plus help text to the Telegram section of the provider
    dialog, translated in all 13 locales. Backend tests cover omitted / blank /
    int-typed / non-numeric values and both send paths; frontend tests cover the
    field being optional, absent for other providers, round-tripping on save, and
    blocking save on a bad value.
2026-08-02 09:37:46 +02:00
maziggy
a817394a23 feat: show live print progress in the browser tab title
Optional and off by default, toggled under Settings -> Appearance. When enabled, the browser tab shows the soonest-finishing print's percentage plus a green progress-
ring favicon, updated live over the existing WebSocket. The preference is stored per-browser in localStorage.

    Adds the usePrintProgressTitle hook (with tests), a ThemeContext preference, the Settings toggle, i18n strings for all locales, and a README entry.
2026-08-02 09:37:28 +02:00
maziggy
cf4cced388 fix(i18n): list Ukrainian after Russian in the language picker
The uk locale was inserted before ru in i18n/index.ts - in the import block,
    the resources map and availableLanguages. Locales are appended to those lists
    as they land (tr, then ru on 2026-07-19, then uk on 2026-07-28), and
    SettingsPage renders availableLanguages in array order, so the picker showed
    Ukrainian above Russian while every other entry followed the order it was
    added.

    Moves uk to the end of all three lists. SUPPORTED_LNGS is sorted
    alphabetically and already had uk in the correct position, so it is unchanged.

    Frontend-only, no behaviour change beyond the picker's row order.
2026-08-02 09:37:13 +02:00
maziggy
65b4c59175 Merge branch 'dev' into feature/p2s-x2d-accessory-fans 2026-08-02 09:36:55 +02:00
maziggy
6d8434b250 Security hardening (maziggy/bambuddy-security #N)
Subprocess output and user-supplied URLs are scrubbed of credentials
    before they reach the application log. Adds a shared redaction helper in
    core/logging_filters and routes the existing support-bundle sanitizer
    through the same pattern.
2026-08-02 09:36:38 +02:00
maziggy
fe476dd98e fix(timelapse): poll longer, diff without a clock, delete once archived (#2704)
Timelapse was on, the video never reached the archive, and Scan for Timelapse
    found nothing afterwards. Across 247 support bundles this was the norm, not an
    edge case: 457 automatic scans scheduled, 262 attached.

    The scan looked four times over ~65s. The attempt that found the video was #1
    272 times, then 17 / 13 / 13 — flat against the cutoff, not decaying, i.e.
    files were still arriving when we stopped. What ran afterwards searched for the
    print name inside the filename; Bambu only writes "video_<timestamp>", so it
    fired 159 times and matched zero.

    The manual Scan had no baseline at all and matched on filename timestamp, FTP
    mtime, or "there is only one video" — all reading a clock a LAN-only printer
    cannot sync. The reporter's P1S was six and a half days out.

    - Poll for minutes instead of ~65s; drop the name-match fallback.
    - Persist the print-start baseline on the archive, so the diff survives a
      restart mid-print and the manual Scan runs the same comparison. With a
      baseline present the clock-based strategies are skipped entirely — they can
      only turn an honest "pick one" into a confident wrong answer.
    - When several files are new (a previous print's video landing late), exclude
      the ones already attached to another archive instead of ordering the
      candidates. Ordering could only be done on the printer's clock.
    - Delete the video from the printer once archived. Keeps /timelapse to
      unclaimed files, which is what makes the diff unambiguous, and stops P1S
      cards filling with AVIs.
    - Gate that delete on a verified transfer: download_file now compares against
      the size from the listing. An FTPS connection closing early does not always
      raise, so a partial buffer was being attached as a complete video — which
      would also have been the one case where deleting the source lost data.

    Bounded twice on purpose: wall-clock deadline plus a derived round cap, since
    the deadline stops bounding the loop as soon as the sleeps are shortened.
    Per-round logging only speaks when the listing changed — 31 rounds of full
    listings would bury the interesting line in the support bundle.

    Migration adds print_archives.timelapse_baseline as JSON, spelled the same on
    both dialects so a migrated database matches a fresh one.

    -----------

    fix(finish-photo): add the timelapse frame to the archive after the notification (#2704)

    When a print records a timelapse, its last frame is the better finish photo:
    the firmware stops recording with the toolhead parked and before the end
    G-code drops the bed, where a live grab at that moment catches a lowered
    plate. Bambuddy waited 60s for the video and then gave up, because the
    print-complete notification blocks on that photo and holding a notification
    for minutes is worse than sending it with the live grab.

    P1-series printers write MJPEG AVI rather than H.264 MP4 and serve it slowly.
    Measured over 261 attaches in the support bundles: P1S median 33s, p90 167s,
    worst 546s, while every other model finished inside 26s. So the printers that
    most needed the better framing were the ones that never got it.

    Keep the notification on the same bound, and keep waiting off to the side.
    _capture_finish_photo_from_timelapse now reports whether it ran out of time or
    concluded — a video that landed and failed extraction is not worth retrying,
    one that never arrived is. On the first, schedule a background task that waits
    up to 15 minutes and inserts the extracted frame at the front of the archive's
    photo list, where the gallery opens.

    The live grab stays on disk: the notification already links to that exact
    file, so removing it would leave a broken image in Discord or Telegram.

    The length check proves we received what the listing said, not that the file
    was finished. The first look happens ~5s after the print ends, while the
    printer may still be writing, so a growing file can be listed short, served
    short, and pass. Re-list after the download and only accept the video once its
    size has stopped changing — a failed re-list counts as not settled, since
    "could not check" must not mean "safe to delete".
2026-08-02 09:36:07 +02:00
maziggy
d14c969197 feat(diagnostics): log end-of-print telemetry for finish-photo trigger research (#2547)
The finish photo needs a "printing done, toolhead parked, filament unload
    not started" moment. stg_cur=22 was meant to be it (#1721) and fires on no
    model in the field: across 247 support bundles there is not one
    FINISH PHOTO MOMENT (stage-22), including the 2026-06-13..07-08 window where
    it was the only pre-FINISH trigger in the code — 104 captures on A1, A1 Mini,
    H2C, H2D, P1S, P2S, X1C and X2D, all of them the FINISH fallback.

    A replacement can't be designed from the bundles we have. Out of that window
    Bambuddy parses only stg_cur and mc_print_sub_stage; every other stage/action
    field arrives and is dropped unread. The candidates that sound right
    (print_real_action, mc_action, mc_stage) are absent from A1/A1 Mini/P1S
    payloads, so none of them can be the universal answer alone.

    Dump the raw fields for the window between the last object layer and
    gcode_state=FINISH at DEBUG. Opens on the first end-of-print signal (last
    layer, progress >= 99, or no remaining time) so a dropped layer_num packet
    doesn't lose it, logs only what changed frame to frame, closes on the
    transition out of RUNNING, and arms once per print.

    Instrumentation only: gated on DEBUG being enabled, read-only against printer
    state, wrapped so it cannot break ingest, and capped at 400 frames per print.
    The probed fields are stage codes, counters and bitfields — nothing
    identifying, and no access code.
2026-08-02 09:35:49 +02:00
maziggy
1cda64c35a fix(mqtt): report why a printer refused the connection instead of looping silently
A printer with a wrong access code gave no explanation anywhere. The connect
    callback's failure branch was a bare `state.connected = False`, discarding the
    CONNACK reason code the printer had just sent, so the only trace was paho's
    follow-up disconnect -- logged every 30 seconds as "rc=Unspecified error",
    which is exactly what a powered-off printer produces. In the report behind this
    fix one of three printers had been in that loop for the whole capture, and
    neither the log nor the support bundle could say why.

    Bambu speaks MQTT 3.1.1, whose CONNACK return codes 4 and 5 paho maps onto
    reason codes 134 and 135. Both are now logged with the printer's own reason
    string and, for those two, the remedy: the access code is regenerated whenever
    LAN Only or Developer Mode is toggled, so it has to be re-read from the screen.
    The access code itself is never logged -- it would land in every bundle.

    The reason is kept on the client as a stable slug and plumbed through
    test_connection into the connection diagnostic, which now distinguishes two
    cases it previously conflated. "The printer refused our credentials" is
    asserted only when the printer said so; when all Bambuddy knows is that there
    is no session, the text hedges and names the alternatives (rebooting, or
    already at its limit of simultaneous connections). The old wording claimed the
    access code was most likely wrong in both cases.

    Frontend needed no change -- ConnectionDiagnostic already renders
    `<status>_<reason>` variants with fallback to the plain per-status text, so an
    unrecognised slug degrades to today's wording rather than a missing key.
2026-08-02 09:35:23 +02:00
maziggy
30ad040ac2 Updated README 2026-08-02 09:34:57 +02:00
maziggy
469bd408a0 Updated CONTRIBUTING.md 2026-08-02 09:34:50 +02:00
maziggy
7e954efb10 Updated BACKERS 2026-08-02 09:34:38 +02:00
maziggy
dbd2fd19d4 Post work PR #2740 2026-08-02 08:32:52 +02:00
MartinNYHC
2915d2221b
Merge pull request #2740 from mpl1337/feature/queue-item-eta
Add per-job ETA to print queue
2026-08-02 08:29:03 +02:00
maziggy
30daed2756 Fix per-job queue ETA showing for jobs that cannot start now
The scheduler only writes waiting_reason on the model-based assignment
path, so a job pinned to a specific printer sits behind a running print
with no marker at all. Every such job rendered an identical "starts now"
ETA that was wrong by the length of everything ahead of it.

Decide eligibility on the page instead: an item gets an ETA only when its
printer is idle and it is the item the scheduler would dispatch next,
following the same ordering the scheduler uses. Staged and future-
scheduled items do not block the item behind them, matching the
scheduler, and items conditional on a previous print are excluded.

The value also froze at first render, since react-query's structural
sharing keeps the queue reference stable and nothing re-rendered the row.
formatETA now accepts a base instant and the page drives it from a 30s
clock shared by every visible row.

Retire the borrowed printers.estimatedCompletion tooltip for a queue key
that says what the number means, translated into all 13 locales.
2026-08-02 08:28:26 +02:00
MartinNYHC
588665d5f4
Merge branch 'dev' into feature/queue-item-eta 2026-08-02 08:09:53 +02:00
mpl1337
0034fdc425 Fix queued ETA visibility and add rendering tests 2026-08-01 13:58:54 +02:00
maziggy
d36632db0f Updated README 2026-08-01 12:53:58 +02:00
maziggy
9259763179 Updated README 2026-08-01 12:53:37 +02:00
MartinNYHC
3d6bf2a303
Merge pull request #2625 from munin92/feature/oidc-env-config
feat(oidc): configure an OIDC provider from environment variables
2026-08-01 11:37:38 +02:00
maziggy
aef4f3a3e9 fix(oidc): strip the required BAMBUDDY_OIDC_* values and register the local-login bypass
A Kubernetes Secret written as a block scalar carries a trailing newline, and
the schema bounds the four required variables by max_length only, so an
unstripped issuer_url was stored and enabled and then raised httpx.InvalidURL
on the first click of the SSO button -- the authorize-time failure the
all-or-nothing rule exists to prevent. Whitespace-only values got through the
same way, contradicting the reader's own "an empty required var counts as
unset". The optional variables have always treated blank as unset; the
required ones now do too.

Also registers BAMBUDDY_LOCAL_LOGIN (#1589) in the typo guard, which logged
"possible typo" for it on every boot while listing every BAMBUDDY_OIDC_*
variable as legitimate.
2026-08-01 11:37:01 +02:00
MartinNYHC
8b46006644
Merge branch 'dev' into feature/oidc-env-config 2026-08-01 11:22:58 +02:00
Marian
9e783fbad6 fix(oidc): keep the local-login bypass lenient under strict env_bool
Promoting env_bool to strict rejection made BAMBUDDY_LOCAL_LOGIN=on raise
EnvOIDCConfigError uncaught on the login/forgot-password path -- a 500 on
the exact recovery endpoint the bypass exists to keep open. env_bool gains
a strict flag (default True for the startup OIDC reader); the local-login
caller opts out so an unrecognized value falls back to "off" instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016q8EAf9Rj7ZHL92sPnXYxy
2026-08-01 09:17:33 +00:00
Marian
77c9bdd694 fix(oidc): reject an unrecognized boolean instead of guessing
_env_bool returned the default for anything outside {true,1,yes}, so
BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED=on silently read as OFF and
BAMBUDDY_OIDC_ENABLED=on silently disabled the provider -- the exact
opposite of what .env.example claimed. Unrecognized values now raise
EnvOIDCConfigError, caught in _apply_env_oidc_provider the same way a
bad DEFAULT_GROUP or a ValidationError already is: logged and left
running, never released on a typo.

Also promotes _env_bool to env_bool now that it has a call site in
auth.py, and corrects the boolean-parsing sentence in .env.example.
2026-08-01 09:17:33 +00:00
Marian
c547505c64 fix(oidc): treat a blank optional env var as unset, not a refusal
BAMBUDDY_OIDC_SCOPES, _EMAIL_CLAIM and _ICON_URL fell back to their
default only when the key was absent, so `BAMBUDDY_OIDC_ICON_URL=` in
a compose file (as .env.example ships it, commented) reached the
schema validator as an empty string and got the whole provider
refused. default_group already treated blank as unset; these three
now follow the same rule.
2026-08-01 09:17:33 +00:00
MartinNYHC
1cdc0e289f
Merge branch 'dev' into feature/queue-item-eta 2026-08-01 10:56:28 +02:00
maziggy
43cb216ae9 fix(settings): stop the Settings page reverting changes made elsewhere (issue #2716)
While the Settings page was mounted it held its own copy of every
setting and synced it from the server exactly once, on first load
(:887-900). A debounced effect then diffed the live ['settings']
cache against that copy and PUT all 77 keys it manages on any
difference, with no way to tell a user edit from a value that had
changed on the server. Anything written server-side while the page
sat open was silently reverted ~500ms later (#2716, reporter
@jmoore-skild).

No interaction was needed to trigger it. The query inherits a 60s
staleTime and react-query's default refetchOnWindowFocus, and ~30
other observers share the key, so a window refocus or a refetch from
any of them moved the cache and the page wrote its page-load snapshot
back over all 77 keys -- showing "Settings saved" while doing it.

The page now tracks the last server snapshot it reconciled with. A
field still equal to that baseline has not been touched since, so a
newer server value is adopted; a field the user has edited keeps
their value and is saved over the top, so the newer of the two writes
wins either way. Typing into a text field while a refetch lands stays
safe, which is what the previous behaviour was protecting -- an
in-progress edit is by definition different from the baseline.

The baseline is seeded from the raw server row rather than from the
copy the page patches a browser-detected external_url into, so that
detection still reads as a local change and is still persisted.

The payload builder and the comparison key lists are unchanged. The
diff simply measures against the baseline instead of the live cache,
so no field can silently stop saving.

Removing the adoption step was verified to reintroduce the revert,
and removing the post-save baseline advance to reintroduce a resend
loop; both are covered by frontend tests asserting on the request
bodies rather than on rendered values.
2026-08-01 10:55:08 +02:00
maziggy
18938a10ee fix(kprofiles): stop reporting rejected K-profile writes as saved
Saving a K-profile was fire-and-forget. set_kprofiles_batch published
and returned True, and the printer's extrusion_cali_set answer was
logged at DEBUG and dropped, so a write the printer refused was
reported to the user as saved (#2718, reporter @jmoore-skild).

The reason it could not simply be gated on: the answer itself was
wrong. Single-nozzle firmware returned result:"fail" with
reason:"invalid tray_id" on writes that demonstrably applied.
Measured against an X1C and an H2D over MQTT, the cause is the
tray_id:-1 Bambuddy itself put in the payload. Sending three
otherwise identical writes isolated it: tray_id:-1 fails, tray_id:0
succeeds, and cali_idx:-1 is accepted either way, so only that one
field is at fault. The H2D ignores the value entirely; the X1C
validates it, complains, and applies the write anyway. BambuStudio
always sends a real tray_id and defaults it to 0 for a manually
entered profile.

With tray_id:0 the acknowledgement is honest, and the printer echoes
back the sequence_id we sent -- confirmed for extrusion_cali_get,
_set and _del on both printer classes -- so it can be matched to the
write that caused it. Writes now return their sequence_id and the
routes await the verdict, turning a real failure into an error that
carries the printer's own reason. A printer that stays silent is
still treated as success: no answer is not evidence of refusal, and
firmware that never answers must not turn every save into an error.

Raises the ack to INFO. It sat at DEBUG, so the one line that
explains a failed save was absent from every support bundle -- the
same reasoning that put ams_filament_drying at INFO for #1447.

Also fixes extrusion_cali_set building its payload from
str(self._sequence_id) without incrementing first, reusing the
previous command's id. Harmless while nothing correlated on it,
fatal now that the write path does.

Adds supports_nozzle_flow_type() for the Standard / High Flow choice,
which the K-Profiles UI previously showed as "Not reported by
printer" -- not a value anyone can save. Most printers omit the
nozzle identity from their calibration table entirely, and the slicer
treats that as Standard rather than unknown; Bambuddy now does the
same and keeps the choice editable. The field is hidden only where
the model ships a single nozzle variant, using the slicer's own rule
(len(nozzle_volume) // len(nozzle_diameter) > 1 over the machine
preset) evaluated across every bundled Bambu profile. That puts only
A1, A1 Mini and A2L on the hidden side -- it is not the single-
versus-dual-nozzle split, since P1P, P1S, P2S, X1, X1C, X1E and H2S
are all single-nozzle and all carry two variants. Editing a profile
also no longer writes back an empty nozzle_id.

Wiki records that on printers which omit the field the chosen flow
type is discarded by the firmware and reads back as Standard, in
Bambu Studio as well, so it does not get filed as a bug again.
2026-08-01 10:35:22 +02:00
maziggy
af282b3527 fix(kprofiles): populate the filament picker from all preset tiers (issue #2719)
Add K-Profile built its Filament dropdown from the profiles already on
the printer, so on a printer with none the field was empty, required
and unsatisfiable (#2719, reporter @jmoore-skild). The modal's own
hint described the dead end: create the profile in Bambu Studio first.

The dropdown now uses the app-wide lookup order -- local imported,
Orca Cloud, Bambu Cloud, hardcoded built-in table -- same as the AMS
slot picker and the SliceModal tier groups. The built-in table is
compiled into the backend, so the list can never be empty: a new
printer with no cloud account and nothing imported still gets a first
profile.

Not fixed the way the report suggested. Seeding from
/printers/available-filaments would have offered only what happens to
be in an AMS right now, which on the reported printer is nothing; its
tray_info_idx is empty or a cloud user preset rather than a filament
id; it aggregates across every printer of the same model; and it is
gated on QUEUE_CREATE, which the K-Profiles page does not hold.

The printer indexes its calibration table by filament_id, so the
picked preset is reduced to one before anything is sent. Built-in
entries and Bambu official cloud presets carry one; a cloud user
preset needs its detail fetched (never base_id -- that collapses a
custom preset onto its inherited generic, #1053); imported and Orca
presets have no Bambu id at all and take the closest generic for
their material, via the same table the AMS slot configure flow uses
so the two agree. A filament that resolves to nothing is refused with
a named error rather than written under a wrong id.

Collapses duplicates from two separate causes. A cloud account
carries one copy of each filament per printer model, and with the
"@BBL <model>" suffix stripped for display those rows are
indistinguishable -- deduped within each tier by resolved filament id,
by display name for user presets that have none. Cloud setting_ids
also carry a "_NN" variant suffix, so the built-in tier's
already-covered check never matched and listed the same filament
again; the bare id is now recorded alongside.

Groups the options by source with an optgroup per tier, styled in
index.css: browsers render optgroup labels small, grey and italic,
which buries the one thing distinguishing a "Bambu PLA Basic" you
imported from the one the built-in table ships.

Drops the second getKProfiles(printer, "0.4") query that existed only
to seed the old dropdown. It ran concurrently with the main fetch
whenever a non-0.4mm nozzle was selected -- the two-requests-in-flight
case that made K-profile fetches time out.

---

fix(ui): cancel a dialog's deferred close when it unmounts

The AMS slot configure and K-Profile dialogs hold a success state
briefly and then close themselves -- 1.5s to 4s after the command
goes out, so the printer has time to process it before the list
refetches. Each did that with a bare setTimeout closing over setState
and the parent's onClose, and nothing cancelled it.

The timer therefore ran whether or not the dialog was still there.
Dismissing it inside that window, or the printer card re-rendering
underneath it, left a pending close that fired later and dismissed
whatever dialog was open by then. It also threw outright when the
surrounding environment was gone first: a test tearing down its DOM
before the 1.5s elapsed produced "ReferenceError: window is not
defined" out of react-dom's resolveUpdatePriority, reported as an
unhandled error against a suite that otherwise passed.

Routes all five through a useCancellableTimeout hook -- two in
ConfigureAmsSlotModal, three in KProfileModal, the latter with the
longest windows and so the widest exposure. Scheduling replaces any
pending timer and unmounting clears it.
2026-08-01 09:36:42 +02:00
mpl1337
84ca1e49d4 Add per-job ETA to print queue 2026-08-01 09:30:18 +02:00
maziggy
a35ba8fa5f fix(kprofiles): read the nozzle diameter the printer actually sent (issue #1748)
Every K-profile came back as 0.4mm on printers running any other
nozzle (#1748, reporters @Liquidmasl and @jmoore-skild). The printer
puts nozzle_diameter on the extrusion_cali_get envelope only; the
per-filament entries carry setting_id, filament_id, name, k_value,
n_coef and cali_idx, and nothing else. The parser read the field per
entry with a hardcoded "0.4" fallback, so the fallback fired on every
profile of every response. The envelope value was already in scope,
read into response_nozzle and used only to match the request.

This never reproduced on H2D because that firmware does include the
field per entry. Both construction sites are in the same handler, so
the code path is shared; what differs is the payload, and every
single-nozzle model omits it.

The display was the least of it. Editing is delete-and-re-add on
single-nozzle printers, and the dialog rebuilt nozzle_id and
nozzle_diameter from its own greyed-out selects, so saving an
untouched 0.6mm profile rewrote it on the printer as HH00-0.4.
Deleting aimed extrusion_cali_del at the wrong nozzle the same way.
Both now pass through what the printer reported. The cali_idx cascade
in inventory.py, spoolman_inventory.py and spoolman.py matches on
nozzle_diameter, so on a 0.6 or 0.8 nozzle it never found the
printer-side entry and the assignment silently failed to stick --
that is the "cannot auto-map a K-profile" half of the report, fixed
at the source without touching those three call sites.

nozzle_id has no source in the payload at all, and state.nozzles
carries material (hardened_steel), not flow, so it cannot honestly
produce HH/HS. Rather than keep inventing one, the UI now says the
printer did not report it: the card shows the diameter alone, the
dialog shows "Not reported by printer", and the High Flow / Standard
filter is hidden instead of being offered as a control that can only
ever empty the list. Import stops stamping HH00 on profiles whose
source reported none.

Also correlates K-profile requests by sequence_id. Responses were
matched by nozzle diameter through a single shared expectation slot,
so a second request overwrote the first's and the first's valid
answer was discarded as a mismatch -- the "Failed to get K-profiles
after 3 attempts" in the same logs, with the printer having answered
correctly both times. Pending state is now one entry per request,
keyed by the id we already send, with the nozzle match kept as a
fallback for firmware that does not echo it back.

Fixes the flow-type select naming a new profile with the opposite
label, which contradicted the identical expression 44 lines above it.
2026-08-01 08:49:44 +02:00
maziggy
aa07415270 Updated CONTRIBUTING.md 2026-08-01 08:28:48 +02:00
maziggy
b8225d9e9f Housekeeping 2026-08-01 08:28:20 +02:00
maziggy
a94f1ef4ff Updated CONTRIBUTING.md 2026-08-01 08:28:06 +02:00
MartinNYHC
21d61c3535
Merge branch 'dev' into feature/oidc-env-config 2026-07-31 17:05:24 +02:00
maziggy
455a9e4ba7 fix(backup): collect cloud profiles from every connected account (#2717)
Enabling Cloud Profiles for a Git backup produced nothing, and said it had
worked. Two independent faults, either one sufficient.

The collector looked for a "setting" list. The Bambu Cloud listing endpoint
is keyed by preset type instead, each key holding private and public arrays,
so the loop body never executed once — and the entries carry no type of
their own either, which routes/cloud.py already knew: it takes the type from
the outer key and maps Bambu's "print" to process. Two bugs on one line.

It also asked build_authenticated_cloud for the credential store used when
authentication is disabled. With auth on, tokens live on User rows, so the
collector returned at "Cloud not authenticated" before ever reaching the bad
key. Every multi-user install was collecting from zero accounts.

Neither failure surfaced. backup_metadata.json recorded the configured flag
rather than the outcome, so it claimed cloud_profiles: true on runs that
wrote nothing, and the log read "Collected cloud profiles: 0 filament, 0
printer, 0 process" at INFO — which is exactly what a successful backup of
an empty account looks like.

Cloud profiles now come from every connected account across both clouds. The
toggle predates Orca Cloud entirely, and Orca has the same three preset
types, so both are collected and grouped the same way:

    cloud_profiles/bambu/user-3/{filament,printer,process}.json
    cloud_profiles/orca/user-3/{filament,printer,process}.json

Accounts are keyed by Bambuddy user id, "global" when auth is off. Never by
email: a backup repository can be public, and the Bambu listing's user_id is
dropped for the same reason. Both credential stores are read on every run,
because a Settings row survives someone enabling auth later and dropping it
would silently stop backing that account up.

Bambu costs one get_setting_detail per private preset. The listing is
metadata only, and without base_id and setting the backup is a list of names
that create_setting cannot rebuild from. Public presets are skipped — Bambu's
bundled catalogue is the same hundreds of entries for everyone, always
re-downloadable, not recreatable under your account, and would rewrite the
repository on every run. Orca needs no second call; its sync-pull carries
each profile's content inline. Where the Orca route drops a profile whose
content.type it cannot map, the backup writes it to other.json instead:
silently omitting a profile because Orca added a type is the same class of
bug as this one.

Failures are contained per account and per preset, and counted rather than
swallowed. A partial backup that looks complete is how this stayed invisible.

The metadata now reports what was collected, per cloud and per account, and a
run that collects nothing while the category is enabled warns with the reason
instead of an INFO line that reads like success.

The checkbox gated on the viewer's own Bambu sign-in, which is not the same
question as whether there is anything to back up — with auth enabled the
accounts belong to individual users, and an administrator who never signed
in personally saw the category disabled with plenty in scope. It now gates
on the total across both clouds and shows the counts. That comes from its
own endpoint rather than a field on /config, since /config answers null
until the first save and would disable the toggle during the very setup it
belongs to. Counts only, never identities.

One deliberate restraint. _build_authenticated_service clears stored
credentials when a refresh is rejected, which is right for a route — the
user is on the page and can pair again — and wrong for a scheduled job.
Orca reports every rejection with one composite reason ("unknown, expired,
revoked, or already used"), so a genuine revocation cannot be told apart
from a lost token-rotation race, and acting destructively on a signal that
cannot be disambiguated is the #2562 mistake in a different cloud. It also
gains nothing: the Profiles route hits the same failure and clears it then,
with the user present. Background callers now pass clear_on_auth_failure=
False and skip the account. A successful refresh is still persisted either
way — by that point the old token is consumed, so dropping the new pair
would break a working pairing for real.

Restore is not part of this. Nothing reads cloud_profiles/* yet; the format
carries base_id/setting for Bambu and content for Orca so that it can.
2026-07-31 16:59:33 +02:00
maziggy
82656c8760 Updated BACKERS 2026-07-31 16:22:55 +02:00
maziggy
3c49990387 Updated BACKERS 2026-07-31 16:22:22 +02:00
MartinNYHC
11bad70a6b
Merge pull request #2700 from Striker72rus/feature/save-ams-mapping-toggle
Feature/save ams mapping toggle
2026-07-31 16:17:19 +02:00
maziggy
4f2c073a34 fix(vp): gate the slicer's AMS pick behind the toggle and scope its badges (#2700)
Round-3 review of the "Save AMS mapping" PR.

The queue item's ams_mapping was set unconditionally, on the reasoning that
honouring the slicer's own pick is a correctness fix rather than a feature.
It is both. Storing a resolved mapping makes _ensure_ams_mapping return
early, so _compute_ams_mapping_for_printer never runs — and that function is
where prefer_lowest_filament lives, along with the AMS-filament-backup gate
that qualifies it (#1766), the inventory-remain overrides, and the per-slot
force-colour overrides. Every existing queue-mode VP pointed at a printer
would have quietly lost all of it on upgrade, without a setting to turn it
back on.

So save_ams_mapping now gates the queue item too, not just the archive
persistence. Off is exactly the old behaviour. The correctness case the PR
was written for — two spools of the same red PLA, and the slot the user
picked in the slicer thrown away — is still fixed, for anyone who asks for
it.

Force color match wins over it when both are on. Its only effect on a
fixed-printer item is the filament_overrides written onto the queue item,
and those are read inside the function a stored mapping skips, so the two
toggles sitting next to each other on the same card silently cancelled. The
dispatch now matches strictly, as asked, while the slicer's pick is still
saved onto the archive — that is what the toggle's name promises, and a
later reprint is a separate decision from this print. The queue-add fallback
applies the same rule to a request that carries force-colour overrides.

A mapping shorter than a plate's highest slot id cannot address that plate's
own slots, and _ensure_ams_mapping would have kept it anyway, since it only
rejects an all-unresolved one. Each plate now checks the length it needs and
falls back to a computed mapping if the array does not reach. Bambu Studio
sends a file-global array, so this normally never fires; it also means a
multi-plate Send All degrades safely if that ever stops being true.

The badges claimed more than they delivered. Both rendered whenever a saved
mapping existed, ignoring which printer it belonged to, while the tooltips
promised the reprint would reuse those exact spools — true only on the
printer the trays were resolved against. The queue row's flag is now
computed against that row's own printer, which is precisely when dispatch
reuses the mapping, and the archive card names the printer instead of
implying any of them will do. It hides itself when that printer no longer
exists. Retranslated in all 13 locales.

Frontend tests, which the PR had none of. The printer-scoping rule is now a
pure function rather than an inline expression, covered for the mismatched
printer, the no-printer-selected case that would otherwise compare undefined
against undefined, and malformed extra_data. The toggle's undo bookkeeping
is covered for unresolved slots, short mappings, and hand-made picks —
preserved when the toggle never wrote that slot, replaced when it did, which
is behaviour worth pinning either way.

Also reverts all three queue-mode switches when a save fails, not just the
new one; without it the card shows a setting the server rejected.
2026-07-31 16:16:39 +02:00
MartinNYHC
4b4cb18a64
Merge branch 'dev' into feature/save-ams-mapping-toggle 2026-07-31 15:39:13 +02:00
maziggy
c457cf54bf feat(support): record process memory, threads and children in bundles (#2734)
A bundle described everything except the process it runs in. So a report of
memory climbing over days until the OOM killer fires arrives with no way to
act on it: the numbers that name the mechanism only exist while it is
happening, and by the time anyone asks, the container has been restarted.

The new `process` section carries what actually separates the candidates.
Resident against virtual memory: 650MB RSS with 12.9GB VMS is address
space — thread stacks or allocator arenas — not a heap full of live data,
and that reading is the opposite of the one the reporter drew from the same
figures. Thread count and child-process count then split those two apart,
and a census of live objects by type names what a growing heap is filling
up with. Open files, sockets and uptime round it out.

Three constraints worth keeping:

The heap census is skipped above 2GB. gc.get_objects() materialises every
tracked object, so it costs most on exactly the process that can least
afford it — a bundle generated to diagnose runaway memory must not be the
allocation that tips the host over. Everything else is still collected, and
the skip is recorded with its reason rather than silently omitted.

Children are recorded by executable name only. An ffmpeg command line
carries the camera URL, and with it the camera's password.

Collection runs off the event loop and every metric is independently
best-effort. psutil raises on hardened kernels and in restricted
containers, and the bundle is how someone reports a problem in the first
place — it has to be produced even when half the numbers are unavailable.

This does not fix #2734, and nothing here should be read as having found
its cause. The bundle's own evidence contradicts both proposed causes: the
orphan janitor ran 7 times in 26 days over 725 stream-ends and killed no
orphaned ffmpeg, which is not the #776 signature; and the 5 "database is
locked" errors all fall between two OOM kills, making them a symptom of the
memory pressure rather than a source of it.
2026-07-31 15:32:08 +02:00
Marian
6eea61dc78 fix(oidc): survive a failing rollback in the never-raise handler too
The recovery rollback after a failed commit was itself unguarded, so a
rollback that raises on a wedged connection would still take the boot
down -- the exact failure the never-raise contract exists to prevent.
Suppressed; the caller's `async with` discards the session regardless.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016q8EAf9Rj7ZHL92sPnXYxy
2026-07-31 13:18:21 +00:00
maziggy
ce3e59884a fix(slicer): bound slices by silence, not by total slicing time (#2730)
A heavy MakerWorld model — one Bambu Studio also takes a long time over —
failed after five minutes with "Slicer sidecar unreachable". The sidecar
was reachable the whole time and still slicing when we hung up on it.

SlicerApiService carried a hardcoded 300s timeout, passed to httpx as a
bare float so it covered connect, read, write and pool alike. On a single
long request that is not a health check, it is a cap on how long a model is
allowed to take. And because httpx.ReadTimeout subclasses RequestError,
expiry landed in the same handler as a refused connection and was reported
as an unreachable sidecar — so the reporter went and updated their sidecar
container, which was never the problem.

The information to do better was already being collected. _poll_progress
polls /slice/progress/{id} once a second alongside the blocking POST to
drive the live progress toast, so at minute five Bambuddy had fresh
evidence the slicer was working. It killed the request anyway.

So the read timeout comes off the HTTP call and the poller supervises
instead: the deadline moves forward on every progress update, and only
genuine silence ends the wait. A model that keeps reporting runs to
completion however long it takes. Connect and pool keep short timeouts —
a sidecar that will not accept a connection is unreachable and should
still say so quickly.

Only a *changed* progress payload counts as alive. The sidecar re-serves
its last snapshot on every poll, so counting repeats would leave the
watchdog unable to detect a stall at all.

The window is floored at three poll intervals: liveness can only be
observed as fast as the poller ticks, so anything shorter would expire in
the gap between two polls and fail every slice instantly.

New setting slicer_stall_timeout_minutes (Settings > Workflow > Slicer),
default 15, range 1-240, alongside the sidecar URL and gated on
use_slicer_api like its neighbours. Sidecars too old to report progress
have no liveness signal, so for those the same number bounds total elapsed
time — the old behaviour, configurable and no longer 300s flat. The
message says which case applies and where to change it.

SlicerTimeoutError is its own type and maps to 504, not 502: the sidecar
answered throughout, we stopped waiting. Connection failures keep
SlicerApiUnavailableError. The preview slice path gets the same treatment.
2026-07-31 15:14:01 +02:00
Marian
be19461470 refactor(auth): reuse oidc_env's truthy-bool helper for BAMBUDDY_LOCAL_LOGIN
_local_login_env_bypass() re-inlined the same {"true", "1", "yes"} set
oidc_env._env_bool already enforces, so the typo-guard existed in two
places. oidc_env has no module-scope import of models (its model
imports are lazy inside apply_env_oidc_provider), so importing
_env_bool at module scope here is not a cycle -- confirmed by
importing backend.app.main and this module directly.
2026-07-31 13:12:44 +00:00
Marian
f6e0d76731 docs(oidc): document issuer URL policy and name-collision adoption
.env.example described what the required vars do but not two sharp
edges: the issuer must be a public HTTPS URL (an in-cluster
http://keycloak:8080 is silently refused), and BAMBUDDY_OIDC_NAME
matches an existing UI-created provider by name and takes it over.
2026-07-31 13:11:41 +00:00
Marian
c4b5d42f48 fix(oidc): log distinctly when env config adopts a UI-created provider
A name collision with a provider that was NOT already env-managed
overwrites its issuer, client id and secret in place and locks it
behind the env-managed 409 -- but it logged the same routine "applied"
line as an ordinary re-apply, giving no signal a UI provider was just
taken over. Adoption is now a WARNING with its own wording; a routine
re-apply of an already env-managed provider keeps the INFO line.
2026-07-31 13:11:15 +00:00
Marian
6dff1e9644 fix(oidc): make apply_env_oidc_provider never raise on DB errors
The db.execute/db.commit calls in the upsert and release paths sat
outside the try/except that only wrapped OIDCProviderCreate, so a
commit failure at startup (connection blip, WAL lock) propagated out
of the lifespan and took the instance down -- the exact outcome this
module exists to avoid. The body now runs inside a private
_apply_env_oidc_provider(), with the public entry point catching,
logging and rolling back on any exception.
2026-07-31 13:10:14 +00:00
maziggy
284709f850 fix(projects): drop deleted prints from their project, and refresh the view (#2731)
Deleting a print that belonged to a project left it on the project page as
a card with a missing thumbnail, and there was no way to remove it.

Deleting a print is a soft delete by default (#1343): the files go from
disk, the row stays so global Quick Stats keeps counting its filament,
time and cost. Every other consumer filters those rows out. The projects
module filtered none of them — the only deleted_at check in the whole file
was for LibraryFile — so a deleted print kept its project_id and kept
being listed, pointing at a thumbnail that no longer existed. The same
broken previews appeared on the overview cards, and in the timeline, where
the entry links to an archive that no longer opens. Unassigning was
impossible because the only UI that can change a print's project lives on
the Archives page, which correctly hides deleted prints: visible on the
project, unreachable from anywhere.

All eight project-scoped archive queries now filter, counts included. That
last part is a deliberate divergence from #1343, where the whole point of
the soft delete is that the contribution survives: a project is a piece of
work with a definite membership, not a lifetime total, so a project that
lists eleven prints must not claim twelve. The reasoning is recorded at
the constant so nobody later "fixes" it back.

remove_archives_from_project keeps working on hidden rows on purpose — it
is the repair path for links written before this. The BOM print_name
lookups are left alone; naming a since-deleted print is still correct.

Two more consumers had the same gap. The CSV/Excel export handed back rows
the interface says are gone — filtered at the base query, since the export
is the list you are looking at saved to a file. Per-project failure
analysis measured a failure rate against prints deleted from the project,
and disagreed with the project's own numbers; only the project-scoped
branch filters, global analysis still counts every run including orphans
as #1390 established.

Finally, the project page needed a manual reload to catch up. staleTime is
60s and the delete mutations invalidated only ['archives'], so a project
visited within the minute served its cached copy, print still there. The
project-assign mutations had the mirror-image bug: ['projects'] refreshed
the overview cards but never ['project', id]. Both now go through one
shared helper covering every project-derived key, as bare prefixes so all
cached project ids are matched.
2026-07-31 14:50:25 +02:00
maziggy
3abab1fd45 fix(printers): recover MQTT sessions that stopped reconnecting (#2732)
The reporter's printer lost its session to a keep-alive timeout at 02:19
and did not come back until 11:24 — nine hours offline, with the web UI
open throughout.

check_staleness() was never going to catch it. Its first line is
`if self.state.connected and self.is_stale()`, so it only ever handles the
half-broken session that is still connected but has gone quiet. This
client had connected=False from 02:19:42 (the offline notification fired a
minute later), so every call returned immediately, and paho's own retry was
the only thing left watching. When that stopped making progress nothing
noticed.

Adds a sweep every 60s that rebuilds a client when all four hold: it is
disconnected, it had a working session before, it has been silent for five
minutes, and its MQTT port still answers. The port check is what keeps this
from becoming a nuisance — a switched-off printer is left to paho, so a
farm powering down overnight causes no client churn and no log spam. The
five-minute grace sits well past the 60s stale timeout and the 30s max
reconnect backoff, so a session recovering on its own is never interrupted.

The rebuild goes through force_reconnect_stale_session from async context,
which takes the hard-reset path: fresh client_id and paho's QoS 1 queue
dropped, so a project_file left unacked on the dead session cannot replay
into the new one and trip 0500_4003 (#1136). Rate-limited per printer,
cooldown cleared when the printer returns, and the sweep continues past a
client that throws rather than abandoning the rest of the farm. The log
line names how long the printer was gone and the last connect error, so a
session that dies repeatedly leaves a trail.

check_port gains a public alias in printer_diagnostic rather than having
the watchdog reach for the private name.

Also corrects the Developer Mode path added in the previous commit: the
wiki documents it under Settings > Network, not Settings > General. The
menu path is dropped from the translated string entirely, since it varies
by model and firmware and the wiki carries the detail.
2026-07-31 14:28:17 +02:00
maziggy
5e2b7b53e6 fix(printers): surface the printer's own "command verification failed"
A P1S on firmware 01.10.00.00 rejected every control command and said so:
HMS 0500-0500-0001-0007, "MQTT command verification failed". Bambuddy
received that, dropped it, and reported a healthy printer instead.

The frontend filtered it out. This code's meaning lives in attr's low half
(0500) and code's high half (0001), both of which the MMMM_EEEE short form
discards, so it collapsed to "0500_0007" — no catalog entry, no firmware
actions, and filterKnownHMSErrors drops uncatalogued action-less errors.
Catalog lookups now try full_code first, in both the description and the
filter, and errors matched that way display the four-group code the
printer's own screen shows. The remedy line is ours, not Bambu's: their
wiki says to update Studio or Handy, which does not apply to a print sent
from Bambuddy.

The developer-mode probe made it worse. It read anything that was not an
explicit refusal as confirmation, and this firmware answers the probe with
an empty result while refusing everything else — so an inference drawn
from a non-answer became "developer_mode: pass" in the support bundle of a
printer that had not accepted a command all day. The probe now has three
outcomes: explicit success enables, explicit verify-failure disables,
anything else stays unknown and the diagnostic reports skip.

The HMS is authoritative over that inference in both directions. It forces
developer_mode False when present, and clears back to unknown when the
printer stops reporting it, so enabling Developer Mode and restarting the
printer is picked up without restarting Bambuddy.

Dispatch no longer treats a refusal as a wedge. The watchdog latches the
HMS across both phases and fails the item on the first attempt naming the
code and the fix, rather than spending three uploads and 270s a lap to
arrive at a message about SD cards. The check runs after the active-state
exit in both phases, so a lingering HMS can never abort a print that is
visibly running.

Also: the "wrong or mis-cased serial number" hint no longer fires in the
moment after a reconnect. _report_messages_since_connect is reset by
_on_connect, so a reconnect landing microseconds before the staleness
check leaves it at 0 for reasons that have nothing to do with the serial —
this reporter's healthy printer was told to go check its serial 1 ms after
reconnecting.
2026-07-31 14:14:58 +02:00
maziggy
11dc612bc4 feat(obico): authenticate to a token-protected ML API (#2733)
Obico's ml_api container takes an optional ML_API_TOKEN environment variable.
With it set, ml_api/auth.py answers a bare 401 to any request whose
Authorization header isn't "Bearer <token>"; with it unset it ignores the
header entirely. Bambuddy never sent one, so pointing it at a protected server
meant deleting the token there — which the reporter had set for their Home
Assistant integration and did not want to undo.

Settings -> Failure Detection gains an ML API Token field. When it is empty no
header is sent, so an unconfigured install's request stays byte-identical to
what shipped before the setting existed.

This failed in the worst possible way, and that is the more important half of
the change. Obico decorates /p/ with token_required but leaves /hc/ open. Test
Connection pinged /hc/, so it reported success against a server that was
rejecting every real detection call, the settings looked right, and detection
silently never ran. The only symptom was a generic "ML API call failed" buried
in the status card.

So the test now proves what it claims. After health passes it probes GET /p/
with no img parameter: the auth decorator runs before the handler, so 401 means
the token was rejected and 422 ("Invalid request params") means it was
accepted. No inference work is done either way. A probe that itself errors
reports the token as unknown rather than as working — the UI says it could not
be checked instead of claiming success.

The detection loop checks for 401 before raise_for_status, so a rejected token
is reported as a rejected token, naming the setting and the environment
variable, instead of surfacing "401 Unauthorized" with no hint of what to do.
The message never contains the token; a test pins that.

The setting name carries "token", so the support bundle's keyword redactor
masks it with no new rule. Resolving "field omitted" to the saved token is the
route's job, keeping test_connection a pure outbound call with no database
access.

Second fix, same issue: support bundles misreported which printers Obico
watches. The bundle split obico_enabled_printers on commas and read an empty
value as "no printers". The settings UI writes a JSON array, and empty means
*all* printers — the default — so a working Obico setup showed obico_enabled
false against every printer in its own bundle. That is the reporter's bundle
exactly, and it points anyone reading it at the wrong subsystem. The bundle now
parses the setting the way ObicoDetectionService does, keeps a comma fallback
for any install that stored the legacy shape, and factors in the global switch.
2026-07-31 13:39:17 +02:00
maziggy
6844aa292f fix(ams): offer every K profile the printer holds for a generic filament preset (#2710)
The reporter's A1 mini has nine Flow Dynamics calibrations, all of them saved
under Generic PLA and named after the spool's colour — "Dark Brown", "Glow",
"Marble". Bambu Studio lists all nine for that slot. Configure AMS Slot offered
one: the profile already bound to the slot. After a slot reset it offered none,
leaving the slicer as the only way to assign a K value.

Two independent faults, both tripped by picking a built-in generic preset.

The filament-id match discarded Bambu's generic GFx99 ids as too broad. But the
comparison already requires both sides to carry the same id, so that exclusion
could only ever fire when the selected preset was itself the generic one —
precisely the case where the match is right. The printer keeps one calibration
table per filament id, so a slot on Generic PLA should offer everything
calibrated under Generic PLA. Equal ids now match, generic or not.

The name fallback was dead for the same presets: parsePresetName reads the
leading "Generic" in "Generic PLA" as a manufacturer, which put the matcher into
brand-gated mode and demanded the word GENERIC appear in the profile name. No
real profile has it. "Generic" is no longer treated as a brand, so profiles still
match on material when a printer reports no filament_id with its calibrations.

The one profile that did appear came from the #1689 safety net that always
surfaces the slot's active cali_idx — which is also why a reset slot, having no
active profile, showed an empty list.

Neither fix can be complete on its own, because profile names are free text and
nothing ties "Marble" to a material. The picker now also lists every remaining
profile on the printer under "Other K profiles on this printer", so a profile
that exists can always be selected. Applying one from that group needs no new
backend work: configure_ams_slot already realigns the slot's filament context to
the chosen profile's, which is what makes the cali_idx stick.

Options are keyed by name+k_value rather than the bare name, so two profiles
sharing a name are no longer indistinguishable in the select. Both render blocks
carry the change — the modal duplicates the picker for its full-screen variant.

isMatchingCalibration gets the same generic-id rule for the spool form's PA
suggester, with two guards. A new generic-id-to-material table means a PETG spool
can never claim GFL99 profiles just because both sides stored a generic id
(Nylon and PA compare as one material). And a spool that names its own brand
keeps the stricter name path, so its suggestions stay brand-specific rather than
becoming the printer's whole generic table.
2026-07-31 13:15:35 +02:00
maziggy
db6cdb0745 fix(camera): take the finish photo when the print ends, not when its last layer starts (#2547)
The photo fired the moment layer_num reached total_layer_num. That edge is
where the printer *starts* its final layer, not where it finishes it: the
reporter's H2C capture shows it arriving at 92% with mc_remaining_time=2,
three minutes and seventeen seconds and one filament change before the print
actually ended, so the frame caught the toolhead mid-print over the model.

The trigger also latched _finish_photo_captured, which locked out both the
stage-22 and FINISH triggers for the rest of the print — so on firmware that
never reports an end-of-print filament unload (H2C and A1 Mini confirmed)
nothing could replace the bad frame.

Remove the last-layer trigger. The photo is now taken at the FINISH-state
trigger, which every model sends and which lands after the toolhead parks.

Since Bambu's end G-code drops the plate ~100mm just before that, restore the
framing before capturing: absolute G90/G1 Z to max_z_height + 10mm clearance,
settle, capture, then drop it back so the print is as reachable as the printer
left it. Absolute is the safety argument — that Z is a height the toolhead
occupied seconds earlier, so it is inside the travel limits by construction and
leaves the nozzle above the part, and it is unambiguous across model families
because Z is the nozzle-to-bed gap whether the bed moves or the toolhead does.
M211 is never touched (#2579). This is what #1145, #1397 and #1565 asked for.

The height is only trusted when two independent sources agree: the archive is
matched by the finished print's subtask_name by equality (not LIKE, so "Cube"
cannot resolve to "Cube v2"), and its layer count from the 3MF must match the
layer count the printer reported over MQTT. Matching on "most recent archive
for this printer" was not safe — on_print_complete pops the _active_prints
binding concurrently, and a print Bambuddy failed to archive would have
resolved to its predecessor. A wrong height is the one failure that could drive
the nozzle into the model.

The move is additionally skipped when the print height is unknown, when a queue
item is pending for the printer, when the printer has left FINISH, and when the
new finish_photo_restore_plate setting is off.

for every FINISH-state capture — which is what shipped the mid-print photo —
the bank is used only when the dispatcher recorded that it injected End G-code
into this print, since a SwapMod snippet may have ejected the plate. The flag is
handed over in two steps (mark_pending at dispatch, adopt at print start) so it
can never outlive its print: a job started from the slicer or SD card adopts
False rather than inheriting its predecessor's answer. Those prints also skip
the plate move outright, bank or no bank.

The bank now refreshes on mc_percent advances as well as layer changes, via a
new on_print_progress callback. Layer changes stop the instant the final layer
begins, which left the #1867 fallback frame stale by the whole length of that
layer; progress keeps ticking there and freezes before the End G-code runs, so
a swapped plate still cannot reach the bank. The last-layer throttle exemption
is dropped, since it would now fire a grab on every percent tick.

On the timelapse path the moment producer returns early, so the consumer does
the restore itself before its live-grab fallback — the documented usual outcome
on P1-series, where the video has not transferred by the time the notification
goes out and the shipped photo was of an already-dropped plate. The two waits
are now derived from the settle window and the video poll timeout rather than
hardcoded; at the old flat 75s that fallback was guaranteed to be cut off
mid-settle.

extract_max_z_height_from_3mf reads only a bounded prefix of the plate G-code,
since a sliced plate is routinely tens of megabytes and the header is ~40 lines.
It returns None for missing, unparseable, zero and negative values so callers
must treat "don't know" as such rather than defaulting.
2026-07-31 12:55:05 +02:00
Sergey Dontsov
13c37ffe51 fix(vp): scope saved AMS mapping to the printer it was resolved against
Round-2 review fixes for #2700.

Blocking: the toggle didn't actually gate the archive write. archive.py's
promotion fired for any print_data carrying ams_mapping, but bambu_mqtt's
request-topic interception captures ams_mapping unconditionally for every
print source (slicer-direct LAN prints included). Since main.py's
real-printer auto-archive path forwards the full MQTT payload as
print_data, every archive on any install — VP or not — grew
extra_data.slicer_ams_mapping. Fixed by replacing the print_data-sniffing
with an explicit `slicer_ams_mapping` param on archive_print() that only
the VP-queue path (already gated on save_ams_mapping) ever passes.

Blocking: a saved mapping could get reused on a printer it was never
resolved against — tray IDs only mean something relative to one printer's
AMS layout. extra_data.slicer_ams_mapping is now stored as
{mapping, printer_id} instead of a bare array:
- add_to_queue's fallback only fires when the reprint's target printer_id
  matches the mapping's origin printer.
- The frontend's archiveAmsMapping only surfaces (and the Mapping button
  only appears) when the print modal's selected printer matches too.
- A model-based VP (target_printer_id=None, no MQTT bridge to any real
  printer) never stamps a mapping in the first place — there's no live AMS
  layout for the slicer to have resolved tray IDs against.

Also from review:
- Multi-plate archives now get the Mapping button too (the per-plate
  FilamentMapping loop was missing archiveAmsMapping entirely).
- Added coverage for the previously-untested late-MQTT archive patch path
  (_restamp_recent_queue_item), including the model-based-VP skip case.
- usingArchiveMapping now also resets on printer change, not just
  plate/archive (it already worked via the printer-scoping above, but is
  now an explicit dependency too).
- The Mapping button's revert (OFF) now undoes only the slots it itself
  set, not every manual pick in scope — matches the comment above it.
- Added a comment on why negative-value slots (external spool) are
  skipped rather than cleared when applying a saved mapping.
2026-07-31 11:08:58 +03:00
Sergey Dontsov
bab1cfb906 feat(vp): per-VP "Save AMS mapping" toggle + reprint auto-apply
Lets a reprint reuse the AMS slot the slicer itself picked, instead of
re-deriving one from the file's static type/color.

When a Print Queue VP has "Save AMS mapping" on, the slicer's own
live-resolved ams_mapping (from the project_file MQTT command) is
persisted onto the archive as extra_data.slicer_ams_mapping. A later
reprint can reuse it via a new "Mapping" button in the filament-mapping
panel — one click snaps every slot to the saved pick, click again
reverts to auto-match. Archive cards and queue rows get an "AMS mapping
saved" badge so it's visible beforehand. add_to_queue also falls back
to the saved mapping automatically when the caller sends no explicit
ams_mapping (e.g. a plain reprint with no per-slot edits).

The queue item's own ams_mapping (used for that dispatch) is still
captured unconditionally whenever the slicer provides it — that part is
a correctness fix, not gated behind the toggle. Only the archive
persistence for future reprints is opt-in.

Split out from the original combined PR per review: this half is
genuinely opt-in and low-risk (#2684). The dispatch-time validation
gate that keeps a stored mapping honest (#1308) changes behaviour for
every existing user and will land as its own PR.

Review fixes applied:
- _extract_slicer_ams_mapping_json: dropped the unreachable `v is None`
  arm and rejected bool explicitly (isinstance(v, int) accepts bool).
- Translated the Russian docstring text to English.
- save_ams_mapping's model comment moved to a trailing comment on the
  column line, matching the file's convention.
- usingArchiveMapping now resets when the plate or archive changes, so
  the Mapping button can't read ON against a mapping it never applied.
- Translated "Click to change slot assignment" and "Re-read".
- add_to_queue's fallback is now called out explicitly in code comments
  and covered by three new integration tests (fallback fires, explicit
  mapping wins, unrelated extra_data doesn't false-trigger).

Closes #2684
2026-07-31 11:08:58 +03:00
MartinNYHC
1c2d219b89
Merge pull request #2723 from bitbarista/fix/camera-rotation-finish-photo-timelapse
fix(camera): apply camera_rotation to finish photos and layer-timelapse frames
2026-07-31 09:36:00 +02:00
MartinNYHC
cd7b869419
Merge branch 'dev' into fix/camera-rotation-finish-photo-timelapse 2026-07-31 09:33:35 +02:00
maziggy
432e956eff fix(camera): rotate every still exactly once, and cover the sources that let ffmpeg write the file
Review follow-ups on applying camera_rotation to finish photos and
layer-timelapse frames.

Rotating the frame popped from _stage22_finish_frames rotated one of its
sources twice. The cache has two kinds of feeder: live grabs, which are raw,
and the #1867 in-print bank, whose bytes come from
_capture_snapshot_for_notification and have already been rotated on the way
in. The consumer cannot tell them apart, so on the finish_state trigger - the
path the bank exists to serve, on firmware that never emits stg_cur=22 - a 180
degree rotation cancelled itself out and the photo was upside-down again,
which is the reported symptom exactly; 90 and 270 landed 180 out. Rotation now
happens where each frame is captured, so every entry in the cache carries one
rotation whatever produced it, and the invariant is stated both where the
cache is declared and where it is consumed.

Two finish-photo sources were still writing unrotated files: the built-in
camera's own capture_finish_photo, and the still extracted from a
printer-recorded timelapse - which is the *preferred* source for a built-in
camera print, so a user with a rotation set got a correctly oriented photo or
not depending on which source happened to win. Neither ever holds the frame as
bytes; ffmpeg writes the file and they return a filename. apply_camera_rotation_to_file
handles that case and is best-effort - a failed rotate leaves the unrotated
file rather than losing a delivered photo. The archived video itself is the
printer's own file and is not re-encoded, so it still plays at the camera's
native orientation; the CHANGELOG says so rather than leaving it to be
discovered.

apply_camera_rotation logs at debug, not info. It was on a path that runs once
per layer, where a tall print would have put hundreds of lines in the log for
something the surrounding capture already reports at debug.

The moved rotation logic had no test of its own - every existing test patches
it out and asserts the call, so a flipped sign or a dropped expand=True would
have shipped green. test_camera_rotation.py drives the real round trip: a
corner marker pins which way it turns, the dimensions pin that the frame is
not cropped, and an undecodable frame comes back by identity because a capture
path must not lose a frame to a failed rotate.

Tests for the fix itself sit on both sides of the cache. The producer half is
driven directly; the consumer half is a closure nested inside on_print_complete
with nothing able to reach it, so it is pinned by an AST guard - checked
against the source because the alternative is no check at all. Reverting
main.py to the pre-fix shape fails three of the five, the guard among them.

The three new tests used Path("/tmp/test") for a patched base_dir, which Bandit
flagged (B108); they take tmp_path now.
2026-07-31 09:30:06 +02:00
MartinNYHC
3ae854ffcf
Merge pull request #2722 from bitbarista/fix/timelapse-orphaned-session-cleanup
fix(camera): sweep orphaned timelapse session directories on startup
2026-07-31 09:03:51 +02:00
maziggy
08c9ec6749 fix(camera): protect an in-progress stitch from the orphan sweep, and only sweep this feature's own files
Review follow-ups on the orphaned timelapse session cleanup.

The sweep's own docstring said min_age_seconds made it safe to call mid-run.
It did not. on_print_complete drops the session from _active_sessions before
handing frames_dir to ffmpeg, so for the length of a stitch the directory
matches no active session, and its mtime is the last layer's frame write -
which on a tall print's final layer is easily older than the margin. The
default margin is 300s and the stitch timeout is also 300s, so the two were
tied with no headroom at all: a sweep landing in that window deleted ffmpeg's
input from under it. _finalizing_sessions now covers the stitch, set as the
session leaves _active_sessions and cleared in a finally so a failed stitch
cannot leak the marker and make that printer's leftovers permanently
un-sweepable. The docstring names all three guards and which gap each covers,
including that the margin does have real headroom for the two cases it suits -
a session mid-creation, and the freshly written .mp4 awaiting attach.

The file branch now requires the timelapse_<session_id>.mp4 shape its own
comment describes. It previously deleted any file under
timelapse_frames/<printer_id>/ past the margin; nothing else writes there
today, but age alone is not a reason to delete a file this feature did not
create.

Dropped ignore_errors=True from the rmtree. It made the surrounding
except OSError unreachable, so a read-only mount or a permissions problem was
counted and logged as a successful removal - and that log is the only evidence
an operator has of what was deleted.

Tests 5 -> 9: sparing a session mid-stitch, the finalizing marker cleared even
when the stitch raises, unrelated files left alone, and a failed removal not
counted. The failure test's rmtree stub honours the real contract and returns
silently when ignore_errors=True, because that silent no-op is exactly what the
old call could never observe; a stub that raised unconditionally would have
passed against both versions and proved nothing.

main.py is unchanged: it has no module-level logger, and the inline
logging.getLogger(__name__) the sweep uses is the idiom throughout lifespan.
2026-07-31 09:02:58 +02:00
MartinNYHC
4bd6761487
Merge branch 'dev' into fix/timelapse-orphaned-session-cleanup 2026-07-31 08:49:55 +02:00
MartinNYHC
804256e3bf
Merge pull request #2721 from bitbarista/fix/external-camera-capture-coalescing
fix(camera): share one connection between concurrent one-shot external-camera captures
2026-07-31 08:48:32 +02:00
maziggy
4888485a54 fix(camera): redact credentials, contain failures, and stop the external-camera test claiming a connection it never opened
Review follow-ups on the external-camera capture coalescing.

The coalescing was transplanted from camera.py, which is keyed by printer IP
and so has nothing to hide in a log line. These keys carry the camera URL, and
an RTSP camera URL routinely embeds user:pass@ - so the five new log lines
printed the password, one of them at warning level, where it reaches support
bundles. All five now go through _log_key(), which redacts before truncating:
slicing first can cut the URL short of the @ the pattern anchors on and leave
the password intact, which is why every other URL log in the module already
does it in that order.

_capture_frame_uncoalesced gained the blanket catch its camera.py counterpart
has. That is load-bearing once captures are shared: the wrapper hands one
task's outcome to every caller waiting on it and can only give a follower its
own turn for an outcome it recognises, so an escaping exception reached all of
them at once and none retried - one caller's failure becoming N. The per-type
helpers catch narrowly (aiohttp.ClientError / OSError / timeouts), so the
guarantee belongs here rather than resting on their coverage. CancelledError
is re-raised ahead of it, since the wrapper distinguishes a cancelled leader
from a failed one.

test_connection reports whether it shared a capture. It reaches capture_frame
like any other consumer, so a test landing while Obico is polling got that
frame back and answered "connected" for a connection it never made - the one
answer a connection test must not give silently. It still shares rather than
forcing its own capture, because forcing one would open the second handle to a
single-reader device that this whole mechanism exists to prevent. The response
carries `coalesced`, which also gives capture_in_flight() the consumer its
camera.py counterpart has in the Diagnose tool, and the Test button says
"shared with a capture already running" instead of a bare success.

Tests 12 -> 20: an unexpected error reported as a failed capture, a raising
leader whose follower still gets a frame, the three coalesced states, and
redaction on each log line that can carry a URL. The raising-leader test
patches _capture_rtsp_frame rather than _capture_frame_uncoalesced, since a
stand-in installed in the latter's place sits above the catch and would test
the wrapper against a shape it can no longer be handed.
2026-07-31 08:47:57 +02:00
MartinNYHC
6ad5bf30f7
Merge branch 'dev' into fix/external-camera-capture-coalescing 2026-07-31 08:32:46 +02:00
MartinNYHC
c3448dae91
Merge branch 'dev' into feature/oidc-env-config 2026-07-31 08:20:49 +02:00
maziggy
3daae22f3d Security hardening (maziggy/bambuddy-security #8) 2026-07-31 08:18:05 +02:00
Carl
5db4c75ca0 fix(camera): apply camera_rotation to layer-timelapse frames too
Same gap as the finish-photo fix: camera_rotation was only ever wired
into the notification-snapshot path, so a layer-timelapse video came
out upside-down whenever a rotation was configured - every frame
(fresh or reused from the live view's buffer) was written to disk raw.

Extracts the rotation logic out of main.py into a shared
apply_camera_rotation(image_data, rotation, logger) in services/camera.py
(taking the rotation value directly rather than a printer object, so
both main.py's printer-shaped callers and layer_timelapse's plain int
field can use it). main.py's _apply_camera_rotation becomes a thin
compatibility wrapper so its existing call sites are unchanged.

Threads a `rotation` field through TimelapseSession/start_session,
set from printer.camera_rotation in _maybe_start_layer_timelapse, and
applies it (via asyncio.to_thread, since PIL rotation is CPU-bound) in
capture_layer before each frame is written - after #2707's
live_frame_for_capture() resolves the frame, regardless of whether it
came fresh or from the live view's buffer.

Rebased onto #2707's landed implementation: capture_layer now calls
live_frame_for_capture() instead of the older is_stream_active/
try_get_active_buffered_frame pair this was originally written
against, so the layer-timelapse tests are updated to match, and the
now-redundant TestCaptureLayerAvoidsCompetingWithLiveViewer class
(superseded by #2707's own test_external_camera_live_frame_reuse.py)
is dropped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 17:34:13 +01:00
Carl
f475f8e015 fix(camera): apply camera_rotation to finish photos
_apply_camera_rotation was only ever wired into
_capture_snapshot_for_notification (print-start photo, in-print frame
bank) - the finish-photo pipeline (_background_finish_photo's three
branches: stage-22 pre-captured frame, external-camera fallback,
built-in buffered-frame fallback) saved frames straight to disk with
no rotation, so a finish photo came out upside-down whenever
camera_rotation was configured. Pre-existing gap, unrelated to
yesterday's collision fixes - just more visible now that a finish
photo reliably gets captured at all.

Built-in camera's final capture_finish_photo() fallback (writes its
own file, returns only a filename) is left unrotated - out of scope
here since Carl uses an external camera; would need its own change to
services/camera.py.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 17:27:16 +01:00
Carl
f89e2454ac docs: add CHANGELOG entry for #2709 orphaned timelapse session cleanup 2026-07-30 17:26:55 +01:00
Carl
24863203fb fix(camera): sweep orphaned timelapse session directories on startup
_active_sessions is in-memory only, so a process restart mid-print
loses track of any active layer-timelapse session without ever calling
cancel_session()/cleanup() - the frames directory (and, if stitching
had already produced output before the restart, a stray
timelapse_<session_id>.mp4) are then orphaned on disk permanently, with
no equivalent to the ffmpeg orphan janitor to reap them.

Confirmed live: 38MB of exactly this leftover on the OrangePi after
several restarts during this week's testing, including two corrupt
48-byte .mp4s from stitches that got interrupted mid-write.

Adds cleanup_orphaned_timelapse_sessions(), run once at startup: for
each printer_id under timelapse_frames/, remove any frame directory or
stitched-output file that doesn't match that printer's current active
session (if any) and is older than a defensive margin (5 min default).
A restart-recovered print never gets a new timelapse session either
(#1353's _maybe_start_layer_timelapse only fires on fresh PRINT_START
events), so nothing orphaned here can ever be resumed - safe to always
remove once it's old enough not to be a startup race.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 17:25:25 +01:00
Carl
68f5651f97 fix(camera): share one connection between concurrent one-shot external-camera captures
#2705 fixed simultaneous captures colliding on the built-in camera path,
keyed by printer IP through capture_camera_frame_bytes(). External
cameras reach the same collision through a different function -
external_camera.capture_frame() - that #2705 didn't touch, and a V4L2
USB device allows exactly one open handle just like Bambu's own RTSP
limit.

Nothing coalesced two one-shot capturers here either: Obico polling,
the in-print frame bank, the finish-photo moment, plate detection and
the notification snapshot could each open their own connection to the
same USB camera and collide - is_stream_active() only stops a
capturer from competing with an attached viewer, not with another
capturer (that's what #2707 fixed).

capture_frame() is now a single-flight coalescing wrapper (actual
dispatch moved to _capture_frame_uncoalesced), keyed by (url,
camera_type, snapshot_url) - snapshot_url is part of the key since
#1177's override routes to a completely different endpoint. Mirrors
#2705's shape: coalesces, doesn't cache (a call after the previous one
finishes always captures fresh); each caller keeps its own timeout via
wait_for(shield(...)) rather than inheriting the leader's; a follower
whose leader fails takes its own turn instead of inheriting a failure
it never had a chance to avoid, bounded at two rounds; cancellation is
disambiguated via leader.cancelled() so a follower's own cancellation
still propagates while a cancelled leader is treated as a failed one.

12 tests mirroring test_camera_capture_coalescing.py's structure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 17:15:20 +01:00
maziggy
c0ed97f287 Post work PR #2691 2026-07-30 18:09:15 +02:00
MartinNYHC
9a324d98b4
Merge pull request #2691 from gzimbric/feature/p2s-x2d-accessory-fans
feat(printers): expose P2S/X2D accessory fans (left aux + exhaust)
2026-07-30 18:05:31 +02:00
maziggy
ac3e3cc60f fix(printers): don't retract a fan kit on a partial airduct frame
device.airduct is pushed field by field - the modeCur handler reads it with
an "in" check for that reason - so a frame can carry parts without carrying
every fan. Absence in that list is what tells us a kit is not fitted, and
taken from a truncated frame it made both accessory badges vanish mid-print
and started rejecting fan=aux2 on a printer that has the fan.

A parts list now counts as a full inventory only when it carries ids 1 (part
cooling) and 2 (aux). Neither is optional on a machine that reports an
airduct at all, and both appear in every layout in the support-package
archive - P2S base 1,2 / P2S+kit 1,2,3 / X2D 1,2,3,10 / H2C,H2D,H2S 1,2,3,6.
Anything narrower is a diff frame: its speeds are applied, presence is left
alone. Presence can still be added from a partial frame; only retraction
needs the full list, so a kit that really is removed still disappears.

Also compose showChamberFan from both model lists rather than branching
between them, so the P2S/X2D entries in MODELS_WITH_CHAMBER_FAN stay
reachable instead of reading as dead, and note in the fan-speed docstring
that the aux2 gate also rejects between connect and the first airduct push.
2026-07-30 18:04:38 +02:00
MartinNYHC
9ebfcddbdb
Merge branch 'dev' into feature/p2s-x2d-accessory-fans 2026-07-30 17:37:48 +02:00
maziggy
db538e43f1 fix(slice): give the slice modal one filament row per project slot (#2712)
The filament list is positional from the modal down to the CLI's
filament_N.json parts, but for a source that already carries slice_info the
requirements endpoint returns only the slots the plate consumes. A
MakerWorld model declaring four filaments and painting with slot 4 alone
therefore showed one dropdown, whose PETG pick the CLI bound to slot 1 —
slot 4 sliced with the profile baked into the source, and the print came out
PLA.

The endpoint now takes full_slots, which widens that answer to every
project slot with used_in_plate flags, and only the slice modal passes it.
Print-time AMS matching shares the endpoint and keeps the used-only list, so
it still asks for exactly the spools the job needs.
2026-07-30 17:34:26 +02:00
maziggy
83142c726c fix(slice): report a finished slice once, not once per queued poll
setInterval does not await an async callback. Slicing a large project
blocks the backend for seconds, so poll ticks piled up behind one stalled
request, each holding a snapshot taken while the job was still active.
They resolved together, and every one of them ran the completion path —
one toast and two query invalidations each. A 20s stall against the 1.5s
interval produced 13 "Sliced X" toasts from a single slice.

Only one poll round is now in flight at a time, which also stops queueing
requests against a backend that is already saturated. Completion is
recorded once per job id, and a round still awaiting a response when the
effect tears down now returns instead of acting.
2026-07-30 17:14:07 +02:00
Marian
ed29319cf9 docs(oidc): drop the upgrade-path claim from the release-path rationale
The two-flagged-row state was never released, so no installation can carry it
in -- the reason to release every flagged row is not repair, it is that the
sweep's invariant is not enforced anywhere and scalar_one_or_none() turns a
broken one into a failed boot. Comment, test docstring and test name say that
instead.
2026-07-30 14:44:49 +00:00
Marian
3c679459e1 feat(oidc): set the default group from the environment, by name
Without it every account auto-created through the env provider fell back to
Viewers (routes/mfa.py), and because the provider is locked the UI could not
correct it either -- a real limitation for a declarative deployment running
BAMBUDDY_OIDC_AUTO_CREATE_USERS=true.

BAMBUDDY_OIDC_DEFAULT_GROUP names a group rather than an id: ids are handed out
per installation, so the same compose file would point at a different group on
the next deployment. The name is matched exactly, resolved against the database
before anything is written, and default_group_id joins _APPLIED_FIELDS so
dropping the variable clears the group again -- the environment is the whole
truth for this row.

A name that matches no group is refused rather than defaulted: silently landing
users in Viewers is the failure this variable exists to remove, and the API
already answers 422 for a default_group_id that does not exist. The refusal is
logged and survivable, and it says which of the two cases happened, because
they differ sharply -- an existing provider keeps running on its last good
config, while on a first boot nothing is created and no SSO button appears.

Raised by maziggy in review of #2625 as a scope decision; documented in
.env.example and in the companion wiki PR.
2026-07-30 14:43:23 +00:00
Marian
ac2f4d058e fix(oidc): hide the icon buttons on the env-managed provider too
refresh-icon and remove-icon rendered outside the !provider.is_env_managed
block, and both routes answer 409 for that provider -- so a click could only
ever produce an error toast, which is the reason the comment right below them
gives for hiding everything else. Its icon comes from BAMBUDDY_OIDC_ICON_URL
and is re-applied on every boot.

Reported by maziggy in review of #2625.
2026-07-30 14:43:23 +00:00
Marian
d64f5d9651 fix(oidc): release the row the env config managed before a rename
apply_env_oidc_provider matches the provider by BAMBUDDY_OIDC_NAME but never
cleared is_env_managed from the row it managed previously. Renaming the
variable therefore left two flagged rows, and both consequences are reachable
by ordinary config edits: the old row stayed enabled with a stale issuer and
secret on the login page while _refuse_if_env_managed answered 409 to every
attempt to edit, disable or delete it -- the dead end reachable only through
the database that the release path exists to prevent -- and unsetting the
variables later hit scalar_one_or_none() on two rows, so MultipleResultsFound
propagated out of the lifespan and the app stopped booting.

The upsert now sweeps the flag off every other row, the same shape the
autologin sweep one block down already uses: disable and release rather than
delete, for the same cascade reason as everywhere else in this branch. The
release path releases every flagged row it finds instead of exactly one -- the
sweep should keep that at one, but a release path that dies with
MultipleResultsFound the moment that invariant breaks is a second way to lose
the boot, and the query costs the same either way.

Releasing now clears is_autologin as well. Without it a released row keeps a
latent autologin claim: update_oidc_provider only re-runs the exclusivity sweep
when a request sets is_autologin=True, so merely re-enabling the row in the UI
would silently make it the autologin target again.

Reported by maziggy in review of #2625, with the rename reproduction.
2026-07-30 14:43:23 +00:00
maziggy
ad785a95cb fix(queue): withdraw an expected print when the command never goes out
feat(db): warn when the connection pool can outgrow the PostgreSQL server

fix(mqtt): an unusable layer_num must not drop the printer connection

test: patch settings.base_dir via monkeypatch so it unwinds on error

test: restore the config module after reloading it
2026-07-30 16:31:09 +02:00
maziggy
beca3a8d73 fix(mqtt): keep the layer total that arrives with the print-start frame (#2702)
fix(support): redact push_status values, not the serialised JSON (#2702)
2026-07-30 15:11:08 +02:00
maziggy
88dc56d6e1 Security hardening (maziggy/bambuddy-security #7)
fix(settings): accept JSON booleans on the Spoolman settings endpoint
2026-07-30 13:52:56 +02:00
maziggy
d9da60dd8d fix(camera): reuse the live view's frame for external-camera captures (#2707)
On a printer with an external camera, watching the live view while a print
ran meant the layer timelapse recorded almost nothing and the finish photo
went out with no image. The reporter measured 0 of 87 layer captures on one
print and 0 of 105 on another, both watched throughout. A USB camera allows
one V4L2 handle, so a capture during a live view fails outright.

The built-in camera has had this rule since #1348 and #1271: reuse the
viewer's buffered frame rather than opening a second connection. It was
never extended to the external paths, and it could not have been -- the
buffer it depends on was only ever populated by the built-in paths.
generate_mjpeg_stream yields multipart-wrapped chunks, so the route layer
could not recover the JPEG, and a guarded caller would have found an empty
buffer and skipped every time.

So the stream now publishes each raw frame through a new on_frame callback
(parallel to on_process from #2675), and the six one-shot consumers reuse
it: layer timelapse, the finish-photo moment and its background fallback,
the notification snapshot, Obico polling, and the plate check. A viewer
attached with nothing buffered yet skips that one attempt rather than
competing -- kicking the viewer off is worse than missing a frame.

on_frame exceptions are logged and swallowed, like iter_subscriber's
on_unsubscribe: buffering is a side effect and must never be able to take
the live stream down with it. The external stream's teardown now releases
the buffered frame too, ownership-checked so a concurrent viewer of the
same printer keeps its own.

Two side effects on paths not touched here, both improvements: the snapshot
endpoint and the finish-photo fallback chain consult get_buffered_frame and
can now serve an external camera's live frame. plate_detection's docstring
already claimed this behaviour while implementing it only for the built-in
fallback; that drift is resolved.
2026-07-30 13:05:15 +02:00
maziggy
40e7b60e8c fix(camera): drain a streaming ffmpeg's stderr continuously (#2707)
ffmpeg is spawned with stderr=PIPE and it was only read on the error
paths, so for the life of a working stream nobody read that pipe. ffmpeg
writes its banner, the input analysis, then a progress line at a steady
rate; a 64 KiB pipe fills eventually, ffmpeg blocks writing to it, frames
stop, and the stream's own 30s timeout fires -- logged as "RTSP read
timeout" with no hint that we starved it ourselves.

How long that takes is unmeasured and evidently long: one H2D upstream
ran 21m36s without stalling, and an earlier 512 B/s extrapolation of mine
was mostly the one-off startup banner. So this is a bounded resource being
treated as unbounded, not a fault anyone has reported.

_FfmpegStderrTail drains the pipe continuously and keeps a 16 KiB rolling
tail. That tail is what the error paths now report, which is better
material than before: it holds what ffmpeg said as things went wrong,
where the on-demand read returned whatever was printed first -- usually
the banner, which the summariser strips anyway.

Three readers wanted this one pipe, and asyncio rejects concurrent reads
on a StreamReader, so the collector is authoritative: it registers by pid,
_read_ffmpeg_stderr returns its tail when present and otherwise reads the
pipe unchanged, and _terminate_ffmpeg skips its own stderr drain when the
collector owns it (the collector keeps draining through teardown, which is
all wait() needs). The generator starts it after the immediate-failure
check, which reads the pipe directly because the process is already dead,
and releases it after _terminate_ffmpeg.

text() goes through _summarize_ffmpeg_stderr like every other stderr log
here, so the access code ffmpeg echoes in its input URL stays masked.
aclose() awaits the cancelled pump rather than firing and forgetting, so
no pending task survives into loop teardown.
2026-07-30 12:52:11 +02:00
maziggy
f26bcbbcce fix(camera): one registry key per stream, not per printer (issue #2707)
Closing a camera view and reopening it immediately could leave the new
stream unregistered while it was running and delivering frames. The
damage was all indirect: is_stream_active() reported no viewer, so Obico
polling and snapshots opened a second camera connection against the live
view (the thing #1348 and #1271 exist to prevent); the janitor's /proc
scan found an ffmpeg missing from _active_streams and killed the live
stream as an orphan; and /camera/stop reported "Stopped 0" with a stream
running.

The fan-out stream id was f"{printer_id}-fanout" -- constant per printer,
so every successive stream shared one registry key, and the departing
generator's finally popped whatever was under it, including its
successor's entry. The same finally also cleared the per-printer frame
buffer unconditionally, discarding the new stream's frame. It needed the
two streams to overlap, which the 4s teardown made easy.

Each stream now gets its own key via _new_fanout_stream_id(), so a
generator can only clean up after itself -- the external-camera path
already does this (#2675) and this brings the fan-out path in line. The
per-printer dicts are released through _release_printer_frame_state(),
which checks that no other stream for the printer is still running; both
the RTSP and chamber-image cleanups had the same unconditional pop.

Also hoisted time and uuid to module level and dropped four
function-local `import time` statements. A local import shadows the name
for the whole function, so any use on a branch that doesn't reach the
import raises UnboundLocalError -- a real hazard in camera_stream, whose
external-camera branch imported both while the RTSP path needs them too.
A test pins camera_stream as free of function-local imports.
2026-07-30 12:35:27 +02:00
maziggy
18cc906fad fix(camera): drain ffmpeg's pipes during teardown (#NNNN)
Closing a camera view logged "ffmpeg didn't terminate gracefully,
killing" followed by "ffmpeg did not exit within 2.0s of SIGKILL;
abandoning wait", on every single close. Both waits expired every time,
so teardown took a fixed 4.00s -- and since the firmware allows one
camera connection, that was 4s in which nothing else could use it.

ffmpeg is spawned with stdout and stderr as pipes and the teardown paths
have stopped reading them, so it sits blocked in write() on a full 64 KiB
pipe. SIGTERM cannot be acted on there: the handler only sets a flag that
the main loop polls, and the loop never gets back to the check. SIGKILL
does kill it, but asyncio resolves Process.wait()'s waiter through
_try_finish(), which requires every pipe transport to report
disconnected; paused, unread pipes never reach EOF, so wait() blocks with
returncode already set. A negative-control test shows returncode=-9 at
the instant the abandon fires.

Draining both pipes while stopping the process fixes both halves: 4.00s
becomes ~0.15s. The signal ladder and its bounds stay as backstops, so a
genuinely wedged process still cannot hang a stream, a Stop request or
the janitor.

This corrects _FFMPEG_KILL_TIMEOUT's premise and #2580's conclusion. That
12-hour hang was the unbounded form of this same self-inflicted stall, not
an ffmpeg stuck in uninterruptible I/O -- the process observed doing it
was in state S, which cannot survive a delivered SIGKILL. Bounding the
wait capped the symptom without removing the cause.
2026-07-30 10:31:22 +02:00
maziggy
73afa95047 fix(camera): share one connection between concurrent one-shot captures (#2705)
Bambu firmware allows exactly one camera connection. The existing guards
(is_stream_active / try_get_active_buffered_frame, #1271 and #1348) only
stop a one-shot capturer from competing with the fan-out broadcaster.
Nothing coordinated the capturers with each other, so with no viewer
attached every consumer correctly concluded it was not competing with a
viewer and then collided with the others. On the reporter's P2S an Obico
poll and a snapshot opened two RTSP sockets 207 ms apart, which knocked
over the fan-out stream feeding the camera wall; it was then reaped for
having received no frames for 58s.

capture_camera_frame_bytes() now coalesces: the first caller opens the
connection, callers arriving while it is in flight await the same result.
Eight paths reach that function independently - Obico polling, the
snapshot route, the finish-photo moment and its disk-writing sibling,
plate detection, the camera test and the diagnose tool - so the
single-flight sits at the bottom of the stack and no call site changes.

Keyed by IP, since that is what the firmware's limit applies to and the
function never sees a printer_id. The key excludes the timeout on
purpose: the call sites disagree about it, from 10s to 30s, so keying on
it would mean the Obico-vs-snapshot pair from the report never coalesced
at all.

It coalesces, it does not cache. A call arriving after the previous
capture finished still captures fresh, because plate detection and the
finish-photo path judge a running print from these frames and a stale one
there is worse than a slow one - #1397 was a finish photo taken seconds
late showing the bed already lowered.

Each caller waits on its own deadline rather than inheriting whichever
one happened to open the connection, and shield() means giving up leaves
the capture running for whoever else is still waiting. A follower whose
leader fails takes a turn of its own instead of inheriting a failure it
never had a chance to avoid; the leader has finished by then, so there is
nothing left to compete with. Bounded at two rounds. That also covers the
follower whose timeout is longer than the leader's, which coalescing
alone cannot. Cancellation is disambiguated via leader.cancelled(), so a
follower's own cancellation propagates while a cancelled leader is
treated as a failed one.

The leader is deliberately not wrapped in a second wait_for: the
implementation already enforces the timeout internally, where it can also
kill the ffmpeg process, and an outer deadline would abandon the
subprocess instead of killing it.

The diagnose tool now marks a stage whose frame came from a capture
already in flight as coalesced_capture. The pass is real evidence the
camera works, but duration_ms is then mostly time spent queueing, and a
diagnostic must not report a connection it never opened - the same reason
that file declares its live_stream_active shortcut instead of quietly
passing. Failures are not annotated, since a follower whose leader fails
goes on to capture on its own.
2026-07-30 09:49:56 +02:00
maziggy
9ef06449ef fix(filament): a unique preset match no longer counts as a colour match (#2687)
The Filament Mapping panel reported "(Ready)" with a green tick for a slot
where the slice wanted dark red and the auto-matched tray held dark green.
Manually picking that same tray reported the mismatch correctly, which is
what made it obvious something was inconsistent.

Auto-match ranks candidates by tray_info_idx first, and a uniquely-matching
preset was accepted as definitive on the premise "same preset = same spool =
same colour". The preset names the variant, not the spool: GFA00 is PLA
Basic, GFA01 PLA Matte, GFA17 PLA Translucent, in every colour Bambu sells.
The reporter's own bundle has eight GFA00 trays in eight colours. With one
Matte spool loaded, every Matte requirement idx-matched it and the colour
comparison was never reached - which is why this surfaced on PLA Matte and
not on Basic, where several spools are usually loaded and the match falls
through to the branch that does compare colours.

The verdict now comes from the tray that was selected rather than from which
rule selected it, and both branches share one comparison so they cannot
drift apart again. Selection is unchanged - the right variant still wins per
mismatch and the slot stays selected.

A requirement with no colour at all is treated as satisfied rather than
mismatched; 3MFs that omit it parse to "" and there is nothing to disagree
with. That also affects the manual branch, which used to flag it.

No dispatch change: _get_missing_force_color_slots already required an exact
colour, so force colour match was gated correctly throughout.
2026-07-30 09:26:46 +02:00
maziggy
9ef03067f7 feat(file-manager): show last activity on folder rows via the existing date toggle (issue #2680)
Follow-up to #2680: the calendar toggle only put dates on the file pane, so
the folder tree had no way to show the timestamp it was already sorting on.
FolderTreeItem now takes showModified and renders latest_activity_at under
the folder name, threaded through the recursive call so nested folders get it
too. No backend change - the field was already on the wire from the sort fix.

Folders are labelled "last activity", not "last modified", and get their own
i18n key. The value is the newest timestamp among the folder, its files and
everything below it, so a folder can read as newer than its own directory
mtime - calling that "modified" would look like a fresh instance of the
ls -lt mismatch the issue was originally about. Folders with no activity
render nothing rather than an Invalid Date placeholder.

The name span moved into a flex column so the second line does not disturb
the row's link badge, file count or kebab menu. That broke a folder-delete
test that reached the row via parentElement, now fixed to use closest().

Separately, the #996 collapse describe left an implementation on the
module-global localStorage.getItem mock, which silently collapsed the folder
tree for every describe after it. It resets in afterEach now; without that,
any later test asserting on nested folders fails for reasons unrelated to
what it is testing.
2026-07-30 09:09:47 +02:00
gzimbric
b938b83136 fix(tests): add the new fan fields to the plate-clear status fixture
mqtt_relay reads state.left_aux_fan_speed, but the SimpleNamespace fixture in
test_plate_clear_mqtt_notification enumerates its fields explicitly, so the two
status-payload tests raised AttributeError. I updated the equivalent fixture in
test_printer_manager_status_broadcast and missed this one — running only the
touched suites is what hid it.

Also addresses the round-2 review notes:

- exhaust_fan_present: documented that the H2 series reports part 3 too, so the
  flag is not model-specific despite the name.
- Mask the part id after shifting, matching get_flag_bits(id, 4, 8), for
  consistency with the state decode. No behaviour change for any observed id.
- Noted the unmapped H2 id 6 beside the id branches.
- Reject fan=aux2 when the printer reports no left_aux_fan_speed, so a POST
  against an A1 no longer sends M106 P10 for absent hardware. The UI already
  hid the badge; this closes the same hole on the API.
- Added a test asserting EXHAUST_FAN_LABEL_MODELS and the frontend's
  MODELS_WITH_EXHAUST_LABEL cannot drift apart.
2026-07-29 11:33:09 -05:00
maziggy
ef9357849b Post work PR #2693 2026-07-29 14:42:34 +02:00
MartinNYHC
68cf5a8804
Merge pull request #2693 from Chachigo/feature/print-progress-in-title
feat: show live print progress in the browser tab title
2026-07-29 14:39:09 +02:00
maziggy
24322c71cb fix(tab-progress): drop the redundant status poll and quieten the test suite
The hook is mounted globally in WebSocketProvider, so refetchInterval on its
per-printer status queries added one request per printer every 30s on every
page. The Printers page already runs that fallback on the same query key, and
useWebSocket writes ['printerStatus', id] straight into the cache, so the poll
bought nothing outside the Printers page and cost a request per printer per
tab everywhere else.

Also captures document.title at mount instead of restoring to a hardcoded
'Bambuddy', so the default no longer has to be kept in sync with index.html.

jsdom has no canvas backend, so getContext('2d') logged a "Not implemented"
jsdomError with a full React stack on every run of the hook's tests, and the
favicon branch bailed on the null context and went untested. Stubbing
getContext/toDataURL removes the noise and lets the ring code run, so the
favicon swap and the restore-on-toggle-off path are now asserted.
2026-07-29 14:38:18 +02:00
MartinNYHC
c9aec8293f
Merge branch 'dev' into feature/print-progress-in-title 2026-07-29 14:19:37 +02:00
maziggy
6cda236dce feat(notifications): optional Telegram forum topic via message_thread_id (#1518)
Telegram groups with Topics enabled always received notifications in the
General topic, since only Bot Token and Chat ID were configurable. Splitting
notifications per printer meant running a separate chat for each one.

The Telegram provider now takes an optional Forum Topic ID - the last number
in a topic's link, t.me/c/1234567890/25 - and routes its messages there. Left
empty, nothing changes.

The value is coerced to an int once in _send_telegram and attached to both the
sendMessage JSON body and the sendPhoto form data. That ordering matters:
Telegram rejects a string message_thread_id in the JSON body while accepting
one in the multipart call, so passing the raw form value through would have
worked for thumbnail notifications and 400'd for plain-text ones. A
non-numeric value is rejected in the form and again server-side before any
request goes out.

No migration - provider config is a JSON blob.

Adds Forum Topic ID plus help text to the Telegram section of the provider
dialog, translated in all 13 locales. Backend tests cover omitted / blank /
int-typed / non-numeric values and both send paths; frontend tests cover the
field being optional, absent for other providers, round-tripping on save, and
blocking save on a bad value.
2026-07-29 14:10:38 +02:00
Chachigo
fe03e0ec1d feat: show live print progress in the browser tab title
Optional and off by default, toggled under Settings -> Appearance. When enabled, the browser tab shows the soonest-finishing print's percentage plus a green progress-ring favicon, updated live over the existing WebSocket. The preference is stored per-browser in localStorage.

Adds the usePrintProgressTitle hook (with tests), a ThemeContext preference, the Settings toggle, i18n strings for all locales, and a README entry.
2026-07-29 13:30:25 +02:00
maziggy
c11bcecfd8 fix(i18n): list Ukrainian after Russian in the language picker
The uk locale was inserted before ru in i18n/index.ts - in the import block,
the resources map and availableLanguages. Locales are appended to those lists
as they land (tr, then ru on 2026-07-19, then uk on 2026-07-28), and
SettingsPage renders availableLanguages in array order, so the picker showed
Ukrainian above Russian while every other entry followed the order it was
added.

Moves uk to the end of all three lists. SUPPORTED_LNGS is sorted
alphabetically and already had uk in the correct position, so it is unchanged.

Frontend-only, no behaviour change beyond the picker's row order.
2026-07-29 13:22:24 +02:00
MartinNYHC
ea1869aeb3
Merge branch 'dev' into feature/oidc-env-config 2026-07-29 13:09:20 +02:00
maziggy
e782fe237f Updated BACKERS 2026-07-29 13:07:55 +02:00
maziggy
d7fe72672b Updated BACKERS 2026-07-29 13:07:24 +02:00
MartinNYHC
f2babc9027
Merge branch 'dev' into feature/p2s-x2d-accessory-fans 2026-07-29 12:36:45 +02:00
maziggy
e325948dcc Security hardening (maziggy/bambuddy-security #N)
Subprocess output and user-supplied URLs are scrubbed of credentials
before they reach the application log. Adds a shared redaction helper in
core/logging_filters and routes the existing support-bundle sanitizer
through the same pattern.
2026-07-29 12:36:17 +02:00
maziggy
f189ec826d Updated CHANGELOG 2026-07-29 11:56:03 +02:00
maziggy
5a67dffe4f fix(timelapse): poll longer, diff without a clock, delete once archived (#2704)
Timelapse was on, the video never reached the archive, and Scan for Timelapse
found nothing afterwards. Across 247 support bundles this was the norm, not an
edge case: 457 automatic scans scheduled, 262 attached.

The scan looked four times over ~65s. The attempt that found the video was #1
272 times, then 17 / 13 / 13 — flat against the cutoff, not decaying, i.e.
files were still arriving when we stopped. What ran afterwards searched for the
print name inside the filename; Bambu only writes "video_<timestamp>", so it
fired 159 times and matched zero.

The manual Scan had no baseline at all and matched on filename timestamp, FTP
mtime, or "there is only one video" — all reading a clock a LAN-only printer
cannot sync. The reporter's P1S was six and a half days out.

- Poll for minutes instead of ~65s; drop the name-match fallback.
- Persist the print-start baseline on the archive, so the diff survives a
  restart mid-print and the manual Scan runs the same comparison. With a
  baseline present the clock-based strategies are skipped entirely — they can
  only turn an honest "pick one" into a confident wrong answer.
- When several files are new (a previous print's video landing late), exclude
  the ones already attached to another archive instead of ordering the
  candidates. Ordering could only be done on the printer's clock.
- Delete the video from the printer once archived. Keeps /timelapse to
  unclaimed files, which is what makes the diff unambiguous, and stops P1S
  cards filling with AVIs.
- Gate that delete on a verified transfer: download_file now compares against
  the size from the listing. An FTPS connection closing early does not always
  raise, so a partial buffer was being attached as a complete video — which
  would also have been the one case where deleting the source lost data.

Bounded twice on purpose: wall-clock deadline plus a derived round cap, since
the deadline stops bounding the loop as soon as the sleeps are shortened.
Per-round logging only speaks when the listing changed — 31 rounds of full
listings would bury the interesting line in the support bundle.

Migration adds print_archives.timelapse_baseline as JSON, spelled the same on
both dialects so a migrated database matches a fresh one.

-----------

fix(finish-photo): add the timelapse frame to the archive after the notification (#2704)

When a print records a timelapse, its last frame is the better finish photo:
the firmware stops recording with the toolhead parked and before the end
G-code drops the bed, where a live grab at that moment catches a lowered
plate. Bambuddy waited 60s for the video and then gave up, because the
print-complete notification blocks on that photo and holding a notification
for minutes is worse than sending it with the live grab.

P1-series printers write MJPEG AVI rather than H.264 MP4 and serve it slowly.
Measured over 261 attaches in the support bundles: P1S median 33s, p90 167s,
worst 546s, while every other model finished inside 26s. So the printers that
most needed the better framing were the ones that never got it.

Keep the notification on the same bound, and keep waiting off to the side.
_capture_finish_photo_from_timelapse now reports whether it ran out of time or
concluded — a video that landed and failed extraction is not worth retrying,
one that never arrived is. On the first, schedule a background task that waits
up to 15 minutes and inserts the extracted frame at the front of the archive's
photo list, where the gallery opens.

The live grab stays on disk: the notification already links to that exact
file, so removing it would leave a broken image in Discord or Telegram.

The length check proves we received what the listing said, not that the file
was finished. The first look happens ~5s after the print ends, while the
printer may still be writing, so a growing file can be listed short, served
short, and pass. Re-list after the download and only accept the video once its
size has stopped changing — a failed re-list counts as not settled, since
"could not check" must not mean "safe to delete".
2026-07-29 11:51:30 +02:00
maziggy
c1a4c99059 feat(diagnostics): log end-of-print telemetry for finish-photo trigger research (#2547)
The finish photo needs a "printing done, toolhead parked, filament unload
not started" moment. stg_cur=22 was meant to be it (#1721) and fires on no
model in the field: across 247 support bundles there is not one
FINISH PHOTO MOMENT (stage-22), including the 2026-06-13..07-08 window where
it was the only pre-FINISH trigger in the code — 104 captures on A1, A1 Mini,
H2C, H2D, P1S, P2S, X1C and X2D, all of them the FINISH fallback.

A replacement can't be designed from the bundles we have. Out of that window
Bambuddy parses only stg_cur and mc_print_sub_stage; every other stage/action
field arrives and is dropped unread. The candidates that sound right
(print_real_action, mc_action, mc_stage) are absent from A1/A1 Mini/P1S
payloads, so none of them can be the universal answer alone.

Dump the raw fields for the window between the last object layer and
gcode_state=FINISH at DEBUG. Opens on the first end-of-print signal (last
layer, progress >= 99, or no remaining time) so a dropped layer_num packet
doesn't lose it, logs only what changed frame to frame, closes on the
transition out of RUNNING, and arms once per print.

Instrumentation only: gated on DEBUG being enabled, read-only against printer
state, wrapped so it cannot break ingest, and capped at 400 frames per print.
The probed fields are stage codes, counters and bitfields — nothing
identifying, and no access code.
2026-07-29 09:48:18 +02:00
maziggy
91269f14fe fix(mqtt): report why a printer refused the connection instead of looping silently
A printer with a wrong access code gave no explanation anywhere. The connect
callback's failure branch was a bare `state.connected = False`, discarding the
CONNACK reason code the printer had just sent, so the only trace was paho's
follow-up disconnect -- logged every 30 seconds as "rc=Unspecified error",
which is exactly what a powered-off printer produces. In the report behind this
fix one of three printers had been in that loop for the whole capture, and
neither the log nor the support bundle could say why.

Bambu speaks MQTT 3.1.1, whose CONNACK return codes 4 and 5 paho maps onto
reason codes 134 and 135. Both are now logged with the printer's own reason
string and, for those two, the remedy: the access code is regenerated whenever
LAN Only or Developer Mode is toggled, so it has to be re-read from the screen.
The access code itself is never logged -- it would land in every bundle.

The reason is kept on the client as a stable slug and plumbed through
test_connection into the connection diagnostic, which now distinguishes two
cases it previously conflated. "The printer refused our credentials" is
asserted only when the printer said so; when all Bambuddy knows is that there
is no session, the text hedges and names the alternatives (rebooting, or
already at its limit of simultaneous connections). The old wording claimed the
access code was most likely wrong in both cases.

Frontend needed no change -- ConnectionDiagnostic already renders
`<status>_<reason>` variants with fallback to the plain per-status text, so an
unrecognised slug degrades to today's wording rather than a missing key.
2026-07-29 09:02:39 +02:00
maziggy
fc1467ab03 Updated CHANGELOG 2026-07-29 08:42:00 +02:00
maziggy
3a6cf04a7e fix(vp): relay A2L AMS filament to the slicer instead of blanking every slot
Every slot of the A2L's AMS Lite rendered as "?" in Bambu Studio through the
Virtual Printer while Bambuddy's own AMS card was correct, and a filament set
by hand in Studio reverted about a second later.

The A2L reports its AMS Lite as physical unit id 16 but packs the slot presence
bits at base 24, so bambu_mqtt normalises the id to 6 at the ingest boundary and
every internal reader gets the right bits. The VP bridge is not downstream of
that: BambuMQTTClient._on_message fans raw payload bytes out to raw-message
handlers before parsing, so mqtt_bridge._on_printer_raw does its own json.loads
and still holds id 16. It then called the shared apply_tray_exist_bits, which
computed 16*4 = bits 64-67 -- never set -- concluded all four slots were empty,
and wiped tray_type / tray_color / tray_info_idx / tag_uid / tray_uuid / remain
from the copy sent to the slicer. That runs on every push, which is why a manual
pick could not survive the next 1 Hz cached-as-base report.

apply_tray_exist_bits now folds the unit id through normalize_am_unit_id, so 16
and 6 land on the same bit base whichever id the caller holds. The bridge's
cached ids stay physical on purpose -- Studio addresses the Lite as 16, sending
ams_get_rfid {ams_id: 16} through the VP -- so normalising the cache instead
would have broken the slicer's own command path.

Confirmed from the reporter's debug log, which shows the cleanup clearing slots
at bits 64-67 under the VP's log label. Before #2670 added the
0 <= ams_id <= 15 range guard this wiped the slots; after it, unit 16 fell out
of the guard and the A2L got no empty-slot cleanup at all -- two different wrong
answers, both fixed here.
2026-07-29 08:41:33 +02:00
maziggy
d0efb9db9e fix(vp): relay A2L AMS filament to the slicer instead of blanking every slot
Every slot of the A2L's AMS Lite rendered as "?" in Bambu Studio through the
Virtual Printer while Bambuddy's own AMS card was correct, and a filament set
by hand in Studio reverted about a second later.

The A2L reports its AMS Lite as physical unit id 16 but packs the slot presence
bits at base 24, so bambu_mqtt normalises the id to 6 at the ingest boundary and
every internal reader gets the right bits. The VP bridge is not downstream of
that: BambuMQTTClient._on_message fans raw payload bytes out to raw-message
handlers before parsing, so mqtt_bridge._on_printer_raw does its own json.loads
and still holds id 16. It then called the shared apply_tray_exist_bits, which
computed 16*4 = bits 64-67 -- never set -- concluded all four slots were empty,
and wiped tray_type / tray_color / tray_info_idx / tag_uid / tray_uuid / remain
from the copy sent to the slicer. That runs on every push, which is why a manual
pick could not survive the next 1 Hz cached-as-base report.

apply_tray_exist_bits now folds the unit id through normalize_am_unit_id, so 16
and 6 land on the same bit base whichever id the caller holds. The bridge's
cached ids stay physical on purpose -- Studio addresses the Lite as 16, sending
ams_get_rfid {ams_id: 16} through the VP -- so normalising the cache instead
would have broken the slicer's own command path.

Confirmed from the reporter's debug log, which shows the cleanup clearing slots
at bits 64-67 under the VP's log label. Before #2670 added the
0 <= ams_id <= 15 range guard this wiped the slots; after it, unit 16 fell out
of the guard and the A2L got no empty-slot cleanup at all -- two different wrong
answers, both fixed here.
2026-07-29 08:40:42 +02:00
maziggy
1b1f2b4ecb Updated CHANGELOG 2026-07-29 08:40:36 +02:00
Marian
1116b43fbd fix(oidc): never log the client_secret when env config is rejected
apply_env_oidc_provider logged the raw Pydantic exception on rejection.
client_secret has max_length=512, so a longer value raises string_too_long
and str(exc) embeds input_value=..., leaking BAMBUDDY_OIDC_CLIENT_SECRET into
the logs (maziggy review, PR #2625).

Split the catch: ValidationError logs errors(include_input=False), which
strips submitted values; any other exception logs only its class name, never
str(exc). Rejection stays survivable — a bad config is still skipped and the
app still boots.

Adds two regression tests: an over-long secret is rejected without the value
reaching the log, and a non-ValidationError is survived without leaking its
message.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 21:05:29 +00:00
Marian
c163b3524a fix(oidc): identify the env provider by name, and release it when unconfigured
Two problems, both from using is_env_managed as the provider's identity.

An operator who names the env provider after one that already exists hit the
unique constraint on `name` during the insert. That happens inside the
lifespan, so the app did not boot -- from a function whose docstring promises
it never raises. The lookup now matches on the name, which is unique, so an
existing provider is adopted and updated instead of duplicated.

And removing the config left the row disabled but still flagged, so the API
went on refusing every edit and delete while nothing managed it any more: a
dead end reachable only through the database. The flag is now cleared as well,
handing the provider back to the UI. Re-adding the config finds the same row
by name, so the account links it carries survive the round trip.

Falls out of the same change: the issuer URL and client id can be rotated
under an unchanged name without orphaning those links.

Found by Marian asking what happens when you want to change the provider --
the answer was "you cannot, ever again".

Refs #2593
2026-07-28 21:05:29 +00:00
Marian
f7c5efc7b2 docs: document the BAMBUDDY_OIDC_* variables in .env.example
Follows the BAMBUDDY_LOCAL_LOGIN block's style: what it is for, when it
activates, and the two things an operator cannot guess from the variable names
-- that removing the config disables rather than deletes the provider, because
deleting would permanently drop every account link, and that the UI shows it
read-only because startup would overwrite an edit anyway.

Also states why auto-link is refused without verified emails, since that is
the one setting that will be silently skipped if someone gets it wrong.

Refs #2593
2026-07-28 21:05:29 +00:00
Marian
05aebac67b feat(oidc): show the env-managed provider as locked in settings
The API answers 409 to any write against this provider, so offering edit,
delete and the enable toggle would promise a change that cannot land -- the
operator would click, see nothing happen, and have no way to tell why. The
controls are hidden and a lock badge names the reason instead.

Reuses settings.environmentManagedLabel, the string the Home Assistant
env-managed fields already use: same situation, same wording, and no new key
to keep in parity across eleven locales.

is_env_managed is optional on the client type so a response from an older
backend still type-checks.

Refs #2593
2026-07-28 21:05:29 +00:00
Marian
8d1b2b9027 chore(config): register BAMBUDDY_OIDC_* in the typo-guard
Unknown BAMBUDDY_* vars log "possible typo" on every boot, so a correct OIDC
config would have told its operator it was wrong, once per restart.

The test asserts against the reader's own variable list rather than a copied
one, so a thirteenth variable added later fails here instead of surfacing in
somebody's logs.

Refs #2593
2026-07-28 21:05:29 +00:00
Marian
ccc90ff34c feat(oidc): expose is_env_managed in the provider response
The frontend needs it to render the provider read-only. Without the flag the
UI would offer editable fields whose writes the API then refuses with 409 --
the change would look accepted right up until it wasn't.

Refs #2593
2026-07-28 21:05:29 +00:00
Marian
c9ee259807 feat(oidc): refuse API writes to the env-managed provider
Startup rewrites this row from BAMBUDDY_OIDC_* on every boot, so an edit
through the UI would be accepted and then silently reverted at the next
restart -- the operator would watch their change vanish with nothing
explaining why. A 409 says so instead.

Covers all four mutating routes, including the two icon ones: the icon comes
from BAMBUDDY_OIDC_ICON_URL and would be restored the same way. Extracted as
one helper rather than four copies of the same check, so a fifth route cannot
be added with the guard silently missing.

Locking it is safe because BAMBUDDY_LOCAL_LOGIN (#1589) remains the documented
recovery path if the provider itself becomes unusable. A test pins that
UI-created providers stay editable -- the lock must not leak onto them.

Refs #2593
2026-07-28 21:05:29 +00:00
Marian
e7a413e745 feat(oidc): apply the env provider during startup
Placed after init_db(): is_env_managed only exists once run_migrations has
added it, so an upsert before that would fail on every existing installation.

The wiring gets its own tests because the apply tests cannot cover it -- they
call apply_env_oidc_provider() directly, so deleting this call would leave the
feature dead with a fully green suite. Verified: removing the call fails the
three startup tests while all seven apply tests still pass.

They assert against the lifespan's source rather than running it. The function
is ~460 lines and starts printer connections, MQTT and schedulers; executing
it would exercise everything except the line in question. The docstring says
plainly that this proves the call exists and runs after migrations, and
proves nothing about its behaviour.

Refs #2593
2026-07-28 21:05:29 +00:00
Marian
58602a3f1b feat(oidc): upsert the env-managed provider
The row is updated in place, never delete-recreated: user_oidc_links
references it with ON DELETE CASCADE, so recreating the provider would
silently unlink every account bound to it. For the same reason, removing the
variables disables the provider rather than deleting it -- the links would not
come back when the config does.

Config goes through OIDCProviderCreate, the schema the API already uses, so
the environment cannot reach a state the UI would have refused. That covers
the SEC-1 auto-link check: auto-link plus unverified email is an account
takeover, and it is rejected here exactly as it is in the UI.

Nothing raises. This runs during startup, so a typo in one variable must not
stop the app from booting -- a rejected config is logged and skipped, leaving
the previous provider untouched.

Refs #2593
2026-07-28 21:05:29 +00:00
Marian
d6ecd92480 feat(oidc): read BAMBUDDY_OIDC_* env config
A declarative deployment has no way to click through the settings UI, so one
provider can be configured entirely from the environment. This reads and
defaults only -- validity is decided later by the same OIDCProviderCreate
schema the API uses, so env config cannot bypass a check the UI enforces.

All four required vars or nothing, and an empty one counts as unset: a
provider missing its secret would otherwise be written to the database and
fail at authorize time, far from the typo in the compose file that caused it.
Booleans follow the project's existing spelling convention (true/1/yes), so an
unrecognised value leaves the documented default rather than guessing.

Refs #2593
2026-07-28 21:05:29 +00:00
Marian
e3cada51ac feat(oidc): add is_env_managed column to oidc_providers
Marks the single provider that BAMBUDDY_OIDC_* environment variables define,
so a later change can upsert it on startup and refuse UI/API writes to it. The
row is never delete-recreated: user_oidc_links.provider_id is FK ON DELETE
CASCADE, so dropping the provider would take every account link with it.

The migration carries its own test rather than relying on the model test. A
table created from metadata already has the column, so that path never
exercises the ALTER; an installed instance gets it only through
run_migrations, and that is the path an upgrade actually takes. Covered:
the column appears on a pre-existing table, rows created before the upgrade
read as not env-managed, and re-running is a no-op because every boot replays
the whole migration set.

Refs #2593
2026-07-28 21:05:29 +00:00
Gabe
8fcee5d048 i18n(uk): add the accessory fan keys to the new Ukrainian locale
uk.ts landed upstream (#2695) while this branch was open; add leftAuxiliary
and exhaust so all 13 locales stay in parity.
2026-07-28 11:18:43 -05:00
Gabe
84a7b797cd fix(printers): decode airduct part state from its low 8 bits
Review feedback on #2691: `state` is bit-packed like its sibling `range`
(end << 16 | start), and Bambu Studio decodes it with
get_flag_bits(state, 0, 8). Masking with & 0xFF before clamping means a
packed value decodes to the real percentage instead of clamping to 100.

Also moves the uses_exhaust_fan_label import to the top of printers.py with
the other imports.

Tests: packed value (60 << 16 | 45) decodes to 45, and plain 0-100 values
round-trip unchanged.
2026-07-28 11:16:23 -05:00
Gabe
03c35e06ce fix(printers): order the fan badges to match the physical layout
Place the left auxiliary fan badge before the right-hand auxiliary badge so
the row reads part cooling -> left aux -> aux -> chamber/exhaust, matching
the printer's physical left-to-right arrangement.

Adds a test asserting the rendered badge order.
2026-07-28 11:16:23 -05:00
Gabe
15ec0bf1c5 fix(printers): use the model-appropriate name in the fan-speed response
The fan-speed endpoint always reported 'Chamber fan set to N%', so on
P2S/X2D — where the printer card labels that fan 'Exhaust' — clicking
Exhaust produced a toast saying Chamber fan.

Adds uses_exhaust_fan_label() to printer_models so the badge label and the
API response share one source of truth, and uses it to pick 'Exhaust fan'
vs 'Chamber fan' in the response message.

Tests: helper coverage for P2S/X2D (incl. internal codes N7/N6), other
enclosed models, and unknown/missing model; API test asserting the message
matches the badge label per model.
2026-07-28 11:16:23 -05:00
Gabe
9ee162d51a feat(printers): expose P2S/X2D accessory fans (left aux + exhaust)
The P2S/X2D have two fans bambuddy didn't fully handle. On the P2S both are
add-on kits; on the X2D they ship from the factory.

1. Left auxiliary part cooling fan — not shown or controllable. It is reported
   ONLY as device.airduct part id 10 (raw id 160 >> 4; FAN_REMOTE_COOLING_1 in
   Bambu Studio's DevFan::ParseV3_0) and is never mirrored into a flat
   big_fanX_speed field, which is why it was invisible. This is the gap
   identified in #2576, where the single 'Auxiliary' fan (big_fan1 / M106 P2)
   only reaches the right-hand aux fan.

2. Chamber exhaust fan — shown on every P2S labelled 'Chamber Fan'. On P2S/X2D
   Bambu's firmware/UI (and Bambu Studio's FAN_CHAMBER_0_IDX) call it 'Exhaust',
   and it is a kit on the P2S rather than built in.

Both are now detected from device.airduct.parts, which lists only the fans that
physically exist, so each tile appears only when the hardware is present.

- bambu_mqtt: parse airduct part 10 -> left_aux_fan_speed (None when absent) and
  part 3 presence -> exhaust_fan_present; set_fan_speed() accepts index 10 plus a
  set_left_aux_fan() helper
- schema / status route / printer_manager broadcast / mqtt_relay expose both fields
- POST /printers/{id}/fan-speed accepts fan=aux2 -> M106 P10, the command Bambu's
  official P2S machine profiles use
- frontend: 'Left Auxiliary Fan' tile shown when reported; big_fan2 tile labelled
  'Exhaust' and presence-gated on P2S/X2D, unchanged 'Chamber Fan' elsewhere
- i18n: leftAuxiliary + exhaust for all 12 locales

Verified fan -> field map on a live P2S (fw 01.02.00.00), stable across cooling
and heating airduct modes:
  Part cooling -> cooling_fan_speed / airduct id 1  (built in)
  Aux          -> big_fan1_speed    / airduct id 2  (built in)
  Exhaust      -> big_fan2_speed    / airduct id 3  (kit)
  Left aux     -> airduct id 10 only (kit; forced off in heating by mode config)

Tests: airduct id-10 parsing (raw 160 -> id 10, not literal 160), id-3 presence,
base-P2S absence, diff-push survival, clamping, malformed entries, M106 P10
emission, invalid-index rejection; fan-speed API aux2->10 mapping; frontend tile
presence and labelling per model/kit.
2026-07-28 11:16:22 -05:00
MartinNYHC
b51b6ed863
Merge pull request #2695 from Alexandr0856/feature/ukrainian-localization
Add Ukrainian localization
2026-07-28 15:27:41 +02:00
maziggy
c3cee54c0c fix(i18n): bring the Ukrainian locale up to parity and drop two English strings
The Ukrainian locale was authored before #2622 landed and merged dev in
without picking up the five slice.designSettings* keys that came with it, so
check:i18n failed at 5675 leaves against en's 5680. This is invisible on the
PR because ci.yml only triggers for pull requests targeting main, and this one
targets dev - the only checks that ran were the security workflow's.

Adds the five missing keys, following the file's own convention of rendering
"designer" as "автора" as it already does in slice.useEmbeddedHint.

Two values were still English and the parity gate could not see them, because
check 4 only fires on leaves byte-identical to en: profiles.localProfiles.
pressureAdvance and inventory.paProfileTab both read "Pressure Advance (PA)"
where en has "Pressure Advance" and "PA Profile". The added "(PA)" was enough
to walk past the equality test. Both are now translated, the second as a tab
label matching the Russian. Inline mentions of the term in profiles.subtitle
and kProfilesDescription stay in English, which is what ru does too.

failureDetection.mlUrl is translated rather than allow-listed, and the dead
'{{weight}} г' entry is dropped from UK_COGNATES - that list matches against
the English value, so an entry spelled in Ukrainian could never fire, and
since the unit is now localized no entry is needed at all.

Four straight apostrophes normalized to the typographic form used by the
other 186 in the file. Locale count in the README and the two wiki pages
corrected from 11 to 13; it had already been wrong, omitting Russian.
2026-07-28 15:27:04 +02:00
MartinNYHC
30d26efb3d
Merge branch 'dev' into feature/ukrainian-localization 2026-07-28 15:13:34 +02:00
maziggy
3582f7731d Updated CHANGELOG 2026-07-28 15:09:13 +02:00
maziggy
dd171252dc fix(cloud): complete the CSRF handshake on Bambu Cloud TOTP sign-in (#2696)
Signing in to Bambu Cloud with an authenticator-app account failed every
time with "Invalid code", whatever the code was. Bambu Lab added double-
submit CSRF protection to the bambulab.com web origin - which is where,
and only where, this service posts the two-factor code. The endpoint
refused the request with 403 "CSRF error: missing_cookie" before it ever
evaluated the code, and Bambuddy reported that refusal as a bad code.

Verified against the live endpoint with a deliberately invalid key: a
bare POST returns missing_cookie; GET /api/csrf mints a bbl_csrf_token
cookie; a POST carrying only the cookie returns missing_header; a POST
carrying the cookie plus an x-bbl-csrf-token header reaches application
logic. Landing on the sign-in page first - the intuitive fix - does not
help, as that page sets only Cloudflare's __cf_bm. Of five header
spellings tried, only x-bbl-csrf-token is accepted, so the tests pin it.

verify_totp now performs that handshake against the same origin it will
post to (bambulab.cn for the China region - a token minted by the global
site is a cookie the .cn endpoint never issued), and declines to submit
the code at all when no token can be obtained rather than burning the
user's 30-second TOTP window on a request that is certain to be refused.
A CSRF refusal now also says the code was never checked instead of
masquerading as a wrong code, which is what sent the reporter chasing
clock drift and leading-zero parsing.

Only TOTP sign-ins were affected. Every other cloud call, the email-code
two-factor path included, goes to api.bambulab.com, which is not gated,
and existing stored tokens were unaffected throughout.

The region-routing test's MockTransport needed teaching about the
handshake: it returns one canned response for every request and set no
cookie, so the fix correctly refused to POST and the test lost the URL it
asserts on. It now mints a token for /api/csrf and additionally checks
the handshake stays on the .cn origin.
2026-07-28 15:08:00 +02:00
maziggy
4059d63373 Bumped version 2026-07-28 15:07:28 +02:00
maziggy
9d549050f7 fix(cloud): complete the CSRF handshake on Bambu Cloud TOTP sign-in (#2696)
Signing in to Bambu Cloud with an authenticator-app account failed every
time with "Invalid code", whatever the code was. Bambu Lab added double-
submit CSRF protection to the bambulab.com web origin - which is where,
and only where, this service posts the two-factor code. The endpoint
refused the request with 403 "CSRF error: missing_cookie" before it ever
evaluated the code, and Bambuddy reported that refusal as a bad code.

Verified against the live endpoint with a deliberately invalid key: a
bare POST returns missing_cookie; GET /api/csrf mints a bbl_csrf_token
cookie; a POST carrying only the cookie returns missing_header; a POST
carrying the cookie plus an x-bbl-csrf-token header reaches application
logic. Landing on the sign-in page first - the intuitive fix - does not
help, as that page sets only Cloudflare's __cf_bm. Of five header
spellings tried, only x-bbl-csrf-token is accepted, so the tests pin it.

verify_totp now performs that handshake against the same origin it will
post to (bambulab.cn for the China region - a token minted by the global
site is a cookie the .cn endpoint never issued), and declines to submit
the code at all when no token can be obtained rather than burning the
user's 30-second TOTP window on a request that is certain to be refused.
A CSRF refusal now also says the code was never checked instead of
masquerading as a wrong code, which is what sent the reporter chasing
clock drift and leading-zero parsing.

Only TOTP sign-ins were affected. Every other cloud call, the email-code
two-factor path included, goes to api.bambulab.com, which is not gated,
and existing stored tokens were unaffected throughout.

The region-routing test's MockTransport needed teaching about the
handshake: it returns one canned response for every request and set no
cookie, so the fix correctly refused to POST and the test lost the URL it
asserts on. It now mints a token for /api/csrf and additionally checks
the handshake stays on the .cn origin.
2026-07-28 15:05:28 +02:00
maziggy
d0d658f6a0 Updated CHANGELOG 2026-07-28 15:05:22 +02:00
maziggy
8551e32f14 feat(slicer): keep the designer's print settings when re-slicing for another printer (#2622)
Published models often deviate from the stock Bambu profile on purpose -
five walls, 100% infill, a 0.1mm first layer. Re-slicing one for a
different printer discarded all of it: the picked process preset
overrides the file's embedded settings, and that override is precisely
what makes cross-printer re-slicing work, so it cannot just be dropped.
"Slice as designed" (#2611) does not help - it is all-or-nothing and
only offered when the picked printer already matches the design's
target.

The deviation list does not have to be computed. Bambu Studio writes it
into the 3MF as different_settings_to_system, laid out as
[process, *filaments, printer] - verified against real files at 2, 3 and
4 filament slots. The parser refuses any file whose array length
contradicts its own filament count rather than guessing an index, since
reading the printer slot as the process slot would carry the designer's
machine_start_gcode onto a foreign printer.

The slice dialog now lists exactly which print settings the author
changed and what each was set to, with a checkbox per setting. Design
intent - wall count, infill, layer and first-layer height, supports,
seam, brim, ironing - is ticked by default. Printer-specific values -
speeds, accelerations, jerk, fans, temperatures, prime-tower geometry -
are listed with a badge but start unticked: tuned for the author's
machine, they can be merely wrong on the target or outside the range its
profile accepts, which fails the slice outright.

Only ticked keys are sent, and only keys the source actually flags as
changed are applied. Values are written into the outgoing process JSON,
the same mechanism the support carry-over has used since #1881: for a
Standard preset pick that JSON is an inherits stub, so the patch is the
child in the chain and wins over the flattened parent. Process slot
only - filament picks are honoured as chosen.

The wiki's "this is not a settings merge" note under Slice as designed
described the gap this closes; rewritten to point at the new panel.

Translated in all locales; wiki updated. Covered by backend and frontend
tests.
2026-07-28 14:49:50 +02:00
Olexandr
4cca2b5929 fix(i18n): localize Ukrainian weight unit 2026-07-28 12:42:28 +00:00
Olexandr
d5a02390e9 fix(i18n): refine Ukrainian sign-in wording 2026-07-28 12:41:01 +00:00
Olexandr
077d2ab941 Add Ukrainian localization 2026-07-28 12:41:01 +00:00
maziggy
4e46ba071f feat(printers): show remaining time, ETA and layers on the size-S card (#2674)
Size S exists for one job: watching a whole fleet on a single screen. It
rendered the printer name, a status pip and a progress bar - every other
block on the card is gated behind the expanded view - so it could not
answer the question that view is for, "which printer finishes first".
Dropping to S to fit more printers meant losing the information you
dropped down to compare.

The compact card now carries one line of metrics under the progress bar
while a print is running: remaining time, ETA in the configured
12/24-hour format, and layer progress. These are the values the Medium
card already shows, rendered with the same formatters and the same ETA
styling so the two sizes read alike. Each value is omitted individually
when the printer does not report it, and the row holds its height when
nothing is printing so cards do not shift as prints start and finish.
Card dimensions and grid density are otherwise unchanged.

Frontend only. Wiki updated. Covered by tests - which required teaching
the new test file to mock localStorage.getItem, since the harness
replaces localStorage with bare vi.fn() stubs and setItem is a no-op;
without that the page falls back to its size-M default and the compact
branch never renders.
2026-07-28 14:28:07 +02:00
maziggy
8fd1f884dc feat(mqtt): publish the plate-clear gate and add a notification for it (#2525)
When a print reaches a terminal state Bambuddy holds the queue until
someone confirms the build plate is clear. That gate was visible only in
the Web UI: the printer's own MQTT push reports nothing beyond RUNNING,
PAUSE, FAILED, FINISH and IDLE, so an external automation could not tell
"finished" from "finished and still waiting for a human".

The per-printer status topic now carries an awaiting_plate_clear field,
and every transition is additionally published on a new retained topic,
bambuddy/printers/{serial}/plate_clear. Retained, and published from the
flag itself rather than from printer telemetry: a subscriber learns the
state of every printer the moment it connects, and the state stays
correct after Auto Off powers a printer down - telemetry stops there,
which would otherwise leave the status topic frozen at false.

Publishing is edge-triggered. The queue clears the gate on every
dispatch whether or not it was up, and no subscriber should see a
"plate cleared" for a plate that was never dirty. Persistence and the
WebSocket broadcast stay unconditional; they are idempotent and predate
this.

A matching Plate Clear Required notification event was added, off by
default on every provider because it fires after every print at the
same moment as the print-complete alert. Only the rising edge notifies.
Acknowledging still goes through POST /printers/{id}/clear-plate.

Two tests in test_printer_manager_status_broadcast.py asserted
_schedule_async.call_count == 2 for the setter. The new emission makes
it three on a transition, so they now assert that the persist and
broadcast coroutines are actually scheduled - which is the contract

Translated in all locales; wiki updated. Covered by backend and
frontend tests.
2026-07-28 13:36:55 +02:00
maziggy
f4f76e0121 fix(inventory): allow editing and duplicating stock spools without a slicer preset (#1905)
A spool created by Quick Add, a CSV import or an RFID scan has no slicer
preset, brand or subtype. Reopening it in Edit Spool demanded all three
before anything could be saved, so changing its storage location, cost
or notes was impossible - and Copy Spool had the same gate with no Quick
Add toggle to waive it. The preset you were then forced to pick auto-
filled material, brand and subtype from the preset name, silently
rewriting a hand-entered manufacturer (Elegoo -> Generic) so the spool
no longer appeared where it had been filed.

Editing and copying now require only what the backend requires: the
material. Preset, brand and subtype stay fully visible and editable -
nothing is hidden the way Quick Add hides it - and the required-field
markers no longer advertise a rule that isn't enforced. Selecting a
preset fills only fields that are still empty or that a previously
selected preset had filled, so values the user (or the saved spool)
provided survive; switching between presets still replaces what the
earlier one contributed.

The brand and material dropdowns also no longer filter themselves down
to the brand/material pairs known to the color catalog and slicer
presets. Elegoo is catalogued only for PLA, which made a real product
like Elegoo ASA look impossible to enter. Both lists now always offer
everything known, with paired entries ranked first under Suggested and
the rest under All, and a spool's own custom brand or material is always
present in its own dropdown. The SpoolBuddy write-tag form shares these
fields and gets the same treatment.

Lastly the Quick Add layout no longer leaks out of create mode: quick-
adding a spool and then opening Edit left the edit form in the reduced
layout with no toggle to leave it, because the toggle is create-only.

Frontend only. Translated in all locales; wiki updated. Covered by
validation and form-interaction tests.
2026-07-28 13:09:16 +02:00
maziggy
af7874546a feat(projects): per-file print progress and complete-sets tracking (#1897)
Projects made of many distinct files that each need N prints (e.g. 13
plates x 10 sets = 130 prints) only had aggregate progress. Finding out
"how many times have I printed plate_7?" meant reading the Activity
Timeline line by line, unusable at 130 events.

Projects now take an optional Copies per File target. Every printable
file in the project's linked folders shows an X / N badge with a mini
progress bar (gray not started, amber in progress, green done), and the
progress card gains a Complete Sets bar - the minimum per-file count,
i.e. how many finished assemblies can be shipped right now. Without the
target, printable files show a plain printed-count badge.

Counting matches the aggregate project stats: completed runs only,
served by a new /projects/{id}/file-progress endpoint. Runs attribute
to a file via a new library_file_id stamp on queue-dispatched archives,
falling back to content hash and then filename for historical rows.

Also fixed: files queued from a project-linked File Manager folder now
inherit that project, so their prints count toward project statistics -
previously only prints started from the project page were attributed.

Test-harness fix along the way: the test suite's get_db override never
committed, unlike production get_db, so endpoints relying on the
request-scoped commit silently lost their writes in tests. The override
now mirrors production commit/rollback semantics.
2026-07-28 12:46:31 +02:00
maziggy
1fb6978ee1 feat(library): let users delete empty folders (#1781)
Library folders have no ownership tracking, so folder deletion was
gated entirely behind library:delete_all - a user with
library:delete_own could create folders and delete their own files,
but the emptied folder sat there until an admin removed it.

Users with library:delete_own can now delete folders that are truly
empty: no subfolders and no files, including trashed ones - folder
deletion cascades, so removing a folder that holds another user's
trashed file would silently break trash restore. External folders
(operator-configured mounts) and folders linked to a project or
archive still require library:delete_all even when empty, since
deleting them affects more than the folder itself. The bulk-delete
endpoint applies the same rule instead of skipping all folders for
non-admin users.

The folder tree's Delete entry enables accordingly and shows a
"You can only delete empty folders" hint on non-empty folders. The
backend stays authoritative - a folder that only contains trashed
files is invisible in the tree but still refuses deletion.
2026-07-28 12:05:00 +02:00
maziggy
eae5359fbc feat(printers): show AI failure detection state on printer cards (#1546)
The live Obico classification was only visible under Settings ->
Failure Detection, so tracking how detection matched an ongoing print
meant flipping between the Printers screen and Settings.

Each printer card's badge row now shows an AI badge whenever detection
is enabled for that printer, like the other health badges: gray Idle
while no print is being watched, then green Safe, amber Warning, or
red Failure while a print is actively monitored. The tooltip carries
the current smoothed score; clicking jumps to the full detection
status and history in Settings. Printers excluded from the monitored
subset show no badge.

Served by a new lightweight /obico/printer-status endpoint readable
with printer permissions alone - it exposes only the enabled flag, the
monitored-printer set, and per-printer classification, keeping ML URL
and other configuration behind the existing settings-gated endpoint.
2026-07-28 11:37:36 +02:00
maziggy
62ba751278 feat(notifications): Bark notification provider (#1495)
Bark is the open-source, account-free iOS push app (self-hostable
via bark-server). Configure with just the device key from the app;
the server URL defaults to the official api.day.app relay and
accepts a self-hosted instance. Optional settings: notification
Group, Sound, and iOS Interruption Level - Time Sensitive breaks
through scheduled summaries, Critical bypasses Silent mode and
Focus, Passive delivers silently.

bark-server can wrap failures in an HTTP 200 body ({"code": 400}),
so the sender checks the body code as well as the HTTP status.
Unknown interruption levels are dropped rather than forwarded.
2026-07-28 10:58:08 +02:00
maziggy
49f9d7120d feat(notifications): custom data fields for Home Assistant notify services (#1441)
When an HA notification provider targets a notify service (e.g.
notify.mobile_app_myphone), a new optional Data (JSON) field is
forwarded as the service call's nested "data" object - the same
place HA automations put mobile push options like priority, ttl,
channel, and group. ttl: 0 + priority: high make Android pushes
arrive immediately; channel gives printer alerts their own sound.

JSON rather than key=value lines so numbers stay numbers and nested
options work. Validated on both ends: the UI rejects malformed JSON
before saving, and the sender fails loudly instead of posting a
half-built payload. Only included when configured - the default
persistent_notification.create path is unchanged, as its schema
rejects unknown keys.
2026-07-28 10:44:40 +02:00
maziggy
d68724c689 feat(stats): energy usage in cost records and trends (#1432)
The Most Expensive record on the Statistics page ranked prints by
filament cost alone, ignoring the per-print energy cost Bambuddy
already measures via an attached smart plug. It now ranks by
filament + measured energy cost; prints without smart-plug data
compete on filament cost alone, as before.

Filament Trends gains an Energy Over Time chart: kWh per day (per
hour for ranges of a week or less, per week for long ranges), with
the range's total kWh and energy cost in the header. The chart only
renders when the selected range contains measured energy data, so
setups without smart plugs see no change.

The /archives/slim stats feed now carries each run's energy_kwh /
energy_cost from print_log_entries. Translated in all locales.
Covered by backend and frontend tests.
2026-07-28 10:27:45 +02:00
MartinNYHC
775e9d7649
Merge pull request #2688 from maziggy/1.2.5.1
v1.2.5.1
2026-07-27 15:20:00 +02:00
MartinNYHC
ad19f8a9e5
Merge branch 'main' into 1.2.5.1 2026-07-27 15:19:42 +02:00
maziggy
f957fcc717 Bumped version 2026-07-27 15:03:02 +02:00
maziggy
f7e35a6d99 Updated README 2026-07-27 14:59:03 +02:00
maziggy
ef2865821c Updated README 2026-07-27 14:58:42 +02:00
maziggy
9bb5a1b999 fix(inventory): PA-Profil picker fetches K-profiles across all installed nozzles (#2618)
The Edit Spool "PA-Profil" tab and the SpoolBuddy write-tag page fetched a
printer's calibrations with getKProfiles(printer.id), which defaults the nozzle
filter to 0.4. The printer/MQTT layer filters strictly by that diameter, so on
a multi-nozzle printer a same-filament 0.6mm K-profile was never retrieved and
the picker showed only the 0.4mm entry ("1 match"). (The AMS-Slot config dialog
was already fixed in #1899; these two pickers were not.)

Add installedNozzleDiameters(status) and a shared fetchPrinterCalibrations()
that queries every reported nozzle diameter and merges the results, falling
back to 0.4 when the printer hasn't reported nozzle hardware. Each profile row
now shows a nozzle-diameter badge so identically-named profiles are distinct.
2026-07-27 13:08:57 +02:00
maziggy
da33ba64d1 fix(backup): parse subpath-hosted Gitea/Forgejo repository URLs (#2642)
Gitea/Forgejo served under a ROOT_URL path prefix (e.g. https://host/gitea)
place repos at /<prefix>/owner/repo. The Gitea backend assumed a host-root
layout: parse_repo_url required exactly two path segments (so subpath URLs
raised "Cannot parse repository URL") and get_api_base dropped the prefix,
yielding https://host/api/v1 instead of https://host/gitea/api/v1.

Treat the final two path segments as owner/repo and keep leading segments as
a base-path prefix; derive the API base as {scheme}://{host}{prefix}/api/v1.
Root-hosted instances are unaffected. Forgejo inherits the fix; GitHub/GitLab
are untouched.
2026-07-27 12:53:43 +02:00
maziggy
90379f33f5 Updated BACKERS 2026-07-27 12:35:07 +02:00
maziggy
98b2900812 Updated BACKERS 2026-07-27 12:34:29 +02:00
maziggy
2b9043e2fd Housekeeping 2026-07-27 12:31:13 +02:00
maziggy
60bf1bbab2 chore(deps): patch postcss + brace-expansion; pin react-router 7.18.1 with a documented audit exception
- postcss 8.5.15 -> 8.5.23 (GHSA-r28c-9q8g-f849, source-map path traversal)
- brace-expansion override ^5.0.7 -> ^5.0.8 (GHSA-mh99-v99m-4gvg, DoS)

react-router: pin react-router-dom to exact 7.18.1 (direct dep) and react-router
to 7.18.1 via overrides (transitive). 7.18.1 is the most-patched 7.x -- it clears
14 advisories that older 7.x releases carry, several reachable from a SPA (open-
redirect XSS in Link/useNavigate, route-matching DoS). The one remaining advisory,
GHSA-qwww-vcr4-c8h2, is RSC-mode-only; Bambuddy is a Vite SPA using BrowserRouter
with no RSC runtime (@react-router/server not installed), so the path is
unreachable. The only version that fully clears npm audit is the 8.3.0 major
(no react-router-dom 8.x exists; it needs migrating 50 import sites plus a React
peer bump), deferred as its own change.

Because a version pin can't stop npm from reporting the theoretical 7.11.0
downgrade as fixAvailable, the ci.yml (hard) and security.yml (nightly issue)
audit gates gain a narrow, documented allowlist keyed on the GHSA id. It resolves
the react-router-dom -> react-router advisory chain and stays fail-closed: a
different advisory on react-router still fails the gate, and an isSemVerMajor
guard drops the exemption the moment a non-major fix ships, forcing us to take it.
2026-07-27 12:29:24 +02:00
maziggy
20abfb3b4d Updated CHANGELOG 2026-07-27 12:28:49 +02:00
maziggy
d90b91604f chore(deps): patch postcss + brace-expansion; pin react-router 7.18.1 with a documented audit exception
- postcss 8.5.15 -> 8.5.23 (GHSA-r28c-9q8g-f849, source-map path traversal)
- brace-expansion override ^5.0.7 -> ^5.0.8 (GHSA-mh99-v99m-4gvg, DoS)

react-router: pin react-router-dom to exact 7.18.1 (direct dep) and react-router
to 7.18.1 via overrides (transitive). 7.18.1 is the most-patched 7.x -- it clears
14 advisories that older 7.x releases carry, several reachable from a SPA (open-
redirect XSS in Link/useNavigate, route-matching DoS). The one remaining advisory,
GHSA-qwww-vcr4-c8h2, is RSC-mode-only; Bambuddy is a Vite SPA using BrowserRouter
with no RSC runtime (@react-router/server not installed), so the path is
unreachable. The only version that fully clears npm audit is the 8.3.0 major
(no react-router-dom 8.x exists; it needs migrating 50 import sites plus a React
peer bump), deferred as its own change.

Because a version pin can't stop npm from reporting the theoretical 7.11.0
downgrade as fixAvailable, the ci.yml (hard) and security.yml (nightly issue)
audit gates gain a narrow, documented allowlist keyed on the GHSA id. It resolves
the react-router-dom -> react-router advisory chain and stays fail-closed: a
different advisory on react-router still fails the gate, and an isSemVerMajor
guard drops the exemption the moment a non-major fix ships, forcing us to take it.
2026-07-27 12:27:36 +02:00
maziggy
0bc98beac5 fix(queue): paginate History with Show more instead of a hard 50-cap (#2682)
The Print Queue History tab reported the full count in its header (e.g.
"History (311 items)") but the row builder hard-sliced the list to
items.slice(0, 50) with no control to load the rest, so everything past the
50th finished print was unreachable. The whole history is already loaded
client-side (the queue endpoint has no limit) and sorted -- it just wasn't
drawn.

History now renders progressively: the first page (50) plus a "Show more"
button and a "Showing X of Y" count that loads the next page until the full
list is on screen. The visible count resets to one page only on a deliberate
re-sort or location-filter change -- not on the periodic queue poll, which
produces a fresh array each tick and would otherwise collapse an expanded
view mid-scroll.

Frontend-only; batch grouping and per-row actions unchanged. Two new i18n
keys across all 12 locales. Covered by a test asserting the 50-row cap, the
count label, and that Show more reveals the remainder. Wiki updated.
2026-07-27 12:04:57 +02:00
maziggy
561e94b755 fix(logs): redact LDAP Distinguished Names from support bundle (#2681)
With LDAP auth in use, the debug log carried the full user DN on successful
auth -- e.g. "(DN: CN=Joe Schmoe,CN=Users,DC=ad,DC=example,DC=com, ...)". A DN's
leaf CN is the user's real name, PII on par with the email address already
redacted, and it passed straight into an uploaded support bundle. The log
sanitizer (shared by the support bundle and the in-app bug report) had no DN
pattern; DNs also leak via ldap3 exception strings and group-mapping logs.

- sanitize_log_content: redact LDAP DNs to [DN] -- a run of >=2 attr=value RDN
  components (CN/OU/DC/UID/...). The value class excludes <>;+ (RFC 4514 requires
  them escaped in a value) so the final comma-unbounded component doesn't swallow
  trailing log text such as "-> GroupName". Ordinary key=value lines are untouched.
- ldap_service: stop logging the raw DN on successful auth (username + group
  count suffices), keeping the PII off disk even before bundle sanitization.
2026-07-27 11:53:59 +02:00
maziggy
7c83316797 fix(camera): reap leaked ffmpeg for external USB/RTSP streams (#2675)
Closing an external USB (V4L2) camera view abruptly could leave its ffmpeg
running and holding /dev/videoN open -- LED stuck on, and reopening the view
failed or took 10-30s fighting for exclusive device access. Same class of leak
as #776 (built-in RTSP path), but the external path was never wired into that
fix: external streams registered into none of the _active_streams /
_disconnect_events / spawned-PID registries, so /camera/stop returned
{"stopped": 0} for a live USB stream and the orphan janitor's /proc net matched
only rtsp(s)://bblp: cmdlines. Cleanup ran only via the stream generator's own
finally, which an abrupt disconnect can skip.

- Thread an on_process callback + stop_event through generate_mjpeg_stream into
  _stream_usb / _stream_rtsp; register the process before the startup probe so a
  process that hangs on a locked device (not just one that exits) is reapable.
- Register external streams into the shared registries under a unique
  {printer_id}-ext-{token} id so /camera/stop and cleanup_orphaned_streams find
  and kill them; stop_event prevents the reconnect loops from respawning.
- Extend the /proc safety-net scan to also match USB (-f v4l2) ffmpeg, excluding
  still-active streams and unrelated ffmpeg.
2026-07-27 11:35:27 +02:00
maziggy
8646c40957 fix(slicer): reject invalid sidecar output instead of storing a corrupt slice (#2671)
The slice client only checked the sidecar's HTTP status, not its body. When the
sidecar -- or a reverse proxy in front of it -- returned 200 OK with a body that
wasn't a real 3MF (a stock/misconfigured sidecar, a proxy error page, a truncated
response, or an OrcaSlicer/Bambu Studio CLI crash emitting no output), Bambuddy
wrote that tiny blob to a .gcode.3mf, stored it as a valid sliced file (the
3MF-parse failure was swallowed as "no thumbnail"), and let it be queued and FTP'd
to the printer -- producing the ~28-byte files that "did nothing" and then failed
at print time. Separately, a genuine 413 comes from the proxy in front of the
sidecar rejecting the multi-MB upload (model + profiles), so raising the body
limit on the wrong proxy layer had no effect.

- Factor the duplicated status handling in slice_with_profiles /
  slice_without_profiles into one _handle_slice_response.
- When a 3MF export was requested, validate the body is a real ZIP; otherwise
  raise SlicerApiServerError with an actionable message instead of persisting
  a corrupt file.
- Special-case 413 with a message naming client_max_body_size on the proxy
  directly in front of the sidecar (Cloudflare cap noted).
2026-07-27 11:18:54 +02:00
maziggy
1bdd7d224a fix(library): sort File Manager by real filesystem mtime, recursively (#2680)
The folder tree's "sort by recent activity" and the file pane's date sort
put external (mapped/NAS) files in a near-random order instead of ls -t's
newest-first. Nothing captured the files' on-disk mtime: the sort keyed off
the DB updated_at/created_at, which for a bulk external scan is the same
scan instant for every row, so a whole block tied and sorted arbitrarily;
only rows Bambuddy had later touched individually looked "partially right."
The tree also bubbled up only immediate child-file activity, so a file added
deep in a subtree never lifted its parent folders.

- Add nullable fs_modified_at to LibraryFile and LibraryFolder (dialect-
  branched migration, mirroring the #2615 dispatching_at pattern).
- External scan records each file's and directory's real os.stat().st_mtime
  and refreshes it on every re-scan, so a file edited over the mount
  re-sorts and existing installs backfill on the next scan.
- list_folders computes each folder's activity as a recursive newest-
  descendant roll-up (post-order), so a fresh deep file lifts every ancestor.
- Folder tree sort and the file pane's date sort now use the real mtime,
  falling back to created_at for managed uploads with none.
- New toolbar toggle shows/hides each item's last-modified date in the right
  pane (grid + list), with strings in all locales.

Store the mtime as naive UTC to match the other timestamp columns so activity
comparisons never mix naive and aware values on either dialect. Covered by
integration tests (mtime capture, re-scan refresh, deep-file recursive bubble,
folder mtime) and a frontend test proving fs_modified_at is preferred over
created_at.
2026-07-27 11:01:43 +02:00
maziggy
13183e365a fix(ams): clear empty AMS-HT slot via its real tray_exist_bits position (#2670)
After #2594 every empty-slot clearing path skipped AMS-HT units, so a removed
HT spool never cleared on the printer card while Bambu Studio correctly showed
Empty. Root cause: the HT presence bit is packed as a single consecutive bit in
tray_exist_bits at 16 + (ams_id - 128) (HT-A=16, HT-B=17, ...), not the regular
ams_id*4 position -- so the bitmask cleanup, which skipped id>=128, never
touched it. The HT's state field is firmware-variant (loaded reports 11 on H2D,
9 on the #2594 firmware) and it keeps echoing stale tray_type after removal, so
the bitmask is the only reliable, firmware-independent signal. Confirmed against
a live H2D capture (loaded=0x10f7f, empty=0xf7f) and the OrcaSlicer
DevFilaSystem.cpp reference (is_exists = tray_exist_bits >> (16 + (ams_id-128))).

- apply_tray_exist_bits: handle AMS-HT (128-135) at 16+(ams_id-128) instead of
  skipping it; clears an empty HT and, because a loaded HT keeps its bit set,
  never wrongly clears one (keeps the #2594 fix intact). Ids outside the known
  regular (0-15) and HT (128-135) ranges are left untouched rather than guessed.
- Build the AMS change-hash from the merged state, not the raw payload, so a
  removal signalled only by the bitmask (firmware still echoing tray_type) still
  flips the hash and fires on_ams_change to unbind the spool_assignment row.
- Emit the exists presence bit in the websocket status serializer (REST already
  did) so the card renders "Empty" instead of "?" where state is ambiguous.
2026-07-27 10:34:56 +02:00
maziggy
aa443c6e83 fix(print): keep filament gram usage visible when the name is long (#2669)
In the Print dialog's Filament Mapping, each required filament shows its
name and the grams the job needs, e.g. "Bambu PLA Basic (281.2g)". Name and
grams shared one fixed-width column with truncate on the whole string, so a
long name pushed the "(...g)" off the end and clipped it -- partially on a
wide screen, entirely in mobile portrait. The gram usage is the number that
matters (does the spool have enough left?), so it shouldn't be the part that
gets dropped.

Pin the gram usage (shrink-0, whitespace-nowrap) and let only the name
truncate, with the full name on hover. Applied to both the Specific-Printer
(FilamentMapping) and Any-model (PrinterSelector) panels. Layout only.
2026-07-27 09:22:14 +02:00
maziggy
1234f0830a fix(mqtt): don't let K-profile responses clobber the nozzle size (#2663)
Fetching K-profiles probes every nozzle size in turn (extrusion_cali_get
for 0.2/0.4/0.6/0.8mm), and each response echoes the requested diameter at
the top level. _process_message passed every "print" message -- including
these responses -- to _update_state, which treats a top-level
nozzle_diameter as the installed hardware. So the last size probed (0.8)
overwrote the real nozzle in memory; a genuine status push corrected it and
the next K-profile fetch broke it again, which is why it flickered between
0.8, empty and the correct 0.4. Since 1.2.5 the #1899 mismatch guard then
refused to dispatch, failing prints with a bogus "printer has 0.8mm".

Handle extrusion_cali_get responses only via the K-profile parser and skip
_update_state for them, mirroring the existing get_accessories guard. The
nozzle size now comes solely from the real status push. In-memory only --
affected printers self-correct on the next push after updating.
2026-07-27 09:15:34 +02:00
maziggy
db6d306b35 fix(queue): mobile tap-to-reorder with up/down arrows (#2667)
The print queue couldn't be reordered on a phone. In portrait the drag
grip and selection checkbox are hidden below the sm breakpoint, so there
was no reorder affordance at all; in landscape the grip shows but it
carried touch-action: manipulation while the only dnd-kit sensor is a
PointerSensor with an 8px distance, so touch gestures scrolled instead of
starting a drag. Reordering was effectively mouse-only.

Add tap-friendly up/down arrow buttons to pending rows on mobile (shown
below sm, where the drag handle is hidden). They move a row one step among
its siblings -- standalone items, whole batches, and items within a batch,
in both the flat and per-printer layouts -- and persist through the same
POST /queue/reorder path as drag. Arrows appear only in the manual
"position" sort (shortest-job-first off) where position has meaning, are
gated on queue:reorder, and the first row's up / last row's down render
disabled. Also switch the desktop drag handle's touch-action to none so
mouse-style drag works on touch (landscape phones, tablets). Reuses the
existing queue.moveUp / queue.moveDown translations.
2026-07-27 09:02:36 +02:00
maziggy
6530a6af06 fix(library): send camera stream token for 3D Preview plate thumbnails (#2661)
The File Manager 3D Preview dialog (ModelViewerModal) rendered plate
thumbnails with the raw thumbnail_url. The plate-thumbnail endpoints are
gated behind a camera stream token passed as ?token= (an <img> can't send
an Authorization header), so with auth enabled the browser fetched without
a token and got 401 "Valid camera stream token required" — broken image
icons for every plate. The Slice dialog's picker (PlatePickerModal) and the
Print modal's PlateSelector already append the token via withStreamToken(),
which is why the same file's thumbnails showed there.

Wrap the thumbnail src in withStreamToken(), matching the other two call
sites. The token is synced app-wide and withStreamToken() is a no-op when
auth is off, so non-auth setups are unchanged.
2026-07-27 08:27:05 +02:00
maziggy
800c45536e fix(scheduler): pin the force-color variant when selecting the AMS slot (#2650)
Some checks failed
Security Audit / Python Security Analysis (Bandit) (push) Failing after 5s
Security Audit / Container Security Scan (Trivy) (push) Failing after 5s
Security Audit / Backend Security Audit (push) Failing after 5s
Security Audit / Frontend Security Audit (push) Failing after 6s
Follow-up to 0f203ce: force color match now picks the right AMS slot, not
just the right printer. The slot mapper cleared tray_info_idx when applying
an override, so on a printer holding two same-colour PLA spools of different
variants (Basic GFA00 / Matte GFA01 / Silk GFA06) it could map to the wrong
one. It now keeps the variant for force_color_match overrides (both the 3MF
and no-3MF fallback paths) so the matcher pins the matching tray, and falls
back to type+colour when that variant isn't loaded. A manual filament swap
(a preference override) still clears the idx so it matches the swapped-in
spool rather than the old one.

The printer-card queue-compatibility hint applies the same variant rule.
2026-07-24 13:13:04 +02:00
maziggy
0f203ce7ca fix(scheduler): force color match now distinguishes PLA variants (#2650)
Force color match dispatched onto the wrong PLA sub-variant: a job sliced
for White PLA Matte was treated as an exact match by printers loaded with
White PLA Basic or Silk+, because Bambu reports every variant as
tray_type "PLA" and the distinction lives only in tray_info_idx
(GFA00=Basic, GFA01=Matte, GFA06=Silk).

Two places dropped the field: the VP queue built each force override
without the parsed tray_info_idx, and _get_missing_force_color_slots
compared loaded trays on (type, colour) only.

Carry tray_info_idx into the override and require it to match when both
the override and a candidate tray have one; a blank idx on either side
(custom/third-party spools, older 3MFs) falls back to the historical
type+colour behaviour, so those setups are unaffected.
2026-07-24 12:54:22 +02:00
maziggy
30e4577838 Bumped version 2026-07-24 12:32:36 +02:00
maziggy
559571a35f Bumped version 2026-07-24 12:08:46 +02:00
261 changed files with 38368 additions and 1957 deletions

View file

@ -66,3 +66,64 @@ LOG_TO_FILE=true
# LDAP is governed by its own ldap_enabled toggle and is not affected.
# Leave unset for normal operation.
# BAMBUDDY_LOCAL_LOGIN=true
# --- OIDC provider from the environment (#2593) ------------------------------
# Defines ONE OIDC provider declaratively, for deployments that are managed by
# compose files or GitOps and never touch the settings UI. Providers created in
# the UI are unaffected and keep working alongside this one.
#
# Activates only when all four required vars below are set; an empty value
# counts as unset. The provider is written on startup and re-applied on every
# boot, so the UI shows it as read-only and the API refuses to change it -- an
# edit there would be reverted at the next restart anyway.
#
# Removing the vars DISABLES the provider rather than deleting it: accounts
# linked to it would otherwise lose their link permanently. Re-adding the vars
# enables it again with those links intact.
#
# If you lock yourself out, BAMBUDDY_LOCAL_LOGIN=true above is the way back in.
#
# Required:
# BAMBUDDY_OIDC_NAME=Keycloak
# BAMBUDDY_OIDC_ISSUER_URL=https://sso.example.com/realms/main
# BAMBUDDY_OIDC_CLIENT_ID=bambuddy
# BAMBUDDY_OIDC_CLIENT_SECRET=your-client-secret
#
# Optional, shown with their defaults:
# BAMBUDDY_OIDC_SCOPES=openid email profile
# BAMBUDDY_OIDC_ENABLED=true
# BAMBUDDY_OIDC_AUTO_CREATE_USERS=false
# BAMBUDDY_OIDC_AUTO_LINK_EXISTING=false
# BAMBUDDY_OIDC_EMAIL_CLAIM=email
# BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED=true
# BAMBUDDY_OIDC_ICON_URL=
# BAMBUDDY_OIDC_AUTOLOGIN=false
# BAMBUDDY_OIDC_DEFAULT_GROUP=
#
# Booleans accept true/1/yes or false/0/no (case-insensitive). Blank or unset
# uses the default; any other value is rejected and the provider is skipped.
#
# DEFAULT_GROUP is the group new users land in when AUTO_CREATE_USERS is on;
# without it they get Viewers. It matches a group NAME exactly (case-sensitive)
# -- group ids are assigned per install, so the same compose file would point at
# a different group on every deployment. A name that matches no group is
# refused: the provider is left as it was and the reason is logged, rather than
# quietly creating under-privileged users the locked UI could not correct. On a
# FIRST boot that means no provider is created at all and no SSO button appears
# -- create the group first. Removing the variable clears the group again.
#
# AUTO_LINK_EXISTING binds an OIDC identity to an existing local account with
# the same email address. With EMAIL_CLAIM=email it is refused unless
# REQUIRE_EMAIL_VERIFIED=true, because an identity provider that does not
# verify addresses would let anyone claim someone else's account. The whole
# config is then skipped and logged; the app still starts.
#
# ISSUER_URL must be https:// and publicly reachable -- private, loopback,
# link-local, numeric-encoded and IPv4-mapped hosts are rejected. An in-cluster
# URL like http://keycloak:8080 is refused with a single log line and no SSO
# button; use the externally-reachable HTTPS issuer URL instead.
#
# NAME is matched against the existing providers on every boot: setting it to
# the name of one you already created in the UI ADOPTS and OVERWRITES it (its
# issuer, client id and secret are replaced and it becomes read-only). Pick a
# name that doesn't collide unless that takeover is intended.

View file

@ -201,15 +201,47 @@ jobs:
if path and not info.get('dev') and not info.get('devOptional'):
prod.add(path.split('node_modules/')[-1])
vulns = data.get('vulnerabilities', {})
# Documented advisory exceptions: high/critical findings whose only offered
# 'fix' is a semver-major change and which do not apply to how Bambuddy ships.
# Keyed by GHSA id. An entry only holds while the fix stays major-only (see
# fix_is_major below) - once upstream backports, the gate fails until we take
# the patch. That is what retired the one entry this list used to carry:
# GHSA-qwww-vcr4-c8h2 (React Router RSC-mode CSRF) shipped in 7.18.2, so the
# pin moved rather than the exception staying.
ALLOWLIST = set()
def advisory_ids(name, seen=None):
seen = seen if seen is not None else set()
if name in seen:
return set()
seen.add(name)
ids = set()
for item in vulns.get(name, {}).get('via', []):
if isinstance(item, dict):
url = item.get('url', '')
if '/advisories/' in url:
ids.add(url.rsplit('/', 1)[-1])
elif isinstance(item, str):
ids |= advisory_ids(item, seen)
return ids
def fix_is_major(v):
fa = v.get('fixAvailable')
return isinstance(fa, dict) and fa.get('isSemVerMajor')
def exempt(name, v):
ids = advisory_ids(name)
return bool(ids) and ids <= ALLOWLIST and fix_is_major(v)
fixable = {n: v for n, v in vulns.items()
if n in prod and v.get('severity') in ('high', 'critical') and v.get('fixAvailable')}
if n in prod and v.get('severity') in ('high', 'critical')
and v.get('fixAvailable') and not exempt(n, v)}
skipped = len(vulns) - len({n: v for n, v in vulns.items() if n in prod})
if fixable:
for name, v in fixable.items():
print(f'FIXABLE {v[\"severity\"].upper()}: {name}')
sys.exit(1)
total = sum(1 for n, v in vulns.items() if n in prod and v.get('severity') in ('high', 'critical'))
exempted = sorted(n for n, v in vulns.items() if n in prod and exempt(n, v))
print(f'npm audit: {total} high/critical (0 fixable), {len(vulns)} total ({skipped} npm-internal filtered)')
if exempted:
print('exempted (documented, unreachable): ' + ', '.join(exempted))
"
frontend-typecheck:

View file

@ -308,13 +308,46 @@ jobs:
}
}
const vulns = results.vulnerabilities || {};
// Documented advisory exceptions (keyed by GHSA id) - see ci.yml for the
// full rationale and the matching hard gate. GHSA-qwww-vcr4-c8h2: React
// Router RSC-mode CSRF, not reachable from Bambuddy's BrowserRouter SPA
// (@react-router/server not installed); react-router/-dom pinned to 7.18.1
// (the most-patched 7.x), no non-major fix exists. Auto-surfaces again if a
// non-major fix ships.
const ALLOWLIST = new Set(['GHSA-qwww-vcr4-c8h2']);
function advisoryIds(name, seen) {
seen = seen || new Set();
if (seen.has(name)) return new Set();
seen.add(name);
const ids = new Set();
for (const item of (vulns[name] || {}).via || []) {
if (item && typeof item === 'object') {
const url = item.url || '';
if (url.includes('/advisories/')) ids.add(url.split('/').pop());
} else if (typeof item === 'string') {
for (const id of advisoryIds(item, seen)) ids.add(id);
}
}
return ids;
}
function fixIsMajor(info) {
const fa = info.fixAvailable;
return fa && typeof fa === 'object' && fa.isSemVerMajor;
}
function exempt(name, info) {
const ids = advisoryIds(name);
return ids.size > 0 && [...ids].every(id => ALLOWLIST.has(id)) && fixIsMajor(info);
}
const filtered = {};
const flagged = {};
for (const [name, info] of Object.entries(vulns)) {
if (prodDeps.has(name)) filtered[name] = info;
if (!prodDeps.has(name)) continue;
filtered[name] = info;
if (!exempt(name, info)) flagged[name] = info;
}
results.vulnerabilities = filtered;
fs.writeFileSync('npm-audit-results.json', JSON.stringify(results, null, 2));
const count = Object.keys(filtered).length;
const count = Object.keys(flagged).length;
console.log(count > 0
? count + ' production vulnerabilities found'
: 'No production vulnerabilities (filtered ' + Object.keys(vulns).length + ' npm-internal entries)');

View file

@ -6,29 +6,47 @@ name: Windows Installer
# - Tag push matching v* (release builds, uploaded as a release asset)
# - Manual dispatch (for testing the build pipeline)
#
# The installer is unsigned until SignPath OSS approval lands. Once it
# does, add the SignPath GitHub Action between the ISCC step and the
# upload step.
# Release tags are Authenticode-signed through the SignPath Foundation OSS
# program. Daily prereleases are deliberately left unsigned so they don't burn
# the OSS signing quota; use the `sign` dispatch input to exercise the signing
# path by hand.
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
sign:
description: 'Submit the installer to SignPath for signing'
type: boolean
default: false
# Least-privilege per CodeQL actions/missing-workflow-permissions.
# contents: write is required by softprops/action-gh-release to attach
# the .exe to a tag release; the manual-dispatch path doesn't trigger
# that step and could run with read-only, but a single workflow-level
# block keeps the surface auditable in one place.
# actions: read lets the SignPath connector download the uploaded artifact
# through the API. Declaring a permissions block at all drops every scope we
# don't name to `none`, so the signing step fails to fetch the artifact
# without it.
permissions:
contents: write
actions: read
jobs:
build:
runs-on: windows-latest
timeout-minutes: 30
env:
# Sign real release tags but not `-daily.` prereleases, and let a manual
# run opt in. GitHub's `||` returns the *last* operand when everything is
# falsy (an empty string here, not `false`), so every use site compares
# against the string 'true' rather than treating this as a boolean.
SIGN: ${{ (startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-daily.')) || inputs.sign }}
steps:
- name: Checkout
uses: actions/checkout@v4
@ -57,6 +75,73 @@ jobs:
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" bambuddy.iss
shell: pwsh
# SignPath signs a *GitHub artifact*, not a workspace path: the connector
# pulls the artifact back out through the API, which is why this upload
# has to happen before signing and why upload-artifact must be v4 or newer
# (older versions expose no `artifact-id` output). Kept as a separate,
# clearly-named artifact so an unsigned build is never mistaken for a
# signed one when downloading from the run page.
- name: Upload unsigned installer
id: upload_unsigned
uses: actions/upload-artifact@v7
with:
name: bambuddy-windows-installer-unsigned
path: installers/windows/build/output/*.exe
if-no-files-found: error
# The artifact arrives at SignPath as a .zip (that is simply what
# upload-artifact produces), so the artifact configuration on the SignPath
# side describes a <zip-file> wrapping the <pe-file>. With skip-decompress
# left at its default the signed archive is extracted again here, so
# `signed/` ends up holding the bare .exe.
- name: Sign installer (SignPath)
if: env.SIGN == 'true'
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
# Not a credential -- the organization ID appears in ordinary SignPath
# URLs and is useless without the API token above.
organization-id: '4d7e5b59-d0fb-4a6b-b385-b861e18c6386'
project-slug: 'bambuddy'
signing-policy-slug: 'test-signing'
github-artifact-id: ${{ steps.upload_unsigned.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: installers/windows/build/signed
# Replace the unsigned binary in-place so every downstream step (alias,
# artifact upload, release attachment) keeps working off one directory and
# cannot accidentally publish the unsigned copy.
- name: Promote signed installer
if: env.SIGN == 'true'
shell: pwsh
working-directory: installers/windows/build
run: |
$signed = @(Get-ChildItem -Path signed -Filter *.exe)
if ($signed.Count -ne 1) {
throw "expected exactly one signed .exe, found $($signed.Count)"
}
Move-Item -Force $signed[0].FullName (Join-Path output $signed[0].Name)
Write-Host "promoted signed installer: $($signed[0].Name)"
# Fail loudly rather than shipping an unsigned .exe under a signed
# release. The test certificate is self-signed, so Windows reports the
# signature as untrusted (`UnknownError`) -- that is expected and is not
# what this checks. Only the absence of a signature is treated as a
# failure; swap in a stricter assertion once the production certificate
# is imported.
- name: Verify signature
if: env.SIGN == 'true'
shell: pwsh
working-directory: installers/windows/build/output
run: |
Get-ChildItem -Filter *.exe | ForEach-Object {
$sig = Get-AuthenticodeSignature $_.FullName
if ($sig.Status -eq 'NotSigned') {
throw "$($_.Name) carries no Authenticode signature"
}
Write-Host "$($_.Name): $($sig.Status) / $($sig.SignerCertificate.Subject)"
}
# Stable + beta tag releases (e.g. v0.2.5b1, v0.3.0) get an unversioned
# copy alongside the versioned filename so external surfaces (website,
# wiki, newsletters) can link to a stable URL that survives version
@ -70,6 +155,8 @@ jobs:
# semantically confusing next to the date-stamped versioned name on a
# daily prerelease page, and (b) there's no stable "latest daily" URL
# anyway (`latest` skips prereleases), so the alias adds no value there.
#
# Runs after signing so the alias is a copy of the *signed* binary.
- name: Create unversioned alias (non-daily tags only)
if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-daily.')
shell: pwsh
@ -81,7 +168,7 @@ jobs:
Write-Host "alias: bambuddy-windows-x64-setup.exe -> $($versioned.Name)"
- name: Upload installer artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: bambuddy-windows-installer
path: installers/windows/build/output/*.exe

View file

@ -37,6 +37,7 @@ If you sponsor and your name isn't here within 48h, please write an email to mar
- [@MethodicalMartian](https://github.com/MethodicalMartian)
- [@brianharwell](https://github.com/brianharwell)
- [@shosier01](https://github.com/shosier01)
- [@freifunk-bamberg](https://github.com/freifunk-bamberg)
## Backers ($5/mo+)
@ -67,6 +68,9 @@ If you sponsor and your name isn't here within 48h, please write an email to mar
- [@kycrna](https://github.com/kycrna)
- [@iljur](https://github.com/iljur)
- [@bhamiltoncx](https://github.com/bhamiltoncx)
- [@g7ufo](https://github.com/g7ufo)
- [@Heidelberger2000](https://github.com/Heidelberger2000)
- [@MorganMLGman](https://github.com/MorganMLGman)
---

File diff suppressed because one or more lines are too long

View file

@ -223,21 +223,18 @@ The frontend uses [react-i18next](https://react.i18next.com/) for all user-facin
### Locale Files
Translations live in `frontend/src/i18n/locales/`:
Translations live in `frontend/src/i18n/locales/`. `en.ts` is the reference locale; every other `*.ts` file in that directory is checked against it. The parity check discovers the directory at runtime, so a new locale is picked up automatically — this file never needs updating when one is added.
| File | Language |
|------|----------|
| `en.ts` | English (primary) |
| `de.ts` | German |
| `fr.ts` | French |
| `ja.ts` | Japanese |
| `pt-BR.ts` | Brazilian Portuguese |
[...]
check for possibly more files!!!
To see the current set of locales and check your work:
```bash
cd frontend
npm run check:i18n
```
### Adding New Strings
1. Add the key to the appropriate section in **all three** locale files
1. Add the key to the appropriate section in **every** locale file
2. Use the `useTranslation` hook in your component:
```tsx
@ -253,9 +250,9 @@ function MyComponent() {
### Important Notes
- All three locale files must use the **same key structure** — same nesting, same key paths
- Always add keys to all three locales to maintain parity
- Run frontend tests after changes — locale parity is validated
- Every locale file must use the **same key structure** — same nesting, same key paths
- Always add keys to **every** locale to maintain parity, with real translations rather than English placeholders — the check flags leaves that are identical to `en`
- Run `npm run test:run` before pushing — it chains the parity check, which CI runs too. Plain `npm test` is vitest in watch mode and skips it
- If you find structural inconsistencies between locales, fix them — different key paths cause silent fallback to English
## Authentication & Permissions

View file

@ -6,7 +6,7 @@
<p align="center">
<strong>Your printers. No cloud. Your rules.</strong><br>
Self-hosted command center for Bambu Lab &mdash; from one A1 to a 40-printer farm.
Self-hosted command center for Bambu Lab &mdash; from one A1 to an entire print farm.
</p>
<p align="center">
@ -57,6 +57,7 @@
<a href="https://hackaday.com/2026/06/13/bambuddy-says-bye-to-bambu-lab-cloud-services/"><img src="https://img.shields.io/badge/Hackaday-Read-F2A724?style=flat-square&labelColor=000000" alt="Hackaday"></a>
<a href="https://www.xda-developers.com/finally-have-full-control-bambu-lab-printer-ditched-bambu-cloud/"><img src="https://img.shields.io/badge/XDA--Developers-Read-C8102E?style=flat-square" alt="XDA-Developers"></a>
<a href="https://www.howtogeek.com/free-your-bambu-lab-3d-printer-from-the-cloud/"><img src="https://img.shields.io/badge/How--To%20Geek-Read-33A6CA?style=flat-square" alt="How-To Geek"></a>
<a href="https://www.makeuseof.com/free-browser-tool-beats-bambu-lab-at-own-game/"><img src="https://img.shields.io/badge/MakeUseOf-Read-E02D2D?style=flat-square" alt="MakeUseOf"></a>
<a href="https://www.fabbaloo.com/news/bambuddy-launches-as-open-source-alternative-to-bambu-labs-cloud"><img src="https://img.shields.io/badge/Fabbaloo-Read-F77B0F?style=flat-square" alt="Fabbaloo"></a>
<a href="https://itsfoss.com/news/bambuddy-self-hosted-bambu-lab-alternative/"><img src="https://img.shields.io/badge/It's%20FOSS-Read-00B5AD?style=flat-square" alt="It's FOSS"></a>
<a href="https://www.igorslab.de/en/bambuddy-the-silent-alternative-to-the-bamboo-cloud/"><img src="https://img.shields.io/badge/Igor's%20Lab-Read-E10000?style=flat-square" alt="Igor's Lab"></a>
@ -155,6 +156,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
### 📊 Monitoring & Control
- Real-time printer status via WebSocket
- **Print progress in the browser tab** — optional (off by default, toggle under Settings → Appearance): shows the soonest-finishing print's percentage in the tab title and a progress-ring favicon in your theme accent colour
- Live camera streaming (MJPEG) & snapshots with multi-viewer support — most Bambu printers only allow one upstream connection, so Bambuddy fans out a single shared stream to all browser tabs / cards / overlays
- **Cam Wall view** — Toggle the Printers page from cards into a responsive grid of camera tiles for at-a-glance monitoring across the whole farm. On-screen tiles stream live up to a configurable cap (default 4) so RPi installs stay sustainable; the rest fall back to periodic snapshot polling, and off-screen tiles pause entirely. Per-user settings (live cap, snapshot interval); click any tile to open the floating viewer or the dedicated camera window depending on your existing camera-view preference
- **Long-lived camera tokens** for Home Assistant / Frigate / kiosks — mint a token from Settings → API Keys, paste it once, capped at 365 days, revocable at any time (no infinite tokens — leaked permanent tokens are unsafe by design)
@ -182,7 +184,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
- Configurable drying presets per filament type (temperature & duration for AMS 2 Pro and AMS-HT)
- **Per-filament humidity threshold** — Set a different humidity trigger per filament type (e.g. Nylon at 20%, PLA at 60%, ASA at 30%) instead of one global value. Mixed-material AMS units use the most-restrictive threshold across the loaded spools so a single PLA + Nylon unit triggers at Nylon's level. Drives both the auto-drying scheduler and the hourly humidity alarm so the two can never disagree on whether a unit is "too humid"
- Dual external spool support for H2D (Ext-L / Ext-R)
- **HMS error monitoring with one-click actions** — Live HMS error log with history and the same Resume / Stop / Continue / Retry / Check Assistant / Don't Remind Me action buttons BambuStudio shows. Click and the matching MQTT command goes back to the printer — no more walking to the device just to dismiss a paused-print dialog. Catalog covers every Bambu model (X1 / P1 / A1 / H2 series); buttons are translated in all 11 supported locales
- **HMS error monitoring with one-click actions** — Live HMS error log with history and the same Resume / Stop / Continue / Retry / Check Assistant / Don't Remind Me action buttons BambuStudio shows. Click and the matching MQTT command goes back to the printer — no more walking to the device just to dismiss a paused-print dialog. Catalog covers every Bambu model (X1 / P1 / A1 / H2 series); buttons are translated in all 13 supported locales
- **Heater history charts** — Bambuddy logs nozzle, bed, and chamber readings every minute and surfaces them via a tiny chart icon on each heater tile in the printer card. Click for a per-heater modal with current / average / min / max stats, target overlay, and a 6h / 24h / 48h / 7d time range — works on read-only chamber sensors (X1C / P2S) too. AMS humidity and temperature get the same treatment (already shipped).
- Print success rates & trends
- Filament usage tracking

View file

@ -1,9 +1,11 @@
"""Pure helper functions for OIDC routes.
Hosts the SSRF guard for admin-supplied icon URLs. Stricter than
``_spoolman_helpers.assert_safe_spoolman_url`` Spoolman intentionally allows
loopback/RFC-1918 (same-LAN topology) while OIDC icons must be reachable on
the public internet (IdP-hosted), so private addresses there are SSRF probes.
Hosts the public-internet SSRF guard, used for both admin-supplied icon URLs
and OIDC issuer URLs (via ``schemas.auth._validate_issuer_url``). Stricter
than ``_url_safety.assert_safe_lan_service_url`` LAN services intentionally
allow loopback/RFC-1918 (same-host/same-LAN topology) while an IdP must be
reachable on the public internet, so a private address there is an SSRF probe
rather than a configuration.
"""
from __future__ import annotations
@ -11,15 +13,21 @@ from __future__ import annotations
import ipaddress
from urllib.parse import urlparse
from backend.app.api.routes._url_safety import CLOUD_METADATA_IPS, NUMERIC_IP_RE, unwrap_ipv4_mapped
from backend.app.api.routes._url_safety import (
CLOUD_METADATA_HOSTNAMES,
CLOUD_METADATA_IPS,
NUMERIC_IP_RE,
unwrap_ipv4_mapped,
)
def assert_safe_public_https_url(url: str) -> None:
"""Raise ValueError if *url* is unsafe to fetch as a public HTTPS resource.
Used for OIDC provider icon URLs (#1333). Stricter than the Spoolman SSRF
guard: also rejects loopback, private (RFC-1918), and link-local addresses
because an OIDC icon legitimately lives only on the public internet.
Used for OIDC provider icon URLs (#1333) and OIDC issuer URLs. Stricter
than the LAN-service SSRF guard: also rejects loopback, private
(RFC-1918), and link-local addresses because an IdP and its icon
legitimately live only on the public internet.
Checks performed:
- Scheme must be ``https`` (no ``http://``, ``file://``, ``gopher://``, ).
@ -35,9 +43,12 @@ def assert_safe_public_https_url(url: str) -> None:
- IPv4-mapped IPv6 (``::ffff:127.0.0.1``) unwrapped before the IP-class
check so an attacker can't bypass via IPv6 encoding.
Hostname-based addresses are accepted without DNS resolution (consistent
with ``_validate_issuer_url`` policy the operator is trusted to
configure a sensible IdP host).
Hostname-based addresses are otherwise accepted without DNS resolution
the operator is trusted to configure a sensible IdP host, and resolving
here would both add a TOCTOU gap (DNS can change between validation and
request) and make the validator issue network requests of its own. The
fixed cloud-metadata hostnames are the exception: matching them is a
literal string comparison, not a resolution.
"""
parsed = urlparse(url)
if parsed.scheme.lower() != "https":
@ -45,6 +56,14 @@ def assert_safe_public_https_url(url: str) -> None:
hostname = (parsed.hostname or "").lower()
# "https:///path" parses to an empty hostname; without this it reaches the
# ip_address() ValueError branch and is accepted as a symbolic hostname.
if not hostname:
raise ValueError("icon URL must include a hostname")
if hostname in CLOUD_METADATA_HOSTNAMES:
raise ValueError("icon URL must not point to a cloud metadata endpoint")
if NUMERIC_IP_RE.match(hostname):
raise ValueError("icon URL must not use numeric-encoded IP addresses")

View file

@ -5,17 +5,15 @@ No heavy dependencies — importable in unit tests without the full backend stac
from __future__ import annotations
import ipaddress
import json
import logging
import math
import re
from typing import Any
from urllib.parse import urlparse
from typing_extensions import TypedDict
from backend.app.api.routes._url_safety import CLOUD_METADATA_IPS, NUMERIC_IP_RE, unwrap_ipv4_mapped
from backend.app.api.routes._url_safety import assert_safe_lan_service_url
logger = logging.getLogger(__name__)
@ -80,61 +78,17 @@ class NormalizedFilament(TypedDict):
def assert_safe_spoolman_url(url: str) -> None:
"""Raise ValueError if *url* should be blocked as an SSRF risk.
"""Raise ValueError if the Spoolman *url* should be blocked as an SSRF risk.
Bambuddy is typically deployed on a home LAN alongside Spoolman, so
loopback (127.0.0.1) and RFC-1918 private ranges (192.168.x.x, 10.x.x.x,
172.16-31.x) must be permitted they are THE normal Spoolman topology.
This guard therefore targets the genuinely dangerous cases only.
Thin wrapper over the shared LAN-service policy see
``_url_safety.assert_safe_lan_service_url`` for what is and isn't
rejected, and why loopback/RFC-1918 are deliberately permitted (running
Spoolman on the same host or home LAN is THE normal topology).
Checks performed:
- Scheme must be http or https (no file://, gopher://, dict://, etc.).
- Numeric-encoded IP addresses in decimal (e.g. ``2130706433``) or hex
(e.g. ``0x7f000001``) are rejected. Python's ``ipaddress`` module raises
``ValueError`` for these forms so they would otherwise bypass the
explicit-IP block below, but libc (and browsers) resolve them as valid
IPv4 addresses.
- Cloud provider metadata endpoints (169.254.169.254, 100.100.100.200,
fd00:ec2::254) are blocked the classic SSRF credential-exfil target.
- Multicast (224.0.0.0/4, ff00::/8) and unspecified (0.0.0.0, ::) addresses
are blocked pointless as a destination and suggests misuse.
- IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) are unwrapped so they cannot
bypass the checks above.
Hostname-based addresses ("localhost", "spoolman.lan", "internal.corp")
are out of scope DNS resolution is deliberately not performed here.
Kept as a named function because the "Spoolman URL …" wording in its
errors is user-facing and asserted by existing tests.
"""
parsed = urlparse(url)
if parsed.scheme.lower() not in ("http", "https"):
raise ValueError("Spoolman URL must use http or https")
hostname = (parsed.hostname or "").lower()
# Reject decimal- and hex-encoded IPs (e.g. http://2130706433/ or
# http://0x7f000001/). These slip past ipaddress.ip_address() but libc
# (and browsers) parse them as IPv4 — an obvious bypass if not caught.
if NUMERIC_IP_RE.match(hostname):
raise ValueError("Spoolman URL must not use numeric-encoded IP addresses; use standard dotted-decimal notation")
try:
addr = ipaddress.ip_address(hostname)
except ValueError:
# Not a bare IP address — includes intentional cases such as "localhost" and
# RFC-1918 hostnames ("spoolman.lan", "192.168.1.10" would be caught above as
# a dotted-decimal IP; symbolic names resolve via DNS which is out of scope).
# Running Spoolman on the same host or home LAN is the standard Bambuddy
# topology, so loopback and private ranges are deliberately NOT blocked here.
return
# Unwrap IPv4-mapped IPv6 (::ffff:169.254.169.254 etc.) so attackers can't
# encode a blocked IPv4 into an IPv6 literal to bypass the check.
effective = unwrap_ipv4_mapped(addr)
if effective in CLOUD_METADATA_IPS:
raise ValueError("Spoolman URL must not point to a cloud metadata endpoint")
if effective.is_multicast or effective.is_unspecified:
raise ValueError("Spoolman URL must not point to a multicast or unspecified address")
assert_safe_lan_service_url(url, label="Spoolman URL")
_COLOR_HEX_RE = re.compile(r"^[0-9A-Fa-f]{6}$")

View file

@ -1,19 +1,31 @@
"""Shared URL-safety primitives used by both SSRF guards in this package.
"""Shared URL-safety primitives for the SSRF guards in this package.
The two top-level assertion functions
``_spoolman_helpers.assert_safe_spoolman_url`` (Spoolman, deliberately allows
loopback/RFC-1918 because same-LAN deployment is the standard topology) and
``_oidc_helpers.assert_safe_public_https_url`` (OIDC icons, must be reachable
on the public internet, so loopback/private are rejected) share the
*data* (cloud-metadata IP set, numeric-encoded-IP regex) but not the
*policy*. Only the data lives here. The functions stay in their respective
modules with their distinct policies intact.
Bambuddy has exactly two outbound-URL policies, and which one applies is a
property of the *service*, not of the caller:
- **LAN-service** (``assert_safe_lan_service_url`` below) the service
legitimately lives on the same host or home LAN, so loopback and RFC-1918
must be permitted; blocking them would break the normal topology. Used for
Spoolman, self-hosted notification servers (ntfy, Bark, Gotify, custom
webhooks), Home Assistant, the Obico ML endpoint and the slicer sidecars.
- **Public-internet** (``_oidc_helpers.assert_safe_public_https_url``) the
resource can only sensibly live on the public internet, so a private
address is an SSRF probe rather than a configuration. Used for OIDC issuer
and icon URLs.
Both reject the cases that are dangerous regardless of topology: non-HTTP
schemes, numeric-encoded IPs, cloud-metadata endpoints, multicast and
unspecified addresses, and IPv4-mapped IPv6 encodings of any of the above.
The LAN-service policy lives here because it now has several callers; the
public-internet policy stays in ``_oidc_helpers`` next to its only consumer.
"""
from __future__ import annotations
import ipaddress
import re
from urllib.parse import urlparse
# Cloud-provider metadata endpoints — the classic SSRF credential-exfil
# targets. Both guards reject these unconditionally.
@ -28,6 +40,18 @@ CLOUD_METADATA_IPS = frozenset(
}
)
# The DNS-name form of the same targets. Neither guard resolves hostnames (see
# the TOCTOU note on each), so an IP blocklist alone cannot catch these — but a
# literal-string match needs no resolution and costs nothing. These names only
# resolve inside the respective cloud, so there is no legitimate reason for any
# Bambuddy integration to point at one.
CLOUD_METADATA_HOSTNAMES = frozenset(
{
"metadata.google.internal", # GCP
"metadata.goog", # GCP short form
}
)
# libc and browsers parse numeric-encoded IP forms (decimal ``2130706433``
# for 127.0.0.1, hex ``0x7f000001``) but Python's ``ipaddress.ip_address``
@ -49,3 +73,68 @@ def unwrap_ipv4_mapped(
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
return addr.ipv4_mapped
return addr
def assert_safe_lan_service_url(url: str, *, label: str) -> None:
"""Raise ValueError if *url* is unsafe for a service that may live on the LAN.
``label`` names the setting in the error message ("Spoolman URL", "ntfy
server URL", …) so the user sees which field they need to correct.
Loopback (127.0.0.1) and RFC-1918 private ranges are deliberately
**permitted** Bambuddy is self-hosted and running Spoolman, ntfy,
Bark, Home Assistant, an Obico ML endpoint or a slicer sidecar on the
same host or home LAN is THE normal topology, not an attack. A blanket
private-address block would break those integrations for most installs.
What is rejected is dangerous under any topology:
- Schemes other than http/https. ``httpx`` already raises
``UnsupportedProtocol`` for ``file://``/``gopher://`` etc., so this is
about returning a clear validation error at configuration time rather
than an opaque failure at delivery time.
- Numeric-encoded IPv4 (decimal ``2130706433``, hex ``0x7f000001``)
libc and browsers resolve these, but Python's ``ipaddress`` raises
ValueError on them, so they would slip past the checks below.
- Cloud-provider metadata endpoints the high-value SSRF target, and
never a legitimate destination for any of these services.
- Multicast and unspecified addresses pointless as a destination and
indicative of misuse.
- IPv4-mapped IPv6 encodings of any of the above.
Symbolic hostnames are otherwise accepted without DNS resolution, matching
the public-internet guard: resolution here would be both a TOCTOU (DNS can
change between validation and request) and a request the validator
shouldn't be making. The one exception is the fixed set of cloud-metadata
hostnames, which is a literal-string match and needs no resolution.
"""
parsed = urlparse(url)
if parsed.scheme.lower() not in ("http", "https"):
raise ValueError(f"{label} must use http or https")
hostname = (parsed.hostname or "").lower()
# "http:///path" parses to an empty hostname. Never a valid destination,
# and without this it falls through the ip_address() ValueError branch
# below and is accepted as if it were a symbolic hostname.
if not hostname:
raise ValueError(f"{label} must include a hostname")
if hostname in CLOUD_METADATA_HOSTNAMES:
raise ValueError(f"{label} must not point to a cloud metadata endpoint")
if NUMERIC_IP_RE.match(hostname):
raise ValueError(f"{label} must not use numeric-encoded IP addresses; use standard dotted-decimal notation")
try:
addr = ipaddress.ip_address(hostname)
except ValueError:
return # symbolic hostname — out of scope by design (no DNS check)
effective = unwrap_ipv4_mapped(addr)
if effective in CLOUD_METADATA_IPS:
raise ValueError(f"{label} must not point to a cloud metadata endpoint")
if effective.is_multicast or effective.is_unspecified:
raise ValueError(f"{label} must not point to a multicast or unspecified address")

View file

@ -29,9 +29,11 @@ from backend.app.schemas.archive import ArchiveResponse, ArchiveSlim, ArchiveSta
from backend.app.schemas.print_log import PrintLogResponse
from backend.app.schemas.slicer import SliceRequest
from backend.app.services.archive import ArchiveService
from backend.app.services.design_settings import overrides_from_config
from backend.app.utils.http import build_content_disposition
from backend.app.utils.safe_path import safe_join_under
from backend.app.utils.threemf_tools import (
expand_to_project_slots,
extract_embedded_presets_from_3mf,
extract_nozzle_mapping_from_3mf,
extract_project_filaments_from_3mf,
@ -41,6 +43,9 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/archives", tags=["archives"])
# Path of the embedded slicer config inside a BambuStudio/OrcaSlicer 3MF.
_PROJECT_SETTINGS_PATH = "Metadata/project_settings.config"
def _safe_filename(filename: str) -> str:
"""Extract basename from a client-supplied filename, preventing path traversal.
@ -119,6 +124,28 @@ def _match_timelapse_by_timestamp(
return best_video, best_diff
async def _claimed_timelapse_stems(db, printer_id: int | None, exclude_archive_id: int) -> set[str]:
"""Video filenames already attached to another archive of this printer (#2704).
Lets the baseline diff drop a previous print's late-landing video from the
candidate list without ordering the candidates ordering could only be done
on mtime or the filename timestamp, and both come from a clock the printer
can't sync in LAN-only mode. ``attach_timelapse`` stores the video under the
printer's own filename and the MP4 conversion keeps the stem, so the stem of
``timelapse_path`` is what was claimed.
"""
if printer_id is None:
return set()
rows = await db.execute(
select(PrintArchive.timelapse_path).where(
PrintArchive.printer_id == printer_id,
PrintArchive.id != exclude_archive_id,
PrintArchive.timelapse_path.is_not(None),
)
)
return {Path(p).stem for p in rows.scalars().all() if p}
def _ensure_archive_visible(
archive: PrintArchive | None,
user: User | None,
@ -570,6 +597,8 @@ async def list_archives_slim(
PrintLogEntry.filament_color,
PrintLogEntry.status,
PrintLogEntry.cost,
PrintLogEntry.energy_kwh,
PrintLogEntry.energy_cost,
PrintLogEntry.created_at,
)
.outerjoin(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
@ -612,6 +641,8 @@ async def list_archives_slim(
"started_at": r.started_at,
"completed_at": r.completed_at,
"cost": r.cost,
"energy_kwh": r.energy_kwh,
"energy_cost": r.energy_cost,
"quantity": 1,
"created_at": r.created_at,
}
@ -2262,9 +2293,11 @@ async def scan_timelapse(
from backend.app.core.database import async_session
from backend.app.models.printer import Printer
from backend.app.services.bambu_ftp import (
delete_archived_timelapse,
download_file_bytes_async,
get_ftp_retry_settings,
list_files_async,
remote_file_settled,
with_ftp_retry,
)
@ -2314,18 +2347,48 @@ async def scan_timelapse(
f for f in files if not f.get("is_directory") and f.get("name", "").lower().endswith((".mp4", ".avi"))
]
# Strategy 0: snapshot diff against the baseline captured at print start
# (#2704). This is the same comparison the automatic scan makes, and the
# only one here that doesn't depend on the printer's clock — a printer in
# LAN-only mode can't reach Bambu's NTP server, so the timestamps in both
# the filename and the FTP mtime can be days out. One reporter's P1S was
# six and a half days off, which defeats every strategy below.
#
# When a baseline exists it is authoritative and the clock-based strategies
# are skipped entirely: they can only turn an honest "pick one yourself"
# into a confident wrong answer. Those strategies stay for archives created
# before the baseline was persisted.
used_baseline = archive.timelapse_baseline is not None
if used_baseline:
baseline = set(archive.timelapse_baseline)
async with async_session() as db:
claimed = await _claimed_timelapse_stems(db, archive.printer_id, archive_id)
candidates = [
f for f in video_files if f.get("name", "") not in baseline and Path(f.get("name", "")).stem not in claimed
]
if len(candidates) == 1:
matching_file = candidates[0]
logger.info("Matched timelapse by print-start baseline: %s", matching_file.get("name"))
elif candidates:
# Ambiguous — offer only the plausible files instead of guessing.
video_files = candidates
logger.info("Baseline left %s unclaimed candidates for archive %s", len(candidates), archive_id)
else:
logger.info("Baseline shows no unclaimed new video on the printer for archive %s", archive_id)
# Strategy 1: Match by print name in filename
for f in video_files:
fname = f.get("name", "")
if base_name.lower() in fname.lower():
matching_file = f
break
if not used_baseline:
for f in video_files:
fname = f.get("name", "")
if base_name.lower() in fname.lower():
matching_file = f
break
# Strategy 2: Match by timestamp proximity against print START time.
# Bambu timelapse filename embeds the print start time in printer-local clock.
# See _match_timelapse_by_timestamp for the offset-search rationale and why we
# intentionally don't try to match filename against end time here.
if not matching_file and archive.started_at:
if not used_baseline and not matching_file and archive.started_at:
candidate, diff = _match_timelapse_by_timestamp(video_files, archive.started_at)
if candidate is not None:
matching_file = candidate
@ -2333,7 +2396,7 @@ async def scan_timelapse(
# Strategy 3: Use file modification time from FTP listing
# This handles cases where printer's filename timestamp is wrong but file mtime is correct
if not matching_file and (archive.started_at or archive.completed_at or archive.created_at):
if not used_baseline and not matching_file and (archive.started_at or archive.completed_at or archive.created_at):
from datetime import datetime, timedelta
_archive_start = archive.started_at
@ -2361,7 +2424,7 @@ async def scan_timelapse(
# Strategy 4: If only one timelapse exists and archive was recently completed, use it
# This handles cases where printer clock is wrong or timezone issues exist
if not matching_file and len(video_files) == 1:
if not used_baseline and not matching_file and len(video_files) == 1:
from datetime import datetime, timedelta, timezone
archive_completed = archive.completed_at or archive.created_at
@ -2411,6 +2474,7 @@ async def scan_timelapse(
remote_path,
socket_timeout=ftp_timeout,
printer_model=printer.model,
expected_size=matching_file.get("size"),
max_retries=ftp_retry_count,
retry_delay=ftp_retry_delay,
operation_name=f"Download timelapse {matching_file['name']}",
@ -2422,11 +2486,24 @@ async def scan_timelapse(
remote_path,
socket_timeout=ftp_timeout,
printer_model=printer.model,
expected_size=matching_file.get("size"),
)
if not timelapse_data:
raise HTTPException(500, "Failed to download timelapse")
# Confirm the printer has finished writing before we commit to this file and
# delete the original: matching the listing's size proves we got what it
# said, not that the file was complete (#2704).
if not await remote_file_settled(
printer.ip_address,
printer.access_code,
remote_path,
len(timelapse_data),
printer_model=printer.model,
):
raise HTTPException(409, "The printer is still writing this video — try again in a moment")
# Attach in a fresh short session (the read session was released before FTP).
async with async_session() as db:
success = await ArchiveService(db).attach_timelapse(archive_id, timelapse_data, matching_file["name"])
@ -2434,6 +2511,17 @@ async def scan_timelapse(
if not success:
raise HTTPException(500, "Failed to attach timelapse")
# Safe now, and only now: the transfer matched the size the listing reported
# and the bytes are committed to the archive (#2704).
await delete_archived_timelapse(
printer.ip_address,
printer.access_code,
remote_path,
verified=matching_file.get("size") is not None,
printer_model=printer.model,
printer_name=printer.name,
)
return {
"status": "attached",
"message": f"Timelapse '{matching_file['name']}' attached successfully",
@ -2451,9 +2539,11 @@ async def select_timelapse(
from backend.app.core.database import async_session
from backend.app.models.printer import Printer
from backend.app.services.bambu_ftp import (
delete_archived_timelapse,
download_file_bytes_async,
get_ftp_retry_settings,
list_files_async,
remote_file_settled,
with_ftp_retry,
)
@ -2476,6 +2566,7 @@ async def select_timelapse(
# Find the file on the printer
files = []
remote_path = None
expected_size = None
for timelapse_dir in ["/timelapse", "/timelapse/video", "/record", "/recording"]:
try:
files = await list_files_async(
@ -2484,6 +2575,7 @@ async def select_timelapse(
for f in files:
if f.get("name") == filename:
remote_path = f.get("path") or f"{timelapse_dir}/{filename}"
expected_size = f.get("size")
break
if remote_path:
break
@ -2504,6 +2596,7 @@ async def select_timelapse(
remote_path,
socket_timeout=ftp_timeout,
printer_model=printer.model,
expected_size=expected_size,
max_retries=ftp_retry_count,
retry_delay=ftp_retry_delay,
operation_name=f"Download timelapse {filename}",
@ -2515,17 +2608,41 @@ async def select_timelapse(
remote_path,
socket_timeout=ftp_timeout,
printer_model=printer.model,
expected_size=expected_size,
)
if not timelapse_data:
raise HTTPException(500, "Failed to download timelapse")
# Confirm the printer has finished writing before we commit to this file and
# delete the original: matching the listing's size proves we got what it
# said, not that the file was complete (#2704).
if not await remote_file_settled(
printer.ip_address,
printer.access_code,
remote_path,
len(timelapse_data),
printer_model=printer.model,
):
raise HTTPException(409, "The printer is still writing this video — try again in a moment")
# Attach in a fresh short session (the read session was released before FTP).
async with async_session() as db:
success = await ArchiveService(db).attach_timelapse(archive_id, timelapse_data, filename)
if not success:
raise HTTPException(500, "Failed to attach timelapse")
# Safe now, and only now: the transfer matched the size the listing reported
# and the bytes are committed to the archive (#2704).
await delete_archived_timelapse(
printer.ip_address,
printer.access_code,
remote_path,
verified=expected_size is not None,
printer_model=printer.model,
printer_name=printer.name,
)
return {
"status": "attached",
"message": f"Timelapse '{filename}' attached successfully",
@ -3457,11 +3574,23 @@ async def get_archive_plates(
# Printer / process preset names the 3MF was prepared with — used by the
# SliceModal to default its dropdowns (#1325).
embedded_presets: dict[str, str | None] = {"printer": None, "process": None}
# Process settings the designer changed away from the stock preset (#2622),
# offered in the SliceModal for a cross-printer re-slice. Same payload the
# library plates endpoint returns — SliceModal reads one shape for both.
design_overrides: list[dict] = []
try:
with zipfile.ZipFile(file_path, "r") as zf:
namelist = zf.namelist()
embedded_presets = extract_embedded_presets_from_3mf(zf)
if _PROJECT_SETTINGS_PATH in namelist:
try:
design_overrides = [
o._asdict()
for o in overrides_from_config(json.loads(zf.read(_PROJECT_SETTINGS_PATH).decode("utf-8")))
]
except (ValueError, OSError, KeyError):
design_overrides = []
# Find all plate gcode files to determine available plates
gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
@ -3723,6 +3852,7 @@ async def get_archive_plates(
"has_gcode": has_gcode,
"embedded_printer": embedded_presets["printer"],
"embedded_process": embedded_presets["process"],
"design_overrides": design_overrides,
}
@ -3775,6 +3905,7 @@ async def _try_preview_slice_filaments(
"""
from backend.app.api.routes.settings import get_setting
from backend.app.services.slice_preview import get_preview_filaments
from backend.app.services.slicer_api import get_stall_timeout_seconds
preferred = (await get_setting(db, "preferred_slicer")) or "bambu_studio"
if preferred == "orcaslicer":
@ -3800,6 +3931,7 @@ async def _try_preview_slice_filaments(
file_name=file_path.name,
api_url=api_url,
request_id=request_id,
timeout_seconds=await get_stall_timeout_seconds(db),
)
@ -3808,6 +3940,7 @@ async def get_filament_requirements(
archive_id: int,
plate_id: int | None = None,
request_id: str | None = None,
full_slots: bool = False,
db: AsyncSession = Depends(get_db),
auth_result: tuple[User | None, bool] = Depends(
require_ownership_permission(
@ -3917,6 +4050,14 @@ async def get_filament_requirements(
}
)
# Re-slicing a source that already carries slice_info (#2712).
# See library.py for the full rationale: the slice modal's list is
# positional, so a source using only slot 4 must still present
# four slots or the pick lands on slot 1. The print path keeps the
# used-only list it depends on.
if full_slots and filaments:
filaments = expand_to_project_slots(zf, filaments)
# Unsliced project files: see library.py for full rationale.
# Return the FULL project_settings.config slot list with a
# used_in_plate flag derived from the preview slice; the

View file

@ -35,6 +35,7 @@ from backend.app.core.auth import (
security,
)
from backend.app.core.database import async_session, get_db
from backend.app.core.oidc_env import env_bool
from backend.app.core.permissions import ALL_PERMISSIONS
from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent, EventType, TokenType
from backend.app.models.group import Group
@ -122,7 +123,11 @@ def _local_login_env_bypass() -> bool:
an install whose SSO provider is unreachable. Accepted truthy values:
``true``, ``1``, ``yes`` (case-insensitive).
"""
return os.environ.get("BAMBUDDY_LOCAL_LOGIN", "").strip().lower() in {"true", "1", "yes"}
# strict=False: this runs on the login/forgot-password request path, not at
# startup. An unrecognized value must fall back to "off" (the safe default),
# never raise -- a 500 on the recovery endpoint is the opposite of what this
# bypass is for.
return env_bool("BAMBUDDY_LOCAL_LOGIN", False, strict=False)
def _get_client_ip(request: Request) -> str:

View file

@ -1,10 +1,13 @@
"""Camera streaming API endpoints for Bambu Lab printers."""
import asyncio
import contextlib
import logging
import os
import subprocess
import sys
import time
import uuid
from collections.abc import AsyncGenerator
from fastapi import APIRouter, Depends, HTTPException, Request
@ -19,6 +22,7 @@ from backend.app.core.auth import (
create_camera_stream_token,
)
from backend.app.core.database import get_db
from backend.app.core.logging_filters import redact_url_credentials
from backend.app.core.permissions import Permission
from backend.app.models.printer import Printer
from backend.app.models.user import User
@ -45,12 +49,25 @@ from backend.app.services.camera_profiles import get_camera_profile
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/printers", tags=["camera"])
# Upper bound on waiting for a SIGKILLed ffmpeg to be reaped (#2580). A killed
# ffmpeg stuck in uninterruptible I/O on a dead RTSP socket can take arbitrarily
# long to exit — an unbounded post-kill wait() parked the fan-out stream
# coroutine for 12 hours on a P2S, leaving every viewer attached to a stalled
# broadcaster. Abandoning the wait is safe: cleanup_orphaned_streams' /proc scan
# reaps any Bambu ffmpeg not attached to an active stream on its next pass.
# Grace period for a SIGTERMed ffmpeg to shut down before we SIGKILL it. Only
# reachable when ffmpeg genuinely ignores SIGTERM: _terminate_ffmpeg drains the
# pipes first, and a drained ffmpeg exits in ~0.15s.
_FFMPEG_TERM_TIMEOUT = 2.0
# Upper bound on waiting for a SIGKILLed ffmpeg to be reaped (#2580).
#
# The original diagnosis — "a killed ffmpeg stuck in uninterruptible I/O on a
# dead RTSP socket" — was wrong, and this bound was capping a deadlock of our
# own making rather than waiting out a stuck process. A process that survives
# SIGKILL would have to be in uninterruptible sleep (state D); the ffmpeg seen
# doing this was in state S, and its returncode was already set to -9 while
# wait() was still blocked. The real cause was undrained pipes (see
# _terminate_ffmpeg), which made this timeout fire on *every* camera close.
#
# Kept as a backstop now that the cause is fixed: it should no longer be
# reachable, and if it ever is, abandoning the wait is still safe because
# cleanup_orphaned_streams' /proc scan reaps any Bambu ffmpeg not attached to
# an active stream on its next pass.
_FFMPEG_KILL_TIMEOUT = 2.0
# Track active ffmpeg processes for cleanup
@ -82,6 +99,14 @@ _disconnect_events: dict[str, asyncio.Event] = {}
# Track last frame time per stream_id (not just per printer_id) for stale detection
_stream_last_frame_times: dict[str, float] = {}
# How much of a streaming ffmpeg's stderr to retain: enough for the input
# analysis plus a burst of errors, capped so a long-running stream can't grow it.
_FFMPEG_STDERR_TAIL_BYTES = 16384
# Live stderr collectors by pid — see _FfmpegStderrTail. Present means "this
# process's stderr already has a reader; do not open a second one".
_stderr_tails: dict[int, "_FfmpegStderrTail"] = {}
def get_buffered_frame(printer_id: int) -> bytes | None:
"""Get the last buffered frame for a printer from an active stream.
@ -193,8 +218,6 @@ async def generate_chamber_mjpeg_stream(
# Save frame to buffer for photo capture and track timestamp
if printer_id is not None:
import time
_last_frames[printer_id] = frame
_last_frame_times[printer_id] = time.time()
@ -226,10 +249,7 @@ async def generate_chamber_mjpeg_stream(
_stream_last_frame_times.pop(stream_id, None)
# Clean up frame buffer and timestamps
if printer_id is not None:
_last_frames.pop(printer_id, None)
_last_frame_times.pop(printer_id, None)
_stream_start_times.pop(printer_id, None)
_release_printer_frame_state(printer_id)
# Close the connection
try:
@ -240,14 +260,127 @@ async def generate_chamber_mjpeg_stream(
logger.info("Chamber image stream stopped for %s (stream_id=%s)", ip_address, stream_id)
def _new_fanout_stream_id(printer_id: int) -> str:
"""Registry key for one fan-out stream INSTANCE, not for the printer.
A plain ``f"{printer_id}-fanout"`` meant every successive stream for a
printer shared one key, so a departing generator's cleanup removed the entry
its successor had just registered. The external-camera path already carries a
per-instance suffix for exactly this reason (#2675); this gives the fan-out
path the same property.
The ``f"{printer_id}-"`` prefix is load-bearing ``is_stream_active``,
``stop_camera_stream`` and ``/camera/status`` all find a printer's streams by
scanning for it so the suffix goes on the end.
"""
return f"{printer_id}-fanout-{uuid.uuid4().hex[:8]}"
def live_frame_for_capture(printer_id: int) -> tuple[bool, bytes | None]:
"""Should a one-shot capture stand down for the live view, and to what frame?
Returns ``(defer, frame)``. ``defer`` True means DO NOT open a capture of
your own: use ``frame`` when it isn't None, and otherwise skip this attempt
rather than competing.
Both camera kinds allow exactly one reader Bambu firmware permits one
connection, and a USB camera permits one V4L2 handle so a capture that
races the live view doesn't degrade, it fails outright. #2707 measured 0 of
87 and 0 of 105 layer-timelapse captures on prints watched throughout, and
finish photos going out with no image attached.
Skipping when the buffer is momentarily empty (stream starting, mid-
reconnect) rather than falling through to a capture is the #1348 rule:
opening a competing handle kicks the viewer off, which is a worse outcome
than missing one frame.
"""
if not is_stream_active(printer_id):
return False, None
return True, _last_frames.get(printer_id)
def _release_printer_frame_state(printer_id: int | None) -> None:
"""Drop a printer's buffered frame and timings — unless a stream still owns them.
These three dicts are keyed by printer, not by stream, so a departing
generator must not clear them while a newer stream for the same printer is
running. That used to happen routinely: stream ids were per-printer, so a
predecessor's cleanup wiped its successor's state, leaving
``is_stream_active()`` False with a viewer attached (which is exactly what
the #1348 / #1271 guards read before deciding whether it is safe to open a
second camera connection), the janitor free to reap the live ffmpeg as an
orphan, and snapshots without a frame to reuse.
Call this AFTER removing the departing stream's own key, so the check
reports on other streams rather than on the caller.
"""
if printer_id is None or is_stream_active(printer_id):
return
_last_frames.pop(printer_id, None)
_last_frame_times.pop(printer_id, None)
_stream_start_times.pop(printer_id, None)
async def _drain_pipe(reader) -> None:
"""Read a subprocess pipe to EOF and discard, so it can never block.
Best-effort by design: any read failure means we cannot drain further, and
the caller is tearing the process down regardless.
"""
if reader is None:
return
try:
while await reader.read(65536):
pass
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001 — teardown must not fail on a dying pipe
return
async def _terminate_ffmpeg(process: asyncio.subprocess.Process, stream_id: str | None = None) -> None:
"""Terminate an ffmpeg process gracefully, then kill if needed."""
"""Terminate an ffmpeg process gracefully, then kill if needed.
Drains stdout/stderr throughout, which is load-bearing rather than hygiene.
ffmpeg is spawned with both as pipes, and every caller of this has already
stopped reading stdout so by the time we get here ffmpeg is typically
blocked in write() on a full 64 KiB pipe. Two things then go wrong:
* SIGTERM cannot be acted on. ffmpeg's handler only sets a flag that its
main loop polls, and a loop blocked in write() never reaches the check,
so the whole grace period is dead time.
* SIGKILL does kill it, but wait() cannot observe that. asyncio resolves
Process.wait()'s waiter through BaseSubprocessTransport._try_finish(),
which requires every pipe transport to report disconnected; paused,
unread pipes never reach EOF, so wait() blocks with returncode already
set. That is what made the "did not exit within Ns of SIGKILL" error
fire on every single camera close, and unbounded it was the 12-hour
hang in #2580.
Draining fixes both: SIGTERM becomes actionable and the exit observable.
Measured on an H2D: 4.0s of dead time per close before, ~0.15s after
which matters because the printer allows exactly one camera connection,
so every one of those seconds was a connection nobody could use.
Discarding what we drain is deliberate. The stream loop already reads
stderr on its error paths (_read_ffmpeg_stderr), and it does so before
calling this, so nothing diagnostic is lost.
"""
if process.returncode is not None:
_spawned_ffmpeg_pids.pop(process.pid, None)
return # Already dead
drainers = [asyncio.create_task(_drain_pipe(process.stdout))]
# A streaming ffmpeg's stderr already has a reader (_FfmpegStderrTail), and
# it keeps draining right through teardown, which is all we need here. Adding
# a second reader would race it — asyncio rejects concurrent reads on one
# StreamReader — so only drain stderr when nobody else owns it.
if process.pid not in _stderr_tails:
drainers.append(asyncio.create_task(_drain_pipe(process.stderr)))
try:
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=2.0)
await asyncio.wait_for(process.wait(), timeout=_FFMPEG_TERM_TIMEOUT)
except TimeoutError:
logger.warning("ffmpeg didn't terminate gracefully, killing (stream_id=%s)", stream_id)
process.kill()
@ -256,7 +389,8 @@ async def _terminate_ffmpeg(process: asyncio.subprocess.Process, stream_id: str
except TimeoutError:
# Do NOT keep waiting (#2580): the caller is the stream
# generator, and blocking here pins the fan-out pump forever.
# The orphan janitor reaps the process later.
# The orphan janitor reaps the process later. With the pipes
# drained this should be unreachable — see _FFMPEG_KILL_TIMEOUT.
logger.error(
"ffmpeg did not exit within %.1fs of SIGKILL; abandoning wait (stream_id=%s)",
_FFMPEG_KILL_TIMEOUT,
@ -266,7 +400,11 @@ async def _terminate_ffmpeg(process: asyncio.subprocess.Process, stream_id: str
pass # Already dead
except OSError as e:
logger.warning("Error terminating ffmpeg: %s", e)
_spawned_ffmpeg_pids.pop(process.pid, None)
finally:
for drainer in drainers:
drainer.cancel()
await asyncio.gather(*drainers, return_exceptions=True)
_spawned_ffmpeg_pids.pop(process.pid, None)
def _summarize_ffmpeg_stderr(text: str | None) -> str:
@ -276,9 +414,15 @@ def _summarize_ffmpeg_stderr(text: str | None) -> str:
any actual error message. Logging the full banner on every retry floods
the log (hundreds of lines per failed stream). This filter drops the
banner and caps output at the last 10 meaningful lines.
Credentials are masked here rather than at each ``logger`` call because
this is the one funnel every stderr log in this module passes through.
ffmpeg echoes the RTSP input URL back in its ``Input #0`` line, which
carries the printer access code.
"""
if not text:
return ""
text = redact_url_credentials(text) or ""
banner_prefixes = (
"ffmpeg version ",
" built with ",
@ -296,6 +440,82 @@ def _summarize_ffmpeg_stderr(text: str | None) -> str:
return "\n".join(meaningful[-10:])
class _FfmpegStderrTail:
"""Owns a long-lived ffmpeg's stderr: drains it continuously, keeps the tail.
Reading stderr only when something has already gone wrong leaves a pipe
nobody reads for the whole life of the stream. ffmpeg writes its banner, the
input analysis and then a progress line at a steady rate, so a 64 KiB pipe
fills eventually and ffmpeg blocks writing to it at which point it stops
producing frames, the stream's own read timeout fires, and the log says
"RTSP read timeout" with no hint that we starved it ourselves.
How long that takes is unmeasured and may be a long time: one H2D upstream
ran 21m36s continuously without stalling, so this is a bounded resource
being treated as unbounded rather than an observed failure. Draining removes
the ceiling either way, and the tail is *better* diagnostic material than
the old on-demand read: it holds ffmpeg's most recent output at the moment
things went wrong, where reading the buffered pipe returned whatever was
printed first (usually the startup banner, which the summariser then strips).
Registers itself in ``_stderr_tails`` so the two other readers of this pipe
can defer to it asyncio raises if two coroutines read one StreamReader
concurrently. See ``_read_ffmpeg_stderr`` and ``_terminate_ffmpeg``.
"""
def __init__(self, process: asyncio.subprocess.Process) -> None:
self._process = process
self._buffer = bytearray()
self._task: asyncio.Task | None = None
if process.stderr is None:
return
self._task = asyncio.create_task(self._pump())
_stderr_tails[process.pid] = self
async def _pump(self) -> None:
reader = self._process.stderr
try:
while True:
chunk = await reader.read(8192)
if not chunk:
return # EOF — ffmpeg has exited
self._buffer.extend(chunk)
excess = len(self._buffer) - _FFMPEG_STDERR_TAIL_BYTES
if excess > 0:
del self._buffer[:excess]
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001 — a broken pipe just ends the tail
return
def text(self) -> str | None:
"""The retained tail, summarised. None when nothing was captured.
Goes through _summarize_ffmpeg_stderr like every other stderr log in
this module: ffmpeg echoes its input URL, which carries the access code.
"""
if not self._buffer:
return None
return _summarize_ffmpeg_stderr(self._buffer.decode(errors="replace")) or None
async def aclose(self) -> None:
"""Stop draining and release ownership of the pipe. Idempotent.
Awaits the cancelled pump rather than firing and forgetting, so the task
is finished before the caller moves on an abandoned pending task
becomes an "unraisable exception" warning at an arbitrary later point,
usually during interpreter or loop teardown.
"""
task, self._task = self._task, None
if _stderr_tails.get(self._process.pid) is self:
del _stderr_tails[self._process.pid]
if task is None:
return
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
async def _read_ffmpeg_stderr(process: asyncio.subprocess.Process) -> str | None:
"""Read whatever ffmpeg has written to stderr so far (best-effort).
@ -306,8 +526,18 @@ async def _read_ffmpeg_stderr(process: asyncio.subprocess.Process) -> str | None
banner + stream-analysis lines ffmpeg already printed. Reading in bounded
chunks returns the buffered output promptly whether or not ffmpeg has
exited. Returns the content with ffmpeg's boilerplate banner stripped.
When a _FfmpegStderrTail owns this process's stderr — every streaming
ffmpeg its retained tail is returned instead. Reading the pipe here as
well would race that collector, and asyncio refuses two concurrent readers
on one StreamReader outright.
"""
if not process or not process.stderr:
if not process:
return None
tail = _stderr_tails.get(getattr(process, "pid", None))
if tail is not None:
return tail.text()
if not process.stderr:
return None
chunks: list[bytes] = []
total = 0
@ -428,6 +658,7 @@ async def generate_rtsp_mjpeg_stream(
jpeg_end = b"\xff\xd9"
reconnect_count = 0
process = None
stderr_tail: _FfmpegStderrTail | None = None
got_any_frames = False
try:
@ -480,6 +711,14 @@ async def generate_rtsp_mjpeg_stream(
reconnect_count += 1
continue
# Take ownership of stderr for the life of this process. Started
# only after the immediate-failure check above, which reads the pipe
# directly (correct there: the process is already dead, so
# read-to-EOF returns at once and cannot be raced by a collector).
# Nothing is lost by starting late — the banner ffmpeg printed in the
# meantime is still sitting in the pipe.
stderr_tail = _FfmpegStderrTail(process)
# Read JPEG frames from ffmpeg stdout
buffer = b""
stream_ended = False
@ -523,8 +762,6 @@ async def generate_rtsp_mjpeg_stream(
got_any_frames = True
if printer_id is not None:
import time
_last_frames[printer_id] = frame
_last_frame_times[printer_id] = time.time()
if stream_id:
@ -555,6 +792,12 @@ async def generate_rtsp_mjpeg_stream(
# Clean up this ffmpeg process before reconnecting or exiting
await _terminate_ffmpeg(process, stream_id)
# Released after teardown, not before: _terminate_ffmpeg deliberately
# leaves stderr to this collector, which has to keep draining while
# the process is stopped or wait() can't observe the exit.
if stderr_tail is not None:
await stderr_tail.aclose()
stderr_tail = None
process = None
if client_gone:
@ -597,15 +840,16 @@ async def generate_rtsp_mjpeg_stream(
_stream_last_frame_times.pop(stream_id, None)
# Clean up frame buffer and timestamps
if printer_id is not None:
_last_frames.pop(printer_id, None)
_last_frame_times.pop(printer_id, None)
_stream_start_times.pop(printer_id, None)
_release_printer_frame_state(printer_id)
if process:
await _terminate_ffmpeg(process, stream_id)
logger.info("Camera stream stopped for %s (stream_id=%s)", ip_address, stream_id)
# Same order as in the loop: terminate first, then release stderr.
if stderr_tail is not None:
await stderr_tail.aclose()
# Shut down the TLS proxy
proxy_server.close()
await proxy_server.wait_closed()
@ -665,8 +909,11 @@ async def camera_stream(
# Check for external camera first
if printer.external_camera_enabled and printer.external_camera_url:
import time
# NB: no `import time` / `import uuid` here, and don't reintroduce them.
# A local import anywhere in this function makes the name function-local
# for the WHOLE function, so the RTSP/chamber path below — which never
# executes this branch — would raise UnboundLocalError on any printer
# without an external camera. Both are imported at module level.
from backend.app.services.external_camera import generate_mjpeg_stream
# Limit external camera FPS to reduce browser load
@ -675,22 +922,79 @@ async def camera_stream(
"Using external camera (%s) for printer %s at %s fps", printer.external_camera_type, printer_id, fps
)
# Register the stream into the SAME registries the RTSP/chamber paths use
# (#2675) so `/camera/stop` and cleanup_orphaned_streams can find and kill
# a leaked ffmpeg holding a USB device open. Before this, external streams
# only tracked _active_external_streams and were structurally invisible to
# both the stop endpoint and the janitor. The stream_id keeps the
# `{printer_id}-` prefix both scanners key on, plus a unique suffix so two
# concurrent viewers of one printer don't clobber each other's entry.
stream_id = f"{printer_id}-ext-{uuid.uuid4().hex[:8]}"
stop_event = asyncio.Event()
_disconnect_events[stream_id] = stop_event
# Track stream start
_stream_start_times[printer_id] = time.time()
_active_external_streams.add(printer_id)
# Mutable holder so the wrapper's finally can unregister whatever process
# is currently registered (the RTSP path may respawn across reconnects).
current_proc: dict[str, asyncio.subprocess.Process] = {}
def _register_external_process(proc: asyncio.subprocess.Process) -> None:
prev = current_proc.get("proc")
if prev is not None and prev.pid != proc.pid:
_spawned_ffmpeg_pids.pop(prev.pid, None)
current_proc["proc"] = proc
_active_streams[stream_id] = proc
_spawned_ffmpeg_pids[proc.pid] = time.time()
_stream_last_frame_times[stream_id] = time.time()
def _publish_external_frame(frame: bytes) -> None:
"""Make the live frame reusable by one-shot consumers (#2707).
Only the built-in camera paths populated _last_frames, so every
external-camera consumer layer timelapse, finish photo, Obico,
plate check found an empty buffer and opened its own handle on a
device that allows exactly one reader, which simply failed while a
viewer was attached. Raw frame, not the multipart-wrapped chunk the
generator yields, because that is what those consumers expect.
"""
_last_frames[printer_id] = frame
async def external_stream_wrapper():
"""Wrap external stream to track start/stop and update frame times."""
try:
async for frame in generate_mjpeg_stream(
printer.external_camera_url, printer.external_camera_type, fps
printer.external_camera_url,
printer.external_camera_type,
fps,
on_process=_register_external_process,
on_frame=_publish_external_frame,
stop_event=stop_event,
):
# generate_mjpeg_stream already handles rate limiting;
# just track frame times for stall detection
_last_frame_times[printer_id] = time.time()
# track frame times (per-printer + per-stream) for stall detection
now = time.time()
_last_frame_times[printer_id] = now
_stream_last_frame_times[stream_id] = now
yield frame
finally:
# Best-effort unregister. If an abrupt disconnect skips this
# finally, the registry entries persist — which is exactly what
# lets the stop endpoint / janitor reap the leaked process.
stop_event.set()
proc = current_proc.get("proc")
if proc is not None:
_spawned_ffmpeg_pids.pop(proc.pid, None)
_active_streams.pop(stream_id, None)
_disconnect_events.pop(stream_id, None)
_stream_last_frame_times.pop(stream_id, None)
_active_external_streams.discard(printer_id)
# Now that this path publishes a buffered frame, it has to
# retract it too — ownership-checked, so a concurrent viewer of
# the same printer keeps its own. Also clears the per-printer
# timings this path used to leave behind.
_release_printer_frame_state(printer_id)
logger.info("External camera stream ended for printer %s", printer_id)
return StreamingResponse(
@ -722,8 +1026,6 @@ async def camera_stream(
# attached — otherwise /camera/status would report stream_uptime jumping
# backward whenever a second viewer joins. The upstream generator's
# finally clears this entry when the upstream actually ends.
import time
_stream_start_times.setdefault(printer_id, time.time())
# Fan-out broadcaster (#1089): one upstream connection per printer, shared
@ -736,7 +1038,7 @@ async def camera_stream(
# broadcaster. Concurrent viewers share that rate; new viewers after
# teardown create a fresh broadcaster at their requested fps.
fanout_key = f"printer-{printer_id}"
upstream_stream_id = f"{printer_id}-fanout"
upstream_stream_id = _new_fanout_stream_id(printer_id)
def _factory(disconnect_event: asyncio.Event):
# Re-bind locals into the closure so the async generator below sees
@ -1529,9 +1831,14 @@ async def delete_reference(
def _scan_bambu_ffmpeg_pids() -> list[int]:
"""Scan /proc for ffmpeg processes with Bambu RTSP URLs.
"""Scan /proc for ffmpeg processes that are ours.
Two shapes are matched, both unambiguously Bambuddy's:
- Bambu RTSP: no other software connects to ``rtsp(s)://bblp:``.
- External USB (V4L2): an ffmpeg spawned with ``-f v4l2`` is our USB camera
stream (#2675). Only orphans are killed — the caller excludes PIDs still in
``_active_streams``, so a live USB stream (now registered there) is spared.
These are definitely ours no other software connects to rtsp(s)://bblp:.
This catches orphans that survive app restarts and are not in any tracking dict.
"""
import os
@ -1544,8 +1851,11 @@ def _scan_bambu_ffmpeg_pids() -> list[int]:
try:
with open(f"/proc/{entry}/cmdline", "rb") as f:
cmdline = f.read()
# Match both rtsp:// (via TLS proxy) and rtsps:// (direct)
if b"ffmpeg" in cmdline and (b"rtsp://bblp:" in cmdline or b"rtsps://bblp:" in cmdline):
if b"ffmpeg" not in cmdline:
continue
# Match both rtsp:// (via TLS proxy) and rtsps:// (direct), plus
# the `-f v4l2` input flag our USB camera command always carries.
if b"rtsp://bblp:" in cmdline or b"rtsps://bblp:" in cmdline or b"v4l2" in cmdline:
pids.append(int(entry))
except (OSError, PermissionError, ValueError):
continue

View file

@ -12,6 +12,7 @@ from backend.app.core.permissions import Permission
from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
from backend.app.models.user import User
from backend.app.schemas.github_backup import (
CloudAccountCounts,
GitHubBackupConfigCreate,
GitHubBackupConfigResponse,
GitHubBackupConfigUpdate,
@ -49,7 +50,21 @@ async def _enforce_private_repo(repo_url: str, token: str, provider: str) -> Non
Used by POST and PATCH /config so a backup configuration can never be
saved against a public repository.
The URL is policy-checked first: the Gitea and Forgejo backends derive
their API base from this value (``get_api_base``) and then request it with
the supplied token, so an unchecked repository_url is an outbound fetch to
an operator-supplied host. A self-hosted Gitea on the LAN is the normal
case, so the LAN-service tier applies this only rules out the targets
that are wrong under any topology.
"""
from backend.app.api.routes._url_safety import assert_safe_lan_service_url
try:
assert_safe_lan_service_url(repo_url, label="Repository URL")
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
result = await github_backup_service.test_connection(repo_url, token, provider=provider)
if not result.get("success"):
message = result.get("message") or "Connection test failed"
@ -61,6 +76,39 @@ async def _enforce_private_repo(repo_url: str, token: str, provider: str) -> Non
raise HTTPException(status_code=400, detail=_PUBLIC_REPO_ERROR)
async def _count_cloud_accounts(db: AsyncSession) -> tuple[int, int]:
"""How many Bambu / Orca accounts a backup would collect from.
Asks the collector itself rather than re-deriving the rule, so the number
the UI gates on can't drift from the number the backup actually uses
(#2717). Counts only — never who.
"""
try:
bambu, orca = await github_backup_service.cloud_accounts(db)
return len(bambu), len(orca)
except Exception:
# A settings page must still render when a credential store is
# unreadable; the toggle simply shows as unavailable.
logger.warning("Failed to count connected cloud accounts", exc_info=True)
return 0, 0
@router.get("/cloud-accounts", response_model=CloudAccountCounts)
async def get_cloud_accounts(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
):
"""How many cloud accounts the Cloud Profiles category would collect from.
Its own endpoint rather than a field on ``/config``, because the settings
form needs this before any config exists ``/config`` answers ``null``
until the first save, which would leave the toggle disabled during the
very setup it's part of.
"""
bambu, orca = await _count_cloud_accounts(db)
return CloudAccountCounts(bambu=bambu, orca=orca)
def _config_to_response(config: GitHubBackupConfig) -> dict:
"""Convert config model to response dict."""
return {

View file

@ -148,6 +148,9 @@ async def set_kprofile(
)
if not delete_success:
raise HTTPException(500, "Failed to delete existing K-profile for edit")
ok, detail = await client.await_cali_ack(delete_success)
if not ok:
raise HTTPException(500, f"Printer rejected the K-profile edit: {detail}")
# Wait for printer to process the delete before adding
await asyncio.sleep(0.5)
@ -179,6 +182,13 @@ async def set_kprofile(
if not success:
raise HTTPException(500, "Failed to send K-profile command")
# The printer answers extrusion_cali_set with result/reason, echoing our
# sequence_id. Until #2718 that answer was logged at DEBUG and discarded,
# so a rejected write was reported to the user as saved.
ok, detail = await client.await_cali_ack(success)
if not ok:
raise HTTPException(500, f"Printer rejected the K-profile: {detail}")
message = "K-profile updated successfully" if is_edit else "K-profile added successfully"
return {"success": True, "message": message}
@ -239,6 +249,10 @@ async def set_kprofiles_batch(
if not success:
raise HTTPException(500, "Failed to send K-profiles batch command")
ok, detail = await client.await_cali_ack(success)
if not ok:
raise HTTPException(500, f"Printer rejected the K-profiles: {detail}")
return {"success": True, "message": f"Added {len(profiles)} K-profiles"}
@ -283,6 +297,10 @@ async def delete_kprofile(
if not success:
raise HTTPException(500, "Failed to send K-profile delete command")
ok, detail = await client.await_cali_ack(success)
if not ok:
raise HTTPException(500, f"Printer rejected the delete: {detail}")
# Wait for printer to process the delete before frontend refetches
await asyncio.sleep(0.5)

View file

@ -64,10 +64,16 @@ from backend.app.schemas.library import (
)
from backend.app.schemas.slicer import SliceRequest, SliceResponse
from backend.app.services.archive import ThreeMFParser
from backend.app.services.design_settings import (
apply_design_overrides,
extract_design_process_overrides,
overrides_from_config,
)
from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
from backend.app.services.stl_thumbnail import MIN_USABLE_STL_BYTES, generate_stl_thumbnail
from backend.app.utils.filename import InvalidFilenameError, validate_print_filename
from backend.app.utils.threemf_tools import (
expand_to_project_slots,
extract_embedded_presets_from_3mf,
extract_nozzle_mapping_from_3mf,
extract_project_filaments_from_3mf,
@ -77,6 +83,9 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/library", tags=["library"])
# Path of the embedded slicer config inside a BambuStudio/OrcaSlicer 3MF.
_PROJECT_SETTINGS_PATH = "Metadata/project_settings.config"
def _ensure_library_file_visible(
library_file: LibraryFile | None,
@ -751,24 +760,34 @@ async def list_folders(
)
file_counts = dict(file_counts_result.all())
# Latest immediate-child file activity per folder (#1770). Sibling of the
# file_counts subquery — same WHERE clause, MAX(updated_at) instead of
# COUNT(id). Subfolder descent is not aggregated here; the frontend's
# "sort by recent activity" mode is satisfied by immediate-parent bubble.
# Latest immediate-child file activity per folder (#1770/#2680). Real on-disk
# mtime when we have it (external scans populate ``fs_modified_at``), else the
# DB ``updated_at`` — COALESCE so external rows scanned before this field
# existed, and internal uploads, still contribute a signal. This is the
# per-folder *leaf* value; subtree descent is aggregated recursively below.
latest_file_activity_result = await db.execute(
select(LibraryFile.folder_id, func.max(LibraryFile.updated_at))
select(
LibraryFile.folder_id,
func.max(func.coalesce(LibraryFile.fs_modified_at, LibraryFile.updated_at)),
)
.where(LibraryFile.folder_id.isnot(None), LibraryFile.deleted_at.is_(None))
.group_by(LibraryFile.folder_id)
)
latest_file_activity = dict(latest_file_activity_result.all())
# Build tree structure
# Build tree structure. Each folder's initial ``latest_activity_at`` is its own
# leaf activity: the newer of its real directory mtime (fallback updated_at)
# and its immediate files' mtime. The recursive bubble below then rolls each
# subtree's newest descendant up to its ancestors (#2680 — sorting must match
# ``ls -t`` recursively, so a freshly-added deep file lifts every parent).
folder_map = {}
root_folders = []
for folder, project_name, archive_name in rows:
own_activity = folder.fs_modified_at or folder.updated_at
latest_file = latest_file_activity.get(folder.id)
latest_activity_at = max(folder.updated_at, latest_file) if latest_file is not None else folder.updated_at
if latest_file is not None and latest_file > own_activity:
own_activity = latest_file
folder_item = FolderTreeItem(
id=folder.id,
name=folder.name,
@ -781,7 +800,7 @@ async def list_folders(
external_path=folder.external_path,
external_readonly=folder.external_readonly,
file_count=file_counts.get(folder.id, 0),
latest_activity_at=latest_activity_at,
latest_activity_at=own_activity,
children=[],
)
folder_map[folder.id] = folder_item
@ -794,6 +813,28 @@ async def list_folders(
elif folder.parent_id in folder_map:
folder_map[folder.parent_id].children.append(folder_item)
# Recursive newest-descendant bubble (#2680). Post-order: a folder's activity
# becomes the max of its own leaf activity and every descendant's, so sorting
# the tree by ``latest_activity_at`` surfaces the branch with the most recent
# activity anywhere inside it. Iterative stack keeps deep external mounts off
# Python's recursion limit.
def _bubble(root: FolderTreeItem) -> None:
order: list[FolderTreeItem] = []
stack = [root]
while stack:
node = stack.pop()
order.append(node)
stack.extend(node.children)
for node in reversed(order): # deepest first
for child in node.children:
if child.latest_activity_at is not None and (
node.latest_activity_at is None or child.latest_activity_at > node.latest_activity_at
):
node.latest_activity_at = child.latest_activity_at
for root in root_folders:
_bubble(root)
return root_folders
@ -819,11 +860,12 @@ async def get_folders_by_project(
folders = []
for folder, project_name in rows:
# Get file count + latest file activity (#1770) in one trip
# Get file count + latest file activity (#1770/#2680) in one trip. Prefer
# the real on-disk mtime (external scans), fall back to the DB updated_at.
agg_result = await db.execute(
select(
func.count(LibraryFile.id),
func.max(LibraryFile.updated_at),
func.max(func.coalesce(LibraryFile.fs_modified_at, LibraryFile.updated_at)),
).where(
LibraryFile.folder_id == folder.id,
LibraryFile.deleted_at.is_(None),
@ -831,7 +873,8 @@ async def get_folders_by_project(
)
file_count, latest_file = agg_result.one()
file_count = file_count or 0
latest_activity_at = max(folder.updated_at, latest_file) if latest_file is not None else folder.updated_at
own_activity = folder.fs_modified_at or folder.updated_at
latest_activity_at = max(own_activity, latest_file) if latest_file is not None else own_activity
folders.append(
FolderResponse(
@ -878,11 +921,12 @@ async def get_folders_by_archive(
folders = []
for folder, archive_name in rows:
# Get file count + latest file activity (#1770) in one trip
# Get file count + latest file activity (#1770/#2680) in one trip. Prefer
# the real on-disk mtime (external scans), fall back to the DB updated_at.
agg_result = await db.execute(
select(
func.count(LibraryFile.id),
func.max(LibraryFile.updated_at),
func.max(func.coalesce(LibraryFile.fs_modified_at, LibraryFile.updated_at)),
).where(
LibraryFile.folder_id == folder.id,
LibraryFile.deleted_at.is_(None),
@ -890,7 +934,8 @@ async def get_folders_by_archive(
)
file_count, latest_file = agg_result.one()
file_count = file_count or 0
latest_activity_at = max(folder.updated_at, latest_file) if latest_file is not None else folder.updated_at
own_activity = folder.fs_modified_at or folder.updated_at
latest_activity_at = max(own_activity, latest_file) if latest_file is not None else own_activity
folders.append(
FolderResponse(
@ -1209,23 +1254,61 @@ async def update_folder(
)
async def _restricted_folder_delete_blocker(db: AsyncSession, folder: LibraryFolder) -> str | None:
"""Why a library:delete_own user may NOT delete this folder, or None if they may.
Folders have no ownership tracking, so users without library:delete_all may
only delete folders that are truly empty an empty folder contains nobody's
data (#1781). "Empty" must include trashed files: LibraryFile.folder_id
cascades on folder delete, so a folder holding another user's trashed file
would silently break trash restore.
"""
if folder.is_external:
return "External folders can only be deleted by users with library:delete_all"
if folder.project_id is not None or folder.archive_id is not None:
return "Folders linked to a project or archive can only be deleted by users with library:delete_all"
child_result = await db.execute(select(func.count(LibraryFolder.id)).where(LibraryFolder.parent_id == folder.id))
if (child_result.scalar() or 0) > 0:
return "Only empty folders can be deleted without library:delete_all"
# Includes trashed files (no deleted_at filter) — see docstring.
file_result = await db.execute(select(func.count(LibraryFile.id)).where(LibraryFile.folder_id == folder.id))
if (file_result.scalar() or 0) > 0:
return "Only empty folders can be deleted without library:delete_all (the folder may contain trashed files)"
return None
@router.delete("/folders/{folder_id}")
async def delete_folder(
folder_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_DELETE_ALL)),
auth_result: tuple[User | None, bool] = Depends(
require_ownership_permission(
Permission.LIBRARY_DELETE_ALL,
Permission.LIBRARY_DELETE_OWN,
)
),
):
"""Delete a folder and all its contents (cascade).
Note: Folders require library:delete_all permission since they don't have
ownership tracking.
Folders have no ownership tracking, so cascade deletion requires
library:delete_all. Users with only library:delete_own may delete empty,
non-external, non-linked folders (#1781).
"""
_, can_modify_all = auth_result
result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
folder = result.scalar_one_or_none()
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
if not can_modify_all:
blocker = await _restricted_folder_delete_blocker(db, folder)
if blocker:
raise HTTPException(status_code=403, detail=blocker)
# External folders: only remove DB records, never delete files from external path
is_ext = folder.is_external
@ -1482,6 +1565,16 @@ async def create_external_folder(
)
def _mtime_to_datetime(mtime: float) -> datetime:
"""Convert an ``os.stat().st_mtime`` epoch value to a naive-UTC datetime (#2680).
Naive UTC to match the other library timestamp columns (``created_at`` /
``updated_at`` are naive ``func.now()``), so activity comparisons never mix
naive and aware values on either dialect.
"""
return datetime.fromtimestamp(mtime, tz=timezone.utc).replace(tzinfo=None)
@router.post("/folders/{folder_id}/scan")
async def scan_external_folder(
folder_id: int,
@ -1557,6 +1650,8 @@ async def scan_external_folder(
removed = 0
found_paths: set[str] = set()
seen_rel_dirs: set[str] = set()
# Real on-disk mtime per visited folder id (#2680), applied after the walk.
folder_mtimes: dict[int, datetime] = {}
for dirpath, dirnames, filenames in os.walk(ext_path):
# Filter hidden directories unless configured
@ -1606,6 +1701,15 @@ async def scan_external_folder(
target_folder_id = folder_cache.get(rel_dir, folder_id)
# Record this directory's own mtime (#2680). os.walk visits every
# directory once, so this covers the root external folder and every
# subfolder (existing or just created). Applied to the folder rows
# after the walk completes.
try:
folder_mtimes[target_folder_id] = _mtime_to_datetime(os.stat(dirpath).st_mtime)
except OSError:
pass
for filename in filenames:
# Skip hidden files unless configured
if not folder.external_show_hidden and filename.startswith("."):
@ -1634,7 +1738,17 @@ async def scan_external_folder(
found_paths.add(file_path_str)
if file_path_str in existing_files:
continue # Already tracked
# Already tracked — refresh its on-disk mtime (#2680) so a file
# edited/replaced over the mount (samba, etc.) re-sorts correctly
# and old rows scanned before this field existed get backfilled.
tracked = existing_files[file_path_str]
try:
fs_mtime = _mtime_to_datetime(filepath.stat().st_mtime)
except OSError:
fs_mtime = None
if fs_mtime is not None and tracked.fs_modified_at != fs_mtime:
tracked.fs_modified_at = fs_mtime
continue
# Get file info
try:
@ -1717,6 +1831,7 @@ async def scan_external_folder(
file_hash=None, # Skip hashing external files for performance
thumbnail_path=thumbnail_path,
file_metadata=_without_print_name(file_metadata),
fs_modified_at=_mtime_to_datetime(stat.st_mtime), # #2680: real on-disk mtime
)
db.add(db_file)
added += 1
@ -1767,6 +1882,16 @@ async def scan_external_folder(
sub_folder_obj = sub_folder_result.scalar_one_or_none()
if sub_folder_obj:
await db.delete(sub_folder_obj)
folder_mtimes.pop(sub_fid, None)
# Persist each visited folder's real directory mtime (#2680). Fetched in one
# trip; folders deleted by the cleanup above were dropped from folder_mtimes.
if folder_mtimes:
folders_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id.in_(list(folder_mtimes.keys()))))
for folder_obj in folders_result.scalars().all():
new_mtime = folder_mtimes.get(folder_obj.id)
if new_mtime is not None and folder_obj.fs_modified_at != new_mtime:
folder_obj.fs_modified_at = new_mtime
await db.commit()
@ -1928,6 +2053,7 @@ async def list_files(
created_by_id=f.created_by_id,
created_by_username=f.created_by.username if f.created_by else None,
created_at=f.created_at,
fs_modified_at=f.fs_modified_at,
print_name=print_name,
print_time_seconds=print_time,
filament_used_grams=filament_grams,
@ -2542,6 +2668,17 @@ async def add_files_to_queue(
result = await db.execute(LibraryFile.active().where(LibraryFile.id.in_(request.file_ids)))
files = {f.id: f for f in result.scalars().all()}
# Project attribution (#1897): a file queued from a project-linked folder
# inherits that project, so the resulting archive counts toward the
# project's progress. A file's own project link wins over its folder's.
folder_ids = {f.folder_id for f in files.values() if f.folder_id is not None}
folder_projects: dict[int, int | None] = {}
if folder_ids:
folder_result = await db.execute(
select(LibraryFolder.id, LibraryFolder.project_id).where(LibraryFolder.id.in_(folder_ids))
)
folder_projects = dict(folder_result.all())
# Get max position for queue ordering
pos_result = await db.execute(select(func.coalesce(func.max(PrintQueueItem.position), 0)))
max_position = pos_result.scalar() or 0
@ -2579,6 +2716,8 @@ async def add_files_to_queue(
queue_item = PrintQueueItem(
printer_id=None, # Unassigned
library_file_id=file_id,
project_id=lib_file.project_id
or (folder_projects.get(lib_file.folder_id) if lib_file.folder_id is not None else None),
position=max_position,
status="pending",
)
@ -2644,11 +2783,23 @@ async def get_library_file_plates(
# SliceModal to default its dropdowns (#1325). Initialised here so the
# final return never raises NameError when the file isn't a valid zip.
embedded_presets: dict[str, str | None] = {"printer": None, "process": None}
# Process settings the designer changed away from the stock preset (#2622).
# Offered in the SliceModal so a cross-printer re-slice can carry them
# instead of silently losing them to the picked process profile.
design_overrides: list[dict] = []
try:
with zipfile.ZipFile(file_path, "r") as zf:
namelist = zf.namelist()
embedded_presets = extract_embedded_presets_from_3mf(zf)
if _PROJECT_SETTINGS_PATH in namelist:
try:
design_overrides = [
o._asdict()
for o in overrides_from_config(json.loads(zf.read(_PROJECT_SETTINGS_PATH).decode("utf-8")))
]
except (ValueError, OSError, KeyError):
design_overrides = []
# Find all plate gcode files to determine available plates
gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
@ -2877,6 +3028,7 @@ async def get_library_file_plates(
"is_multi_plate": len(plates) > 1,
"embedded_printer": embedded_presets["printer"],
"embedded_process": embedded_presets["process"],
"design_overrides": design_overrides,
}
@ -2930,6 +3082,7 @@ async def _try_preview_slice_filaments(
"""
from backend.app.api.routes.settings import get_setting
from backend.app.services.slice_preview import get_preview_filaments
from backend.app.services.slicer_api import get_stall_timeout_seconds
preferred = (await get_setting(db, "preferred_slicer")) or "bambu_studio"
if preferred == "orcaslicer":
@ -2955,6 +3108,7 @@ async def _try_preview_slice_filaments(
file_name=file_path.name,
api_url=api_url,
request_id=request_id,
timeout_seconds=await get_stall_timeout_seconds(db),
)
@ -2963,6 +3117,7 @@ async def get_library_file_filament_requirements(
file_id: int,
plate_id: int | None = None,
request_id: str | None = None,
full_slots: bool = False,
db: AsyncSession = Depends(get_db),
auth_result: tuple[User | None, bool] = Depends(
require_ownership_permission(
@ -2979,6 +3134,10 @@ async def get_library_file_filament_requirements(
Args:
file_id: The library file ID
plate_id: Optional plate index to get filaments for a specific plate
full_slots: Return one entry per *project* slot rather than only the
slots the plate consumes. See :func:`_expand_to_project_slots`.
Only the slice modal wants this; print-time AMS matching must keep
the used-only list.
"""
import defusedxml.ElementTree as ET
@ -3081,6 +3240,17 @@ async def get_library_file_filament_requirements(
}
)
# Re-slicing a source that already carries slice_info (#2712).
# The block above answers "what does this plate consume", which is
# what print-time AMS matching needs. The slice modal needs "what
# slots exist", because its list is positional and the CLI binds
# entry N to slot N — so a source using only slot 4 handed the
# user's single pick to slot 1 and sliced slot 4 with the source's
# embedded default. Widen here rather than in the modal so the
# print path keeps the narrow list it depends on.
if full_slots and filaments:
filaments = expand_to_project_slots(zf, filaments)
# Unsliced project files: slice_info had no per-plate data.
# Return the FULL project_settings.config AMS slot list so
# the slicer CLI receives a profile for every project slot
@ -3439,6 +3609,8 @@ async def _run_slicer_with_fallback(
SlicerApiService,
SlicerApiUnavailableError,
SlicerInputError,
SlicerTimeoutError,
get_stall_timeout_seconds,
)
user: User | None = None
@ -3527,6 +3699,21 @@ async def _run_slicer_with_fallback(
# with a PVA slot loaded but never used.
presets["process"] = _patch_process_support_settings(presets["process"], primary_bytes)
# #2622: carry the designer's own process tweaks onto the picked preset.
# BambuStudio records exactly which keys deviate from the system preset
# in `different_settings_to_system`, so a MakerWorld author's 5 walls /
# 100% infill / 0.1mm first layer survive a re-slice for another printer
# instead of being flattened by --load-settings. Opt-in per key: only the
# keys the caller names are applied, and only if the source really lists
# them as changed. Runs after the #1881 support patch so an explicit
# design pick wins over the blanket support carry-over.
if request.design_overrides:
presets["process"] = apply_design_overrides(
presets["process"],
extract_design_process_overrides(primary_bytes),
request.design_overrides,
)
used_embedded_settings = False
# "Slice as designed" (#2611): honour the file's embedded
# project_settings.config instead of the picked profile triplet. Only
@ -3534,7 +3721,9 @@ async def _run_slicer_with_fallback(
# gates the toggle on the picked printer matching the design's target,
# so this path never re-targets across printer models.
embedded_mode = bool(request.use_embedded_settings and is_3mf)
service = SlicerApiService(api_url)
# Bounds silence rather than total slicing time (#2730), so a heavy model
# that keeps reporting progress runs to completion however long it takes.
service = SlicerApiService(api_url, timeout_seconds=await get_stall_timeout_seconds(db))
# #1493: cross-nozzle-class re-slice (single <-> dual). Without
# intervention the slicer rejects with either "G-code in unprintable
@ -3595,10 +3784,26 @@ async def _run_slicer_with_fallback(
# with printer …" (#2628). Replace unused-slot entries with the
# plate's lowest used slot before the real slice so the loaded set is
# materially homogeneous and printer-correct.
if is_3mf and request.plate is not None:
#
# ``plate`` is absent for single-plate and STL sources — the SliceModal
# skips the picker and omits the field — and absent means plate 1, the
# same reading as ``plate_num`` further down and as the schema's own
# description. Treating it as "unknown plate" instead is what left every
# single-plate 3MF unsubstituted (#2711): a MakerWorld project defining
# four filaments but painting only one reached the CLI with the other
# three still holding presets baked into the source for a different
# printer, and the slice died on the first of them.
#
# ``plate=0`` is the slice-all sentinel, not a plate: every slot is used
# by some plate, so there is nothing to substitute. It has to be excluded
# explicitly because the support-filament slots unioned in below are
# read from the project config and are not plate-scoped — they would
# survive the (empty) geometry lookup for plate 0 and become the anchor,
# collapsing every colour of a slice-all onto the support filament.
if is_3mf and request.plate != 0:
from backend.app.services.slicer_3mf_convert import substitute_unused_plate_filaments
filament_jsons = substitute_unused_plate_filaments(primary_bytes, request.plate, filament_jsons)
filament_jsons = substitute_unused_plate_filaments(primary_bytes, request.plate or 1, filament_jsons)
# Cross-class slice-all loop (#1493): when the user asks for
# ``plate=0`` (all plates) AND the source's nozzle class differs from
@ -3761,6 +3966,12 @@ async def _run_slicer_with_fallback(
used_embedded_settings = True
except SlicerInputError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except SlicerTimeoutError as exc:
# 504, not 502: the sidecar answered for the whole run, we stopped
# waiting. Reported separately so the user is told the slice ran out of
# time and where to change that, rather than that the sidecar is
# unreachable — which is what a read timeout used to look like (#2730).
raise HTTPException(status_code=504, detail=str(exc)) from exc
except SlicerApiServerError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
except SlicerApiUnavailableError as exc:
@ -4810,16 +5021,15 @@ async def bulk_delete(
file.deleted_at = now
deleted_files += 1
# Delete folders (cascade will handle contents)
# Note: Folders don't have ownership tracking currently, require *_all permission
# Delete folders (cascade will handle contents). Folders have no ownership
# tracking, so users without *_all permission may only delete empty,
# non-external, non-linked folders (#1781) — same rule as DELETE /folders/{id}.
for folder_id in data.folder_ids:
if not can_modify_all:
# Users without *_all permission cannot delete folders
continue
result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
folder = result.scalar_one_or_none()
if folder:
if not can_modify_all and await _restricted_folder_delete_blocker(db, folder):
continue
# Count files that will be deleted
file_count_result = await db.execute(
select(func.count(LibraryFile.id)).where(

View file

@ -1404,6 +1404,18 @@ async def create_oidc_provider(
return _build_provider_response(provider)
def _refuse_if_env_managed(provider: OIDCProvider) -> None:
"""Startup rewrites this provider from BAMBUDDY_OIDC_* on every boot, so an
edit here would be accepted and then silently reverted at the next restart.
BAMBUDDY_LOCAL_LOGIN (#1589) remains the recovery path if it becomes
unusable, so refusing outright cannot lock anyone out."""
if provider.is_env_managed:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="This OIDC provider is managed by environment variables and cannot be modified.",
)
@router.put("/oidc/providers/{provider_id}", response_model=OIDCProviderResponse)
async def update_oidc_provider(
provider_id: int,
@ -1426,6 +1438,7 @@ async def update_oidc_provider(
provider = result2.scalar_one_or_none()
if not provider:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
_refuse_if_env_managed(provider)
if body.default_group_id is not None:
grp_chk = await db.execute(select(Group).where(Group.id == body.default_group_id))
@ -1503,6 +1516,7 @@ async def delete_oidc_provider(
provider = result2.scalar_one_or_none()
if not provider:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
_refuse_if_env_managed(provider)
await db.delete(provider)
await db.commit()
@ -1571,6 +1585,7 @@ async def delete_oidc_provider_icon(
provider = result.scalar_one_or_none()
if provider is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
_refuse_if_env_managed(provider)
# Setting deferred columns is safe — no read happens, just a write.
provider.icon_url = None
@ -1603,6 +1618,7 @@ async def refresh_oidc_provider_icon(
provider = result.scalar_one_or_none()
if provider is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
_refuse_if_env_managed(provider)
if not provider.icon_url:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,

View file

@ -58,6 +58,7 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
"on_ams_ht_temperature_high": provider.on_ams_ht_temperature_high,
# Build plate detection
"on_plate_not_empty": provider.on_plate_not_empty,
"on_plate_clear_required": provider.on_plate_clear_required,
# Bed cooled
"on_bed_cooled": provider.on_bed_cooled,
# First layer complete
@ -139,6 +140,7 @@ async def create_notification_provider(
on_ams_ht_temperature_high=provider_data.on_ams_ht_temperature_high,
# Build plate detection
on_plate_not_empty=provider_data.on_plate_not_empty,
on_plate_clear_required=provider_data.on_plate_clear_required,
# Bed cooled
on_bed_cooled=provider_data.on_bed_cooled,
# First layer complete

View file

@ -17,6 +17,8 @@ router = APIRouter(prefix="/obico", tags=["obico"])
class TestConnectionRequest(BaseModel):
url: str
# Omitted entirely = test with the saved token; "" = test with no token.
token: str | None = None
@router.get("/status")
@ -37,15 +39,43 @@ async def get_status(
}
@router.get("/printer-status")
async def get_printer_status(
user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
):
"""Per-printer live classification for the printer cards (#1546).
Deliberately excludes configuration (ML URL, action, history) so users
with printers:read but no settings:read can still render the badge.
"""
settings = await obico_detection_service._load_settings()
enabled_printers = settings["enabled_printers"]
# Error strings can embed configured URLs (ML API base, external URL), so
# they stay behind settings:read like the rest of the configuration.
can_see_error = user is None or user.has_permission(Permission.SETTINGS_READ.value)
return {
"enabled": settings["enabled"],
# None = all printers are monitored
"monitored_printers": sorted(enabled_printers) if enabled_printers is not None else None,
"per_printer": obico_detection_service.get_per_printer(),
"last_error": obico_detection_service._last_error if can_see_error else None,
}
@router.post("/test-connection")
async def test_connection(
req: TestConnectionRequest,
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
):
"""Ping the Obico ML API `/hc/` health endpoint. Returns ok + raw body."""
"""Ping the Obico ML API health endpoint and check the token. Returns ok + raw body."""
if not req.url:
return {"ok": False, "status_code": None, "body": None, "error": "URL is empty"}
return await obico_detection_service.test_connection(req.url)
return {"ok": False, "status_code": None, "body": None, "error": "URL is empty", "auth_ok": None}
token = req.token
if token is None:
# Field omitted entirely — test what the service actually uses.
settings = await obico_detection_service._load_settings()
token = settings.get("ml_token") or ""
return await obico_detection_service.test_connection(req.url, token)
@router.get("/cached-frame/{nonce}")

View file

@ -431,6 +431,7 @@ async def _upsert_settings(db: AsyncSession, values: dict[str, str | None]) -> N
async def _build_authenticated_service(
db: AsyncSession,
user: User | None,
clear_on_auth_failure: bool = True,
) -> OrcaCloudService:
"""Construct an :class:`OrcaCloudService` pre-populated with stored
credentials. If the access token is within the refresh-leeway of expiry,
@ -440,7 +441,24 @@ async def _build_authenticated_service(
We don't lock around the refresh: Orca tolerates concurrent refreshes for
~60s (each racer gets its own valid pair on the same connection rather than
a revoke), so a lost race here is harmless last-write-wins on the stored
pair, and whichever pair we keep is valid."""
pair, and whichever pair we keep is valid.
``clear_on_auth_failure`` controls what happens when the refresh is
rejected. Routes leave it on: the caller is a person looking at the UI, and
wiping the dead credentials flips the page to disconnected in front of them
so they can pair again. Background jobs pass ``False`` see the caveat
below.
Why background callers must not clear: Orca reports every rejection with
one composite reason (``unknown, expired, revoked, or already used``), so
a genuine revocation is indistinguishable from a lost refresh-rotation
race. Acting destructively on a signal that can't be disambiguated is the
#2562 mistake in a different cloud. It also gains nothing — a route call
hits the same failure and clears then, at a moment the user can respond to.
A successful refresh is still persisted either way: by that point the old
refresh token is consumed, so dropping the new pair would break a working
pairing for real.
"""
creds = await _load_credentials(db, user)
if not creds.token:
raise HTTPException(status_code=401, detail="Orca Cloud is not connected — sign in first.")
@ -457,8 +475,11 @@ async def _build_authenticated_service(
await svc.refresh()
except OrcaCloudAuthError as e:
# Refresh token was revoked or rotated out from under us. Clear
# the stale credentials so the UI flips to disconnected.
await _clear_credentials(db, user)
# the stale credentials so the UI flips to disconnected — unless
# the caller is a background job, which must not change sign-in
# state on its own.
if clear_on_auth_failure:
await _clear_credentials(db, user)
raise HTTPException(status_code=401, detail=f"Orca Cloud session refresh failed: {e}") from e
except OrcaCloudError as e:
raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e

View file

@ -250,6 +250,24 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
response.nozzle_diameter = item.archive.nozzle_diameter
response.sliced_for_model = item.archive.sliced_for_model
response.bed_type = item.archive.bed_type
# Marks history/reprint rows whose archive carries the slicer's own
# live-resolved AMS-slot pick (extra_data.slicer_ams_mapping) — see
# `_extract_slicer_ams_mapping_json` in virtual_printer/manager.py.
#
# Only when the saved mapping was resolved against *this* row's
# printer: a global tray ID means nothing on another printer, so
# that's the exact condition under which the mapping is reused. A
# badge on a row where nothing gets reused would be a lie (#2700
# review). Model-based rows (printer_id None) never match, which is
# correct — the mapping is not reused there either.
extra = item.archive.extra_data if isinstance(item.archive.extra_data, dict) else {}
saved_mapping = extra.get("slicer_ams_mapping")
response.archive_has_slicer_ams_mapping = (
isinstance(saved_mapping, dict)
and isinstance(saved_mapping.get("mapping"), list)
and item.printer_id is not None
and saved_mapping.get("printer_id") == item.printer_id
)
if item.plate_id:
archive_path = settings.base_dir / item.archive.file_path
if archive_path.exists():
@ -643,6 +661,51 @@ async def add_to_queue(
raise HTTPException(status_code=404, detail="Project not found")
ams_mapping_json = json.dumps(data.ams_mapping) if data.ams_mapping else None
# Reprint fallback: the caller didn't specify an explicit ams_mapping (no
# per-slot filament-mapping edit was made), but the archive carries the
# slicer's own live-resolved AMS-slot pick from the original print (see
# `extra_data.slicer_ams_mapping`, written by the VP-queue path via
# `_extract_slicer_ams_mapping_json`). Reuse it so the reprint dispatches
# to the exact same physical spool instead of the scheduler re-deriving a
# (possibly ambiguous) mapping from just the file's static type/color.
#
# Global tray IDs only mean something relative to the specific printer
# they were resolved against, so this only fires when the reprint targets
# that exact printer (`extra_data.slicer_ams_mapping.printer_id`) — never
# for a model-based dispatch (data.printer_id is None) or a reprint aimed
# at a different printer, where the same tray number can hold a
# completely different spool (#2700 review).
#
# It also stands down when the request carries force-color-match overrides:
# those are the caller asking the scheduler to match strictly against the
# printer's live trays, and they are only ever applied inside
# `_compute_ams_mapping_for_printer` — the function a stored mapping makes
# the scheduler skip. Same precedence as the VP-side toggle pair (#2700
# review).
#
# Note this is otherwise unconditional — it applies regardless of whether
# the physical spool in that slot has changed since the original print.
# #1308 covers re-verifying a stored mapping against live AMS state at
# dispatch time; that check is a separate PR and, once merged, will also
# catch a stale slot inherited through this fallback.
wants_live_color_match = any(
isinstance(o, dict) and o.get("force_color_match") for o in (data.filament_overrides or [])
)
if (
ams_mapping_json is None
and not wants_live_color_match
and archive
and archive.extra_data
and data.printer_id is not None
):
saved = archive.extra_data.get("slicer_ams_mapping")
if (
isinstance(saved, dict)
and saved.get("printer_id") == data.printer_id
and isinstance(saved.get("mapping"), list)
and saved["mapping"]
):
ams_mapping_json = json.dumps(saved["mapping"])
items = []
for i in range(quantity):
item = PrintQueueItem(

View file

@ -64,6 +64,7 @@ from backend.app.services.printer_manager import (
)
from backend.app.utils.filament_ids import filament_id_to_setting_id
from backend.app.utils.http import build_content_disposition
from backend.app.utils.printer_models import uses_exhaust_fan_label
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/printers", tags=["printers"])
@ -798,6 +799,8 @@ async def get_printer_status(
big_fan1_speed=state.big_fan1_speed,
big_fan2_speed=state.big_fan2_speed,
heatbreak_fan_speed=state.heatbreak_fan_speed,
left_aux_fan_speed=state.left_aux_fan_speed,
exhaust_fan_present=state.exhaust_fan_present,
firmware_version=state.firmware_version,
developer_mode=state.developer_mode if state else None,
ams_filament_backup=state.ams_filament_backup if state else None,
@ -3193,16 +3196,28 @@ async def set_chamber_temperature(
@router.post("/{printer_id}/fan-speed")
async def set_fan_speed(
printer_id: int,
fan: str = Query(..., description="Fan to control: part, aux, or chamber"),
fan: str = Query(..., description="Fan to control: part, aux, aux2 (left aux), or chamber"),
speed: int = Query(..., ge=0, le=100, description="Fan speed percentage"),
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
db: AsyncSession = Depends(get_db),
):
"""Set a fan speed by percentage."""
fan_ids = {"part": 1, "aux": 2, "chamber": 3}
"""Set a fan speed by percentage.
Fan index 10 ("aux2") is the optional left auxiliary part cooling fan on
P2S/X2D driven with "M106 P10" exactly like Bambu's official machine
profile gcode does. It only exists when the printer reports airduct part 10,
so the request is rejected rather than sending M106 P10 into the void on a
machine that has no such fan.
That gate also rejects for the short window between connecting and the
first airduct push, when nothing is known about the fan yet. The card hides
the badge over the same window, so there is no control to click; a direct
API caller gets a 400 and should retry once the status reports the fan.
"""
fan_ids = {"part": 1, "aux": 2, "chamber": 3, "aux2": 10}
fan_id = fan_ids.get(fan)
if fan_id is None:
raise HTTPException(400, "fan must be 'part', 'aux', or 'chamber'")
raise HTTPException(400, "fan must be 'part', 'aux', 'aux2', or 'chamber'")
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
@ -3213,12 +3228,31 @@ async def set_fan_speed(
if not client:
raise HTTPException(400, "Printer not connected")
# Presence gate for the accessory fan. Without this, aux2 is accepted for
# every model and an A1 would be sent M106 P10 for a fan it does not have.
# The UI already hides the badge; this closes the same hole on the API.
if fan == "aux2" and getattr(client.state, "left_aux_fan_speed", None) is None:
raise HTTPException(
400,
"This printer does not report a left auxiliary fan "
"(no airduct part 10). The fan is an accessory kit on the P2S "
"and factory-fitted on the X2D.",
)
pwm_speed = round(speed * 255 / 100)
success = client.set_fan_speed(fan_id, pwm_speed)
if not success:
raise HTTPException(500, "Failed to set fan speed")
fan_names = {"part": "Part cooling fan", "aux": "Auxiliary fan", "chamber": "Chamber fan"}
# The enclosure fan is called "Exhaust" on P2S/X2D and "Chamber" elsewhere;
# match whatever the printer card badge shows so the toast agrees with the
# control the user just clicked.
fan_names = {
"part": "Part cooling fan",
"aux": "Auxiliary fan",
"aux2": "Left auxiliary fan",
"chamber": "Exhaust fan" if uses_exhaust_fan_label(printer.model) else "Chamber fan",
}
return {"success": True, "message": f"{fan_names[fan]} set to {speed}%"}

View file

@ -34,6 +34,7 @@ from backend.app.schemas.project import (
BOMItemUpdate,
ProjectChildPreview,
ProjectCreate,
ProjectFileProgress,
ProjectImport,
ProjectListResponse,
ProjectResponse,
@ -51,6 +52,21 @@ router = APIRouter(prefix="/projects", tags=["projects"])
_FAILURE_STATUSES = ("failed", "aborted", "cancelled", "stopped")
# Soft-deleted archives (#1343) keep their row — and therefore their
# ``project_id`` — after their files have been removed from disk, so that global
# Quick Stats can still count their filament / time / cost. Nothing in this
# module filtered on that, which left deleted prints listed on the project with
# thumbnails pointing at files that no longer exist, and no way to unassign them
# (the only unassign UI lives on the Archives page, which correctly hides them)
# — #2731.
#
# Every project-scoped query filters them out, counts included: a project that
# lists 11 prints must not claim 12. That is a deliberate divergence from the
# global Quick Stats behaviour, where the whole point of the soft delete is that
# the contribution survives. A project is a piece of work with a definite
# membership, not a lifetime total, so a print the user deleted has left it.
_LIVE_ARCHIVE = PrintArchive.deleted_at.is_(None)
async def compute_project_stats(
db: AsyncSession, project_id: int, target_count: int | None = None, target_parts_count: int | None = None
@ -82,7 +98,7 @@ async def compute_project_stats(
func.coalesce(func.sum(PrintLogEntry.energy_cost), 0).label("total_energy_cost"),
)
.join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
.where(PrintArchive.project_id == project_id)
.where(PrintArchive.project_id == project_id, _LIVE_ARCHIVE)
)
log_stats = log_stats_result.first()
total_archives = int(log_stats.total_runs or 0)
@ -103,7 +119,7 @@ async def compute_project_stats(
).label("failed_runs"),
)
.join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
.where(PrintArchive.project_id == project_id)
.where(PrintArchive.project_id == project_id, _LIVE_ARCHIVE)
)
items_split = items_split_result.first()
total_items = int(items_split.total_items or 0)
@ -211,7 +227,7 @@ async def list_projects(
).label("failed_count"),
)
.join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
.where(PrintArchive.project_id == project.id)
.where(PrintArchive.project_id == project.id, _LIVE_ARCHIVE)
)
log_quick = log_quick_result.first()
archive_count = int(log_quick.archive_count or 0)
@ -236,7 +252,7 @@ async def list_projects(
# Get archive previews (up to 6 most recent)
archives_result = await db.execute(
select(PrintArchive)
.where(PrintArchive.project_id == project.id)
.where(PrintArchive.project_id == project.id, _LIVE_ARCHIVE)
.order_by(PrintArchive.created_at.desc())
.limit(6)
)
@ -262,6 +278,7 @@ async def list_projects(
status=project.status,
target_count=project.target_count,
target_parts_count=project.target_parts_count,
target_sets=project.target_sets,
budget=project.budget,
tags=project.tags,
due_date=project.due_date,
@ -304,6 +321,7 @@ async def create_project(
color=data.color,
target_count=data.target_count,
target_parts_count=data.target_parts_count,
target_sets=data.target_sets,
notes=data.notes,
tags=data.tags,
due_date=data.due_date,
@ -326,6 +344,7 @@ async def create_project(
status=project.status,
target_count=project.target_count,
target_parts_count=project.target_parts_count,
target_sets=project.target_sets,
notes=project.notes,
attachments=project.attachments,
url=project.url,
@ -361,7 +380,7 @@ async def list_templates(
for project in templates:
# Get archive count
archive_count_result = await db.execute(
select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project.id)
select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project.id, _LIVE_ARCHIVE)
)
archive_count = archive_count_result.scalar() or 0
@ -374,6 +393,7 @@ async def list_templates(
status=project.status,
target_count=project.target_count,
target_parts_count=project.target_parts_count,
target_sets=project.target_sets,
budget=project.budget,
tags=project.tags,
due_date=project.due_date,
@ -415,6 +435,7 @@ async def create_project_from_template(
color=template.color,
target_count=template.target_count,
target_parts_count=template.target_parts_count,
target_sets=template.target_sets,
notes=template.notes,
tags=template.tags,
priority=template.priority,
@ -457,6 +478,7 @@ async def create_project_from_template(
status=project.status,
target_count=project.target_count,
target_parts_count=project.target_parts_count,
target_sets=project.target_sets,
notes=project.notes,
attachments=project.attachments,
url=project.url,
@ -491,6 +513,7 @@ async def get_child_previews(db: AsyncSession, parent_id: int) -> list[ProjectCh
select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
PrintArchive.project_id == child.id,
PrintArchive.status == "completed",
_LIVE_ARCHIVE,
)
)
completed_count = completed_result.scalar() or 0
@ -542,6 +565,7 @@ async def get_project(
status=project.status,
target_count=project.target_count,
target_parts_count=project.target_parts_count,
target_sets=project.target_sets,
notes=project.notes,
attachments=project.attachments,
url=project.url,
@ -590,6 +614,10 @@ async def update_project(
project.target_count = data.target_count
if data.target_parts_count is not None:
project.target_parts_count = data.target_parts_count
# Sent-but-null clears the copies-per-file target (#1897); omitted leaves it
# alone (same #2536 semantics as tags/due_date below).
if "target_sets" in data.model_fields_set:
project.target_sets = data.target_sets
if data.notes is not None:
project.notes = data.notes
# Sent-but-null clears the field; omitted leaves it alone. Guarding on
@ -642,6 +670,7 @@ async def update_project(
status=project.status,
target_count=project.target_count,
target_parts_count=project.target_parts_count,
target_sets=project.target_sets,
notes=project.notes,
attachments=project.attachments,
url=project.url,
@ -702,7 +731,7 @@ async def list_project_archives(
query = (
select(PrintArchive)
.options(selectinload(PrintArchive.project), selectinload(PrintArchive.created_by))
.where(PrintArchive.project_id == project_id)
.where(PrintArchive.project_id == project_id, _LIVE_ARCHIVE)
.order_by(PrintArchive.created_at.desc())
.limit(limit)
.offset(offset)
@ -740,6 +769,76 @@ async def list_project_queue(
return items
@router.get("/{project_id}/file-progress", response_model=list[ProjectFileProgress])
async def get_project_file_progress(
project_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
):
"""Completed-run counts per library file inside a project (#1897).
Counts completed ``PrintLogEntry`` rows (same source as the aggregate
project stats) of archives attributed to this project, and maps each run to
one of the project's library files — the files living in folders linked to
the project, the same set the project detail page renders.
A run is attributed to exactly one file, by the strongest available match:
1. ``archive.library_file_id`` (stamped at queue dispatch since #1897),
2. content hash (covers historical rows),
3. filename (covers hash drift, e.g. re-sliced uploads of the same name).
Files with no completed runs are omitted the frontend treats absence as 0.
"""
result = await db.execute(select(Project.id).where(Project.id == project_id))
if result.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="Project not found")
files_result = await db.execute(
select(LibraryFile.id, LibraryFile.file_hash, LibraryFile.filename)
.join(LibraryFolder, LibraryFile.folder_id == LibraryFolder.id)
.where(LibraryFolder.project_id == project_id, LibraryFile.deleted_at.is_(None))
)
file_rows = files_result.all()
if not file_rows:
return []
# First match wins within each tier, so iteration order (file id) is stable
# when duplicates share a hash or filename.
by_id = {fid for fid, _, _ in file_rows}
by_hash: dict[str, int] = {}
by_name: dict[str, int] = {}
for fid, fhash, fname in file_rows:
if fhash and fhash not in by_hash:
by_hash[fhash] = fid
if fname not in by_name:
by_name[fname] = fid
runs_result = await db.execute(
select(
PrintArchive.library_file_id,
PrintArchive.content_hash,
PrintArchive.filename,
func.count(PrintLogEntry.id),
)
.join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
.where(PrintArchive.project_id == project_id, PrintLogEntry.status == "completed", _LIVE_ARCHIVE)
.group_by(PrintArchive.library_file_id, PrintArchive.content_hash, PrintArchive.filename)
)
counts: dict[int, int] = {}
for lib_file_id, content_hash, filename, run_count in runs_result.all():
if lib_file_id in by_id:
fid = lib_file_id
elif content_hash and content_hash in by_hash:
fid = by_hash[content_hash]
elif filename in by_name:
fid = by_name[filename]
else:
continue
counts[fid] = counts.get(fid, 0) + run_count
return [ProjectFileProgress(file_id=fid, completed_count=n) for fid, n in sorted(counts.items())]
@router.post("/{project_id}/add-archives")
async def add_archives_to_project(
project_id: int,
@ -1402,6 +1501,7 @@ async def create_template_from_project(
color=source.color,
target_count=source.target_count,
target_parts_count=source.target_parts_count,
target_sets=source.target_sets,
notes=source.notes,
tags=source.tags,
priority=source.priority,
@ -1444,6 +1544,7 @@ async def create_template_from_project(
status=template.status,
target_count=template.target_count,
target_parts_count=template.target_parts_count,
target_sets=template.target_sets,
notes=template.notes,
attachments=template.attachments,
url=template.url,
@ -1495,7 +1596,7 @@ async def get_project_timeline(
# Get archives and add events
archives_result = await db.execute(
select(PrintArchive)
.where(PrintArchive.project_id == project_id)
.where(PrintArchive.project_id == project_id, _LIVE_ARCHIVE)
.order_by(PrintArchive.created_at.desc())
.limit(limit)
)
@ -1653,6 +1754,7 @@ async def export_project(
"status": project.status,
"target_count": project.target_count,
"target_parts_count": project.target_parts_count,
"target_sets": project.target_sets,
"notes": project.notes,
"tags": project.tags,
"due_date": project.due_date.isoformat() if project.due_date else None,
@ -1704,6 +1806,7 @@ async def import_project(
status=data.status,
target_count=data.target_count,
target_parts_count=data.target_parts_count,
target_sets=data.target_sets,
notes=data.notes,
tags=data.tags,
due_date=data.due_date,
@ -1766,6 +1869,7 @@ async def import_project(
status=project.status,
target_count=project.target_count,
target_parts_count=project.target_parts_count,
target_sets=project.target_sets,
notes=project.notes,
attachments=project.attachments,
url=project.url,
@ -1829,6 +1933,7 @@ async def import_project_file(
status=data.get("status", "active"),
target_count=data.get("target_count"),
target_parts_count=data.get("target_parts_count"),
target_sets=data.get("target_sets"),
notes=data.get("notes"),
tags=data.get("tags"),
due_date=datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None,
@ -1957,6 +2062,7 @@ async def import_project_file(
status=project.status,
target_count=project.target_count,
target_parts_count=project.target_parts_count,
target_sets=project.target_sets,
notes=project.notes,
attachments=project.attachments,
url=project.url,

View file

@ -42,6 +42,88 @@ async def get_setting(db: AsyncSession, key: str) -> str | None:
return setting.value if setting else None
# Accepted spellings for a boolean settings value. Settings live in a VARCHAR
# column and every reader compares them as strings, so these are normalised to
# "true"/"false" on the way in. The sets are deliberately generous: these
# endpoints are part of the documented REST surface, reached by scripts and by
# Home Assistant rest_command, where "True", "1" and "on" are all natural.
_TRUTHY_SETTING_VALUES = frozenset({"true", "1", "yes", "on"})
_FALSY_SETTING_VALUES = frozenset({"false", "0", "no", "off"})
def setting_is_true(value: object) -> bool:
"""Return True if a *stored* settings value means "on".
Deliberately narrower than the spellings ``normalize_bool_setting`` accepts:
it matches only what every other reader in the codebase treats as on
(``value.lower() == "true"``). Submitted values are canonicalised on write,
so a stored value is always "true"/"false"/""; accepting "1" or "on" here
would make this function disagree with the rest of the app about any legacy
row containing them.
A bool is tolerated for the case of a row written before values were
normalised, where SQLite coerced a raw bool into the VARCHAR column.
"""
if isinstance(value, bool):
return value
if value is None:
return False
return str(value).strip().lower() == "true"
def normalize_bool_setting(key: str, value: object) -> str:
"""Coerce a boolean-ish settings value to the canonical "true"/"false".
Raises HTTPException(400) for values with no sensible interpretation, so an
API client gets a message naming the field instead of a 500.
A JSON boolean is the natural thing for an API client to send, and before
this normalisation it caused two distinct failures on
``PUT /settings/spoolman``: ``bool.lower()`` raised AttributeError, and the
raw bool was written into a VARCHAR column, which SQLite silently coerces
to 1/0 while asyncpg rejects outright. Both surfaced as an opaque 500.
"""
if isinstance(value, bool): # must precede the int branch — bool is an int
return "true" if value else "false"
if isinstance(value, int):
if value in (0, 1):
return "true" if value else "false"
raise HTTPException(400, f"{key} must be a boolean; got the number {value}")
if isinstance(value, str):
candidate = value.strip().lower()
if not candidate:
# Empty is stored verbatim rather than normalised to "false".
# get_spoolman_settings reads these with ``or "<default>"``, so an
# empty stored value means "use the default" — and two of them
# (spoolman_report_partial_usage, auto_add_unknown_rfid) default to
# ON. Rewriting "" to "false" would silently switch them off for any
# client that submits a blank value.
return ""
if candidate in _TRUTHY_SETTING_VALUES:
return "true"
if candidate in _FALSY_SETTING_VALUES:
return "false"
raise HTTPException(400, f"{key} must be a boolean; got {value!r}")
raise HTTPException(400, f"{key} must be a boolean; got {type(value).__name__}")
def normalize_str_setting(key: str, value: object) -> str:
"""Return a string settings value, rejecting types that would store garbage.
``str()`` on a dict or list would persist its repr, so those are refused
rather than silently written. Numbers are accepted and stringified: a port
or a bare host submitted unquoted is a plausible client mistake, not a
reason to fail the request.
"""
if isinstance(value, str):
return value
if value is None:
return ""
if isinstance(value, bool | int | float):
return str(value)
raise HTTPException(400, f"{key} must be a string; got {type(value).__name__}")
async def get_external_login_url(db: AsyncSession) -> str:
"""Get the external URL for the login page.
@ -82,6 +164,7 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
"auto_archive",
"save_thumbnails",
"capture_finish_photo",
"finish_photo_restore_plate",
"spoolman_enabled",
"spoolman_disable_weight_sync",
"spoolman_report_partial_usage",
@ -435,14 +518,20 @@ async def update_spoolman_settings(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
):
"""Update Spoolman integration settings."""
"""Update Spoolman integration settings.
The body is a free-form dict rather than a schema, so each value is
normalised before it is persisted see ``normalize_bool_setting`` for why
a JSON boolean used to produce a 500 here.
"""
if "spoolman_enabled" in settings:
old_val = await get_setting(db, "spoolman_enabled") or "false"
new_val = settings["spoolman_enabled"]
was_enabled = setting_is_true(await get_setting(db, "spoolman_enabled"))
new_val = normalize_bool_setting("spoolman_enabled", settings["spoolman_enabled"])
now_enabled = new_val == "true"
await set_setting(db, "spoolman_enabled", new_val)
# Switching to Spoolman: clear built-in inventory slot assignments
if old_val.lower() != "true" and new_val.lower() == "true":
if not was_enabled and now_enabled:
from backend.app.models.spool_assignment import SpoolAssignment
result = await db.execute(delete(SpoolAssignment))
@ -452,21 +541,20 @@ async def update_spoolman_settings(
# spoolman_slot_assignments rows linger and would wrongly count as
# "assigned" in any mode-agnostic check (e.g. the missing-spool-
# assignment notification, which unions both tables — #1473).
elif old_val.lower() == "true" and new_val.lower() != "true":
elif was_enabled and not now_enabled:
from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
result = await db.execute(delete(SpoolmanSlotAssignment))
logger.info("Cleared %d Spoolman slot assignments on switch to internal mode", result.rowcount)
if "spoolman_url" in settings:
await set_setting(db, "spoolman_url", settings["spoolman_url"])
await set_setting(db, "spoolman_url", normalize_str_setting("spoolman_url", settings["spoolman_url"]))
if "spoolman_sync_mode" in settings:
await set_setting(db, "spoolman_sync_mode", settings["spoolman_sync_mode"])
if "spoolman_disable_weight_sync" in settings:
await set_setting(db, "spoolman_disable_weight_sync", settings["spoolman_disable_weight_sync"])
if "spoolman_report_partial_usage" in settings:
await set_setting(db, "spoolman_report_partial_usage", settings["spoolman_report_partial_usage"])
if "auto_add_unknown_rfid" in settings:
await set_setting(db, "auto_add_unknown_rfid", settings["auto_add_unknown_rfid"])
await set_setting(
db, "spoolman_sync_mode", normalize_str_setting("spoolman_sync_mode", settings["spoolman_sync_mode"])
)
for bool_key in ("spoolman_disable_weight_sync", "spoolman_report_partial_usage", "auto_add_unknown_rfid"):
if bool_key in settings:
await set_setting(db, bool_key, normalize_bool_setting(bool_key, settings[bool_key]))
spoolman_changed = "spoolman_enabled" in settings or "spoolman_url" in settings

View file

@ -9,6 +9,7 @@ import logging
import os
import platform
import re
import time
import zipfile
from datetime import datetime, timezone
from pathlib import Path
@ -300,6 +301,115 @@ def _get_container_memory_limit() -> int | None:
return None
# Above this RSS the heap census is skipped — see _collect_process_info.
_GC_CENSUS_RSS_LIMIT = 2 * 1024**3
def _collect_process_info() -> dict:
"""Snapshot this process's resource usage, for reports about it growing.
Bundles used to carry nothing about Bambuddy's own footprint, which made
"memory climbs over days until the OOM killer fires" impossible to triage
from a bundle alone the reporter of #2734 had to be asked to run commands
by hand, and the numbers that would have identified the mechanism could not
be recovered after the fact.
The four figures below separate the mechanisms that look identical from
outside:
* ``rss_bytes`` vs ``vms_bytes`` a large virtual size against a modest
resident one is address space, not live data: thread stacks or allocator
arenas rather than a heap that keeps growing.
* ``num_threads`` every leaked MQTT client reconnect would leave a paho
network thread behind, each reserving its stack.
* ``children`` the ffmpeg-per-camera-stream leak class (#776).
* ``open_files`` / ``connections`` descriptors held by streams or sockets
that were never closed.
Everything is best-effort: psutil raises on hardened kernels and inside
restricted containers, and a support bundle must still be produced when it
does. Child command lines are reduced to the executable name a full
ffmpeg argv carries the camera URL, and with it the camera's password.
"""
import psutil
out: dict = {}
try:
proc = psutil.Process()
except Exception:
return {"available": False}
out["available"] = True
try:
mem = proc.memory_info()
out["rss_bytes"] = mem.rss
out["rss_formatted"] = _format_bytes(mem.rss)
out["vms_bytes"] = mem.vms
out["vms_formatted"] = _format_bytes(mem.vms)
except Exception:
pass
try:
out["num_threads"] = proc.num_threads()
except Exception:
pass
try:
out["uptime_seconds"] = int(time.time() - proc.create_time())
except Exception:
pass
try:
out["open_files"] = len(proc.open_files())
except Exception:
pass
try:
out["connections"] = len(proc.net_connections(kind="inet"))
except Exception:
pass
# Children by executable name only. The count per name is what identifies a
# leak; the arguments would leak credentials.
try:
names: dict[str, int] = {}
for child in proc.children(recursive=True):
try:
names[child.name()] = names.get(child.name(), 0) + 1
except Exception:
names["<unknown>"] = names.get("<unknown>", 0) + 1
out["children_total"] = sum(names.values())
out["children_by_name"] = dict(sorted(names.items(), key=lambda kv: -kv[1]))
except Exception:
pass
# Live object counts by type, top 15. Identifies a heap that is growing and
# what it is growing with — the one thing RSS alone cannot say.
#
# Skipped above _GC_CENSUS_RSS_LIMIT. gc.get_objects() materialises a list
# of every tracked object, so the census costs most on exactly the process
# that can least afford it: a bundle generated to diagnose runaway memory
# must not be the allocation that tips the host over. The numbers that
# actually separate the mechanisms — RSS vs VMS, threads, children — are
# collected above and unaffected.
rss = out.get("rss_bytes")
if rss is not None and rss > _GC_CENSUS_RSS_LIMIT:
out["gc_census"] = (
f"skipped: process is using {_format_bytes(rss)}, above the "
f"{_format_bytes(_GC_CENSUS_RSS_LIMIT)} limit for walking the heap"
)
return out
try:
import gc
counts: dict[str, int] = {}
for obj in gc.get_objects():
name = type(obj).__name__
counts[name] = counts.get(name, 0) + 1
out["gc_tracked_objects"] = sum(counts.values())
out["gc_top_types"] = dict(sorted(counts.items(), key=lambda kv: -kv[1])[:15])
except Exception:
pass
return out
def _format_bytes(size_bytes: int) -> str:
"""Format bytes into human-readable string."""
if size_bytes < 1024:
@ -647,20 +757,29 @@ async def _collect_slicer_api_info() -> dict:
return info
def _parse_obico_enabled_printers(raw: str) -> set[int]:
"""Parse the comma-separated `obico_enabled_printers` setting. Same shape as
obico_detection.py uses but tolerant of legacy formats."""
def _parse_obico_enabled_printers(raw: str | None) -> set[int] | None:
"""Parse the `obico_enabled_printers` setting the way the detection service does.
The setting is a JSON array of printer IDs and an empty value means *all*
printers see ``ObicoDetectionService._load_settings``. This used to split
on commas and treat empty as *none*, so a bundle from a default Obico setup
reported every printer as unmonitored while the service was in fact polling
all of them. Returns ``None`` for "all printers"; a comma-separated fallback
is kept in case an install ever stored the legacy shape.
"""
if not raw or not raw.strip():
return set()
return None
try:
parsed = json.loads(raw)
except (json.JSONDecodeError, TypeError):
parsed = None
if isinstance(parsed, list):
return {int(item) for item in parsed if isinstance(item, (int, str)) and str(item).strip().isdigit()}
result: set[int] = set()
for token in raw.split(","):
token = token.strip()
if not token:
continue
try:
if token.isdigit():
result.add(int(token))
except ValueError:
continue
return result
@ -690,6 +809,12 @@ async def _collect_support_info() -> dict:
"database": {},
"printers": [],
"settings": {},
# Bambuddy's own footprint. Cheap to collect and the only thing that
# makes a "memory grows over days" report triageable from the bundle
# rather than a round trip of shell commands (#2734). Off the event
# loop: the heap census walks every tracked object, and a bundle
# request must not stall status ingest while it does.
"process": await asyncio.to_thread(_collect_process_info),
}
# Docker-specific info
@ -729,18 +854,27 @@ async def _collect_support_info() -> dict:
printers = result.scalars().all()
statuses = printer_manager.get_all_statuses()
# Pre-load the obico per-printer enabled-list. Settings are loaded later
# in this function (and would overwrite this key in info["settings"]),
# so do a targeted query here for the per-printer flag below.
obico_enabled_set: set[int] = set()
# Pre-load the obico settings that decide which printers are monitored.
# Settings are loaded later in this function (and would overwrite these
# keys in info["settings"]), so do a targeted query here for the
# per-printer flag below. ``None`` means every printer is monitored.
obico_enabled_set: set[int] | None = None
obico_globally_enabled = False
try:
obico_row = (
await db.execute(select(Settings).where(Settings.key == "obico_enabled_printers"))
).scalar_one_or_none()
if obico_row is not None:
obico_enabled_set = _parse_obico_enabled_printers(obico_row.value)
obico_rows = {
row.key: row.value
for row in (
await db.execute(
select(Settings).where(Settings.key.in_(["obico_enabled_printers", "obico_enabled"]))
)
)
.scalars()
.all()
}
obico_enabled_set = _parse_obico_enabled_printers(obico_rows.get("obico_enabled_printers"))
obico_globally_enabled = (obico_rows.get("obico_enabled") or "false").lower() == "true"
except Exception:
logger.debug("Failed to load obico_enabled_printers", exc_info=True)
logger.debug("Failed to load obico settings", exc_info=True)
# Check reachability in parallel
reachability_tasks = [_check_port(p.ip_address, 8883) for p in printers]
@ -784,7 +918,8 @@ async def _collect_support_info() -> dict:
"has_vt_tray": has_vt_tray,
"external_camera_configured": bool(printer.external_camera_url),
"plate_detection_enabled": printer.plate_detection_enabled,
"obico_enabled": printer.id in obico_enabled_set,
"obico_enabled": obico_globally_enabled
and (obico_enabled_set is None or printer.id in obico_enabled_set),
"hms_error_count": len(state.hms_errors) if state else 0,
"developer_mode": state.developer_mode if state else None,
"nozzle_rack_count": len(state.nozzle_rack) if state else 0,
@ -1227,6 +1362,35 @@ def _redact_raw_push_status(raw: dict) -> dict:
return out
def _sanitize_push_status_values(node, sensitive_strings: dict[str, str]):
"""Sanitize a push_status snapshot's string *values*, never its JSON text.
This used to run :func:`sanitize_log_content` over the serialised snapshot.
That pass includes a generic Bambu-serial regex
(``0[0-3][A-Z0-9][A-Z0-9]{9,13}`` in ``log_reader``) which matches the
decimal expansion of a float just as happily as a serial: an AMS ``k`` flow
factor of ``0.0199999995529652`` came out as ``0.[SERIAL]``, and the bundle
shipped invalid JSON unusable for exactly the ground-truth purpose the
snapshot exists for (found while diagnosing #2702).
Walking the structure instead leaves numbers, bools and None untouched, so
the output always parses. Keys are structural and never rewritten.
"""
if isinstance(node, str):
return sanitize_log_content(node, sensitive_strings)
if isinstance(node, dict):
return {k: _sanitize_push_status_values(v, sensitive_strings) for k, v in node.items()}
if isinstance(node, list | tuple):
# Tuples too: `json.dumps` renders them as arrays, so stringifying one
# here would change the file's shape rather than just its content.
return [_sanitize_push_status_values(v, sensitive_strings) for v in node]
if node is None or isinstance(node, bool | int | float):
return node
# Anything else (datetime, Decimal, …) would be stringified by json.dumps'
# ``default=str`` *after* this pass and so escape sanitisation entirely.
return sanitize_log_content(str(node), sensitive_strings)
async def _get_recent_sanitized_logs(max_lines: int = 200) -> str:
"""Get recent log lines, sanitized for inclusion in bug reports."""
# Collect sensitive strings from DB for redaction
@ -1300,12 +1464,13 @@ async def generate_support_bundle(
"captured_at": datetime.now(timezone.utc).isoformat(),
"raw_data": redacted,
}
# Belt-and-suspenders: pass the JSON text through the string-based
# sanitizer so any user-named string (printer name, serial baked
# into a tray uuid) the structural pass missed still gets caught.
snapshot_json = json.dumps(snapshot, indent=2, default=str)
snapshot_json = sanitize_log_content(snapshot_json, sensitive_strings)
zf.writestr(f"push-status/printer-{i + 1}.json", snapshot_json)
# Belt-and-suspenders: pass every string value through the
# string-based sanitizer so any user-named string (printer name,
# serial baked into a tray uuid) the structural pass missed still
# gets caught. Values only — sanitizing the serialised JSON text
# corrupted numeric literals (see _sanitize_push_status_values).
snapshot = _sanitize_push_status_values(snapshot, sensitive_strings)
zf.writestr(f"push-status/printer-{i + 1}.json", json.dumps(snapshot, indent=2, default=str))
# Add log file
# Off the event loop: this reads up to 10 MB and then runs one full regex

View file

@ -39,6 +39,7 @@ class VirtualPrinterCreate(BaseModel):
target_printer_id: int | None = None
auto_dispatch: bool = True
queue_force_color_match: bool = False
save_ams_mapping: bool = False
gcode_injection: bool = False
bind_ip: str | None = None
remote_interface_ip: str | None = None
@ -53,6 +54,7 @@ class VirtualPrinterUpdate(BaseModel):
target_printer_id: int | None = None
auto_dispatch: bool | None = None
queue_force_color_match: bool | None = None
save_ams_mapping: bool | None = None
gcode_injection: bool | None = None
bind_ip: str | None = None
remote_interface_ip: str | None = None
@ -109,6 +111,7 @@ async def _vp_to_dict(vp, db: AsyncSession, status: dict | None = None) -> dict:
"target_printer_id": vp.target_printer_id,
"auto_dispatch": vp.auto_dispatch,
"queue_force_color_match": vp.queue_force_color_match,
"save_ams_mapping": vp.save_ams_mapping,
"gcode_injection": vp.gcode_injection,
"bind_ip": vp.bind_ip,
"remote_interface_ip": vp.remote_interface_ip,
@ -245,6 +248,7 @@ async def create_virtual_printer(
target_printer_id=body.target_printer_id,
auto_dispatch=body.auto_dispatch,
queue_force_color_match=body.queue_force_color_match,
save_ams_mapping=body.save_ams_mapping,
gcode_injection=body.gcode_injection,
bind_ip=body.bind_ip,
remote_interface_ip=body.remote_interface_ip,
@ -423,6 +427,8 @@ async def update_virtual_printer(
vp.auto_dispatch = body.auto_dispatch
if body.queue_force_color_match is not None:
vp.queue_force_color_match = body.queue_force_color_match
if body.save_ams_mapping is not None:
vp.save_ams_mapping = body.save_ams_mapping
if body.gcode_injection is not None:
vp.gcode_injection = body.gcode_injection
if body.bind_ip is not None:

View file

@ -7,7 +7,7 @@ from pydantic import Field
from pydantic_settings import BaseSettings
# Application version - single source of truth
APP_VERSION = "1.2.5"
APP_VERSION = "1.2.5.2"
GITHUB_REPO = "maziggy/bambuddy"
BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report")
@ -135,6 +135,25 @@ _INTENTIONAL_UNSETTINGS = {
"LOG_DIR", # config.py (above)
"LOG_LEVEL", # main.py logging setup
"BUG_REPORT_RELAY_URL", # config.py (above)
# #1589 — api/routes/auth.py reads this on the login path. Unregistered it
# logged "possible typo" at every boot, telling an operator who is locked
# out and following the documented recovery that the variable is not real.
"BAMBUDDY_LOCAL_LOGIN",
# #2593 — core/oidc_env.py reads these directly; they are not Settings
# fields because they map to an OIDCProvider row, not to app config.
"BAMBUDDY_OIDC_NAME",
"BAMBUDDY_OIDC_ISSUER_URL",
"BAMBUDDY_OIDC_CLIENT_ID",
"BAMBUDDY_OIDC_CLIENT_SECRET",
"BAMBUDDY_OIDC_SCOPES",
"BAMBUDDY_OIDC_ENABLED",
"BAMBUDDY_OIDC_AUTO_CREATE_USERS",
"BAMBUDDY_OIDC_AUTO_LINK_EXISTING",
"BAMBUDDY_OIDC_EMAIL_CLAIM",
"BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED",
"BAMBUDDY_OIDC_ICON_URL",
"BAMBUDDY_OIDC_AUTOLOGIN",
"BAMBUDDY_OIDC_DEFAULT_GROUP",
}
_known_settings_fields = {f.upper() for f in settings.model_fields}

View file

@ -27,6 +27,11 @@ def _set_sqlite_pragmas(dbapi_conn, connection_record):
# /system/db-pool can report it without re-deriving the dialect defaults.
_pool_config: dict = {}
# What the PostgreSQL server itself will allow, read once at startup. None on
# SQLite, or when the probe could not run. Reported by get_pool_status() so a
# support bundle carries both sides of the comparison.
_server_connection_limits: dict | None = None
def _resolve_pool_kwargs() -> dict:
"""Build the pool kwargs for ``create_async_engine`` (issue #2572).
@ -151,6 +156,10 @@ def get_pool_status() -> dict:
return {
"dialect": "sqlite" if is_sqlite() else "postgresql",
"config": dict(_pool_config),
# Both sides of the ceiling-vs-server comparison, so a support bundle
# shows whether a TooManyConnectionsError was a misconfiguration or a
# genuine leak. None on SQLite or if the startup probe couldn't run.
"server_limits": dict(_server_connection_limits) if _server_connection_limits else None,
**gauges,
}
@ -317,6 +326,107 @@ async def init_db():
await seed_spool_catalog()
await seed_color_catalog()
await check_pool_fits_server()
async def check_pool_fits_server() -> None:
"""Warn when the pool may ask PostgreSQL for more connections than it allows.
``pool_size + max_overflow`` is the most connections one worker process will
ever open. If that exceeds what the server permits, the pool never reaches
its own limit and so never queues: it goes straight to the server, which
refuses with ``TooManyConnectionsError``. That surfaces wherever the next
connection happened to be needed in the reported case, halfway through a
queue dispatch, which then left an expected-print registration and a dispatch
claim behind (#2702 follow-up).
The distinction is worth knowing when reading a log: SQLAlchemy's own
``QueuePool limit ... timed out`` means the pool is the bottleneck (too much
concurrency, or connections held too long), whereas asyncpg's
``TooManyConnectionsError`` means the pool's ceiling is above the server's.
Not clamped, deliberately. Pool sizes are fixed when the engine is created,
which happens at import before any connection exists to ask the server
with and ``engine`` / ``async_session`` are imported by name in ~150 places,
so swapping the engine afterwards would leave stale references. The correct
ceiling also depends on the worker count and on anything else sharing the
server, neither of which Bambuddy can see. So this reports the mismatch with
both numbers and the knobs to fix it, and leaves the choice to the operator.
"""
global _server_connection_limits
if is_sqlite():
return
from sqlalchemy import text
in_use: int | None = None
try:
async with engine.connect() as conn:
max_conn = int((await conn.execute(text("SHOW max_connections"))).scalar_one())
reserved = int((await conn.execute(text("SHOW superuser_reserved_connections"))).scalar_one())
try:
in_use = int(
(
await conn.execute(
text("SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend'")
)
).scalar_one()
)
except Exception as exc:
# `pg_stat_activity.backend_type` is PostgreSQL 10+, and a
# restricted role sees fewer rows. The count is a nice-to-have
# for spotting other clients; the warning itself only needs the
# two settings above, so losing it must not cost the warning.
# Done last on purpose: a failed statement can abort the
# transaction, and nothing else uses this connection after it.
logger.debug("Could not count client backends: %s", exc)
except Exception as exc:
# A diagnostic must never be the reason startup fails. An older server
# or a restricted role may refuse these.
logger.debug("Could not read PostgreSQL connection limits: %s", exc)
return
available = max_conn - reserved
ceiling = _pool_config.get("pool_size", 0) + _pool_config.get("max_overflow", 0)
_server_connection_limits = {
"max_connections": max_conn,
"superuser_reserved_connections": reserved,
"available_to_bambuddy": available,
"client_backends_at_startup": in_use,
"pool_ceiling_per_worker": ceiling,
}
if ceiling > available:
in_use_note = (
f" {in_use} client connection(s) are open on the server right now, including "
"this one — a count well above 1 means something else shares it."
if in_use is not None
else ""
)
logger.warning(
"DB pool may exceed what PostgreSQL allows: this worker can open up to %d "
"connections (pool_size %d + max_overflow %d) but the server permits %d "
"(max_connections %d minus %d reserved for superusers).%s Exhaustion surfaces "
"as TooManyConnectionsError at whatever ran next, not as a pool timeout. "
"Lower DB_POOL_SIZE / DB_MAX_OVERFLOW, or raise the server's "
"max_connections — and account for every worker process and any other "
"client sharing this server.",
ceiling,
_pool_config.get("pool_size", 0),
_pool_config.get("max_overflow", 0),
available,
max_conn,
reserved,
in_use_note,
)
else:
logger.info(
"DB pool fits the server: up to %d connection(s) per worker, %d available (max_connections %d).",
ceiling,
available,
max_conn,
)
# B2: Module-level counter exposing the number of rows skipped during the last
# _migrate_encrypt_legacy_secrets() invocation. Surfaced via /encryption-status
@ -1253,6 +1363,15 @@ async def run_migrations(conn):
conn, "ALTER TABLE virtual_printers ADD COLUMN queue_force_color_match BOOLEAN DEFAULT FALSE"
)
# Migration: Add save_ams_mapping column to virtual_printers. Opt-in flag:
# when true, VP queue-mode uploads persist the slicer's own AMS-slot pick
# onto the archive (`extra_data.slicer_ams_mapping`) for reuse on reprint.
# Default false to preserve current behaviour for upgraders.
if is_sqlite():
await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN save_ams_mapping BOOLEAN DEFAULT 0")
else:
await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN save_ams_mapping BOOLEAN DEFAULT FALSE")
# Per-VP opt-in for auto-print G-code injection (#1516). Default false so
# existing gcode_snippets users don't silently start injecting on VP/Studio
# Send jobs after upgrading.
@ -3689,6 +3808,14 @@ async def run_migrations(conn):
else:
await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN is_autologin BOOLEAN DEFAULT false")
# Migration: Add is_env_managed column to oidc_providers (#2593). Marks the
# provider upserted from BAMBUDDY_OIDC_* env vars on startup. Postgres
# rejects ``DEFAULT 0`` for BOOLEAN columns.
if is_sqlite():
await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN is_env_managed BOOLEAN DEFAULT 0")
else:
await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN is_env_managed BOOLEAN DEFAULT false")
# Migration: Add dispatch_attempts to print_queue (#2555). Counts the times
# the start-watchdog reverted the row from 'printing' back to 'pending' so a
# printer that never actually starts stops being retried forever. INTEGER
@ -3768,10 +3895,60 @@ async def run_migrations(conn):
"ALTER TABLE smart_plugs ADD COLUMN IF NOT EXISTS controls_printer_power BOOLEAN DEFAULT true",
)
# Migration: real filesystem mtime for library files/folders (#2680). The
# folder tree's "sort by recent activity" and the file pane's date sort must
# track the on-disk mtime (``ls -t``), not Bambuddy's DB ``updated_at`` — for
# a bulk external scan every row's ``updated_at`` is the same scan instant, so
# ordering was arbitrary. Nullable; the timestamp type differs by dialect
# (SQLite DATETIME vs Postgres TIMESTAMP) so an existing-DB upgrade doesn't hit
# "type datetime does not exist" on Postgres. On a fresh DB create_all() already
# built the column, so the ALTER is swallowed as "already exists".
if is_sqlite():
await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN fs_modified_at DATETIME")
await _safe_execute(conn, "ALTER TABLE library_folders ADD COLUMN fs_modified_at DATETIME")
else:
await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN fs_modified_at TIMESTAMP")
await _safe_execute(conn, "ALTER TABLE library_folders ADD COLUMN fs_modified_at TIMESTAMP")
# Migration: Disambiguate the four ``user_print_*`` notification template
# names by appending " Email" (#1792). See ``_migrate_rename_user_print_template_names``.
await _migrate_rename_user_print_template_names(conn)
# Migration: per-file print progress inside a project (#1897).
# - print_archives.library_file_id: which library file a queued run was
# dispatched from; nullable, no FK constraint added to existing tables
# (SQLite can't ADD CONSTRAINT; the application uses SET NULL semantics
# via the ORM on fresh installs and tolerates dangling ids by matching
# hash/filename as fallback anyway).
# - projects.target_sets: optional copies-per-file target. INTEGER is
# spelled identically on SQLite and Postgres — no dialect branch.
await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN library_file_id INTEGER")
await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN target_sets INTEGER")
# Migration: persist the timelapse snapshot-diff baseline (#2704).
# The list of video filenames present on the printer when the print began,
# so the diff survives a restart and the manual scan can use it instead of
# the clock-based matching that a LAN-only printer defeats. No dialect
# branch: SQLAlchemy renders this column as `JSON` on both SQLite and
# Postgres for a fresh install (checked with CreateTable against each
# dialect), so spelling the ALTER the same way keeps a migrated database
# identical to a new one. Matching matters on Postgres in particular —
# asyncpg binds the serialised value as json and would reject a TEXT column
# (mirrors the `projects.attachments JSON` migration above).
await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN timelapse_baseline JSON")
# Migration: plate-clear-required notification opt-in (#2525). Off by
# default — it fires after every print, at the same moment as the
# print-complete alert. Postgres rejects `DEFAULT 0` for BOOLEAN.
if is_sqlite():
await _safe_execute(
conn, "ALTER TABLE notification_providers ADD COLUMN on_plate_clear_required BOOLEAN DEFAULT 0"
)
else:
await _safe_execute(
conn, "ALTER TABLE notification_providers ADD COLUMN on_plate_clear_required BOOLEAN DEFAULT false"
)
_USER_PRINT_TEMPLATE_RENAMES: tuple[tuple[str, str, str], ...] = (
("user_print_start", "User Print Started", "User Print Started Email"),

View file

@ -1,4 +1,4 @@
"""Logging filters for the Bambuddy log pipeline.
"""Logging filters and redaction helpers for the Bambuddy log pipeline.
Holds two filters: ``WriteRequestsOnlyFilter`` keeps the file-side
uvicorn access log focused on state-changing HTTP methods, and
@ -6,12 +6,58 @@ uvicorn access log focused on state-changing HTTP methods, and
caused by Starlette's ``BaseHTTPMiddleware`` cancellation propagation
(see the filter's docstring for details). Both live here so tests can
import them without pulling in ``backend.app.main``'s startup graph.
Also holds :data:`URL_CREDENTIALS_PATTERN` and
:func:`redact_url_credentials`, the single place where the shape of a
credentialed URL is defined for the whole backend.
"""
from __future__ import annotations
import asyncio
import logging
import re
# ``scheme://user:secret@host`` — the only URL shape that carries a secret.
# Both userinfo parts exclude ``/`` so the match can never run past the
# authority into the path, and exclude whitespace so a wrapped log line can't
# glue two URLs together. ``secret`` is otherwise unrestricted and greedy so
# it reaches the *last* ``@`` before the path, which is where RFC 3986 ends
# the userinfo — that keeps an unescaped ``@`` inside a password (legal in an
# external camera URL) from leaving its tail in the log. Named groups let
# callers choose how much to mask: the log pipeline keeps the username, the
# support-bundle sanitizer drops it (see ``log_reader.sanitize_log_content``).
#
# The scheme's repetition is bounded deliberately. As an unbounded ``*`` the
# match was quadratic in the length of the subject (CodeQL py/polynomial-redos):
# on a long run of scheme-legal characters the engine restarts at every offset
# and consumes to the end each time before failing to find ``://``. Measured at
# 557ms for a 32KB line, quadrupling per doubling. ffmpeg echoes the operator's
# camera URL back in its stderr, and that whole string reaches this pattern
# before any truncation, so the subject length is attacker-influenced. A cap
# makes the work per offset constant. 63 is far above any real scheme (the
# longest registered one is under 20 characters), and a longer pseudo-scheme
# still gets its secret masked — the match simply starts from a later offset.
URL_CREDENTIALS_PATTERN = re.compile(
r"(?P<scheme>[a-zA-Z][a-zA-Z0-9+.\-]{0,63}://)(?P<user>[^/:@\s]+):(?P<secret>[^/\s]+)@"
)
def redact_url_credentials(text: str | None) -> str | None:
"""Mask the password in every ``scheme://user:secret@host`` URL in *text*.
Subprocesses echo their input URL back at us ffmpeg prints the RTSP
input in its ``Input #0`` line, so logging its stderr verbatim publishes
the printer access code (or an external camera's password) into
``bambuddy.log``, which users routinely attach to public issues.
The username, host, port and path survive so the line stays useful for
diagnosis; only the secret is replaced. Returns *text* unchanged when
there is nothing to mask, including ``None``/``""``.
"""
if not text or "://" not in text or "@" not in text:
return text
return URL_CREDENTIALS_PATTERN.sub(r"\g<scheme>\g<user>:[REDACTED]@", text)
class WriteRequestsOnlyFilter(logging.Filter):

View file

@ -0,0 +1,278 @@
"""Read the single OIDC provider defined by BAMBUDDY_OIDC_* env vars (#2593).
A declarative deployment (compose, Helm, GitOps) has no way to click through
the settings UI, so one provider can be configured entirely from the
environment. This module only reads and defaults; validity is decided by the
same OIDCProviderCreate schema the API uses, so env config cannot bypass a
check the UI enforces.
"""
from __future__ import annotations
import contextlib
import logging
import os
from pydantic import ValidationError
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
# All four or nothing: a provider missing its secret would be written to the
# database and then fail at authorize time, long after the operator could
# connect the failure to a typo in their compose file.
_REQUIRED = (
"BAMBUDDY_OIDC_NAME",
"BAMBUDDY_OIDC_ISSUER_URL",
"BAMBUDDY_OIDC_CLIENT_ID",
"BAMBUDDY_OIDC_CLIENT_SECRET",
)
_TRUTHY = {"true", "1", "yes"}
_FALSY = {"false", "0", "no"}
class EnvOIDCConfigError(Exception):
"""A BAMBUDDY_OIDC_* value the reader cannot interpret. Only ever carries a
boolean variable's name and value -- booleans are not secret, so the message
is safe to log in full (unlike client_secret, which never reaches here)."""
def env_bool(key: str, default: bool, *, strict: bool = True) -> bool:
"""Parse a boolean env var. Absent or blank -> default (empty == unset).
strict (the default): an unrecognized non-empty value raises
EnvOIDCConfigError, so a typo is refused loudly rather than silently read as
the wrong thing. strict=False: an unrecognized value falls back to the
default instead -- for a caller on a request path where a raise would be a
500, not a skipped startup config (see _local_login_env_bypass).
"""
value = os.environ.get(key)
if value is None or value.strip() == "":
return default # absent or blank == unset -> default, per the module's promise
norm = value.strip().lower()
if norm in _TRUTHY:
return True
if norm in _FALSY:
return False
if strict:
raise EnvOIDCConfigError(f"{key}={value!r} is not a recognized boolean (use true/1/yes or false/0/no)")
return default
def read_env_oidc_config() -> dict | None:
"""The provider's fields from the environment, or None if it isn't configured.
An empty required var counts as unset -- `BAMBUDDY_OIDC_CLIENT_SECRET=` in
a compose file is a forgotten value, not an intentional empty secret. Blank
means blank *after* stripping, and the surviving value is stripped too: a
Kubernetes Secret written as a block scalar (``stringData: secret: |``) or
created from a file carries a trailing newline that nothing downstream
rejects -- max_length is the only bound the schema puts on these four. An
issuer_url with a trailing newline is stored and enabled, and then fails
with httpx.InvalidURL on the first click of the SSO button, which is the
authorize-time failure the all-or-nothing rule above exists to prevent.
"""
required = {key: (os.environ.get(key) or "").strip() for key in _REQUIRED}
if not all(required.values()):
return None
return {
"name": required["BAMBUDDY_OIDC_NAME"],
"issuer_url": required["BAMBUDDY_OIDC_ISSUER_URL"],
"client_id": required["BAMBUDDY_OIDC_CLIENT_ID"],
"client_secret": required["BAMBUDDY_OIDC_CLIENT_SECRET"],
"scopes": (os.environ.get("BAMBUDDY_OIDC_SCOPES") or "").strip() or "openid email profile",
"is_enabled": env_bool("BAMBUDDY_OIDC_ENABLED", True),
"auto_create_users": env_bool("BAMBUDDY_OIDC_AUTO_CREATE_USERS", False),
"auto_link_existing_accounts": env_bool("BAMBUDDY_OIDC_AUTO_LINK_EXISTING", False),
"email_claim": (os.environ.get("BAMBUDDY_OIDC_EMAIL_CLAIM") or "").strip() or "email",
"require_email_verified": env_bool("BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED", True),
"icon_url": (os.environ.get("BAMBUDDY_OIDC_ICON_URL") or "").strip() or None,
"is_autologin": env_bool("BAMBUDDY_OIDC_AUTOLOGIN", False),
# A name, not an id: ids are assigned per install, so the same compose
# file would point at a different group on every deployment. Resolved
# against the database in apply_env_oidc_provider -- the reader has no
# session and stays dumb.
"default_group": (os.environ.get("BAMBUDDY_OIDC_DEFAULT_GROUP") or "").strip() or None,
}
# Everything the schema validates and the model stores, except client_secret --
# that one goes through the property so it is encrypted at rest.
_APPLIED_FIELDS = (
"name",
"issuer_url",
"client_id",
"scopes",
"is_enabled",
"auto_create_users",
"auto_link_existing_accounts",
"email_claim",
"require_email_verified",
"icon_url",
"is_autologin",
# Written on every boot, so a group that is no longer declared is cleared:
# the environment is the whole truth for this row, and the API lock means
# a lingering value could not be removed in the UI either.
"default_group_id",
)
async def apply_env_oidc_provider(db: AsyncSession) -> None:
"""Upsert the env-managed provider, or release it when the config is gone.
Never raises: this runs during startup, and a typo in one variable -- or a
DB error on commit -- must not stop the app from booting. A rejected
config is logged and skipped.
"""
try:
await _apply_env_oidc_provider(db)
except Exception as exc: # noqa: BLE001 -- startup must survive any failure here
# Never str(exc): a DB error message can echo a configured value. Class only.
logger.error("BAMBUDDY_OIDC_* could not be applied: %s", type(exc).__name__)
# A commit may have half-applied; roll back so the shared session is
# left clean for the rest of startup. Suppressed because rollback on a
# wedged connection can itself raise -- and the whole point here is that
# nothing in this path takes the boot down. The session is discarded by
# the caller's `async with` regardless.
with contextlib.suppress(Exception):
await db.rollback()
async def _apply_env_oidc_provider(db: AsyncSession) -> None:
# Imported here rather than at module scope: app.core is imported by the
# models themselves, so a top-level import would be a cycle.
from backend.app.models.group import Group
from backend.app.models.oidc_provider import OIDCProvider
from backend.app.schemas.auth import OIDCProviderCreate
try:
config = read_env_oidc_config()
except EnvOIDCConfigError as exc:
# Same disposition as a ValidationError or an unmatched DEFAULT_GROUP:
# log clearly and leave any running provider as it was. Safe to log the
# full message -- EnvOIDCConfigError only ever carries a boolean var.
logger.error("BAMBUDDY_OIDC_* config rejected, provider not applied: %s", exc)
return
if config is None:
# Nothing to look up by name any more, so the previously managed rows are
# found by the flag -- and then released. All of them: the upsert's sweep
# should keep that at one, but scalar_one_or_none() would raise
# MultipleResultsFound out of the lifespan the moment it isn't, and
# losing the boot is too steep a price for an invariant check.
released_rows = (
(await db.execute(select(OIDCProvider).where(OIDCProvider.is_env_managed.is_(True)))).scalars().all()
)
for released in released_rows:
# Disabled, never deleted: user_oidc_links.provider_id is FK ON
# DELETE CASCADE, so removing the row would unlink every bound
# account and the links would not come back when the variables do.
# The flag is cleared as well: with no config behind it, a provider
# the API still refuses to edit or delete would be a dead end
# reachable only through the database.
released.is_enabled = False
released.is_env_managed = False
# Cleared too, or the released row keeps a latent autologin claim:
# update_oidc_provider only re-runs the exclusivity sweep when a
# request sets is_autologin=True, so re-enabling this row in the UI
# would silently make it the autologin target again.
released.is_autologin = False
logger.info(
"BAMBUDDY_OIDC_* is unset -- provider %r disabled and released to the UI.",
released.name,
)
if released_rows:
await db.commit()
return
# Identity is the name, which is unique on the table. Matching on the flag
# instead meant an operator who named the env provider after one that
# already existed hit that unique constraint during startup -- and this
# function runs in the lifespan, so the app would not boot.
existing = (await db.execute(select(OIDCProvider).where(OIDCProvider.name == config["name"]))).scalar_one_or_none()
# Resolved before anything is written, so a name that matches no group
# leaves the running provider untouched. Refused rather than defaulted:
# falling back would put every auto-created user in Viewers (routes/mfa.py)
# for as long as the typo lives, and the API answers 422 for a
# default_group_id that does not exist -- env config gets the same answer.
group_name = config.pop("default_group", None)
if group_name is not None:
group = (await db.execute(select(Group).where(Group.name == group_name))).scalar_one_or_none()
if group is None:
# Spelled out because the two cases differ sharply: an existing
# provider keeps running on its last good config, while on a first
# boot nothing is created at all and the login page has no SSO
# button until the name matches.
logger.error(
"BAMBUDDY_OIDC_DEFAULT_GROUP=%r matches no group, provider not applied (%s).",
group_name,
"previous config left running" if existing is not None else "no provider created",
)
return
config["default_group_id"] = group.id
try:
# The same schema the API uses, so env config cannot reach a state the
# UI would have refused (notably the SEC-1 auto-link check).
validated = OIDCProviderCreate(**config)
except ValidationError as exc:
# errors(include_input=False) strips the submitted values -- str(exc)
# embeds input_value=... and would leak BAMBUDDY_OIDC_CLIENT_SECRET.
logger.error(
"BAMBUDDY_OIDC_* config rejected, provider not applied: %s",
exc.errors(include_input=False),
)
return
except Exception as exc: # noqa: BLE001 -- any rejection must be survivable
# Log only the exception class, never str(exc): an unexpected error here
# could carry a configured value in its message. Structural guarantee,
# not one contingent on which exceptions the schema validators raise.
logger.error("BAMBUDDY_OIDC_* config could not be applied: %s", type(exc).__name__)
return
# Computed before `existing` is reassigned below: a freshly-created row is
# not an adoption, and a found row that was already env-managed is a
# routine re-apply -- only a found row that the UI created is an adoption.
adopted_ui_provider = existing is not None and not existing.is_env_managed
if existing is None:
existing = OIDCProvider(is_env_managed=True)
db.add(existing)
for field in _APPLIED_FIELDS:
setattr(existing, field, getattr(validated, field))
existing.client_secret = validated.client_secret
existing.is_env_managed = True
await db.flush() # the id is needed by the sweeps below
# Renaming BAMBUDDY_OIDC_NAME matches nothing, so the row managed until now
# stays behind. Left flagged it would keep a stale issuer and secret on the
# login page while the API refuses every edit, disable and delete on it
# (409) -- the dead end reachable only through the database that the release
# path exists to prevent -- and the next release would find two rows and
# take the boot down with MultipleResultsFound. Released, not deleted, for
# the same cascade reason as everywhere else.
await db.execute(
update(OIDCProvider)
.where(OIDCProvider.id != existing.id, OIDCProvider.is_env_managed.is_(True))
.values(is_env_managed=False, is_enabled=False, is_autologin=False)
)
if existing.is_autologin:
await db.execute(
update(OIDCProvider)
.where(OIDCProvider.id != existing.id, OIDCProvider.is_autologin.is_(True))
.values(is_autologin=False)
)
await db.commit()
if adopted_ui_provider:
logger.warning(
"Env-managed OIDC provider %r adopted an existing UI-created provider of the "
"same name; its issuer, client and secret are now managed by BAMBUDDY_OIDC_*.",
existing.name,
)
else:
logger.info("Env-managed OIDC provider %r applied.", existing.name)

File diff suppressed because it is too large Load diff

View file

@ -12,6 +12,12 @@ class PrintArchive(Base):
id: Mapped[int] = mapped_column(primary_key=True)
printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id"), nullable=True)
project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
# Which library file this run was dispatched from (#1897). Set by the queue
# scheduler when it archives a library-file print; older rows are matched by
# content_hash/filename instead. SET NULL so deleting a file keeps history.
library_file_id: Mapped[int | None] = mapped_column(
ForeignKey("library_files.id", ondelete="SET NULL"), nullable=True
)
# File info
filename: Mapped[str] = mapped_column(String(255))
@ -26,6 +32,16 @@ class PrintArchive(Base):
# both locally and on the printer's SD after extraction — the user
# didn't opt in to a timelapse recording.
bambuddy_forced_timelapse: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
# Video filenames present in the printer's /timelapse directory when this
# print started (#2704). The printer writes its video only at print end, so
# anything not in this list belongs to this print — a comparison that needs
# no clock, which matters because a LAN-only printer can't reach Bambu's NTP
# server and its filename timestamps are arbitrarily wrong. Persisted (not
# just held in memory) so the diff survives a restart and so the manual
# "Scan for Timelapse" button can use it instead of guessing from
# timestamps. NULL for archives predating this, and for baselines taken at
# completion time, which are useless by construction.
timelapse_baseline: Mapped[list | None] = mapped_column(JSON, nullable=True)
source_3mf_path: Mapped[str | None] = mapped_column(String(500)) # Original project 3MF from slicer
f3d_path: Mapped[str | None] = mapped_column(String(500)) # Fusion 360 design file

View file

@ -31,6 +31,14 @@ class LibraryFolder(Base):
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
# Real on-disk modification time of the directory this folder mirrors (#2680).
# For external folders this is captured from ``os.stat().st_mtime`` on scan so
# the tree's "sort by recent activity" matches ``ls -t`` instead of ordering by
# the DB row's ``updated_at`` (which is the scan instant, identical for every
# row of a bulk scan). Null for managed (internal) folders, which have no
# meaningful directory mtime — callers fall back to ``updated_at``/``created_at``.
fs_modified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Relationships
parent: Mapped["LibraryFolder | None"] = relationship(
"LibraryFolder",
@ -102,6 +110,14 @@ class LibraryFile(Base):
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
# Real on-disk modification time of the file (#2680). Captured from
# ``os.stat().st_mtime`` for external files on scan so the file pane's date
# sort and the folder tree's recursive "recent activity" bubble reflect the
# actual filesystem mtime (``ls -t``) rather than the DB ``updated_at`` (the
# scan instant, identical across a bulk scan). Null for managed uploads —
# callers fall back to ``created_at``.
fs_modified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Relationships
folder: Mapped["LibraryFolder | None"] = relationship(back_populates="files")
project: Mapped["Project | None"] = relationship()

View file

@ -84,6 +84,8 @@ class NotificationProvider(Base):
# Event triggers - Build plate detection
on_plate_not_empty = Column(Boolean, default=True) # Objects detected on plate before print
# Off by default: fires after every print, alongside the print-complete alert (#2525)
on_plate_clear_required = Column(Boolean, default=False) # Print ended, queue gated until plate is confirmed clear
# Event triggers - Bed cooled after print
on_bed_cooled = Column(Boolean, default=False) # Bed cooled below threshold after print

View file

@ -85,6 +85,12 @@ DEFAULT_TEMPLATES = [
"title_template": "Plate Not Empty - Print Paused",
"body_template": "{printer}: Objects detected on build plate. Print has been paused. Clear plate and resume.",
},
{
"event_type": "plate_clear_required",
"name": "Plate Clear Required",
"title_template": "Plate Clear Required",
"body_template": "{printer}: print finished. Confirm the build plate is clear before the queue continues.",
},
{
"event_type": "filament_low",
"name": "Filament Low",

View file

@ -128,6 +128,10 @@ class OIDCProvider(Base):
# authorize-URL fetch fails or times out, and ``/login?fallback=local``
# plus ``BAMBUDDY_LOCAL_LOGIN=true`` provide a documented recovery path.
is_autologin: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
# Marks the single provider defined by BAMBUDDY_OIDC_* env vars. Upserted on
# startup; UI/API writes to it are rejected. Never delete-recreated (user_oidc_links
# FK is ON DELETE CASCADE).
is_env_managed: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
@property
def has_icon(self) -> bool:

View file

@ -30,6 +30,9 @@ class Project(Base):
target_parts_count: Mapped[int | None] = mapped_column(
Integer, nullable=True
) # Optional target number of parts/objects
# Optional copies-per-file target (#1897): every printable file in the
# project's linked folders should be printed this many times ("sets").
target_sets: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Phase 2: Rich text notes (HTML from WYSIWYG editor)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)

View file

@ -49,6 +49,18 @@ class VirtualPrinter(Base):
) # queue mode: pin per-slot type+color from the 3MF onto the queue
# item so the scheduler refuses to dispatch onto a printer with the wrong
# filament loaded (#1188).
save_ams_mapping: Mapped[bool] = mapped_column(
Boolean, server_default="false"
) # queue mode: keep the slicer's own live-resolved AMS-slot pick (the
# `ams_mapping` field on the MQTT `project_file` command) instead of
# re-deriving one from the file's static type/color. Stamps it on the queue
# item so THIS print dispatches to those trays, and onto the archive's
# `extra_data.slicer_ams_mapping` so a later reprint can reuse the same
# physical spools. Off by default: taking the slicer's pick makes the
# scheduler skip `_compute_ams_mapping_for_printer`, and with it
# `prefer_lowest_filament`, its AMS-backup gate (#1766) and the
# inventory-remain overrides — so it stays opt-in per virtual printer
# rather than changing behaviour for upgraders (#2700).
gcode_injection: Mapped[bool] = mapped_column(
Boolean, server_default="false"
) # queue mode: opt this VP's Send/Print jobs into per-model G-code snippet

View file

@ -137,6 +137,8 @@ class ArchiveSlim(BaseModel):
started_at: datetime | None
completed_at: datetime | None
cost: float | None
energy_kwh: float | None = None
energy_cost: float | None = None
quantity: int = 1
created_at: datetime | None

View file

@ -360,28 +360,37 @@ def _validate_icon_url(v: str | None) -> str | None:
def _validate_issuer_url(v: str | None) -> str | None:
"""Nit4: Reject non-HTTPS issuer URLs and private/loopback/link-local hosts.
"""Reject non-HTTPS issuer URLs and SSRF-unsafe hosts.
HTTP is no longer accepted OIDC providers must be reachable over TLS.
Private-network and loopback addresses are rejected to prevent SSRF attacks
where an admin-supplied URL could reach internal services.
An OIDC provider must be reachable over TLS on the public internet, so
this uses the public-internet policy: private, loopback and link-local
addresses are all rejected.
Delegates to the runtime guard ``assert_safe_public_https_url`` for the
same reason ``_validate_icon_url`` does no policy drift between the
schema layer and the fetcher. The hand-rolled version this replaced
checked only ``is_private | is_loopback | is_link_local``, which left
numeric-encoded IPs (``https://2130706433/``), IPv4-mapped IPv6
(``https://[::ffff:127.0.0.1]/``), multicast and unspecified addresses
able to express a target the policy meant to forbid. The guard's
docstring already claimed the two were consistent; now they are.
Lazy-imported because ``_oidc_helpers`` lives under ``api/routes/`` and
schemas avoid top-level imports from that layer.
"""
import ipaddress
from urllib.parse import urlparse
if v is None:
return v
if not v.startswith("https://"):
raise ValueError("issuer_url must start with https://")
host = urlparse(v).hostname or ""
from backend.app.api.routes._oidc_helpers import assert_safe_public_https_url
try:
addr = ipaddress.ip_address(host)
if addr.is_private or addr.is_loopback or addr.is_link_local:
raise ValueError("issuer_url must not point to a private, loopback, or link-local address")
assert_safe_public_https_url(v)
except ValueError as exc:
if "issuer_url" in str(exc):
raise
# hostname is a domain name, not a bare IP — that's fine
# The guard's messages say "icon URL" — rewrite for this field so the
# user sees the setting they actually submitted.
detail = str(exc).replace("icon URL", "issuer_url")
raise ValueError(detail) from exc
return v
@ -518,6 +527,9 @@ class OIDCProviderResponse(BaseModel):
icon_url: str | None = None
default_group_id: int | None = None
is_autologin: bool = False # #1589
# #2593 — the UI renders this provider read-only; without the flag it would
# offer editable fields whose writes the API then refuses with 409.
is_env_managed: bool = False
# Set explicitly in the route handler from `icon_content_type is not None`
# rather than `@computed_field` (project policy) or `icon_data is not None`
# (would trigger an async lazy-load on the deferred BLOB column).

View file

@ -157,6 +157,19 @@ class GitHubBackupLogResponse(BaseModel):
from_attributes = True
class CloudAccountCounts(BaseModel):
"""How many connected cloud accounts a backup would collect presets from.
Counts only, never identities: with auth enabled these are other users'
accounts, and whoever administers the backup has no business learning who
signed in to what. The number is enough to answer the only question the UI
asks is the Cloud Profiles category worth offering at all (#2717).
"""
bambu: int = Field(default=0, description="Connected Bambu Cloud accounts")
orca: int = Field(default=0, description="Connected Orca Cloud accounts")
class GitHubBackupStatus(BaseModel):
"""Schema for current backup status."""

View file

@ -205,6 +205,10 @@ class FileListResponse(BaseModel):
created_by_id: int | None = None
created_by_username: str | None = None
created_at: datetime
# Real on-disk modification time (#2680). Populated for external files from
# their filesystem mtime; null for managed uploads. The file pane's date sort
# and the "Modified" column use ``fs_modified_at ?? created_at``.
fs_modified_at: datetime | None = None
# Key metadata fields for display
print_name: str | None = None

View file

@ -19,6 +19,7 @@ class ProviderType(StrEnum):
DISCORD = "discord"
WEBHOOK = "webhook"
HOMEASSISTANT = "homeassistant"
BARK = "bark"
class NotificationProviderBase(BaseModel):
@ -62,6 +63,9 @@ class NotificationProviderBase(BaseModel):
# Event triggers - Build plate detection
on_plate_not_empty: bool = Field(default=True, description="Notify when objects detected on plate before print")
on_plate_clear_required: bool = Field(
default=False, description="Notify when a finished print is waiting for plate-clear confirmation"
)
# Event triggers - Bed cooled
on_bed_cooled: bool = Field(default=False, description="Notify when bed cools after print")
@ -146,6 +150,7 @@ class NotificationProviderUpdate(BaseModel):
# Event triggers - Build plate detection
on_plate_not_empty: bool | None = None
on_plate_clear_required: bool | None = None
# Event triggers - Bed cooled
on_bed_cooled: bool | None = None

View file

@ -194,6 +194,12 @@ class PrintQueueItemResponse(BaseModel):
# 3MFs: when `plate_id` is set, the value is the matching plate's
# `curr_bed_type` rather than the archive-level first-plate default.
bed_type: str | None = None
# True when the source archive carries the slicer's own live-resolved
# AMS-slot pick (extra_data.slicer_ams_mapping) *and* it was resolved
# against this row's own printer — the only case where dispatch actually
# reuses that exact physical spool instead of the scheduler re-deriving one
# from the file's static type/color.
archive_has_slicer_ams_mapping: bool = False
# User tracking (Issue #206)
created_by_id: int | None = None

View file

@ -2,6 +2,8 @@ from datetime import datetime
from pydantic import BaseModel, Field, field_validator
from backend.app.utils.printer_models import supports_nozzle_flow_type
class PrinterBase(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
@ -81,6 +83,11 @@ class PrinterResponse(PrinterBase):
id: int
is_active: bool
nozzle_count: int = 1 # 1 or 2, auto-detected from MQTT
# Whether the model is sold with both Standard and High Flow nozzles, so a
# K-profile's flow type is a real choice rather than a meaningless field.
# Derived from the model, not from nozzle_count — see
# printer_models.supports_nozzle_flow_type.
supports_nozzle_flow_type: bool = True
print_hours_offset: float = 0.0
external_camera_url: str | None = None
external_camera_type: str | None = None
@ -113,6 +120,7 @@ class PrinterResponse(PrinterBase):
"camera_rotation": printer.camera_rotation,
"is_active": printer.is_active,
"nozzle_count": printer.nozzle_count,
"supports_nozzle_flow_type": supports_nozzle_flow_type(printer.model),
"print_hours_offset": printer.print_hours_offset,
"plate_detection_enabled": printer.plate_detection_enabled,
"created_at": printer.created_at,
@ -360,6 +368,11 @@ class PrinterStatus(BaseModel):
big_fan1_speed: int | None = None # Auxiliary fan
big_fan2_speed: int | None = None # Chamber/exhaust fan
heatbreak_fan_speed: int | None = None # Hotend heatbreak fan
# Left auxiliary part cooling fan (optional P2S/X2D accessory, airduct part id 10).
# None = not installed / not reported by this model.
left_aux_fan_speed: int | None = None
# Chamber exhaust fan present (P2S/X2D External Exhaust Fan kit; airduct part id 3).
exhaust_fan_present: bool = False
# Firmware version (from info.module[name="ota"].sw_ver)
firmware_version: str | None = None
# Developer LAN mode: True = enabled, False = disabled (MQTT encryption), None = unknown

View file

@ -26,6 +26,7 @@ class ProjectCreate(BaseModel):
color: str | None = None
target_count: int | None = None
target_parts_count: int | None = None
target_sets: int | None = None # Copies-per-file target (#1897)
notes: str | None = None
tags: str | None = None
due_date: datetime | None = None
@ -49,6 +50,7 @@ class ProjectUpdate(BaseModel):
status: str | None = None # active, completed, archived
target_count: int | None = None
target_parts_count: int | None = None
target_sets: int | None = None # Copies-per-file target (#1897)
notes: str | None = None
tags: str | None = None
due_date: datetime | None = None
@ -108,6 +110,7 @@ class ProjectResponse(BaseModel):
status: str
target_count: int | None
target_parts_count: int | None = None
target_sets: int | None = None # Copies-per-file target (#1897)
notes: str | None = None
attachments: list | None = None
tags: str | None = None
@ -129,6 +132,13 @@ class ProjectResponse(BaseModel):
from_attributes = True
class ProjectFileProgress(BaseModel):
"""Completed-run count for one library file inside a project (#1897)."""
file_id: int
completed_count: int
class ArchivePreview(BaseModel):
"""Minimal archive data for project preview."""
@ -150,6 +160,7 @@ class ProjectListResponse(BaseModel):
status: str
target_count: int | None
target_parts_count: int | None = None
target_sets: int | None = None # Copies-per-file target (#1897); the shared edit dialog needs it
budget: float | None = None
# The edit dialog is shared with the project detail page and seeds its fields
# from whichever project object it is handed, so the list payload has to carry
@ -276,6 +287,7 @@ class ProjectExport(BaseModel):
status: str
target_count: int | None
target_parts_count: int | None
target_sets: int | None = None
notes: str | None
tags: str | None
due_date: datetime | None
@ -294,6 +306,7 @@ class ProjectImport(BaseModel):
status: str = "active"
target_count: int | None = None
target_parts_count: int | None = None
target_sets: int | None = None
notes: str | None = None
tags: str | None = None
due_date: datetime | None = None

View file

@ -1,9 +1,23 @@
import json
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, ValidationInfo, field_validator
from backend.app.schemas.print_queue import TriState
# Outbound service URLs validated on save, so a bad value is rejected at
# configuration time with a clear message rather than failing opaquely at
# request time. Every one of these services is commonly self-hosted on the same
# host or LAN as Bambuddy, so the LAN-service policy applies: loopback and
# RFC-1918 stay permitted, while cloud-metadata endpoints, numeric-encoded IPs,
# IPv4-mapped IPv6 and non-HTTP schemes are rejected. See
# ``_url_safety.assert_safe_lan_service_url``.
#
# Module-level rather than a class attribute so the CI backstop in
# tests/unit/test_outbound_url_ssrf_guards.py can import the real list and
# cannot drift from it. Any new outbound-URL setting belongs here (or, if it
# must be reachable on the public internet, on the stricter OIDC guard).
LAN_SERVICE_URL_SETTINGS = ("ha_url", "obico_ml_url", "orcaslicer_api_url", "bambu_studio_api_url")
class AppSettings(BaseModel):
"""Application settings schema."""
@ -19,6 +33,16 @@ class AppSettings(BaseModel):
"this print, otherwise it is deleted automatically after the photo is captured."
),
)
finish_photo_restore_plate: bool = Field(
default=True,
description=(
"Raise the build plate back into camera framing before taking the finish photo. "
"Bambu's end G-code drops the plate ~100mm as the last thing it does, leaving the "
"finished print far below the camera's natural framing. Bambuddy moves it back to "
"just above the last printed layer, takes the photo, then lowers it again. Skipped "
"when the print height is unknown or another job is queued for the printer."
),
)
default_filament_cost: float = Field(default=25.0, description="Default filament cost per kg")
currency: str = Field(default="USD", description="Currency for cost tracking")
energy_cost_per_kwh: float = Field(default=0.15, description="Electricity cost per kWh for energy tracking")
@ -261,6 +285,21 @@ class AppSettings(BaseModel):
default="",
description="BambuStudio sidecar URL (e.g. http://localhost:3001). Empty falls back to the BAMBU_STUDIO_API_URL env var.",
)
# How long to keep waiting on a slice that isn't finishing. Measured against
# the sidecar's progress channel, not total elapsed time — a heavy model can
# legitimately slice for half an hour, and a wall-clock ceiling cannot tell
# that apart from a stalled one (#2730). Sidecars too old to report progress
# fall back to using this as a total-elapsed ceiling, which is the pre-#2730
# behaviour with a configurable number.
slicer_stall_timeout_minutes: int = Field(
default=15,
ge=1,
le=240,
description=(
"Give up on a slice after this many minutes with no progress from the sidecar. "
"On sidecars that do not report progress, applies to total slicing time instead."
),
)
# Prometheus metrics endpoint
prometheus_enabled: bool = Field(default=False, description="Enable Prometheus metrics endpoint at /metrics")
@ -443,6 +482,13 @@ class AppSettings(BaseModel):
default="",
description="Self-hosted Obico ML API base URL (e.g., http://192.168.1.10:3333)",
)
obico_ml_token: str = Field(
default="",
description=(
"Bearer token for the Obico ML API, matching the server's ML_API_TOKEN "
"environment variable. Empty when the server runs without one."
),
)
obico_sensitivity: str = Field(
default="medium",
description="Detection sensitivity: 'low', 'medium', or 'high' (adjusts LOW/HIGH thresholds)",
@ -482,6 +528,7 @@ class AppSettingsUpdate(BaseModel):
auto_archive: bool | None = None
save_thumbnails: bool | None = None
capture_finish_photo: bool | None = None
finish_photo_restore_plate: bool | None = None
default_filament_cost: float | None = None
currency: str | None = None
energy_cost_per_kwh: float | None = None
@ -551,6 +598,7 @@ class AppSettingsUpdate(BaseModel):
use_slicer_api: bool | None = None
orcaslicer_api_url: str | None = None
bambu_studio_api_url: str | None = None
slicer_stall_timeout_minutes: int | None = Field(default=None, ge=1, le=240)
prometheus_enabled: bool | None = None
prometheus_token: str | None = None
low_stock_threshold: float | None = Field(default=None, ge=0.1, le=99.9)
@ -593,6 +641,7 @@ class AppSettingsUpdate(BaseModel):
ldap_default_group: str | None = None
obico_enabled: bool | None = None
obico_ml_url: str | None = None
obico_ml_token: str | None = None
obico_sensitivity: str | None = None
obico_action: str | None = None
obico_poll_interval: int | None = Field(default=None, ge=5, le=120)
@ -600,6 +649,47 @@ class AppSettingsUpdate(BaseModel):
default_sidebar_order: str | None = None
forecast_global_lead_time_days: int | None = Field(default=None, ge=0)
@field_validator(*LAN_SERVICE_URL_SETTINGS)
@classmethod
def validate_lan_service_url(cls, v: str | None, info: ValidationInfo) -> str | None:
"""Reject SSRF-unsafe outbound service URLs on save.
Empty (and whitespace-only) is the documented "not configured / fall
back to the env var" value for all four fields and must keep passing.
Values that are not absolute URLs at all ("192.168.1.10:3333",
"localhost:3333") are left alone rather than rejected. Two reasons:
- They are inert. Every consumer of these four settings goes through
httpx, which raises UnsupportedProtocol for a URL with no scheme, so
no request is ever issued and there is nothing to guard against.
- They were storable before this validator existed, and the settings
UI is a plain text input with no scheme enforcement. Newly rejecting
them would break saves that have nothing to do with the URL: the
Obico panel, for one, sends obico_ml_url with every change and
auto-saves, so one legacy value would block toggling detection on or
off. A pre-existing misconfiguration should keep failing where it
already failed (at request time), not spread to unrelated fields.
``urlparse`` is no help in telling the two apart it reads
"localhost:3333" as scheme "localhost" so the test is the literal
"://" that makes a string an absolute URL.
"""
if v is None or not v.strip():
return v
candidate = v.strip()
if "://" not in candidate:
return v
# Lazy-imported: schemas avoid top-level imports from api/routes,
# matching the existing pattern in auth.py's _validate_icon_url.
from backend.app.api.routes._url_safety import assert_safe_lan_service_url
try:
assert_safe_lan_service_url(candidate, label=info.field_name or "URL")
except ValueError as exc:
raise ValueError(str(exc)) from exc
return v
@field_validator("gcode_snippets")
@classmethod
def validate_gcode_snippets(cls, v: str | None) -> str | None:

View file

@ -82,6 +82,17 @@ class SliceRequest(BaseModel):
default=False,
description="If true, request a 3MF response with embedded G-code instead of raw G-code.",
)
design_overrides: list[str] | None = Field(
default=None,
description=(
"3MF only. Process setting keys from the source file's "
"``different_settings_to_system`` to carry onto the picked process "
"preset (#2622) — the designer's own wall count, infill, first-layer "
"height and so on, which ``--load-settings`` would otherwise discard. "
"Only keys the source actually lists as changed are applied; anything "
"else is ignored. ``None``/empty means a plain profile slice."
),
)
use_embedded_settings: bool = Field(
default=False,
description=(

View file

@ -1143,6 +1143,9 @@ class ArchiveService:
subtask_id: str | None = None,
prefer_filename_for_name: bool = False,
plate_id: int | None = None,
library_file_id: int | None = None,
slicer_ams_mapping: list[int] | None = None,
slicer_ams_mapping_printer_id: int | None = None,
) -> PrintArchive | None:
"""Archive a 3MF file with metadata.
@ -1155,6 +1158,8 @@ class ArchiveService:
stored with UUID names)
project_id: Project to associate this archive with (optional, set when triggered
from the project view)
library_file_id: Library file this run was dispatched from (optional,
set by the queue scheduler powers per-file project progress, #1897)
subtask_id: MQTT-provided task identifier (optional). Used to match an
existing archive across a backend restart mid-print so the
original row can be resumed instead of cancelled (#972).
@ -1163,6 +1168,21 @@ class ArchiveService:
metadata. Used by virtual-printer flows so users who rename a job in
BambuStudio's "send to printer" dialog see that name instead of the
creator-baked title (#1152).
slicer_ams_mapping: The slicer's own live-resolved AMS-slot pick, to persist
onto `extra_data.slicer_ams_mapping` for a later reprint to reuse. Deliberately
a distinct parameter, not read off `print_data["ams_mapping"]` that key is
populated on every MQTT print-start callback regardless of source (bambu_mqtt's
request-topic interception captures it for slicer-direct LAN prints too), so
promoting it unconditionally would stamp every archive on installs with no
virtual printer at all. Callers that gate this behind an opt-in (the VP-queue
"Save AMS mapping" toggle) pass it explicitly; everyone else leaves it unset.
slicer_ams_mapping_printer_id: The printer `slicer_ams_mapping`'s tray IDs were
resolved against. Required alongside `slicer_ams_mapping` a global tray ID
only means something relative to one printer's specific AMS layout, so a
mapping saved without knowing which printer it came from can't be safely
reused later on any printer, including the same one (there'd be no way to
tell). A model-based VP with no fixed target printer has no valid value to
pass here and must leave both params unset.
"""
# Verify printer exists if specified
if printer_id is not None:
@ -1251,6 +1271,25 @@ class ArchiveService:
if print_data:
metadata["_print_data"] = print_data
# Promote the slicer's own live-resolved AMS-slot pick, when the caller
# explicitly opted in (see the `slicer_ams_mapping` param docstring for
# why this is NOT read off `print_data["ams_mapping"]`), to a stable
# top-level extra_data key. Lets a later reprint reuse the exact tray
# the user picked/BambuStudio auto-matched at slice time instead of the
# scheduler re-deriving one from just the file's static type/color,
# which can land on the wrong physical spool when that match isn't
# unique. Top-level (not nested under the `_print_data` diagnostic bag)
# so API consumers have a single stable path:
# `archive.extra_data.slicer_ams_mapping`. Stored together with the
# printer it was resolved against — see `slicer_ams_mapping_printer_id`
# param docstring — so a later reprint can tell whether it's even
# applicable before trying to reuse it.
if slicer_ams_mapping and slicer_ams_mapping_printer_id is not None:
metadata["slicer_ams_mapping"] = {
"mapping": slicer_ams_mapping,
"printer_id": slicer_ams_mapping_printer_id,
}
# Determine status and timestamps
status = print_data.get("status", "completed") if print_data else "archived"
started_at = datetime.now(timezone.utc) if status == "printing" else None
@ -1314,6 +1353,7 @@ class ArchiveService:
extra_data=metadata,
created_by_id=created_by_id,
project_id=project_id,
library_file_id=library_file_id,
subtask_id=subtask_id,
plate_id=plate_id,
)

View file

@ -416,6 +416,42 @@ class BambuCloudService:
logger.error("Email verification failed: %s", e)
raise BambuCloudAuthError(f"Verification failed: {e}")
async def _fetch_csrf_token(self, web_origin: str) -> str | None:
"""Seed the ``bbl_csrf_token`` cookie and return its value (#2696).
Bambu added double-submit CSRF protection to the ``bambulab.com`` web
origin. A POST without the cookie is rejected ``403 {"error": "CSRF
error: missing_cookie"}`` before the request body is looked at; with the
cookie but no matching header it becomes ``missing_header``. Only
``GET /api/csrf`` mints one the sign-in *page* sets nothing but
Cloudflare's ``__cf_bm``, so landing there first does not help.
The token is re-fetched per verification rather than cached: the client
is process-wide and long-lived, so a stale cookie could otherwise
disagree with the header we send.
"""
try:
response = await self._client.get(
f"{web_origin}/api/csrf",
headers={"User-Agent": _USER_AGENT, "Accept": "application/json"},
)
except Exception as e:
logger.warning("Failed to fetch Bambu Cloud CSRF token: %s", e)
return None
# httpx stores the Set-Cookie on the shared jar, which is also what makes
# the cookie ride along on the POST below — we only need the value here
# to echo it back in the header.
try:
token = self._client.cookies.get("bbl_csrf_token")
except Exception: # multiple cookies of the same name across domains
token = None
if not token:
logger.warning(
"Bambu Cloud CSRF endpoint returned no bbl_csrf_token (status %s)",
response.status_code,
)
return token
async def verify_totp(self, tfa_key: str, code: str) -> dict:
"""
Complete login with TOTP code from authenticator app.
@ -433,9 +469,24 @@ class BambuCloudService:
# expected application-level "Login failed" JSON, no Cloudflare
# interstitial). Browser-impersonation removed to stay clearly on
# the right side of Bambu Lab's "no falsified client identity" line.
tfa_url = "https://bambulab.com/api/sign-in/tfa"
if "bambulab.cn" in self.base_url:
tfa_url = "https://bambulab.cn/api/sign-in/tfa"
web_origin = "https://bambulab.cn" if "bambulab.cn" in self.base_url else "https://bambulab.com"
tfa_url = f"{web_origin}/api/sign-in/tfa"
# #2696: the web origin is CSRF-protected (double submit). Without
# both halves the endpoint 403s before it ever evaluates the code,
# which surfaced to users as a permanent, misleading "Invalid code".
# api.bambulab.com — where every other call in this service goes,
# including the email-code 2FA path — is not gated, which is why
# only TOTP sign-ins broke.
csrf_token = await self._fetch_csrf_token(web_origin)
if not csrf_token:
return {
"success": False,
"message": (
"Could not obtain a security token from Bambu Cloud. "
"Check the server's internet access and try again."
),
}
response = await self._client.post(
tfa_url,
@ -443,6 +494,10 @@ class BambuCloudService:
"Content-Type": "application/json",
"User-Agent": _USER_AGENT,
"Accept": "application/json",
# Echo of the bbl_csrf_token cookie httpx just stored. Both
# halves are required; the cookie alone yields
# "missing_header".
"x-bbl-csrf-token": csrf_token,
},
json={
"tfaKey": tfa_key,
@ -487,10 +542,26 @@ class BambuCloudService:
# Provide helpful error message
error_msg = data.get("message", "")
# A CSRF rejection means the code was never evaluated (#2696). It
# used to fall through to the generic path below and read as
# "Invalid code", which sent the reporter chasing clock drift and
# leading-zero parsing for a request Bambu had already refused.
csrf_error = data.get("error", "") if isinstance(data.get("error"), str) else ""
if "csrf" in csrf_error.lower() or data.get("reason") in ("missing_cookie", "missing_header"):
logger.error("Bambu Cloud rejected the TOTP request on CSRF grounds: %s", response.text[:200])
return {
"success": False,
"message": (
"Bambu Cloud rejected the sign-in request before checking your code "
"(security-token error). Your code is fine — please try again."
),
}
if "expired" in error_msg.lower():
return {"success": False, "message": "TOTP session expired. Please try logging in again."}
if not error_msg:
error_msg = f"TOTP verification failed (status {response.status_code})"
error_msg = data.get("error") or f"TOTP verification failed (status {response.status_code})"
return {"success": False, "message": error_msg}

View file

@ -353,18 +353,43 @@ class BambuFTPClient:
return files
def download_file(self, remote_path: str) -> bytes | None:
"""Download a file from the printer."""
def download_file(self, remote_path: str, expected_size: int | None = None) -> bytes | None:
"""Download a file from the printer.
``expected_size`` is the byte count the directory listing reported for
this file. Pass it whenever a short read must not be mistaken for a
successful download: an FTPS data connection that closes early does
not always raise, so ``retrbinary`` can hand back a partial buffer that
looks like a perfectly good file to everything downstream. That is
tolerable when the printer keeps its copy, and not tolerable when the
caller goes on to delete the source (#2704).
A zero-byte result is always treated as a failure, matching
:meth:`download_to_file` no caller has a use for an empty file.
"""
if not self._ftp:
return None
try:
buffer = BytesIO()
self._ftp.retrbinary(f"RETR {remote_path}", buffer.write)
return buffer.getvalue()
data = buffer.getvalue()
except (OSError, ftplib.Error):
return None
if not data:
logger.warning("FTP download returned 0 bytes for %s", remote_path)
return None
if expected_size is not None and len(data) != expected_size:
logger.warning(
"FTP download of %s is short: got %s bytes, listing reported %s — treating as failed",
remote_path,
len(data),
expected_size,
)
return None
return data
def download_to_file(self, remote_path: str, local_path: Path) -> bool:
"""Download a file from the printer to local filesystem."""
if not self._ftp:
@ -1301,6 +1326,7 @@ async def download_file_bytes_async(
socket_timeout: float | None = None,
printer_model: str | None = None,
timeout: float = 300.0,
expected_size: int | None = None,
) -> bytes | None:
"""Async wrapper for downloading file as bytes.
@ -1313,6 +1339,9 @@ async def download_file_bytes_async(
video, gcode) which can legitimately take minutes over slow Wi-Fi
the cap only guards against a permanently-starved pool, not a
slow-but-progressing transfer.
expected_size: size from the directory listing; a mismatch fails the
download instead of returning a truncated file. See
:meth:`BambuFTPClient.download_file`.
"""
loop = asyncio.get_event_loop()
@ -1320,7 +1349,7 @@ async def download_file_bytes_async(
client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
if client.connect():
try:
return client.download_file(remote_path)
return client.download_file(remote_path, expected_size=expected_size)
finally:
client.disconnect()
return None
@ -1332,6 +1361,121 @@ async def download_file_bytes_async(
return None
async def remote_file_settled(
ip_address: str,
access_code: str,
remote_path: str,
downloaded_bytes: int,
*,
printer_model: str | None = None,
) -> bool:
"""Confirm the printer has finished writing the file we just downloaded.
Matching the download against the size from the directory listing proves we
received what the listing *said*, not that the file was *finished*. The
timelapse scan's first look happens seconds after the print ends, which is
exactly when the printer is writing the video so a file still growing can
be listed at a partial size, served at that size, and pass the length check
as a complete video (#2704).
That was survivable while the printer kept its copy. It isn't now that a
successful attach deletes the source, so re-list afterwards: if the file has
grown, what we hold is a prefix and the caller should discard it and try
again on the next round.
Returns True when the remote file can no longer differ from what we hold
the size still matches, or the file is gone from the listing entirely and
so cannot grow any further. Returns False when it has changed size, and on
a listing failure, because "we could not check" must not read as "safe to
delete".
"""
directory, _, name = remote_path.rpartition("/")
files = await list_files_async(ip_address, access_code, directory or "/", printer_model=printer_model)
if not files:
logger.warning("[TIMELAPSE] Could not re-list %s to confirm %s is complete", directory or "/", name)
return False
for f in files:
if f.get("name") == name:
size = f.get("size")
if size == downloaded_bytes:
return True
logger.info(
"[TIMELAPSE] %s is still being written (%s bytes now, %s when downloaded) — will retry",
name,
size,
downloaded_bytes,
)
return False
# Vanished between the download and now. Nothing left that could grow, and
# nothing left to delete either.
logger.debug("[TIMELAPSE] %s is no longer on the printer after download", name)
return True
async def delete_archived_timelapse(
ip_address: str,
access_code: str,
remote_path: str,
*,
verified: bool,
printer_model: str | None = None,
printer_name: str = "",
) -> bool:
"""Remove a timelapse from the printer once it is safely in the archive.
Call this only after the attach succeeded (#2704). Keeping ``/timelapse``
down to just the unclaimed videos is what makes the snapshot diff
unambiguous rather than merely usually-right, and it stops P1S cards
filling with AVIs.
``verified`` must say whether the downloaded byte count was checked against
the size the directory listing reported. It is required rather than
defaulted because this is the one irreversible step in the flow: an FTPS
data connection that closes early does not always raise, so an unverified
transfer can be a partial file that looks complete, and deleting the source
would then destroy the only good copy. The check lives here rather than at
each call site so no future caller can omit it.
Best-effort otherwise: a printer that refuses the delete keeps its copy, the
diff still excludes that filename next time because it is attached to an
archive, and nothing else in the flow cares. Returns True only on an actual
delete or a 550 (already gone).
"""
if not verified:
logger.warning(
"[TIMELAPSE] Not deleting %s from printer %s: the download was never size-checked",
remote_path,
printer_name,
)
return False
for attempt in range(1, 4):
try:
result = await delete_file_async(ip_address, access_code, remote_path, printer_model=printer_model)
except Exception as e:
result = DeleteResult.FAILED
logger.warning("[TIMELAPSE] Delete attempt %d/3 raised for %s: %s", attempt, remote_path, e)
if result == DeleteResult.DELETED:
logger.info("[TIMELAPSE] Deleted %s from printer %s after archiving", remote_path, printer_name)
return True
if result == DeleteResult.NOT_FOUND:
# 550 never recovers by waiting — the printer already cleaned up.
logger.debug("[TIMELAPSE] %s already gone from printer %s", remote_path, printer_name)
return True
if attempt < 3:
await asyncio.sleep(2)
logger.warning(
"[TIMELAPSE] Could not delete %s from printer %s (it stays on the card; the archive copy is unaffected)",
remote_path,
printer_name,
)
return False
async def get_storage_info_async(
ip_address: str,
access_code: str,

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,7 @@ Supports two camera protocols:
"""
import asyncio
import functools
import logging
import os
import shutil
@ -16,6 +17,8 @@ import uuid
from datetime import datetime
from pathlib import Path
from backend.app.core.logging_filters import redact_url_credentials
logger = logging.getLogger(__name__)
# JPEG markers
@ -32,6 +35,26 @@ _rtsp_socket_timeout_flag: str | None = None
# The cleanup task in routes/camera.py checks this set to avoid killing active captures.
_active_capture_pids: set[int] = set()
# In-flight one-shot captures, keyed by printer IP (#2705).
#
# Bambu firmware allows exactly one camera connection, and the existing guards
# (is_stream_active / try_get_active_buffered_frame, #1271 + #1348) only stop a
# capturer from competing with the fan-out BROADCASTER. They do nothing for
# capturer-vs-capturer with no viewer attached, where every consumer correctly
# concludes it isn't competing with a viewer and then collides with the others.
# Eight paths reach capture_camera_frame_bytes() independently — Obico polling,
# /camera/snapshot, the finish-photo moment and its disk-writing sibling, plate
# detection, the camera test and the diagnose tool — so the single-flight lives
# at the bottom of the stack and needs no call-site changes.
#
# Keyed by IP rather than printer_id because IP is what the firmware's one-
# connection limit applies to: two printer rows pointing at the same address
# still share one camera. (This function never sees a printer_id anyway.) The
# key deliberately excludes the timeout, or callers that disagree about it —
# and they all do, from 10s to 30s — would never coalesce, which is exactly
# the Obico-vs-snapshot pair from the report.
_inflight_captures: dict[str, asyncio.Task[bytes | None]] = {}
def get_ffmpeg_path() -> str | None:
"""Find the ffmpeg executable path.
@ -527,6 +550,38 @@ async def capture_camera_frame(
return False
def capture_in_flight(ip_address: str) -> bool:
"""Return True iff a one-shot capture for this IP is running right now.
For callers that need to know whether they will JOIN someone else's
capture rather than perform their own currently only the diagnose tool,
which reports on what it measured and so must not present a coalesced
frame as proof that it opened its own connection (see camera_diagnose).
Ordinary consumers should ignore this: they want "a recent frame", and
capture_camera_frame_bytes() already does the right thing for them.
"""
task = _inflight_captures.get(ip_address)
return task is not None and not task.done()
def _discard_inflight_capture(ip_address: str, task: asyncio.Task) -> None:
"""Done-callback: drop the finished task from the in-flight registry.
Guarded on identity so a slow task that finishes after a newer capture
has registered can't evict its successor.
Also retrieves the exception, if any. The leader normally awaits the task
and would surface it, but a leader whose own caller was cancelled leaves
nobody to collect it and an unretrieved task exception is logged by
asyncio as a warning with a traceback at an arbitrary later point.
"""
if _inflight_captures.get(ip_address) is task:
del _inflight_captures[ip_address]
if not task.cancelled() and task.exception() is not None:
logger.debug("In-flight camera capture for %s ended in an exception", ip_address)
async def capture_camera_frame_bytes(
ip_address: str,
access_code: str,
@ -535,18 +590,95 @@ async def capture_camera_frame_bytes(
) -> bytes | None:
"""Capture a single frame and return as JPEG bytes (no disk write).
Uses the same protocol selection as capture_camera_frame but returns
bytes directly instead of writing to disk.
Concurrent callers for the same printer share one capture (#2705): the
first opens the connection, everyone arriving while it is in flight awaits
the same result. Every consumer here wants "a recent frame" rather than
"a frame captured at exactly my timestamp", so handing identical bytes to
simultaneous callers is correct and it is the only way to honour the
firmware's one-connection limit without serialising captures behind a lock
(which would just turn a collision into a queue).
This coalesces; it does not cache. A call that arrives after the previous
capture finished always captures fresh. Two consumers of these frames
plate detection and the finish-photo path decide things about a running
print from them, and a stale frame there is worse than a slow one: the
whole of #1397 was a finish photo taken seconds late showing the bed
already lowered.
Args:
ip_address: Printer IP address
access_code: Printer access code
model: Printer model (X1, H2D, P1, A1, etc.)
timeout: Timeout in seconds for the capture operation
timeout: Timeout in seconds for the capture operation. Applies to this
caller's own wait, including when it joins another caller's
capture the call sites disagree about the value (10s for plate
detection, 20s for Obico), and a follower must not silently
inherit the leader's deadline in either direction.
Returns:
JPEG bytes if capture was successful, None otherwise
"""
# A follower whose leader fails takes a turn of its own rather than
# inheriting a failure it never had a chance to avoid — by then the leader
# has finished, so there is no socket left to compete with. Bounded at two
# rounds: if the capture we joined AND its replacement both failed, a third
# connection won't help, and this caller has already spent its patience.
for _ in range(2):
leader = _inflight_captures.get(ip_address)
if leader is None or leader.done():
break
try:
frame = await asyncio.wait_for(asyncio.shield(leader), timeout=timeout)
except TimeoutError:
# shield() keeps the capture running for whoever else is still
# waiting on it — giving up is this caller's decision alone.
logger.warning(
"Gave up waiting %ss on the in-flight camera capture for %s",
timeout,
ip_address,
)
return None
except asyncio.CancelledError:
# Distinguish "the capture I joined was cancelled" from "I was
# cancelled". Only the former is ours to recover from.
if not leader.cancelled():
raise
logger.info("In-flight camera capture for %s was cancelled; capturing our own", ip_address)
continue
if frame is not None:
logger.info(
"Reusing in-flight camera capture for %s: %s bytes (no second connection opened)",
ip_address,
len(frame),
)
return frame
logger.info("In-flight camera capture for %s failed; capturing our own", ip_address)
else:
return None
task = asyncio.create_task(_capture_camera_frame_bytes_uncoalesced(ip_address, access_code, model, timeout))
_inflight_captures[ip_address] = task
task.add_done_callback(functools.partial(_discard_inflight_capture, ip_address))
# No wait_for here: this caller IS the capture, and the implementation
# already enforces `timeout` internally where it can also kill the ffmpeg
# process. A second deadline on top would abandon the subprocess instead.
# shield() so that a cancelled leader (a client navigating away mid-
# snapshot is routine) doesn't take the capture down with it — the
# followers already waiting on it still get their frame.
return await asyncio.shield(task)
async def _capture_camera_frame_bytes_uncoalesced(
ip_address: str,
access_code: str,
model: str | None,
timeout: int = 15,
) -> bytes | None:
"""Open a connection and capture one frame. See capture_camera_frame_bytes.
Callers want that wrapper, not this: it opens a socket unconditionally,
which is the collision #2705 is about.
"""
# Chamber image models: A1/P1 - returns bytes directly
if is_chamber_image_model(model):
logger.info("Capturing camera frame bytes from %s using chamber image protocol (model: %s)", ip_address, model)
@ -608,7 +740,8 @@ async def capture_camera_frame_bytes(
logger.info("Successfully captured camera frame bytes: %s bytes", len(stdout))
return stdout
else:
stderr_text = stderr.decode() if stderr else "Unknown error"
# ffmpeg echoes the RTSP input URL, which carries the access code.
stderr_text = redact_url_credentials(stderr.decode()) if stderr else "Unknown error"
logger.error("ffmpeg frame bytes capture failed (code %s): %s", process.returncode, stderr_text[:200])
return None
@ -703,12 +836,72 @@ async def extract_video_last_frame(video_path: Path, output_path: Path) -> bool:
return False
def apply_camera_rotation(image_data: bytes, rotation: int, logger: logging.Logger) -> bytes:
"""Apply a camera_rotation value (degrees clockwise) to a captured JPEG.
Shared by every capture path that saves a still image (notification
snapshots, finish photos, layer-timelapse frames) - previously only
wired into the notification-snapshot path, which left finish photos
and timelapse videos upside-down whenever camera_rotation was set.
Returns *image_data* itself (identity, not a copy) when there is nothing
to do or the rotate fails; callers that write to disk use that to skip a
pointless rewrite.
"""
if not rotation:
return image_data
try:
from io import BytesIO
from PIL import Image
img = Image.open(BytesIO(image_data))
# PIL rotate is counter-clockwise, so negate for clockwise rotation
img = img.rotate(-rotation, expand=True)
buf = BytesIO()
img.save(buf, format="JPEG", quality=90)
rotated = buf.getvalue()
# Debug, not info: layer-timelapse calls this once per layer, so a tall
# print would otherwise put hundreds of lines in the log for something
# the surrounding capture already reports at debug level.
logger.debug("Applied %d° camera rotation: %s%s bytes", rotation, len(image_data), len(rotated))
return rotated
except Exception as e:
logger.warning("Failed to apply camera rotation: %s", e)
return image_data
async def apply_camera_rotation_to_file(path: Path, rotation: int, logger: logging.Logger) -> None:
"""Rotate a JPEG that has already been written to disk, in place.
Two finish-photo sources never hold the frame as bytes - ``ffmpeg`` writes
the file for them, and they return only a filename - so they can't use
``apply_camera_rotation`` directly. Best-effort: any failure leaves the
unrotated file in place, which is what the caller had before.
"""
if not rotation:
return
try:
data = await asyncio.to_thread(path.read_bytes)
rotated = await asyncio.to_thread(apply_camera_rotation, data, rotation, logger)
if rotated is data:
# Nothing was done (the rotate failed and returned its input) -
# rewriting the same bytes would only risk truncating a good file.
return
await asyncio.to_thread(path.write_bytes, rotated)
except Exception as e:
logger.warning("Failed to rotate %s in place: %s", path.name, e)
async def capture_finish_photo(
printer_id: int,
ip_address: str,
access_code: str,
model: str | None,
archive_dir: Path,
rotation: int = 0,
) -> str | None:
"""Capture a finish photo and save it to the archive's photos folder.
@ -718,6 +911,9 @@ async def capture_finish_photo(
access_code: Printer access code
model: Printer model
archive_dir: Directory of the archive (where the 3MF is stored)
rotation: Printer's configured camera_rotation (degrees clockwise).
ffmpeg writes the file directly here, so the rotation is applied
to it afterwards rather than to bytes in hand.
Returns:
Filename of the captured photo, or None if capture failed
@ -742,6 +938,7 @@ async def capture_finish_photo(
)
if success:
await apply_camera_rotation_to_file(output_path, rotation, logger)
logger.info("Finish photo saved: %s", filename)
return filename
else:

View file

@ -35,6 +35,13 @@ out broadcaster to prevent). When ``is_stream_active`` reports True
AND a buffered frame is fresh (last 10 s), we short-circuit the test
with ``live_stream_active`` and report success the user is
literally watching the camera right now, no test needed.
The related case is another one-shot capture (Obico polling, the cam
wall) being in flight when the user hits Diagnose. There the capture
layer coalesces for us (#2705) and no competing socket is opened, but
the frame we get back was someone else's — so ``first_frame`` still
passes and carries a ``coalesced_capture`` code, because a diagnostic
that reports a connection it didn't open is worse than a slow one.
"""
from __future__ import annotations
@ -46,6 +53,7 @@ from dataclasses import dataclass, field
from backend.app.services.camera import (
capture_camera_frame_bytes,
capture_in_flight,
get_camera_port,
is_chamber_image_model,
)
@ -69,8 +77,10 @@ class CameraDiagnoseStage:
name: str # "tcp_reachable" | "first_frame" | "live_stream_active"
status: str # "ok" | "failed" | "skipped"
duration_ms: int = 0
# Optional machine-readable code for failures so the frontend can
# render a stage-specific hint without parsing free-text errors.
# Optional machine-readable code so the frontend can render a stage-
# specific hint without parsing free-text errors. Usually a failure
# reason; "coalesced_capture" qualifies a PASS whose frame came from a
# capture already in flight, so duration_ms isn't a connection time.
code: str | None = None
@ -166,6 +176,15 @@ async def _check_first_frame(
"""Stage 2 — capture one frame end-to-end. Combines auth + protocol
handshake + first keyframe; either it works or it doesn't."""
started = time.monotonic()
# A capture already running for this printer (an Obico poll, the cam wall)
# means capture_camera_frame_bytes will hand us THAT capture's frame rather
# than opening its own connection (#2705). Good for the printer, but this
# stage exists to report what it measured: the frame would be real evidence
# the camera works, while duration_ms would be mostly time spent queueing,
# and a pass would be claimed for a connection we never opened. So the
# stage says so, the same way the live-stream shortcut above declares
# itself instead of quietly passing.
coalesced = capture_in_flight(ip_address)
try:
jpeg = await capture_camera_frame_bytes(
ip_address=ip_address,
@ -190,7 +209,11 @@ async def _check_first_frame(
name="first_frame",
status="ok",
duration_ms=int((time.monotonic() - started) * 1000),
code="coalesced_capture" if coalesced else None,
)
# No annotation on the failure path: a follower whose leader fails goes on
# to capture on its own, so a None here means this stage did get its own
# attempt (or watched two consecutive captures fail — same verdict).
return CameraDiagnoseStage(
name="first_frame",
status="failed",

View file

@ -0,0 +1,193 @@
"""Carry a 3MF designer's own process tweaks across a re-slice (#2622).
A MakerWorld model is often published with deliberate deviations from the stock
Bambu process preset 5 walls, 100% infill, a 0.1mm first layer. Re-slicing that
file for a different printer used to drop every one of them: ``--load-settings``
is authoritative, so the picked process preset wins over the 3MF's embedded
``Metadata/project_settings.config``.
We do not have to *compute* what the designer changed. BambuStudio already did,
and wrote the answer into the file:
different_settings_to_system = [
"enable_support;inner_wall_speed;sparse_infill_density;...", # [0] process
"filament_change_length;filament_prime_volume", # [1..N] filaments
"machine_start_gcode;bed_custom_model;...", # [-1] printer
]
The array is ``1 + len(filament_settings_id) + 1`` long verified against real
files at 2, 3 and 4 filament slots. Index 0 is exactly the set of process keys
that differ from the system preset, which is the reporter's step 1 for free: no
baseline resolution, no shipping BBL profiles into Bambuddy, and no new endpoint
on the slicer sidecar (which exposes bundled presets by name only, with no way to
flatten one).
Delivery is the mechanism ``_patch_process_support_settings`` already proved in
#1881: write the values into the process JSON that goes out as ``--load-settings``.
For a "standard" preset pick that JSON is a ``{inherits: }`` stub, so the keys we
write are the *child* in the inherits chain and win over the flattened parent.
Not every key is safe to carry, though. Real files put ``inner_wall_speed``,
``outer_wall_speed`` and ``prime_tower_max_speed`` in that list values tuned for
the designer's machine that can be plain wrong, or out of range, on the target.
Those are classified :data:`PRINTER_COUPLED` and offered unticked; the caller
decides. Nothing is applied that the caller did not ask for by name.
"""
from __future__ import annotations
import json
import logging
import zipfile
from io import BytesIO
from typing import Any, NamedTuple
logger = logging.getLogger(__name__)
_PROJECT_SETTINGS = "Metadata/project_settings.config"
class DesignOverride(NamedTuple):
"""One process setting the designer changed away from the system preset."""
key: str
value: Any
printer_coupled: bool
# Process keys whose sane value depends on the machine, not on the design intent.
# The designer picked these for *their* printer's kinematics, chamber and hotend;
# carrying them onto another model risks a slice that is merely slower/uglier —
# or a hard range-validation reject from the CLI, which is how the very first
# slicer spike died. Offered, but never pre-selected.
#
# Matching is by exact key OR by suffix/substring rule below, because Bambu's
# process schema has dozens of per-feature speed keys and an exhaustive literal
# list would rot on every slicer release.
_PRINTER_COUPLED_EXACT: frozenset[str] = frozenset(
{
"default_acceleration",
"independent_support_layer_height",
"precise_z_height",
"travel_acceleration",
"enable_wrapping_detection",
}
)
# Substring rules for the families that are always machine-coupled. Kept
# deliberately narrow: "speed", "acceleration"/"accel" and "jerk" are the
# kinematic families, "fan"/"temperature" follow the hotend and chamber, and
# "prime_tower" follows the target's toolchange hardware.
_PRINTER_COUPLED_SUBSTRINGS: tuple[str, ...] = (
# Prime-tower geometry (and whether there is one at all) follows the target's
# extruder count and bed, not the design — a real file carries five of these.
"prime_tower",
"_speed",
"speed_",
"acceleration",
"_accel",
"jerk",
"fan_speed",
"_temperature",
"temperature_",
)
def is_printer_coupled(key: str) -> bool:
"""Whether carrying this process key across printer models is risky."""
if key in _PRINTER_COUPLED_EXACT:
return True
lowered = key.lower()
return any(token in lowered for token in _PRINTER_COUPLED_SUBSTRINGS)
def _split_changed_keys(entry: Any) -> list[str]:
"""Parse one ``different_settings_to_system`` entry into its key names."""
if not isinstance(entry, str):
return []
return [part.strip() for part in entry.split(";") if part.strip()]
def extract_design_process_overrides(zip_bytes: bytes) -> list[DesignOverride]:
"""Process settings the 3MF's designer changed away from the system preset.
Returns an empty list for anything that is not a BambuStudio-style 3MF
carrying both ``project_settings.config`` and a well-formed
``different_settings_to_system`` including OrcaSlicer files and older
exports that predate the field. Callers treat empty as "nothing to offer",
which is the pre-feature behaviour.
"""
try:
with zipfile.ZipFile(BytesIO(zip_bytes), "r") as zf:
if _PROJECT_SETTINGS not in zf.namelist():
return []
config = json.loads(zf.read(_PROJECT_SETTINGS).decode("utf-8"))
except (zipfile.BadZipFile, json.JSONDecodeError, UnicodeDecodeError, OSError, KeyError):
return []
return overrides_from_config(config)
def overrides_from_config(config: Any) -> list[DesignOverride]:
"""``extract_design_process_overrides`` on an already-parsed config dict."""
if not isinstance(config, dict):
return []
changed = config.get("different_settings_to_system")
if not isinstance(changed, list) or not changed:
return []
# Sanity-check the layout before trusting index 0. The array should be
# [process, *filaments, printer]; a file whose length disagrees with its own
# filament count is one we do not understand, and guessing there could carry
# printer G-code into the process slot.
filaments = config.get("filament_settings_id")
if isinstance(filaments, list) and len(changed) != len(filaments) + 2:
logger.debug(
"3MF different_settings_to_system has %d entries for %d filaments "
"(expected %d) — skipping design-settings carry-over",
len(changed),
len(filaments),
len(filaments) + 2,
)
return []
overrides: list[DesignOverride] = []
# Index 0 is the process slot — see the layout in the module docstring. The
# length check above is what earns the right to index it blindly.
for key in _split_changed_keys(changed[0]):
if key not in config:
# Listed as changed but absent from the flattened config — nothing
# to carry. Seen with keys the slicer renamed between versions.
continue
overrides.append(DesignOverride(key=key, value=config[key], printer_coupled=is_printer_coupled(key)))
overrides.sort(key=lambda o: o.key)
return overrides
def apply_design_overrides(process_json: str, overrides: list[DesignOverride], selected_keys: list[str]) -> str:
"""Write the selected designer values into the outgoing process JSON.
``selected_keys`` is authoritative a key the caller did not name is not
applied even when it is present in ``overrides``. Returns ``process_json``
unchanged when nothing is selected or the JSON is unparseable, so a bad
input degrades to a plain profile slice rather than failing it.
"""
if not selected_keys or not overrides:
return process_json
wanted = set(selected_keys)
by_key = {o.key: o.value for o in overrides if o.key in wanted}
if not by_key:
return process_json
try:
process_cfg = json.loads(process_json)
except json.JSONDecodeError:
return process_json
if not isinstance(process_cfg, dict):
return process_json
process_cfg.update(by_key)
logger.info("Carrying %d design setting(s) onto the picked process preset: %s", len(by_key), sorted(by_key))
return json.dumps(process_cfg)

View file

@ -99,9 +99,15 @@ class ExportService:
Returns:
Tuple of (file_bytes, filename, content_type)
"""
# Build query
# Build query. Soft-deleted archives (#1343) are excluded: this export
# is the list the user is looking at, saved to a file, and that list
# hides them — an export that silently contains rows the UI says are
# gone is worse than useless for reconciling anything (#2731).
query = (
select(PrintArchive).options(selectinload(PrintArchive.project)).order_by(PrintArchive.created_at.desc())
select(PrintArchive)
.options(selectinload(PrintArchive.project))
.where(PrintArchive.deleted_at.is_(None))
.order_by(PrintArchive.created_at.desc())
)
# Apply filters

View file

@ -8,15 +8,18 @@ to ensure they are well-formed before use.
"""
import asyncio
import functools
import logging
import re
import shutil
from collections.abc import AsyncGenerator
from collections.abc import AsyncGenerator, Callable
from pathlib import Path
from urllib.parse import urlparse
import aiohttp
from backend.app.core.logging_filters import redact_url_credentials
logger = logging.getLogger(__name__)
@ -173,6 +176,70 @@ def get_ffmpeg_path() -> str | None:
return None
# In-flight one-shot captures, keyed by (url, camera_type, snapshot_url) —
# the tuple that actually identifies the physical resource being contended
# (#2707 comment thread, following #2705's shape for the built-in path).
#
# V4L2 USB devices allow exactly one open handle, and is_stream_active() /
# try_get_active_buffered_frame() (#2707) only stop a one-shot capturer from
# competing with the fan-out live view. They do nothing for capturer-vs-
# capturer with no viewer attached, where every consumer correctly concludes
# it isn't competing with a viewer and then collides with the others -
# exactly the #2705 report, just for this module's callers instead of
# capture_camera_frame_bytes()'s (Obico polling, the in-print frame bank,
# the finish-photo moment, plate detection, and the notification snapshot
# all reach capture_frame() independently).
#
# snapshot_url is part of the key (not just url/camera_type) because it
# routes to a completely different endpoint (#1177) - two printers that
# share a camera_url but differ only in snapshot_url must not coalesce.
_inflight_captures: dict[tuple[str, str, str | None], asyncio.Task[bytes | None]] = {}
def capture_in_flight(url: str, camera_type: str, snapshot_url: str | None = None) -> bool:
"""Return True iff a one-shot capture for this key is running right now.
Mirrors camera.py's capture_in_flight() for the built-in path - for a
caller that needs to know it will JOIN someone else's capture rather
than open its own connection. Ordinary consumers should ignore this:
they want "a recent frame", and capture_frame() already does the right
thing for them.
"""
task = _inflight_captures.get((url, camera_type, snapshot_url))
return task is not None and not task.done()
def _discard_inflight_capture(key: tuple[str, str, str | None], task: asyncio.Task) -> None:
"""Done-callback: drop the finished task from the in-flight registry.
Guarded on identity so a slow task that finishes after a newer capture
has registered for the same key can't evict its successor.
Also retrieves the exception, if any: the leader normally awaits the
task and would surface it, but a leader whose own caller was cancelled
leaves nobody to collect it, and an unretrieved task exception is
logged by asyncio as a warning with a traceback at an arbitrary later
point otherwise.
"""
if _inflight_captures.get(key) is task:
del _inflight_captures[key]
if not task.cancelled() and task.exception() is not None:
logger.debug("In-flight external-camera capture for %s ended in an exception", _log_key(key))
def _log_key(key: tuple[str, str, str | None]) -> str:
"""Render an in-flight key for a log line, with credentials redacted.
Unlike camera.py's coalescing — which is keyed by IP address and so has
nothing to hide these keys carry the camera URL, and an RTSP camera URL
routinely embeds ``user:pass@``. Redact before truncating: slicing first
can cut the URL short of the ``@`` the pattern anchors on and leave the
password in the log, which is why every other URL log in this module does
it in this order.
"""
return redact_url_credentials(key[0])[:50] if key[0] else "None"
async def capture_frame(
url: str,
camera_type: str,
@ -184,7 +251,10 @@ async def capture_frame(
Args:
url: Live-stream URL (MJPEG stream, RTSP URL, HTTP snapshot URL, or USB device path).
camera_type: "mjpeg", "rtsp", "snapshot", or "usb".
timeout: Connection timeout in seconds.
timeout: Connection timeout in seconds. Applies to this caller's own
wait, including when it joins another caller's capture - call
sites disagree about the value, and a follower must not silently
inherit the leader's deadline in either direction.
snapshot_url: Optional override for single-frame capture. When set, fetched
via plain HTTP GET regardless of `camera_type`. Bypasses MJPEG warm-up
handling on sources that expose a dedicated frame endpoint (e.g. go2rtc's
@ -193,21 +263,120 @@ async def capture_frame(
Returns:
JPEG bytes or None on failure
Concurrent callers for the same (url, camera_type, snapshot_url) share
one capture (#2705-shape fix, filed for the external-camera path as a
follow-up on #2707): the first opens the connection, everyone arriving
while it's in flight awaits the same result. This coalesces; it does
not cache - a call that arrives after the previous capture finished
always captures fresh, since plate detection and the finish-photo path
judge a running print from these frames and a stale one there is worse
than a slow one (#1397).
"""
if snapshot_url:
logger.debug("capture_frame using snapshot override url=%s...", snapshot_url[:50])
return await _capture_snapshot(snapshot_url, timeout)
logger.debug("capture_frame called: type=%s, url=%s...", camera_type, url[:50] if url else "None")
if camera_type == "mjpeg":
return await _capture_mjpeg_frame(url, timeout)
elif camera_type == "rtsp":
return await _capture_rtsp_frame(url, timeout)
elif camera_type == "snapshot":
return await _capture_snapshot(url, timeout)
elif camera_type == "usb":
return await _capture_usb_frame(url, timeout)
key = (url, camera_type, snapshot_url)
# A follower whose leader fails takes a turn of its own rather than
# inheriting a failure it never had a chance to avoid - by then the
# leader has finished, so there's no connection left to compete with.
# Bounded at two rounds: if the capture we joined AND its replacement
# both failed, a third attempt won't help, and this caller has already
# spent its patience.
for _ in range(2):
leader = _inflight_captures.get(key)
if leader is None or leader.done():
break
try:
frame = await asyncio.wait_for(asyncio.shield(leader), timeout=timeout)
except TimeoutError:
# shield() keeps the capture running for whoever else is still
# waiting on it - giving up is this caller's decision alone.
logger.warning(
"Gave up waiting %ss on the in-flight external-camera capture for %s", timeout, _log_key(key)
)
return None
except asyncio.CancelledError:
# Distinguish "the capture I joined was cancelled" from "I was
# cancelled". Only the former is ours to recover from.
if not leader.cancelled():
raise
logger.info("In-flight external-camera capture for %s was cancelled; capturing our own", _log_key(key))
continue
if frame is not None:
logger.debug(
"Reusing in-flight external-camera capture for %s: %d bytes (no second connection opened)",
_log_key(key),
len(frame),
)
return frame
logger.debug("In-flight external-camera capture for %s failed; capturing our own", _log_key(key))
else:
logger.warning("Unknown camera type: %s", camera_type)
return None
task = asyncio.create_task(_capture_frame_uncoalesced(url, camera_type, timeout, snapshot_url))
_inflight_captures[key] = task
task.add_done_callback(functools.partial(_discard_inflight_capture, key))
# No wait_for here: this caller IS the capture, and each dispatched
# _capture_* function already enforces `timeout` internally, where it
# can also kill the ffmpeg process - a second deadline on top would
# abandon the subprocess instead of killing it. shield() so a cancelled
# leader (a client navigating away mid-request is routine) doesn't take
# the capture down with it - followers already waiting on it still get
# their frame.
return await asyncio.shield(task)
async def _capture_frame_uncoalesced(
url: str,
camera_type: str,
timeout: int,
snapshot_url: str | None,
) -> bytes | None:
"""Open a connection and capture one frame. See capture_frame().
Callers want that wrapper, not this: it opens a connection
unconditionally, which is the collision #2705/#2707 are about.
Failure is reported as ``None``, never as an exception. That is load-
bearing now that captures are shared: the coalescing wrapper hands one
task's outcome to every caller waiting on it, and it can only give a
follower its own turn for an outcome it can recognise. An exception
escaping here would instead propagate to every follower at once
turning one caller's failure into N — and none of them would retry.
The per-type helpers below each catch what they expect and return None,
but they catch narrowly (``aiohttp.ClientError``/``OSError``/timeouts),
so this is the structural guarantee rather than one contingent on their
coverage. Mirrors ``_capture_camera_frame_bytes_uncoalesced`` in
camera.py, which ends in the same blanket catch for the same reason.
"""
try:
if snapshot_url:
# Redact before truncating — slicing first can cut the URL short of the
# ``@`` the pattern anchors on and leave the password in the log.
logger.debug("capture_frame using snapshot override url=%s...", redact_url_credentials(snapshot_url)[:50])
return await _capture_snapshot(snapshot_url, timeout)
logger.debug(
"capture_frame called: type=%s, url=%s...",
camera_type,
redact_url_credentials(url)[:50] if url else "None",
)
if camera_type == "mjpeg":
return await _capture_mjpeg_frame(url, timeout)
elif camera_type == "rtsp":
return await _capture_rtsp_frame(url, timeout)
elif camera_type == "snapshot":
return await _capture_snapshot(url, timeout)
elif camera_type == "usb":
return await _capture_usb_frame(url, timeout)
else:
logger.warning("Unknown camera type: %s", camera_type)
return None
except asyncio.CancelledError:
# Cancellation is not a capture failure and must stay distinguishable:
# the wrapper checks ``leader.cancelled()`` to decide whether a
# follower may take its own turn.
raise
except Exception:
logger.exception("External camera capture failed for %s", redact_url_credentials(url)[:50] if url else "None")
return None
@ -311,7 +480,7 @@ async def _capture_mjpeg_frame(url: str, timeout: int) -> bytes | None:
"""
safe_url = _sanitize_camera_url(url, ("http", "https"))
if not safe_url:
logger.error("Invalid MJPEG URL format: %s...", url[:50])
logger.error("Invalid MJPEG URL format: %s...", redact_url_credentials(url)[:50])
return None
jpeg_start = b"\xff\xd8"
@ -438,7 +607,8 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
)
if process.returncode != 0:
logger.error("ffmpeg RTSP capture failed: %s", stderr.decode()[:200])
# ffmpeg echoes the RTSP input URL, which carries the camera password.
logger.error("ffmpeg RTSP capture failed: %s", redact_url_credentials(stderr.decode())[:200])
return None
if not stdout or len(stdout) < 100:
@ -504,7 +674,7 @@ async def _capture_snapshot(url: str, timeout: int) -> bytes | None:
# Sanitize URL - returns reconstructed URL from validated components
safe_url = _sanitize_camera_url(url, ("http", "https"))
if not safe_url:
logger.error("Invalid snapshot URL format: %s...", url[:50])
logger.error("Invalid snapshot URL format: %s...", redact_url_credentials(url)[:50])
return None
try:
@ -557,12 +727,26 @@ async def test_connection(url: str, camera_type: str) -> dict:
"""Test camera connection.
Returns:
Dict with {success: bool, error?: str, resolution?: str}
Dict with {success: bool, error?: str, resolution?: str, coalesced: bool}
``coalesced`` is True when the frame came from a capture that was already
running rather than from a connection this test opened. Captures are shared
(see ``capture_frame``), so a test that lands while Obico is polling or
while any other one-shot consumer is mid-capture gets that frame back and
would otherwise report a healthy connection it never made, which is the one
answer a *connection test* must not give silently. Forcing an uncoalesced
capture here would be worse: it would open the second handle to a
single-reader device that this whole mechanism exists to prevent. So the
test still shares, and says so. Mirrors the ``coalesced_capture`` code the
built-in diagnostic reports for the same situation (camera_diagnose.py).
"""
logger.info("Testing camera connection: type=%s, url=%s...", camera_type, url[:50])
logger.info("Testing camera connection: type=%s, url=%s...", camera_type, redact_url_credentials(url)[:50])
# Sampled before the call, while it can still distinguish "someone else is
# mid-capture" from "I am the one capturing".
coalesced = capture_in_flight(url, camera_type)
try:
frame = await capture_frame(url, camera_type, timeout=10)
logger.info("Capture result: %s bytes", len(frame) if frame else 0)
logger.info("Capture result: %s bytes%s", len(frame) if frame else 0, " (coalesced)" if coalesced else "")
if frame:
# Try to get resolution from JPEG header
@ -581,24 +765,52 @@ async def test_connection(url: str, camera_type: str) -> dict:
except (IndexError, ValueError):
pass # Resolution detection is optional; fall back to default
return {"success": True, "resolution": resolution}
return {"success": True, "resolution": resolution, "coalesced": coalesced}
else:
return {"success": False, "error": "Failed to capture frame from camera"}
return {"success": False, "error": "Failed to capture frame from camera", "coalesced": coalesced}
except Exception as e:
# Sanitize error message - don't expose internal details
error_type = type(e).__name__
logger.error("Camera connection test failed: %s", e)
return {"success": False, "error": f"Connection failed: {error_type}"}
return {"success": False, "error": f"Connection failed: {error_type}", "coalesced": coalesced}
async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> AsyncGenerator[bytes, None]:
async def generate_mjpeg_stream(
url: str,
camera_type: str,
fps: int = 10,
*,
on_process: Callable[[asyncio.subprocess.Process], None] | None = None,
on_frame: Callable[[bytes], None] | None = None,
stop_event: asyncio.Event | None = None,
) -> AsyncGenerator[bytes, None]:
"""Generator yielding MJPEG frames for streaming.
Args:
url: Camera URL or USB device path
camera_type: "mjpeg", "rtsp", "snapshot", or "usb"
fps: Target frames per second
on_process: Called with the spawned ffmpeg process for the ``usb`` and
``rtsp`` paths so the route layer can register it into the shared
stream registries that's what lets ``/camera/stop`` and the orphan
janitor find and kill a leaked ffmpeg that's holding a USB device
open (#2675). Without it the process is reachable only from this
generator's own ``finally``, which an abrupt client disconnect can
skip (same cancellation-timing class as #776).
on_frame: Called with each RAW frame, before it is wrapped for the wire,
so the route layer can publish it as the printer's buffered frame
(#2707). It has to be a callback: what this generator yields is
multipart-wrapped, so a consumer of the stream cannot recover the
JPEG, and until now nothing populated the buffer for external
cameras at all leaving every one-shot consumer (layer timelapse,
finish photo, Obico, plate check) with nothing to reuse and no
option but to open a competing handle on a single-reader device.
Exceptions are logged and swallowed: buffering must never be able
to break the live stream.
stop_event: When set, the reconnect loops stop retrying so an explicit
stop (which kills the current ffmpeg) doesn't immediately respawn a
new process and reacquire the device.
Yields:
MJPEG frame data with HTTP multipart boundaries
@ -606,6 +818,15 @@ async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> As
frame_interval = 1.0 / max(fps, 1)
last_frame_time = 0.0
def _publish(frame: bytes) -> bytes:
"""Hand the raw frame to on_frame, then format it for the wire."""
if on_frame is not None:
try:
on_frame(frame)
except Exception:
logger.exception("on_frame callback raised")
return _format_mjpeg_frame(frame)
if camera_type == "mjpeg":
# Proxy MJPEG stream directly, with reconnect on timeout
max_retries = 3
@ -616,8 +837,8 @@ async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> As
current_time = asyncio.get_event_loop().time()
if current_time - last_frame_time >= frame_interval:
last_frame_time = current_time
yield _format_mjpeg_frame(frame)
if not frame_yielded or attempt == max_retries:
yield _publish(frame)
if not frame_yielded or attempt == max_retries or (stop_event is not None and stop_event.is_set()):
break
logger.warning(
"External MJPEG stream ended, reconnecting (attempt %d/%d)...",
@ -631,10 +852,10 @@ async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> As
max_retries = 3
for attempt in range(max_retries + 1):
frame_yielded = False
async for frame in _stream_rtsp(url, fps):
async for frame in _stream_rtsp(url, fps, on_process=on_process):
frame_yielded = True
yield _format_mjpeg_frame(frame)
if not frame_yielded or attempt == max_retries:
yield _publish(frame)
if not frame_yielded or attempt == max_retries or (stop_event is not None and stop_event.is_set()):
break
logger.warning(
"External RTSP stream ended, reconnecting (attempt %d/%d)...",
@ -645,8 +866,8 @@ async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> As
elif camera_type == "usb":
# Use ffmpeg to stream from USB camera
async for frame in _stream_usb(url, fps):
yield _format_mjpeg_frame(frame)
async for frame in _stream_usb(url, fps, on_process=on_process):
yield _publish(frame)
elif camera_type == "snapshot":
# Poll snapshot URL at interval
@ -654,7 +875,7 @@ async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> As
try:
frame = await _capture_snapshot(url, timeout=10)
if frame:
yield _format_mjpeg_frame(frame)
yield _publish(frame)
await asyncio.sleep(frame_interval)
except asyncio.CancelledError:
break
@ -683,7 +904,7 @@ async def _stream_mjpeg(url: str) -> AsyncGenerator[bytes, None]:
# Sanitize URL - returns reconstructed URL from validated components
safe_url = _sanitize_camera_url(url, ("http", "https"))
if not safe_url:
logger.error("Invalid MJPEG stream URL: %s...", url[:50])
logger.error("Invalid MJPEG stream URL: %s...", redact_url_credentials(url)[:50])
return
try:
@ -724,7 +945,12 @@ async def _stream_mjpeg(url: str) -> AsyncGenerator[bytes, None]:
logger.error("MJPEG stream error: %s", e)
async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
async def _stream_rtsp(
url: str,
fps: int,
*,
on_process: Callable[[asyncio.subprocess.Process], None] | None = None,
) -> AsyncGenerator[bytes, None]:
"""Stream frames from RTSP URL via ffmpeg.
For rtsps:// URLs, a local TLS proxy (Python OpenSSL) is used instead
@ -805,12 +1031,18 @@ async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
# Register immediately — before the startup probe below — so a process
# that hangs on connect (rather than exiting) is still reachable by the
# stop endpoint / orphan janitor (#2675).
if on_process is not None:
on_process(process)
# Brief check for immediate startup failures
await asyncio.sleep(0.1)
if process.returncode is not None:
stderr = await process.stderr.read()
logger.error("ffmpeg RTSP stream failed immediately: %s", stderr.decode()[:300])
# ffmpeg echoes the RTSP input URL, which carries the camera password.
logger.error("ffmpeg RTSP stream failed immediately: %s", redact_url_credentials(stderr.decode())[:300])
return
buffer = b""
@ -865,7 +1097,12 @@ async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
await proxy_server.wait_closed()
async def _stream_usb(device: str, fps: int) -> AsyncGenerator[bytes, None]:
async def _stream_usb(
device: str,
fps: int,
*,
on_process: Callable[[asyncio.subprocess.Process], None] | None = None,
) -> AsyncGenerator[bytes, None]:
"""Stream frames from USB camera via ffmpeg."""
ffmpeg = get_ffmpeg_path()
if not ffmpeg:
@ -907,6 +1144,12 @@ async def _stream_usb(device: str, fps: int) -> AsyncGenerator[bytes, None]:
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
# Register immediately — before the startup probe below — so a process
# that hangs in open()/ioctl on a still-locked device (rather than
# exiting with a "busy" error) is still reachable by the stop endpoint /
# orphan janitor (#2675).
if on_process is not None:
on_process(process)
# Give ffmpeg a moment to start and check for immediate failures
await asyncio.sleep(0.5)

View file

@ -55,8 +55,15 @@ class FailureAnalysisService:
if project_id:
from backend.app.models.archive import PrintArchive
# Soft-deleted archives (#1343) keep their project_id, so without
# this the failure rate for a project still counts prints the user
# deleted from it — and disagrees with the project's own numbers,
# which now exclude them (#2731).
project_archive_ids = await self.db.execute(
select(PrintArchive.id).where(PrintArchive.project_id == project_id)
select(PrintArchive.id).where(
PrintArchive.project_id == project_id,
PrintArchive.deleted_at.is_(None),
)
)
archive_ids = [row[0] for row in project_archive_ids.fetchall()]
if archive_ids:

View file

@ -63,14 +63,21 @@ class GiteaBackend(GitHubBackend):
return tree_node.get("sha")
return None
# Gitea/Forgejo can be hosted under a URL path prefix (ROOT_URL like
# https://host/gitea), so the repo lives at /<prefix...>/<owner>/<repo>
# rather than at the host root (#2642). Capture the scheme+host+prefix as
# one group and the final two path segments as owner/repo; the lazy prefix
# group is empty for a root-hosted instance. One shared pattern keeps
# parse_repo_url() and get_api_base() from drifting.
_HTTPS_REPO_RE = re.compile(
r"(https?://[\w.\-]+(?::\d+)?(?:/[\w.\-]+)*?)/([\w.\-]{1,100})/([\w.\-]{1,100})(?:\.git)?/?$"
)
def parse_repo_url(self, url: str) -> tuple[str, str]:
"""Return (owner, repo) — accepts both https:// and http:// for self-hosted instances."""
if not url or len(url) > 500:
raise ValueError("Invalid Git URL: URL too long or empty")
match = re.match(
r"https?://[\w.\-]+(:\d+)?/([\w.\-]{1,100})/([\w.\-]{1,100})(?:\.git)?/?$",
url,
)
match = self._HTTPS_REPO_RE.match(url)
if match:
return match.group(2), match.group(3).removesuffix(".git")
match = re.match(
@ -82,8 +89,8 @@ class GiteaBackend(GitHubBackend):
raise ValueError(f"Cannot parse repository URL: {url}")
def get_api_base(self, repo_url: str) -> str:
"""Derive API base from the repository URL's scheme and host."""
match = re.match(r"(https?://[\w.\-]+(:\d+)?)/", repo_url)
"""Derive API base from the repository URL's scheme, host and any path prefix."""
match = self._HTTPS_REPO_RE.match(repo_url)
if match:
return f"{match.group(1)}/api/v1"
raise ValueError(f"Cannot derive API base from URL: {repo_url}")

View file

@ -8,7 +8,7 @@ import logging
from datetime import datetime, timedelta, timezone
import httpx
from sqlalchemy import desc, select
from sqlalchemy import desc, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.database import async_session
@ -18,11 +18,61 @@ from backend.app.models.printer import Printer
from backend.app.models.settings import Settings
from backend.app.models.spool import Spool
from backend.app.models.spool_usage_history import SpoolUsageHistory
from backend.app.models.user import User
from backend.app.services.git_providers.factory import get_provider_backend
from backend.app.services.printer_manager import printer_manager
logger = logging.getLogger(__name__)
# Bambu's listing endpoint is keyed by preset type and calls process presets
# "print". Same mapping as `routes/cloud.py` — kept in step with it, since a
# divergence here silently drops a whole preset type from every backup.
_BAMBU_PRESET_TYPES = {
"filament": "filament",
"printer": "printer",
"print": "process",
}
def _bambu_preset_record(setting_id, our_type: str, entry: dict, detail: dict) -> dict:
"""One Bambu preset as stored in the backup: metadata plus the payload.
``base_id`` and ``setting`` are the two fields ``BambuCloudService.
create_setting`` needs, so a restore can rebuild the preset rather than
just list it.
``user_id`` from the listing is deliberately dropped. It identifies the
account and adds nothing to a rebuild, and backup repositories can be
public.
"""
return {
"setting_id": str(setting_id),
"name": detail.get("name") or entry.get("name") or "Unknown",
"type": our_type,
"version": detail.get("version") or entry.get("version"),
"updated_time": entry.get("updated_time"),
"base_id": detail.get("base_id"),
"filament_id": detail.get("filament_id"),
"setting": detail.get("setting") or {},
}
def _orca_profile_record(entry: dict) -> dict:
"""One Orca profile as stored in the backup.
``content`` is kept whole rather than picked apart: it is the profile, the
sync API hands it over inline, and Orca owns its shape. Narrowing it here
would mean guessing which keys a future restore needs.
"""
return {
"id": str(entry.get("id")) if entry.get("id") is not None else None,
"name": entry.get("name"),
"updated_time": entry.get("updated_time"),
"created_time": entry.get("created_time"),
"content": entry.get("content"),
}
# Schedule intervals in seconds
SCHEDULE_INTERVALS = {
"hourly": 3600,
@ -279,11 +329,13 @@ class GitHubBackupService:
{
"backup_metadata.json": {...},
"kprofiles/{serial}/{nozzle}.json": {...},
"cloud_profiles/filament.json": [...],
"cloud_profiles/printer.json": [...],
"cloud_profiles/process.json": [...],
"cloud_profiles/bambu/{account}/{filament,printer,process}.json": {...},
"cloud_profiles/orca/{account}/{filament,printer,process}.json": {...},
"settings/app_settings.json": {...},
}
``{account}`` is ``global`` when auth is disabled, otherwise
``user-{id}`` one directory per connected cloud account (#2717).
"""
files: dict[str, dict | list] = {}
@ -306,10 +358,20 @@ class GitHubBackupService:
self._backup_progress = "Collecting K-profiles from printers..."
await self._collect_kprofiles(db, files)
# Collect cloud profiles
# Collect cloud profiles. `contents.cloud_profiles` is corrected below
# from what was configured to what was actually written — it claimed
# `true` on every backup, including the ones that collected nothing
# (#2717), which is exactly the signal a restore needs to be able to
# trust.
if config.backup_cloud_profiles:
self._backup_progress = "Collecting cloud profiles from Bambu Cloud..."
await self._collect_cloud_profiles(db, files)
self._backup_progress = "Collecting cloud profiles from Bambu Cloud and Orca Cloud..."
cloud_summary = await self._collect_cloud_profiles(db, files)
collected = bool(cloud_summary.get("bambu") or cloud_summary.get("orca"))
metadata["contents"]["cloud_profiles"] = collected
if collected:
# Per-cloud, per-account counts, so a restore can tell an empty
# account from one that failed to collect.
metadata["cloud_profiles"] = cloud_summary
# Collect app settings
if config.backup_settings:
@ -374,69 +436,284 @@ class GitHubBackupService:
if printer_profiles:
logger.info("Collected K-profiles for %s: %s", serial, printer_profiles)
async def _collect_cloud_profiles(self, db: AsyncSession, files: dict):
"""Collect Bambu Cloud profiles if authenticated."""
# Backup runs without a user context, so fall back to the auth-disabled
# Settings storage. ``build_authenticated_cloud`` honours the stored
# region so China-region tokens are validated against api.bambulab.cn.
async def _collect_cloud_profiles(self, db: AsyncSession, files: dict) -> dict:
"""Collect slicer presets from every connected cloud account.
Two clouds, and on an auth-enabled install any number of accounts in
each: Bambu Cloud tokens live on ``User.cloud_token`` and Orca Cloud
tokens on ``User.orca_cloud_token``, falling back to the global
``Settings`` table only when auth is disabled. The previous version
asked for the auth-disabled store unconditionally, so it collected
nothing at all on any install with auth on (#2717).
Layout is one directory per cloud per account, both clouds grouped the
same way so a restore reads them identically::
cloud_profiles/bambu/user-3/{filament,printer,process}.json
cloud_profiles/orca/user-3/{filament,printer,process}.json
Accounts are keyed by Bambuddy user id (``global`` when auth is off),
never by email a backup repository can be public.
Returns a per-cloud summary for ``backup_metadata.json`` so the
metadata records what was actually collected rather than what was
merely enabled.
"""
summary: dict = {"bambu": {}, "orca": {}}
bambu_accounts, orca_accounts = await self.cloud_accounts(db)
if not bambu_accounts and not orca_accounts:
# Enabled but nothing to collect. Deliberately a warning: the INFO
# line this replaces read as a successful collection of nothing,
# which is how #2717 went unnoticed through every backup.
logger.warning(
"Cloud profiles are enabled for backup, but no Bambu Cloud or Orca Cloud "
"account is connected — nothing to collect."
)
return summary
for account_key, user in bambu_accounts:
try:
counts = await self._collect_bambu_profiles(db, files, account_key, user)
except Exception:
logger.warning("Failed to collect Bambu Cloud profiles for %s", account_key, exc_info=True)
continue
if counts:
summary["bambu"][account_key] = counts
for account_key, user in orca_accounts:
try:
counts = await self._collect_orca_profiles(db, files, account_key, user)
except Exception:
logger.warning("Failed to collect Orca Cloud profiles for %s", account_key, exc_info=True)
continue
if counts:
summary["orca"][account_key] = counts
if not summary["bambu"] and not summary["orca"]:
logger.warning(
"Cloud profiles are enabled and %d Bambu / %d Orca account(s) are connected, "
"but no presets were collected — see the per-account warnings above.",
len(bambu_accounts),
len(orca_accounts),
)
else:
logger.info("Collected cloud profiles: %s", summary)
return summary
async def cloud_accounts(self, db: AsyncSession) -> tuple[list, list]:
"""Enumerate connected accounts as ``(account_key, user_or_None)`` per cloud.
With auth enabled every user holds their own credentials, so a backup
that only looked at the global store saw none of them. With auth
disabled there is a single global row and no ``User`` at all, which is
what ``user=None`` means to both clouds' credential loaders.
Both stores are read regardless: a ``Settings`` row survives enabling
auth later, and dropping it silently would lose that account's presets.
"""
from backend.app.api.routes.cloud import get_stored_token
from backend.app.api.routes.orca_cloud import _load_credentials
bambu: list = []
orca: list = []
global_token, _email, _region = await get_stored_token(db, None)
if global_token:
bambu.append(("global", None))
global_orca = await _load_credentials(db, None)
if global_orca.token:
orca.append(("global", None))
result = await db.execute(
select(User).where(or_(User.cloud_token.isnot(None), User.orca_cloud_token.isnot(None)))
)
for user in result.scalars().all():
if user.cloud_token:
bambu.append((f"user-{user.id}", user))
if user.orca_cloud_token:
orca.append((f"user-{user.id}", user))
return bambu, orca
async def _collect_bambu_profiles(self, db: AsyncSession, files: dict, account_key: str, user) -> dict:
"""Collect one Bambu Cloud account's custom presets, with their payloads.
The listing endpoint is keyed by preset type, each holding ``private``
and ``public`` lists there is no flat ``setting`` array, and the
entries carry no ``type`` of their own, which is why the type comes
from the outer key here exactly as it does in ``routes/cloud.py``.
Bambu calls process presets ``print``.
``public`` is skipped: those are Bambu's own bundled catalogue, the
same hundreds of entries for every user, re-downloadable at any time
and not recreatable under your account anyway. Backing them up would
churn the repository on every run for nothing.
Each private preset then costs one ``get_setting_detail`` call, because
the listing carries only metadata. Without ``base_id`` and ``setting``
the backup is a list of names, not something a restore can rebuild
from. Bounded by the number of *custom* presets, and the backup already
makes a round-trip per printer for K-profiles.
"""
from backend.app.api.routes.cloud import build_authenticated_cloud
cloud = await build_authenticated_cloud(db, user=None)
cloud = await build_authenticated_cloud(db, user=user)
if cloud is None or not cloud.is_authenticated:
if cloud is not None:
await cloud.close()
logger.info("Cloud not authenticated, skipping cloud profiles")
return
logger.info("Bambu Cloud not authenticated for %s, skipping", account_key)
return {}
counts: dict = {}
try:
settings = await cloud.get_slicer_settings()
if not settings:
return
if not isinstance(settings, dict) or not settings:
logger.warning("Bambu Cloud returned no slicer settings for %s", account_key)
return {}
# Separate by type
filament_settings = []
printer_settings = []
process_settings = []
failed = 0
for api_key, our_type in _BAMBU_PRESET_TYPES.items():
type_data = settings.get(api_key)
if not isinstance(type_data, dict):
continue
private = type_data.get("private")
if not isinstance(private, list) or not private:
continue
for setting in settings.get("setting", []) if isinstance(settings.get("setting"), list) else []:
setting_type = setting.get("type", "")
if setting_type == "filament":
filament_settings.append(setting)
elif setting_type == "printer":
printer_settings.append(setting)
elif setting_type == "process":
process_settings.append(setting)
profiles = []
for entry in private:
setting_id = entry.get("setting_id") or entry.get("id")
if not setting_id:
continue
try:
detail = await cloud.get_setting_detail(str(setting_id))
except Exception as e:
# One unreadable preset must not cost the rest of the
# account, but it must not vanish quietly either.
failed += 1
logger.warning(
"Failed to fetch Bambu Cloud preset %s (%s) for %s: %s",
setting_id,
entry.get("name", "unnamed"),
account_key,
e,
)
continue
profiles.append(_bambu_preset_record(setting_id, our_type, entry, detail))
if filament_settings:
files["cloud_profiles/filament.json"] = {
"version": "1.0",
"profiles": filament_settings,
}
if profiles:
files[f"cloud_profiles/bambu/{account_key}/{our_type}.json"] = {
"version": "2.0",
"cloud": "bambu",
"type": our_type,
"profiles": profiles,
}
counts[our_type] = len(profiles)
if printer_settings:
files["cloud_profiles/printer.json"] = {
"version": "1.0",
"profiles": printer_settings,
}
if process_settings:
files["cloud_profiles/process.json"] = {
"version": "1.0",
"profiles": process_settings,
}
logger.info(
"Collected cloud profiles: %d filament, %d printer, %d process",
len(filament_settings),
len(printer_settings),
len(process_settings),
)
except Exception:
logger.warning("Failed to collect cloud profiles", exc_info=True)
if failed:
counts["failed"] = failed
return counts
finally:
await cloud.close()
async def _collect_orca_profiles(self, db: AsyncSession, files: dict, account_key: str, user) -> dict:
"""Collect one Orca Cloud account's profiles, grouped the same three ways.
Cheaper than Bambu: the sync-pull listing already carries each
profile's full ``content``, so there is no per-profile fetch.
The type lives at ``content.type`` and is mapped through the same
``_ORCA_TYPE_TO_BAMBU`` table the Orca tab uses, so the backup groups
exactly as the UI does. Where that route *drops* a profile whose type
it can't map, this writes it to ``other.json`` instead — a backup that
silently omits a profile because Orca added a type is the same class of
bug as #2717 itself.
Uses the route layer's ``_build_authenticated_service`` rather than
re-implementing the refresh: the Orca refresh token is single-use and
rotating, and that helper already persists the new pair atomically
before returning.
Passes ``clear_on_auth_failure=False``, so a rejected refresh skips the
account instead of disconnecting it. A backup is an observer; it should
not change anyone's sign-in state on a schedule, least of all on a
rejection reason Orca does not disambiguate. The next time the user
opens the Orca Profiles page that route clears the dead pairing anyway,
with the user present to pair again.
"""
from fastapi import HTTPException
from backend.app.api.routes.orca_cloud import (
_ORCA_TYPE_TO_BAMBU,
_build_authenticated_service,
)
try:
svc = await _build_authenticated_service(db, user, clear_on_auth_failure=False)
except HTTPException as e:
# Either way the stored credentials are untouched and this account
# is skipped, not disconnected — but the two need different advice.
# A rejected refresh will not fix itself and needs the user to pair
# again; an unreachable Orca is very likely gone by the next run.
if e.status_code == 401:
logger.warning(
"Orca Cloud rejected the stored session for %s, so its profiles are not in this "
"backup. Later runs will skip it too until the account is paired again under "
"Profiles > Orca Cloud Profiles — which is also where the dead credentials get "
"cleared. Cause: %s",
account_key,
e.detail,
)
else:
logger.warning(
"Orca Cloud unreachable for %s, skipping its profiles this run: %s",
account_key,
e.detail,
)
return {}
except Exception as e:
logger.warning("Orca Cloud not usable for %s: %s", account_key, e, exc_info=True)
return {}
counts: dict = {}
try:
raw_profiles = await svc.list_profiles()
grouped: dict[str, list] = {}
unknown_types: dict[str, int] = {}
for entry in raw_profiles:
if not isinstance(entry, dict):
continue
content = entry.get("content")
raw_type = content.get("type") if isinstance(content, dict) else None
our_type = _ORCA_TYPE_TO_BAMBU.get(str(raw_type)) if raw_type is not None else None
if our_type is None:
unknown_types[str(raw_type) if raw_type is not None else "<missing>"] = (
unknown_types.get(str(raw_type) if raw_type is not None else "<missing>", 0) + 1
)
our_type = "other"
grouped.setdefault(our_type, []).append(_orca_profile_record(entry))
for our_type, profiles in grouped.items():
files[f"cloud_profiles/orca/{account_key}/{our_type}.json"] = {
"version": "2.0",
"cloud": "orca",
"type": our_type,
"profiles": profiles,
}
counts[our_type] = len(profiles)
if unknown_types:
logger.warning(
"Orca Cloud sent %d profile(s) for %s with unmapped content.type values %s"
"backed up to other.json rather than dropped.",
sum(unknown_types.values()),
account_key,
unknown_types,
)
return counts
finally:
await svc.close()
async def _collect_settings(self, db: AsyncSession, files: dict):
"""Collect app settings."""
result = await db.execute(select(Settings))

View file

@ -187,17 +187,40 @@ class HomeAssistantService:
@staticmethod
def _validate_url(url: str) -> str | None:
"""Validate HA URL scheme and block dangerous destinations."""
"""Normalise a caller-supplied HA URL, or return None if it is unsafe.
The stored ``ha_url`` setting is already validated at the schema layer
(``LAN_SERVICE_URL_SETTINGS`` in schemas/settings.py), but
``test_connection`` takes its URL straight from the request body, so
the same policy has to be applied here.
Delegates to ``_url_safety.assert_safe_lan_service_url`` rather than
the string blocklist this replaces. That blocklist only knew three
literal hostnames plus a ``169.254.`` prefix and never parsed the
hostname as an IP, so it let through the Alibaba (100.100.100.200)
and AWS-IPv6 (fd00:ec2::254) metadata endpoints, numeric-encoded
loopback, multicast, and IPv4-mapped IPv6 encodings of the IMDS
address it did know about.
Loopback and RFC-1918 remain permitted Home Assistant is a
LAN-resident service by design, and the shared guard is documented
that way.
"""
from backend.app.api.routes._url_safety import assert_safe_lan_service_url
try:
parsed = urlparse(url)
assert_safe_lan_service_url(url, label="Home Assistant URL")
except ValueError:
return None
if parsed.scheme not in ("http", "https") or not parsed.hostname:
# Guard passed, so the scheme is http/https and a hostname is present;
# re-parse only to drop query/fragment and normalise the authority.
parsed = urlparse(url)
if not parsed.hostname:
return None
blocked = ("169.254.169.254", "metadata.google.internal", "0.0.0.0") # nosec B104
if parsed.hostname.lower() in blocked or (parsed.hostname or "").startswith("169.254."):
return None
return f"{parsed.scheme}://{parsed.hostname}" + (f":{parsed.port}" if parsed.port else "") + (parsed.path or "")
# urlparse strips the brackets off an IPv6 literal, so they have to go
# back on or the rebuilt URL is unparseable ("http://fd00::1:8123").
host = f"[{parsed.hostname}]" if ":" in parsed.hostname else parsed.hostname
return f"{parsed.scheme.lower()}://{host}" + (f":{parsed.port}" if parsed.port else "") + (parsed.path or "")
async def test_connection(self, url: str, token: str) -> dict:
"""Test connection to Home Assistant.

View file

@ -6,11 +6,13 @@ Captures a frame on each layer change and stitches them into a video on print co
import asyncio
import logging
import shutil
import time
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from backend.app.core.config import settings
from backend.app.services.camera import apply_camera_rotation
from backend.app.services.external_camera import capture_frame
logger = logging.getLogger(__name__)
@ -18,6 +20,15 @@ logger = logging.getLogger(__name__)
# Active timelapse sessions: {printer_id: TimelapseSession}
_active_sessions: dict[int, "TimelapseSession"] = {}
# Sessions whose frames are being stitched right now: {printer_id: session_id}.
# on_print_complete removes the session from _active_sessions *before* handing
# frames_dir to ffmpeg, so for the length of a stitch (up to 300s) nothing in
# _active_sessions marks that directory as in use. Without this second registry
# the only thing standing between an in-progress stitch and
# cleanup_orphaned_timelapse_sessions() is the age margin — whose default is
# exactly the stitch timeout, so there is no headroom at all.
_finalizing_sessions: dict[int, str] = {}
def get_ffmpeg_path() -> str | None:
"""Get the path to ffmpeg executable."""
@ -41,6 +52,7 @@ class TimelapseSession:
camera_url: str
camera_type: str
snapshot_url: str | None = None # Optional single-frame override; #1177
rotation: int = 0 # Printer's configured camera_rotation, degrees clockwise
last_layer: int = -1
frame_count: int = 0
session_id: str = field(default_factory=lambda: datetime.now().strftime("%Y%m%d_%H%M%S"))
@ -67,8 +79,29 @@ class TimelapseSession:
self.last_layer = layer_num
try:
frame_data = await capture_frame(self.camera_url, self.camera_type, snapshot_url=self.snapshot_url)
# Reuse the live view's frame instead of opening a second handle on
# a single-reader device (#2707). Unguarded, a print watched from
# start to finish recorded zero successful layer captures, and the
# stitched video came out empty or badly truncated.
from backend.app.api.routes.camera import live_frame_for_capture
defer, buffered = live_frame_for_capture(self.printer_id)
if defer:
if not buffered:
# Viewer attached but nothing buffered yet: skip this layer
# rather than compete and kick them off (#1348).
logger.debug(
"Skipping layer %s for printer %s: viewer attached, no buffered frame yet",
layer_num,
self.printer_id,
)
return False
frame_data = buffered
else:
frame_data = await capture_frame(self.camera_url, self.camera_type, snapshot_url=self.snapshot_url)
if frame_data:
if self.rotation:
frame_data = await asyncio.to_thread(apply_camera_rotation, frame_data, self.rotation, logger)
frame_path = self.frames_dir / f"layer_{layer_num:05d}.jpg"
await asyncio.to_thread(frame_path.write_bytes, frame_data)
self.frame_count += 1
@ -187,6 +220,7 @@ def start_session(
url: str,
cam_type: str,
snapshot_url: str | None = None,
rotation: int = 0,
) -> TimelapseSession:
"""Start new timelapse session for a printer.
@ -197,6 +231,8 @@ def start_session(
cam_type: Camera type ("mjpeg", "rtsp", "snapshot")
snapshot_url: Optional single-frame URL override; when set, layer captures
fetch from it directly instead of opening the live stream. #1177.
rotation: Printer's configured camera_rotation (degrees clockwise),
applied to every captured frame before it's saved.
Returns:
The new TimelapseSession
@ -210,6 +246,7 @@ def start_session(
camera_url=url,
camera_type=cam_type,
snapshot_url=snapshot_url,
rotation=rotation,
)
_active_sessions[printer_id] = session
logger.info("Started timelapse session for printer %s", printer_id)
@ -254,6 +291,12 @@ async def on_print_complete(printer_id: int) -> Path | None:
# Create output path in parent of frames dir
output_path = session.frames_dir.parent / f"timelapse_{session.session_id}.mp4"
# The session is already out of _active_sessions, so mark it finalizing for
# the length of the stitch — otherwise a sweep running now sees a frames
# directory that matches no session and whose mtime is the last layer's
# write, which on a tall print's final layer is easily older than the age
# margin, and deletes ffmpeg's input from under it.
_finalizing_sessions[printer_id] = session.session_id
try:
success = await session.stitch(output_path)
if success:
@ -267,6 +310,8 @@ async def on_print_complete(printer_id: int) -> Path | None:
logger.error("Timelapse completion failed: %s", e)
session.cleanup()
return None
finally:
_finalizing_sessions.pop(printer_id, None)
def cancel_session(printer_id: int):
@ -284,3 +329,92 @@ def cancel_session(printer_id: int):
def get_active_sessions() -> dict[int, TimelapseSession]:
"""Get all active timelapse sessions."""
return _active_sessions.copy()
def cleanup_orphaned_timelapse_sessions(min_age_seconds: float = 300) -> int:
"""Remove timelapse_frames/<printer_id>/* left behind by a crash or
restart that happened while a session was active.
_active_sessions is in-memory only, so a process restart loses track of
any in-flight session without ever calling cancel_session()/cleanup() -
the frames directory (and, if stitching had already produced output
before the restart, a stray `timelapse_<session_id>.mp4`) are then
orphaned on disk with nothing else to reap them (unlike the ffmpeg
orphan janitor in routes/camera.py, there was no equivalent here).
Safe to call once at startup: normal operation always cleans up via
on_print_complete/cancel_session, so anything found here predates this
process - and a restart-recovered print doesn't get a new timelapse
session either (`_maybe_start_layer_timelapse` is only wired into fresh
PRINT_START events, see #1353), so an orphaned directory can never be
resumed.
Also safe to call mid-run, which needs all three guards rather than the
age margin alone:
* `_active_sessions` covers a session that is still capturing.
* `_finalizing_sessions` covers the stitch window. on_print_complete drops
the session from `_active_sessions` before handing frames_dir to ffmpeg,
so without this the directory matches no session for up to 300s while
being actively read.
* `min_age_seconds` covers the remaining gap - a session in the middle of
being created, and the stitched `.mp4` between ffmpeg finishing it and
the caller attaching and unlinking it. Both are freshly written, so the
margin has real headroom there; it did NOT have any for the stitch
window, whose length is bounded by the same 300s.
Returns the number of orphaned directories/files removed.
"""
base_dir = settings.base_dir / "timelapse_frames"
if not base_dir.exists():
return 0
now = time.time()
removed = 0
for printer_dir in base_dir.iterdir():
if not printer_dir.is_dir():
continue
try:
printer_id = int(printer_dir.name)
except ValueError:
continue
active_session = _active_sessions.get(printer_id)
in_use_session_ids = {
active_session.session_id if active_session else None,
_finalizing_sessions.get(printer_id),
} - {None}
for entry in printer_dir.iterdir():
# Frame dirs are named "<session_id>/"; stitched-but-not-yet-
# attached output files are "timelapse_<session_id>.mp4" (see
# on_print_complete's output_path). Anything else under here was
# not written by this module, so leave it alone rather than
# deleting a file on the strength of its age.
if entry.is_dir():
entry_session_id = entry.name
elif entry.name.startswith("timelapse_") and entry.name.endswith(".mp4"):
entry_session_id = entry.name[len("timelapse_") : -len(".mp4")]
else:
continue
if entry_session_id in in_use_session_ids:
continue
try:
if now - entry.stat().st_mtime < min_age_seconds:
continue
except OSError:
continue
try:
# No ignore_errors: it would swallow a failed removal while the
# count and the log line below still claimed success, and that
# log is the only evidence an operator has of what was deleted.
if entry.is_dir():
shutil.rmtree(entry)
else:
entry.unlink(missing_ok=True)
removed += 1
logger.info("Removed orphaned timelapse artifact: %s", entry)
except OSError as e:
logger.warning("Failed to remove orphaned timelapse artifact %s: %s", entry, e)
return removed

View file

@ -256,10 +256,14 @@ def authenticate_ldap_user(config: LDAPConfig, username: str, password: str) ->
return None
info = _extract_user_info(service_conn, config, user_entry, username)
# Don't log the raw DN — its leaf CN is the user's real name (PII, #2681).
# The username + group count is enough to confirm a successful auth; the
# support-bundle sanitizer also redacts any DN that slips through (e.g. an
# ldap3 exception string), but keeping it out of the log at the source is
# the primary hygiene per the "no private data in logs" rule.
logger.info(
"LDAP authentication successful for user: %s (DN: %s, groups: %d)",
"LDAP authentication successful for user: %s (groups: %d)",
info.username,
user_dn,
len(info.groups),
)
return info

View file

@ -14,6 +14,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.config import settings
from backend.app.core.logging_filters import URL_CREDENTIALS_PATTERN
from backend.app.models.printer import Printer
from backend.app.models.settings import Settings
from backend.app.models.user import User
@ -25,6 +26,21 @@ logger = logging.getLogger(__name__)
# parse it out; the log-health scanner does not.
LOG_LINE_PATTERN = re.compile(r"^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2},\d{3})\s+(\w+)\s+\[([^\]]+)\]\s+(.*)$")
# LDAP Distinguished Names carry PII — the leaf ``CN=`` is the user's real name
# (#2681). Match a run of at least two ``attr=value`` RDN components joined by
# commas, where ``attr`` is a known LDAP attribute type. Requiring two components
# keeps this from clobbering an incidental ``key=value`` in an unrelated log line,
# while still catching DNs wherever they surface — the deliberate "auth successful"
# line, ldap3 exception strings, and group DNs alike. Bias is intentionally toward
# redaction: over-redacting a rare debug line to ``[DN]`` is a safe failure; leaking
# a name is not.
# The value char class excludes `<>;+` — RFC 4514 requires those escaped inside a
# DN value, so an unescaped one marks the end of the DN, not part of it. That stops
# the final (comma-unbounded) component from greedily swallowing trailing log text
# such as ``… -> GroupName``.
_LDAP_RDN = r"(?:CN|OU|DC|UID|O|L|ST|C|SN|GN|DN|E|MAIL|STREET|GIVENNAME|SURNAME)=[^,\n<>;+]+"
_LDAP_DN_PATTERN = re.compile(rf"(?i)\b{_LDAP_RDN}(?:\s*,\s*{_LDAP_RDN})+")
class LogEntry(BaseModel):
"""A single parsed log entry."""
@ -153,12 +169,18 @@ def sanitize_log_content(content: str, sensitive_strings: dict[str, str] | None
continue # Skip very short strings to prevent over-redaction
content = re.sub(re.escape(value), label, content)
# Replace credentials in URLs (e.g. http://user:pass@host, rtsps://bblp:code@host)
content = re.sub(r"((?:https?|rtsps?)://)[^/:@\s]+:[^/@\s]+@", r"\1[CREDENTIALS]@", content)
# Replace credentials in URLs (e.g. http://user:pass@host, rtsps://bblp:code@host).
# Shares its pattern with the log-pipeline redaction in ``core.logging_filters`` so
# the two can't drift; the bundle drops the username too, where the live log keeps
# it for diagnosis.
content = URL_CREDENTIALS_PATTERN.sub(r"\g<scheme>[CREDENTIALS]@", content)
# Replace email addresses
content = re.sub(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", "[EMAIL]", content)
# Replace LDAP Distinguished Names (#2681) — PII on par with email.
content = _LDAP_DN_PATTERN.sub("[DN]", content)
# Replace Bambu Lab printer serial numbers (format: 00M/01D/01S/01P/03W + alphanumeric, 12-16 chars total)
content = re.sub(r"\b0[0-3][A-Z0-9][A-Z0-9]{9,13}\b", "[SERIAL]", content, flags=re.IGNORECASE)

View file

@ -240,7 +240,14 @@ class MQTTRelayService:
# Printer Events
# =========================================================================
async def on_printer_status(self, printer_id: int, state: Any, printer_name: str, printer_serial: str):
async def on_printer_status(
self,
printer_id: int,
state: Any,
printer_name: str,
printer_serial: str,
awaiting_plate_clear: bool = False,
):
"""Publish printer status change (throttled to 1 update/sec per printer)."""
if not self.enabled or not self.connected:
return
@ -275,6 +282,15 @@ class MQTTRelayService:
"big_fan1_speed": state.big_fan1_speed,
"big_fan2_speed": state.big_fan2_speed,
"heatbreak_fan_speed": state.heatbreak_fan_speed,
"left_aux_fan_speed": state.left_aux_fan_speed,
"exhaust_fan_present": state.exhaust_fan_present,
# Bambuddy-side gate, not printer telemetry (#2525). Mirrors what the
# Web UI already receives via printer_state_to_dict, so an external
# automation can tell "finished" from "finished and still waiting for
# someone to clear the bed". Edge changes are also published on
# printers/{serial}/plate_clear — this topic only refreshes when the
# printer pushes telemetry, which stops entirely after Auto Off.
"awaiting_plate_clear": awaiting_plate_clear,
}
self._publish(
@ -283,6 +299,37 @@ class MQTTRelayService:
retain=True,
)
async def on_plate_clear_state(
self,
printer_id: int,
printer_name: str,
printer_serial: str,
awaiting: bool,
):
"""Publish the plate-clear gate as it flips (#2525).
Retained, unlike the other per-printer event topics, because this is a
*state* an automation needs on subscribe rather than a moment it might
have missed. The status topic carries the same field, but only refreshes
when the printer pushes telemetry after Auto Off cycles the printer the
retained status payload would sit at ``awaiting_plate_clear: false``
indefinitely while the gate is in fact still up.
"""
if not self.enabled or not self.connected:
return
self._publish(
f"{self.topic_prefix}/printers/{printer_serial}/plate_clear",
{
"printer_id": printer_id,
"printer_name": printer_name,
"printer_serial": printer_serial,
"awaiting": awaiting,
"timestamp": datetime.now(timezone.utc).isoformat(),
},
retain=True,
)
async def on_printer_online(self, printer_id: int, printer_name: str, printer_serial: str):
"""Publish printer came online event."""
if not self.enabled or not self.connected:

View file

@ -64,6 +64,55 @@ def _looks_like_cloudflare_challenge(response: httpx.Response) -> bool:
return "just a moment" in body or "cf-chl-bypass" in body or "cf-chl-opt" in body or "challenge-platform" in body
def _assert_safe_provider_url(url: str, *, label: str) -> str | None:
"""Validate a provider URL taken from user-supplied config.
Returns an error message on rejection, or None when the URL is
acceptable the ``_send_*`` methods return ``tuple[bool, str]`` rather
than raising, so a message is more useful here than an exception.
Uses the LAN-service policy: self-hosting ntfy, Bark, Gotify or a webhook
receiver on the home LAN is normal and must keep working, so loopback and
RFC-1918 stay permitted. Cloud-metadata endpoints, numeric-encoded IPs and
non-HTTP schemes are rejected.
"""
from backend.app.api.routes._url_safety import assert_safe_lan_service_url
try:
assert_safe_lan_service_url(url, label=label)
except ValueError as exc:
return str(exc)
return None
def _opaque_http_failure(response: httpx.Response, *, label: str) -> str:
"""Failure message for a provider whose destination host the user supplies.
The response body is deliberately **not** returned to the caller. Provider
URLs are configurable by anyone holding ``NOTIFICATIONS_CREATE`` which
the default Operators group carries and which does not imply
``SETTINGS_UPDATE`` and ``POST /notifications/test-config`` accepts a URL
straight from the request body without persisting anything. Echoing the
response body there turned an intended "does my webhook work?" check into
an authenticated read primitive against any host the Bambuddy process can
reach, including services that are not exposed to the network at all.
Providers whose host Bambuddy hardcodes (Pushover, Telegram, CallMeBot)
keep returning the upstream body there is no trust boundary to cross
when the destination cannot be influenced.
The body is logged at debug level, where it stays available to whoever
already administers the host without being handed back over the API.
"""
logger.debug(
"%s delivery failed with HTTP %s; body: %s",
label,
response.status_code,
(response.text or "")[:200],
)
return f"HTTP {response.status_code} from the configured {label} (see server logs at debug level for details)"
class NotificationService:
"""Service for sending notifications through various providers."""
@ -225,6 +274,8 @@ class NotificationService:
return await self._send_webhook(config, title, message)
elif provider_type == "homeassistant":
return await self._send_homeassistant(config, title, message, db=db)
elif provider_type == "bark":
return await self._send_bark(config, title, message)
else:
return False, f"Unknown provider type: {provider_type}"
except Exception as e:
@ -251,6 +302,56 @@ class NotificationService:
else:
return False, f"HTTP {response.status_code}: {response.text[:200]}"
async def _send_bark(self, config: dict, title: str, message: str) -> tuple[bool, str]:
"""Send notification via Bark, the self-hostable iOS push service (#1495).
POSTs JSON to {server}/push. Defaults to the official api.day.app
relay; a self-hosted bark-server works by overriding the server URL.
"""
server = (config.get("server") or "https://api.day.app").strip().rstrip("/")
device_key = (config.get("device_key") or "").strip()
if not device_key:
return False, "Device key is required"
url_error = _assert_safe_provider_url(server, label="Bark server URL")
if url_error:
return False, url_error
payload: dict[str, Any] = {
"device_key": device_key,
"title": title,
"body": message,
}
group = (config.get("group") or "").strip()
if group:
payload["group"] = group
sound = (config.get("sound") or "").strip()
if sound:
payload["sound"] = sound
level = (config.get("level") or "").strip()
if level in ("active", "timeSensitive", "critical", "passive"):
payload["level"] = level
client = await self._get_client()
response = await client.post(f"{server}/push", json=payload)
if response.status_code == 200:
# bark-server can report failures inside an HTTP 200 body
# ({"code": 400, "message": ...}), so the status alone isn't proof.
try:
body = response.json()
except ValueError:
body = None
if isinstance(body, dict) and body.get("code") not in (200, None):
# Only the numeric code is echoed. A server chosen by the caller
# controls this body too, so the free-text message is a (narrow)
# read channel of the same kind _opaque_http_failure closes.
logger.debug("Bark reported error %s: %s", body.get("code"), str(body.get("message"))[:200])
return False, f"Bark error {body.get('code')} (see server logs at debug level for details)"
return True, "Message sent successfully"
return False, _opaque_http_failure(response, label="Bark server")
async def _send_ntfy(
self,
config: dict,
@ -267,6 +368,10 @@ class NotificationService:
if not topic:
return False, "Topic is required"
url_error = _assert_safe_provider_url(server, label="ntfy server URL")
if url_error:
return False, url_error
url = f"{server}/{topic}"
# ntfy reads Title/Message from HTTP headers. httpx enforces ASCII
# for str header values, but printer names and filenames can contain
@ -319,7 +424,7 @@ class NotificationService:
"Fight Mode, or front the server with Cloudflare Access using a "
"service token. (#1534)"
)
return False, f"HTTP {response.status_code}: {response.text[:200]}"
return False, _opaque_http_failure(response, label="ntfy server")
async def _send_pushover(
self, config: dict, title: str, message: str, image_data: bytes | None = None
@ -394,6 +499,19 @@ class NotificationService:
if not bot_token or not chat_id:
return False, "Bot token and chat ID are required"
# Optional forum topic (#1518). Telegram expects message_thread_id as an
# integer in the JSON sendMessage body — a string 400s there even though
# the multipart sendPhoto call below would happily accept one. Coerce it
# once, up front, so both call sites agree and a bad value fails loudly
# instead of silently breaking only the text notifications.
thread_id_raw = str(config.get("message_thread_id") or "").strip()
message_thread_id: int | None = None
if thread_id_raw:
try:
message_thread_id = int(thread_id_raw)
except ValueError:
return False, f"Invalid message thread ID: {thread_id_raw!r} is not a number"
# Escape underscores in the message body so Telegram Markdown
# parsing doesn't break on job names like "A1_plate_8" or error
# codes like "0300_0001". The title is already wrapped in *bold*
@ -408,18 +526,23 @@ class NotificationService:
if image_data:
# Use sendPhoto to attach the thumbnail with the caption
url = f"https://api.telegram.org/bot{bot_token}/sendPhoto"
form: dict[str, Any] = {"chat_id": chat_id, "caption": message, "parse_mode": "Markdown"}
if message_thread_id is not None:
form["message_thread_id"] = message_thread_id
response = await client.post(
url,
data={"chat_id": chat_id, "caption": message, "parse_mode": "Markdown"},
data=form,
files={"photo": ("photo.jpg", image_data, "image/jpeg")},
)
else:
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
data = {
data: dict[str, Any] = {
"chat_id": chat_id,
"text": message,
"parse_mode": "Markdown",
}
if message_thread_id is not None:
data["message_thread_id"] = message_thread_id
response = await client.post(url, json=data)
if response.status_code == 200:
@ -621,6 +744,10 @@ class NotificationService:
if not webhook_url:
return False, "Webhook URL is required"
url_error = _assert_safe_provider_url(webhook_url, label="Webhook URL")
if url_error:
return False, url_error
# Build payload based on format
if payload_format == "slack":
# Slack/Mattermost format - just text field
@ -666,7 +793,7 @@ class NotificationService:
if response.status_code in (200, 201, 202, 204):
return True, "Webhook delivered successfully"
else:
return False, f"HTTP {response.status_code}: {response.text[:200]}"
return False, _opaque_http_failure(response, label="webhook endpoint")
except Exception as e:
return False, f"Webhook error: {str(e)}"
@ -741,6 +868,24 @@ class NotificationService:
"message": message,
}
# Optional custom service-data (#1441), forwarded as HA's nested "data"
# object so mobile-app push options (priority, ttl, channel, group, ...)
# reach the notify service. Only included when configured — the default
# persistent_notification.create schema rejects unknown keys.
raw_data = config.get("data")
if raw_data:
if isinstance(raw_data, str):
try:
parsed_data = json.loads(raw_data)
except json.JSONDecodeError as e:
return False, f"Invalid JSON in the Data field: {e}"
else:
parsed_data = raw_data
if not isinstance(parsed_data, dict):
return False, 'The Data field must be a JSON object, e.g. {"priority": "high", "ttl": 0}'
if parsed_data:
payload["data"] = parsed_data
client = await self._get_client()
response = await client.post(url, json=payload, headers=headers)
@ -749,7 +894,11 @@ class NotificationService:
elif response.status_code == 401:
return False, "Home Assistant authentication failed - check your token"
else:
return False, f"HTTP {response.status_code}: {response.text[:200]}"
# ha_url comes from global settings (SETTINGS_UPDATE, admin-only), so
# this is a narrower channel than the per-request provider URLs — but
# it lands in the same NOTIFICATIONS_CREATE-gated test response, so it
# gets the same treatment.
return False, _opaque_http_failure(response, label="Home Assistant endpoint")
async def _send_to_provider(
self,
@ -794,6 +943,8 @@ class NotificationService:
)
elif provider.provider_type == "homeassistant":
return await self._send_homeassistant(config, title, message, db=db)
elif provider.provider_type == "bark":
return await self._send_bark(config, title, message)
else:
return False, f"Unknown provider type: {provider.provider_type}"
except Exception as e:
@ -1317,6 +1468,39 @@ class NotificationService:
variables=variables,
)
async def on_plate_clear_required(
self,
printer_id: int,
printer_name: str,
db: AsyncSession,
):
"""Handle plate-clear-required event — a print ended and the queue is gated (#2525).
Distinct from ``on_plate_not_empty``, which is the camera check *before* a
print starts. This one fires on the rising edge of the Bambuddy-side
awaiting-plate-clear flag, i.e. whenever a print reaches a terminal state
and the next queued job can't dispatch until someone confirms the bed is
free. Off by default on every provider: it lands at the same moment as the
print-complete notification, so opting in is a deliberate choice.
"""
providers = await self._get_providers_for_event(db, "on_plate_clear_required", printer_id)
if not providers:
return
variables = {"printer": printer_name}
title, message = await self._build_message_from_template(db, "plate_clear_required", variables)
await self._send_to_providers(
providers,
title,
message,
db,
"plate_clear_required",
printer_id,
printer_name,
variables=variables,
)
async def on_filament_low(
self,
printer_id: int,

View file

@ -44,6 +44,19 @@ _frame_cache: dict[str, tuple[bytes, float]] = {}
_frame_cache_lock = asyncio.Lock()
def auth_headers(token: str | None) -> dict[str, str]:
"""Bearer header for the ML API, or nothing when no token is configured.
Obico's ML API gates ``/p/`` behind ``ML_API_TOKEN`` (``ml_api/auth.py``):
with the variable set it answers a bare 401 to any request whose
``Authorization`` header isn't ``Bearer <token>``, and with it unset it
ignores the header entirely. Sending nothing when unconfigured keeps the
request byte-identical to what shipped before the setting existed.
"""
token = (token or "").strip()
return {"Authorization": f"Bearer {token}"} if token else {}
def _prune_frame_cache() -> None:
"""Drop entries older than FRAME_CACHE_TTL. Called under the cache lock."""
now = time.monotonic()
@ -111,6 +124,7 @@ class ObicoDetectionService:
keys = [
"obico_enabled",
"obico_ml_url",
"obico_ml_token",
"obico_sensitivity",
"obico_action",
"obico_poll_interval",
@ -133,6 +147,7 @@ class ObicoDetectionService:
return {
"enabled": rows.get("obico_enabled", "false").lower() == "true",
"ml_url": (rows.get("obico_ml_url") or "").rstrip("/"),
"ml_token": (rows.get("obico_ml_token") or "").strip(),
"sensitivity": rows.get("obico_sensitivity", "medium"),
"action": rows.get("obico_action", "notify"),
"poll_interval": int(rows.get("obico_poll_interval", "10")),
@ -193,6 +208,21 @@ class ObicoDetectionService:
return None
if printer.external_camera_enabled and printer.external_camera_url:
# Same rule as the built-in branch below, which this used to skip:
# an external camera is single-reader too, so polling while a viewer
# is attached just fails (#2707).
from backend.app.api.routes.camera import live_frame_for_capture
defer, buffered = live_frame_for_capture(printer_id)
if defer:
if buffered:
return buffered
logger.info(
"Obico: viewer attached for printer %s but buffer empty; "
"skipping this poll to avoid competing camera handle (#2707)",
printer_id,
)
return None
return await capture_external_frame(
printer.external_camera_url,
printer.external_camera_type,
@ -264,7 +294,23 @@ class ObicoDetectionService:
try:
async with httpx.AsyncClient(timeout=DETECTION_TIMEOUT) as client:
resp = await client.get(ml_url, params={"img": snapshot_url})
resp = await client.get(
ml_url,
params={"img": snapshot_url},
headers=auth_headers(settings.get("ml_token")),
)
if resp.status_code == 401:
# The server runs with ML_API_TOKEN set and rejected ours.
# Say so plainly: the health endpoint is ungated, so "Test
# Connection" passes against exactly this configuration and
# a raw 401 gives the user nothing to act on (#2733).
self._last_error = (
"Obico ML API rejected the token (401). Set Settings → Failure Detection → "
"ML API Token to the ML_API_TOKEN the server runs with, or clear ML_API_TOKEN "
"on the server."
)
logger.warning("%s (printer %s)", self._last_error, printer_id)
return
resp.raise_for_status()
payload = resp.json()
except Exception as e:
@ -320,6 +366,21 @@ class ObicoDetectionService:
# ---- queries ----
def get_per_printer(self) -> dict:
"""Live classification per actively monitored printer.
Only printers with a running, monitored print have a state entry, so
consumers get "show nothing" for idle printers for free.
"""
return {
pid: {
"class": self._last_class.get(pid, "safe"),
"frame_count": state.frame_count,
"score": round(state.ewm_mean, 4),
}
for pid, state in self._states.items()
}
def get_status(self, sensitivity: str = "medium") -> dict:
# Report the thresholds for the configured sensitivity, not a hardcoded
# "medium" — otherwise the Status panel always shows the medium row
@ -329,33 +390,86 @@ class ObicoDetectionService:
return {
"is_running": self._task is not None and not self._task.done(),
"last_error": self._last_error,
"per_printer": {
pid: {
"class": self._last_class.get(pid, "safe"),
"frame_count": state.frame_count,
"score": round(state.ewm_mean, 4),
}
for pid, state in self._states.items()
},
"per_printer": self.get_per_printer(),
"thresholds": {"low": low, "high": high},
"history": list(self._history),
}
async def test_connection(self, url: str) -> dict:
"""Ping the ML API health endpoint. Returns {ok, status_code, body, error}."""
target = f"{url.rstrip('/')}/hc/"
async def test_connection(self, url: str, token: str = "") -> dict:
"""Ping the ML API and check the token. Returns {ok, status_code, body, error, auth_ok}.
The stored ``obico_ml_url`` setting is validated at the schema layer,
but this route takes its URL from the request body, so the same
LAN-service policy has to be applied here or the guard is trivially
sidestepped by testing a URL instead of saving it. The response body
is returned to the caller (it is the health signal the endpoint
answers "ok"), which is exactly why the destination must be inside
policy before the request is made.
``token`` is used verbatim resolving "not supplied" to the saved
setting is the route's job, so this stays a pure outbound call.
Health alone cannot answer whether the token works, because Obico
gates ``/p/`` but leaves ``/hc/`` open which is how a token-protected
server passed this test while every detection call came back 401
(#2733). So a second, side-effect-free probe follows: ``/p/`` with no
``img`` parameter. The auth decorator runs before the handler, so 401
means the token was rejected and 422 ("Invalid request params") means
it was accepted. No inference work is done either way.
"""
from backend.app.api.routes._url_safety import assert_safe_lan_service_url
try:
assert_safe_lan_service_url(url, label="Obico ML URL")
except ValueError as exc:
return {"ok": False, "status_code": None, "body": None, "error": str(exc), "auth_ok": None}
headers = auth_headers(token)
base = url.rstrip("/")
try:
async with httpx.AsyncClient(timeout=HEALTH_TIMEOUT) as client:
resp = await client.get(target)
body = resp.text.strip()
return {
"ok": resp.status_code == 200 and body.lower() == "ok",
"status_code": resp.status_code,
"body": body,
"error": None,
}
resp = await client.get(f"{base}/hc/", headers=headers)
body = resp.text.strip()
healthy = resp.status_code == 200 and body.lower() == "ok"
if not healthy:
return {
"ok": False,
"status_code": resp.status_code,
"body": body,
"error": None,
"auth_ok": None,
}
auth_ok: bool | None
try:
probe = await client.get(f"{base}/p/", headers=headers)
auth_ok = probe.status_code != 401
except Exception:
# The health check already succeeded, so don't fail the
# whole test on the probe — report the token as unknown.
auth_ok = None
except Exception as e:
return {"ok": False, "status_code": None, "body": None, "error": str(e) or type(e).__name__}
return {
"ok": False,
"status_code": None,
"body": None,
"error": str(e) or type(e).__name__,
"auth_ok": None,
}
if auth_ok is False:
return {
"ok": False,
"status_code": 401,
"body": body,
"error": (
"The ML API is reachable but rejected the token. It runs with ML_API_TOKEN set — "
"enter that value as the ML API Token, or clear ML_API_TOKEN on the server."
),
"auth_ok": False,
}
return {"ok": True, "status_code": resp.status_code, "body": body, "error": None, "auth_ok": auth_ok}
obico_detection_service = ObicoDetectionService()

View file

@ -604,16 +604,28 @@ async def capture_camera_image(
# Try external camera first if requested and available
if use_external and external_camera_url and external_camera_type:
try:
from backend.app.api.routes.camera import live_frame_for_capture
from backend.app.services.external_camera import capture_frame
image_data = await capture_frame(
external_camera_url,
external_camera_type,
snapshot_url=external_camera_snapshot_url,
)
if image_data:
camera_source = "external"
logger.debug("Captured frame from external camera for printer %s", printer_id)
# What this function's docstring already promised, but only the
# built-in fallback below delivered: an external camera is
# single-reader too, so capturing while a viewer watches fails
# (#2707).
defer, buffered = live_frame_for_capture(printer_id)
if defer:
if buffered:
image_data = buffered
camera_source = "external (buffered)"
logger.debug("Using buffered external frame for printer %s", printer_id)
else:
image_data = await capture_frame(
external_camera_url,
external_camera_type,
snapshot_url=external_camera_snapshot_url,
)
if image_data:
camera_source = "external"
logger.debug("Captured frame from external camera for printer %s", printer_id)
except Exception as e:
logger.warning("Failed to capture from external camera: %s", e)

View file

@ -0,0 +1,68 @@
"""Whether Bambuddy injected End G-code into the print now running (#2547).
The finish-photo path has to know one thing at print completion that no MQTT
field reports: did this print end with user End G-code? If it did, a SwapMod
snippet may already have ejected the plate, so the scene in front of the camera
at ``gcode_state=FINISH`` is not the finished print and the photo must come from
the in-print frame bank instead (#1867).
Only the dispatcher ever sees this, so it is recorded here in two steps:
1. ``mark_pending`` when the scheduler injects an End G-code snippet.
2. ``adopt`` when the printer reports a print starting, which moves the pending
flag onto the running print and consumes it.
The two steps exist so the flag can never outlive its print. A print Bambuddy
did not dispatch started from the slicer, the SD card, or the printer's own
screen finds no pending flag and correctly adopts ``False``, instead of
inheriting the answer from whatever ran before it.
In-memory and best-effort: a restart mid-print loses the flag, and ``False`` is
the safe way to be wrong (a live grab that might show a swapped plate, rather
than silently substituting a mid-print frame).
"""
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
# Printers the scheduler has injected End G-code for, awaiting a print start.
_pending: set[int] = set()
# Printers whose *currently running* print has injected End G-code.
_active: set[int] = set()
def mark_pending(printer_id: int) -> None:
"""Record that the job now being sent to ``printer_id`` has End G-code."""
_pending.add(printer_id)
logger.debug("[DISPATCH-CTX] printer %s: End G-code injected, awaiting print start", printer_id)
def adopt(printer_id: int) -> bool:
"""Bind any pending flag to the print that just started, and return it.
Called once per print start. Always writes ``_active`` including the
``False`` case so a print Bambuddy didn't dispatch clears its
predecessor's flag rather than inheriting it.
"""
injected = printer_id in _pending
_pending.discard(printer_id)
if injected:
_active.add(printer_id)
logger.debug("[DISPATCH-CTX] printer %s: running print has injected End G-code", printer_id)
else:
_active.discard(printer_id)
return injected
def end_gcode_injected(printer_id: int) -> bool:
"""True if the print currently running on ``printer_id`` has End G-code."""
return printer_id in _active
def clear(printer_id: int) -> None:
"""Forget everything about this printer (disconnect, removal, tests)."""
_pending.discard(printer_id)
_active.discard(printer_id)

View file

@ -23,6 +23,7 @@ from backend.app.models.settings import Settings
from backend.app.models.smart_plug import SmartPlug
from backend.app.models.spool_assignment import SpoolAssignment
from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
from backend.app.services import print_dispatch_context
from backend.app.services.bambu_ftp import (
UploadCancelled,
cache_3mf_download,
@ -31,6 +32,7 @@ from backend.app.services.bambu_ftp import (
upload_file_async,
with_ftp_retry,
)
from backend.app.services.bambu_mqtt import HMS_MQTT_VERIFY_FAILED
from backend.app.services.filament_deficit import compute_deficit_for_queue_item
from backend.app.services.notification_service import notification_service
from backend.app.services.printer_manager import (
@ -173,6 +175,25 @@ def _mapping_is_all_unresolved(mapping: list | None) -> bool:
return all(t is None or (isinstance(t, int) and t < 0) for t in mapping)
def _mqtt_commands_rejected(status) -> bool:
"""True when the printer is currently reporting that it refused a command.
``HMS_MQTT_VERIFY_FAILED`` means the firmware's authorization check rejected
a control command it could not verify. Queries still answer, so the printer
looks connected and idle while project_file, gcode_line and
ams_change_filament are all dropped no amount of waiting or re-uploading
changes that (#2732).
Tolerates a missing status and errors without a ``full_code`` (the 8-char
``print_error`` path builds HMSError differently), so this is safe to call on
every watchdog poll.
"""
for err in getattr(status, "hms_errors", None) or []:
if getattr(err, "full_code", "") == HMS_MQTT_VERIFY_FAILED:
return True
return False
def _installed_nozzle_diameters(status) -> list[float]:
"""Parse the installed nozzle diameters from a PrinterState (#1899).
@ -280,17 +301,29 @@ class PrintScheduler:
# event-loop thread, so this dict needs no lock.
# item_id -> (task, printer_id)
self._inflight: dict[int, tuple[asyncio.Task, int | None]] = {}
# Expected prints registered by `_start_print` that have not yet had a
# print command sent. Populated at registration, dropped once
# `start_print()` succeeds, and rolled back by `_dispatch_one` on every
# other exit. Same threading argument as `_inflight` above: one
# sequential caller, callbacks on the same loop, so no lock.
# item_id -> (printer_id, remote_filename, archive_id)
self._unconfirmed_expected_print: dict[int, tuple[int, str, int]] = {}
async def run(self):
"""Main loop - check queue every interval."""
self._running = True
logger.info("Print scheduler started")
await self._clear_stale_dispatch_claims()
await self._clear_stale_dispatch_claims(at_startup=True)
while self._running:
dispatched = False
try:
# No-op while any upload is in flight; on a quiet tick it releases
# a claim whose best-effort clear failed (e.g. the database was
# briefly unreachable), instead of leaving the row wedged until
# the next restart.
await self._clear_stale_dispatch_claims()
dispatched = await self.check_queue()
except Exception as e:
logger.error("Scheduler error: %s", e)
@ -299,14 +332,29 @@ class PrintScheduler:
# not stall behind the idle interval; otherwise sleep normally (#2555).
await asyncio.sleep(self._fast_check_interval if dispatched else self._check_interval)
async def _clear_stale_dispatch_claims(self) -> None:
"""Clear dispatch claims left behind by a crash/restart mid-upload (#2615).
async def _clear_stale_dispatch_claims(self, *, at_startup: bool = False) -> None:
"""Clear dispatch claims with no live dispatch coroutine behind them (#2615).
A claim is only ever held by a live dispatch coroutine, and no coroutine
survives a process restart so every ``dispatching_at`` present at startup
is stale. Clearing them lets those still-pending rows be re-selected for a
fresh, consistent dispatch instead of being wedged out of the selection
query forever. Called once at the top of ``run()``."""
A claim is only ever held by a live dispatch coroutine, so when this
process has nothing in ``_inflight`` every ``dispatching_at`` in the table
is stale. At startup that is trivially true no coroutine survives a
restart. It is equally true on any later tick where no upload is running,
which is what makes this safe to repeat rather than only run once.
Repeating it matters because ``_clear_dispatch_claim`` is best-effort: if
the database is briefly unreachable at exactly the moment dispatch ends,
the claim survives and the row is wedged out of the selection query. That
used to last until the next restart (#2702 follow-up, seen when
PostgreSQL refused a connection mid-dispatch).
``_inflight`` is populated when the task is spawned, before the coroutine
claims its row, and pruned by a done-callback that cannot run before the
coroutine's own ``finally`` — so "claim present, nothing in flight" has no
race window and needs no age threshold. A size-derived upload deadline
(``max(600s, size/25KB/s)``) has no safe fixed bound anyway.
"""
if self._inflight:
return
try:
async with async_session() as db:
res = await db.execute(
@ -314,9 +362,13 @@ class PrintScheduler:
)
await db.commit()
if res.rowcount:
logger.info("Cleared %d stale dispatch claim(s) at startup (#2615)", res.rowcount)
logger.info(
"Cleared %d orphaned dispatch claim(s)%s (#2615)",
res.rowcount,
" at startup" if at_startup else "",
)
except Exception as exc:
logger.error("Failed to clear stale dispatch claims at startup: %s", exc)
logger.error("Failed to clear orphaned dispatch claims: %s", exc)
def stop(self):
"""Stop the scheduler."""
@ -929,6 +981,14 @@ class PrintScheduler:
return
await self._start_print(item_db, item)
finally:
# Undo an expected-print registration whose print command never
# went out. One choke point covers every way `_start_print` can
# end without sending: a raised exception (a DB failure mid-
# dispatch is the reported case), an early return, a cancel
# winning the #1853 CAS, or `start_print()` returning False.
# A confirmed send removes the entry itself, so this is a no-op
# on the happy path.
self._rollback_unconfirmed_expected_print(item_id)
# Release the claim on every exit. Once dispatch has finished the
# row's status carries the lock (printing/failed/cancelled are all
# != pending), so the token is only needed for the duration of the
@ -936,6 +996,30 @@ class PrintScheduler:
# dispatchable again on the next tick.
await self._clear_dispatch_claim(item_db, item_id)
def _rollback_unconfirmed_expected_print(self, item_id: int) -> None:
"""Drop an expectation for a print command that was never sent.
Best-effort and never raises: this runs in the ``finally`` of dispatch,
where the interesting exception is usually the one already propagating.
"""
pending = self._unconfirmed_expected_print.pop(item_id, None)
if pending is None:
return
printer_id, remote_filename, archive_id = pending
try:
from backend.app.main import unregister_expected_print
unregister_expected_print(printer_id, remote_filename, archive_id)
except Exception:
logger.warning(
"Queue item %s: failed to unregister expected print (printer=%s, file=%s, archive=%s)",
item_id,
printer_id,
remote_filename,
archive_id,
exc_info=True,
)
async def _claim_for_dispatch(self, db: AsyncSession, item_id: int) -> bool:
"""Atomically stamp ``dispatching_at`` on a still-pending, unclaimed row.
@ -954,12 +1038,39 @@ class PrintScheduler:
async def _clear_dispatch_claim(self, db: AsyncSession, item_id: int) -> None:
"""Clear the dispatch claim (#2615). Best-effort: a failure here must not
mask the dispatch outcome, and startup reconciliation clears any leftover."""
try:
await db.execute(update(PrintQueueItem).where(PrintQueueItem.id == item_id).values(dispatching_at=None))
await db.commit()
except Exception as exc:
logger.warning("Queue item %s: failed to clear dispatch claim: %s", item_id, exc)
mask the dispatch outcome.
Retried, because the failure mode in practice is transient and narrow: a
database that is momentarily unreachable PostgreSQL out of connection
slots is the observed case refuses this write for a second or two while
the dispatch that just ended is still holding the row out of the selection
query. One attempt was enough to wedge the item; a couple of spaced
attempts clear it. Each attempt rolls back first, since a failed write
leaves the session needing it before it can be reused.
If every attempt fails, ``_clear_stale_dispatch_claims`` picks the row up
on the next quiet tick.
"""
for attempt in range(1, 4):
try:
await db.execute(update(PrintQueueItem).where(PrintQueueItem.id == item_id).values(dispatching_at=None))
await db.commit()
return
except Exception as exc:
try:
await db.rollback()
except Exception:
pass
if attempt == 3:
logger.warning(
"Queue item %s: failed to clear dispatch claim after %d attempts: %s "
"— a later quiet tick will release it",
item_id,
attempt,
exc,
)
return
await asyncio.sleep(0.5 * attempt)
async def _find_idle_printer_for_model(
self,
@ -1158,6 +1269,14 @@ class PrintScheduler:
to carry ``force_color_match: True``. The printer must have **every** such slot loaded
with an exact type+color match.
When both the override and a candidate tray carry a ``tray_info_idx``, they must also
match on it: Bambu reports every PLA variant as ``tray_type == "PLA"``, so the
Basic/Matte/Silk distinction lives only in ``tray_info_idx`` (GFA00/GFA01/GFA06/...).
Without this, a job sliced for PLA Matte matched every white PLA regardless of variant
(#2650). If either side lacks an idx (custom/third-party spools report a blank one, and
older 3MFs carry none) we fall back to the historical type+colour behaviour so those
setups are unaffected.
Returns:
List of ``"TYPE (color)"`` strings for unmatched slots (empty list means all match).
"""
@ -1165,26 +1284,32 @@ class PrintScheduler:
if not status:
return [f"{o.get('type', '?')} ({o.get('color_name') or o.get('color', '?')})" for o in force_overrides]
# Build set of loaded type+colour pairs from AMS and external spool
loaded: set[tuple[str, str]] = set()
# Build loaded (type, colour, tray_info_idx) triples from AMS and external spool.
loaded: list[tuple[str, str, str]] = []
for ams_unit in status.raw_data.get("ams", []):
for tray in ams_unit.get("tray", []):
tray_type = tray.get("tray_type")
tray_color = tray.get("tray_color", "")
if tray_type:
color_norm = tray_color.replace("#", "").lower()[:6]
loaded.add((_canonical_filament_type(tray_type), color_norm))
color_norm = (tray.get("tray_color", "") or "").replace("#", "").lower()[:6]
loaded.append(
(_canonical_filament_type(tray_type), color_norm, tray.get("tray_info_idx", "") or "")
)
for vt in status.raw_data.get("vt_tray") or []:
vt_type = vt.get("tray_type")
if vt_type:
color_norm = (vt.get("tray_color", "") or "").replace("#", "").lower()[:6]
loaded.add((_canonical_filament_type(vt_type), color_norm))
loaded.append((_canonical_filament_type(vt_type), color_norm, vt.get("tray_info_idx", "") or ""))
missing = []
for o in force_overrides:
o_type = _canonical_filament_type(o.get("type") or "")
o_color = (o.get("color") or "").replace("#", "").lower()[:6]
if (o_type, o_color) not in loaded:
o_idx = o.get("tray_info_idx") or ""
satisfied = any(
t_type == o_type and t_color == o_color and (not o_idx or not t_idx or o_idx == t_idx)
for t_type, t_color, t_idx in loaded
)
if not satisfied:
color_label = o.get("color_name") or o.get("color", "?")
missing.append(f"{o_type} ({color_label})")
return missing
@ -1374,9 +1499,18 @@ class PrintScheduler:
override = override_map[req["slot_id"]]
req["type"] = override["type"]
req["color"] = override["color"]
# Clear tray_info_idx so matching uses type+color instead of
# the original 3MF's tray_info_idx (which would match the old filament)
req["tray_info_idx"] = ""
# A manual/preference override SWAPS the slot's filament, so the
# 3MF's original tray_info_idx now points at the old spool and must
# be cleared — matching then falls back to type+colour. A
# force_color_match override is not a swap: it carries the 3MF's
# intended variant (Basic GFA00 / Matte GFA01 / Silk GFA06), so keep
# it here too, letting the matcher pin the correct variant slot on a
# printer holding two same-colour spools of different variants (#2650).
# If that variant isn't loaded the matcher falls back to type+colour,
# so an eligible printer never fails to map.
req["tray_info_idx"] = (
override.get("tray_info_idx", "") if override.get("force_color_match") else ""
)
logger.debug(
"Queue item %s: Override slot %d -> %s %s",
item.id,
@ -1440,7 +1574,11 @@ class PrintScheduler:
"slot_id": o["slot_id"],
"type": o.get("type", ""),
"color": o.get("color", ""),
"tray_info_idx": "",
# These are all force_color_match overrides, so the idx (when the
# 3MF carried one) is the intended variant, not a stale swap —
# keep it so the matcher pins the right variant slot, falling back
# to type+colour when it isn't loaded (#2650).
"tray_info_idx": o.get("tray_info_idx", ""),
}
for o in force_overrides
]
@ -2849,6 +2987,7 @@ class PrintScheduler:
queue_item_id: int,
printer_id: int,
created_by_id: int | None,
reason: str = "Printer accepted the file but never started printing",
) -> None:
"""Tell the user the queue item was failed after exhausting its dispatch retries.
@ -2856,6 +2995,10 @@ class PrintScheduler:
its own hence the fresh one here. Best-effort throughout: the row is
already marked failed and that is the load-bearing part; a notification
provider being down must not resurrect the retry loop we just stopped.
``reason`` defaults to the exhausted-retries wording. The command-rejected
path passes its own, because "accepted the file but never started" is the
opposite of what happened there the printer refused it outright (#2732).
"""
try:
async with async_session() as db:
@ -2868,7 +3011,7 @@ class PrintScheduler:
job_name=job_name,
printer_id=printer_id,
printer_name=printer.name if printer else "Unknown",
reason="Printer accepted the file but never started printing",
reason=reason,
db=db,
)
except Exception as e:
@ -3095,6 +3238,7 @@ class PrintScheduler:
original_filename=filename,
created_by_id=item.created_by_id,
project_id=item.project_id,
library_file_id=item.library_file_id, # per-file project progress (#1897)
plate_id=item.plate_id, # selected plate → Print History (#2603)
)
if archive:
@ -3218,6 +3362,10 @@ class PrintScheduler:
# G-code injection for auto-print systems (#422)
injected_path = None
# #2547: tracked separately from `injected_path`, which is also set when
# only a START snippet was injected. Only an END snippet changes what the
# camera sees at print completion.
end_gcode_injected = False
if item.gcode_injection:
try:
snippets_raw = await self._get_setting(db, "gcode_snippets")
@ -3234,6 +3382,7 @@ class PrintScheduler:
)
if injected_path:
file_path = injected_path
end_gcode_injected = bool(end_gc)
logger.info("Queue item %s: G-code injected for model %s", item.id, printer.model)
else:
logger.warning(
@ -3242,6 +3391,13 @@ class PrintScheduler:
except Exception as e:
logger.warning("Queue item %s: G-code injection failed, using original: %s", item.id, e)
# #2547: the finish-photo path can't learn from telemetry that this print
# ends with user End G-code — which means the plate may be gone by the
# time FINISH arrives (#1867). Flag it here; `on_print_start` binds it to
# the print once the printer confirms it running.
if end_gcode_injected:
print_dispatch_context.mark_pending(printer.id)
# Upload to root directory (not /cache/) - the start_print command references
# files by name only (ftp://{filename}), so they must be in the root
remote_filename = derive_remote_filename(filename)
@ -3404,6 +3560,12 @@ class PrintScheduler:
created_by_id=item.created_by_id,
plate_id=item.plate_id,
)
# Registration happens before the print command by necessity (the
# printer can report the print before the send returns), so record
# what to undo if we never get as far as sending. `_dispatch_one`
# rolls back anything still pending here on every exit — exception,
# early return, or cancel winning the CAS below.
self._unconfirmed_expected_print[item.id] = (item.printer_id, remote_filename, archive.id)
# Propagate the queue item's owner into printer_manager so the
# print-complete callback can credit the user in the PrintLogEntry
@ -3528,6 +3690,10 @@ class PrintScheduler:
)
if started:
# The command is away, so the expectation is now legitimate and must
# survive. Anything still in this dict when _dispatch_one exits gets
# rolled back.
self._unconfirmed_expected_print.pop(item.id, None)
logger.info("Queue item %s: Print started successfully - %s", item.id, filename)
# No dispatch-toast event here: the legacy bg-dispatch path kept
# status='processing' from upload start until the printer acked
@ -3716,9 +3882,20 @@ class PrintScheduler:
Phase A timeout raised from 45 s 90 s as belt-and-braces for slow
transitions that also don't emit an early subtask_id tick.
Both phases also watch for ``HMS_MQTT_VERIFY_FAILED``. A printer that
refuses to verify our commands will never start this job or any other,
so waiting out the full 270 s and re-uploading the 3MF twice more only
burns an upload slot the rest of the farm is queued behind that path
is for a printer that might still come good, which this one cannot
(#2732). It fails the item on the spot with the actual reason instead.
"""
last_status = None
landed_on_subtask = False
# Latched, not level-tested: state.hms_errors is rebuilt from scratch on
# every push carrying an `hms` key, so the fault can come and go between
# 3-second polls. Seeing it once inside the dispatch window is enough.
command_rejected = False
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
await asyncio.sleep(poll_interval)
@ -3749,6 +3926,13 @@ class PrintScheduler:
except Exception:
pass
return
# Checked only after the active-state exit above: a stale HMS left
# over from an earlier job must never abort a print that is visibly
# running. An actually-refused command leaves the printer idle, so
# this ordering costs the detection nothing.
if _mqtt_commands_rejected(status):
command_rejected = True
break
if pre_subtask_id is not None and status.subtask_id is not None and status.subtask_id != pre_subtask_id:
# Phase A exit — printer accepted the file (subtask_id flipped
# to our submission id). Don't return yet: the printer may
@ -3758,7 +3942,7 @@ class PrintScheduler:
landed_on_subtask = True
break
if landed_on_subtask:
if landed_on_subtask and not command_rejected:
phase_b_deadline = time.monotonic() + phase_b_timeout
while time.monotonic() < phase_b_deadline:
await asyncio.sleep(poll_interval)
@ -3778,6 +3962,11 @@ class PrintScheduler:
except Exception:
pass
return
# Same ordering rule as Phase A: a running print wins over a
# lingering HMS.
if _mqtt_commands_rejected(status):
command_rejected = True
break
# No active-state transition. Revert the item so the scheduler can retry.
# Drop the in-memory hold so the retry isn't blocked by it.
@ -3807,6 +3996,20 @@ class PrintScheduler:
return "already_moved_on"
item.dispatch_attempts = (item.dispatch_attempts or 0) + 1
item.started_at = None
if command_rejected:
# No retry budget for this one: the printer refused to verify the
# command, and re-uploading the same 3MF to the same printer will
# be refused the same way. Fail now with the fix rather than after
# three laps of a message about SD cards (#2732).
item.status = "failed"
item.error_message = (
"The printer rejected the print command: MQTT command verification failed "
"(HMS 0500-0500-0001-0007). Enable Developer Mode on the printer, restart it, "
"then start the job again."
)
item.completed_at = datetime.now(timezone.utc)
await db.commit()
return "command_rejected"
if item.dispatch_attempts >= DISPATCH_MAX_ATTEMPTS:
item.status = "failed"
item.error_message = (
@ -3841,6 +4044,25 @@ class PrintScheduler:
return
total_timeout = timeout + (phase_b_timeout if landed_on_subtask else 0.0)
if revert_outcome == "command_rejected":
logger.error(
"Queue item %s: printer %d reported HMS %s (MQTT command verification "
"failed) — the print command was rejected, not lost. Failing the item "
"without retrying; enable Developer Mode on the printer and restart it (#2732)",
queue_item_id,
printer_id,
HMS_MQTT_VERIFY_FAILED,
)
await scheduler._notify_dispatch_gave_up(
queue_item_id,
printer_id,
created_by_id,
reason="Printer rejected the print command (MQTT command verification failed)",
)
# Same reasoning as the landed_on_subtask path below: the file is on
# the printer and a forced reconnect would only add 0500_4003 to a
# problem that has nothing to do with the MQTT session (#1150).
return
if revert_outcome == "gave_up":
logger.error(
"Queue item %s: printer %d never started the print after %d dispatch "

View file

@ -16,6 +16,7 @@ import socket
from backend.app.models.printer import Printer
from backend.app.schemas.printer import DiagnosticCheck, PrinterDiagnosticResult
from backend.app.services.bambu_mqtt import CONNECT_ERROR_AUTH_REJECTED
from backend.app.services.camera import get_camera_port
from backend.app.services.discovery import is_running_in_docker
from backend.app.services.printer_manager import printer_manager
@ -56,6 +57,27 @@ async def _check_port(ip: str, port: int, timeout: float = _PORT_PROBE_TIMEOUT)
return False
# Public alias. The connection watchdog probes the MQTT port before rebuilding a
# client, so it can tell "the printer is switched off" (leave it alone, paho will
# keep retrying) from "the printer is answering but our session is dead" (#2732).
check_port = _check_port
def _auth_reason_params(reason: str | None) -> dict:
"""Map a client's CONNACK-refusal slug onto the check's `params.reason`.
The frontend renders `diagnostic.check.<id>.<status>_<reason>` when a reason
is present and falls back to the plain per-status text otherwise, so an
unknown or absent slug degrades to today's generic wording rather than a
missing string. Only `auth_rejected` currently carries its own message:
that is the one case where the printer positively told us the credentials
were wrong, as opposed to us merely observing that we are not connected.
"""
if reason == CONNECT_ERROR_AUTH_REJECTED:
return {"reason": CONNECT_ERROR_AUTH_REJECTED}
return {}
def _camera_port_for_printer(printer: Printer | None) -> tuple[int, str]:
"""Return the model-specific camera diagnostic port and display protocol."""
if not printer:
@ -249,14 +271,30 @@ async def run_connection_diagnostic(
serial_number=serial_number,
access_code=access_code,
)
checks.append(DiagnosticCheck(id="mqtt_auth", status="pass" if result.get("success") else "fail"))
checks.append(
DiagnosticCheck(
id="mqtt_auth",
status="pass" if result.get("success") else "fail",
params=_auth_reason_params(result.get("reason")),
)
)
except Exception:
logger.debug("test_connection failed during diagnostic", exc_info=True)
checks.append(DiagnosticCheck(id="mqtt_auth", status="fail"))
elif state is not None:
# Existing printer: trust the live MQTT state rather than opening a
# second connection (Bambu printers tolerate few concurrent sessions).
checks.append(DiagnosticCheck(id="mqtt_auth", status="pass" if state.connected else "fail"))
# `connected == False` alone does not say *why* — the live client keeps
# the last CONNACK refusal, so a rejected access code can be reported as
# such instead of as a generic failure the user has to guess at (#2698).
client = printer_manager.get_client(printer.id) if printer else None
checks.append(
DiagnosticCheck(
id="mqtt_auth",
status="pass" if state.connected else "fail",
params={} if state.connected else _auth_reason_params(getattr(client, "last_connect_error", None)),
)
)
else:
checks.append(DiagnosticCheck(id="mqtt_auth", status="skip"))

View file

@ -325,6 +325,7 @@ class PrinterManager:
self._on_status_change: Callable[[int, PrinterState], None] | None = None
self._on_ams_change: Callable[[int, list], None] | None = None
self._on_layer_change: Callable[[int, int], None] | None = None
self._on_print_progress: Callable[[int, int], None] | None = None
self._on_bed_temp_update: Callable[[int, float], None] | None = None
self._on_drying_complete: Callable[[int, int], None] | None = None
self._on_assignment_verified: Callable[[int, int, int, bool, dict], None] | None = None
@ -377,6 +378,13 @@ class PrinterManager:
UI without it. Centralised here so every current AND future caller is
covered without each one having to remember to broadcast.
"""
# Callers re-assert the current value routinely (the queue clears the gate
# on every dispatch, whether or not it was up), so the outward-facing
# emissions below are edge-triggered — an MQTT subscriber or a phone
# notification must not see a "plate cleared" for a plate that was never
# dirty. Persistence and the WebSocket broadcast stay unconditional: they
# are idempotent and predate this (#961/#1128).
changed = awaiting != (printer_id in self._awaiting_plate_clear)
if awaiting:
self._awaiting_plate_clear.add(printer_id)
else:
@ -386,6 +394,45 @@ class PrinterManager:
if self._loop and self._loop.is_running():
self._schedule_async(self._persist_awaiting_plate_clear(printer_id, awaiting))
self._schedule_async(self._broadcast_status_change(printer_id))
if changed:
self._schedule_async(self._emit_plate_clear_change(printer_id, awaiting))
async def _emit_plate_clear_change(self, printer_id: int, awaiting: bool) -> None:
"""Relay a plate-clear gate transition to MQTT and notifications (#2525).
The flag is Bambuddy-side, so nothing about it reaches an external
automation on its own the printer's own MQTT push knows only
RUNNING/PAUSE/FAILED/FINISH/IDLE. Emitted from here rather than from the
three call sites so every current and future caller is covered, the same
reasoning as the WebSocket broadcast above.
Imports are local: ``mqtt_relay`` and ``notification_service`` both sit
above this module in the dependency order.
"""
printer = self.get_printer(printer_id)
if not printer:
return
try:
from backend.app.services.mqtt_relay import mqtt_relay
await mqtt_relay.on_plate_clear_state(printer_id, printer.name, printer.serial_number, awaiting)
except Exception as e:
logger.warning("Failed to publish plate-clear state for printer %d: %s", printer_id, e)
# Only the rising edge is worth a notification — "the bed is now free"
# is not an action item, and the queue clears the gate by itself.
if not awaiting:
return
try:
from backend.app.core.database import async_session
from backend.app.services.notification_service import notification_service
async with async_session() as db:
await notification_service.on_plate_clear_required(printer_id, printer.name, db)
except Exception as e:
logger.warning("Failed to send plate-clear notification for printer %d: %s", printer_id, e)
async def _broadcast_status_change(self, printer_id: int) -> None:
"""Emit a ``printer_status`` WebSocket update for this printer (#1128).
@ -502,6 +549,15 @@ class PrinterManager:
"""Set callback for layer change events. Receives (printer_id, layer_num)."""
self._on_layer_change = callback
def set_print_progress_callback(self, callback: Callable[[int, int], None]):
"""Set callback for print-progress advances (#2547).
Receives (printer_id, percent) each time `mc_percent` increases during a
running print including the final layer, where layer-change events
have already stopped.
"""
self._on_print_progress = callback
def set_bed_temp_update_callback(self, callback: Callable[[int, float], None]):
"""Set callback for bed temperature updates. Receives (printer_id, bed_temp)."""
self._on_bed_temp_update = callback
@ -578,6 +634,10 @@ class PrinterManager:
if self._on_layer_change:
self._schedule_async(self._on_layer_change(printer_id, layer_num))
def on_print_progress(percent: int):
if self._on_print_progress:
self._schedule_async(self._on_print_progress(printer_id, percent))
def on_bed_temp_update(bed_temp: float):
if self._on_bed_temp_update:
self._schedule_async(self._on_bed_temp_update(printer_id, bed_temp))
@ -600,6 +660,7 @@ class PrinterManager:
on_print_complete=on_print_complete,
on_ams_change=on_ams_change,
on_layer_change=on_layer_change,
on_print_progress=on_print_progress,
on_bed_temp_update=on_bed_temp_update,
on_drying_complete=on_drying_complete,
on_print_running_observed=on_print_running_observed,
@ -905,6 +966,11 @@ class PrinterManager:
"success": client.state.connected,
"state": client.state.state if client.state.connected else None,
"model": client.state.raw_data.get("device_model"),
# Why the probe failed, when the printer told us: one of the
# CONNECT_ERROR_* slugs, else None. Lets the add-printer flow
# and the connection diagnostic say "the printer rejected the
# access code" instead of an unqualified failure (#2698).
"reason": None if client.state.connected else client.last_connect_error,
}
finally:
# Off-loop teardown — see docstring. paho's loop_stop() joins the
@ -1154,6 +1220,13 @@ def printer_state_to_dict(
"drying_temp": tray.get("drying_temp"),
"drying_time": tray.get("drying_time"),
"state": state_val,
# Firmware's authoritative presence bit (tray_exist_bits),
# set by apply_tray_exist_bits. The REST serializer already
# emits it (routes/printers.py); without it here the WS
# shallow-merge drops `exists` after the first frame and
# getEmptySlotKind falls back to the firmware-variant state
# 9/10 heuristic — wrong for AMS-HT in both directions (#2670).
"exists": tray.get("exists"),
}
)
# Prefer humidity_raw (actual percentage) over humidity (index 1-5)
@ -1360,6 +1433,8 @@ def printer_state_to_dict(
"big_fan1_speed": state.big_fan1_speed,
"big_fan2_speed": state.big_fan2_speed,
"heatbreak_fan_speed": state.heatbreak_fan_speed,
"left_aux_fan_speed": state.left_aux_fan_speed,
"exhaust_fan_present": state.exhaust_fan_present,
# Chamber light state
"chamber_light": state.chamber_light,
# Active extruder for dual-nozzle printers (0=right, 1=left)

View file

@ -1,10 +1,8 @@
"""Service for controlling smart plugs via generic REST/HTTP API."""
import ipaddress
import json
import logging
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
import httpx
@ -24,18 +22,39 @@ class RESTSmartPlugService:
self.timeout = timeout
@staticmethod
def _validate_url(url: str) -> bool:
"""Block cloud metadata and link-local IPs."""
def _url_error(url: str) -> str | None:
"""Return why *url* is rejected by the LAN-service policy, else None.
Split out from ``_validate_url`` so ``test_connection`` can tell the
user which rule the URL broke instead of a single fixed sentence.
"""
from backend.app.api.routes._url_safety import assert_safe_lan_service_url
try:
parsed = urlparse(url)
hostname = parsed.hostname
if not hostname:
return False
addr = ipaddress.ip_address(hostname)
return not addr.is_loopback and not addr.is_link_local
except ValueError:
# Hostname is not an IP (e.g., "openhab.local") — allow it
return True
assert_safe_lan_service_url(url, label="REST plug URL")
except ValueError as exc:
return str(exc)
return None
@staticmethod
def _validate_url(url: str) -> bool:
"""Apply the shared LAN-service SSRF policy to a REST plug URL.
Delegates to ``_url_safety.assert_safe_lan_service_url`` the same
guard Spoolman, the notification providers and the LAN-service
settings use rather than reimplementing a narrower check. The
hand-rolled version this replaces got the policy wrong in both
directions: it rejected a literal ``127.0.0.1`` (so an openHAB or
Node-RED instance on the same host could only be reached by spelling
it ``localhost``), while allowing every target the shared policy
rejects unconditionally Alibaba/AWS-IPv6 metadata endpoints,
numeric-encoded IPs, multicast and the unspecified address because
anything that wasn't a bare IP literal fell through to ``True``.
Loopback and RFC-1918 stay permitted on purpose: a REST-controlled
plug bridge running next to Bambuddy is the normal topology.
"""
return RESTSmartPlugService._url_error(url) is None
def _parse_headers(self, headers_json: str | None) -> dict[str, str]:
"""Parse JSON string to dict of headers."""
@ -273,8 +292,9 @@ class RESTSmartPlugService:
- success: bool
- error: error message if failed
"""
if not self._validate_url(url):
return {"success": False, "error": "Invalid URL (loopback/link-local addresses are blocked)"}
url_error = self._url_error(url)
if url_error:
return {"success": False, "error": url_error}
parsed_headers = self._parse_headers(headers)

View file

@ -63,6 +63,7 @@ async def get_preview_filaments(
file_name: str,
api_url: str,
request_id: str | None = None,
timeout_seconds: float | None = None,
) -> list[dict] | None:
"""Run a preview slice for ``plate_id``, parse the resulting slice_info,
and return the per-plate filament list.
@ -92,7 +93,11 @@ async def get_preview_filaments(
return cached
try:
async with SlicerApiService(base_url=api_url) as svc:
# Preview slices are bounded the same way as real ones (#2730):
# a heavy plate can take a long time and must not be cut off
# while the slicer is visibly working.
svc_kwargs = {} if timeout_seconds is None else {"timeout_seconds": timeout_seconds}
async with SlicerApiService(base_url=api_url, **svc_kwargs) as svc:
result = await svc.slice_without_profiles(
model_bytes=file_bytes,
model_filename=file_name,

View file

@ -264,14 +264,27 @@ def substitute_unused_plate_filaments(source_3mf_bytes: bytes, plate_id: int | N
doesn't even use.
The substitution is a no-op when:
- ``plate_id`` is None (we can't determine which slots are unused),
- ``plate_id`` is not a real plate ``None`` (caller couldn't say) or
``0`` (the slice-all sentinel, where every slot is used by *some*
plate so there is nothing unused to substitute). Callers that know
"absent means plate 1" must resolve that themselves before calling;
this function will not guess, because guessing wrong rewrites a
filament the plate actually prints with.
- the source isn't a valid 3MF / zip,
- the source doesn't carry plate-extruder metadata (parse returns
empty set treat as "every slot is used", same fallback the
SliceModal uses),
- ``items`` has fewer than 2 entries (nothing to substitute).
The ``0`` guard is load-bearing rather than cosmetic. Plate ids are
1-indexed, so the geometry lookup for plate 0 matches nothing but
the support-filament slots unioned in below come from the project
config and carry no plate scope at all. Without the guard a slice-all
of a project with a dedicated support slot would see ``used`` as just
that one slot, anchor on it, and rewrite every colour in the project
to the support material (#2711).
"""
if plate_id is None or len(items) < 2:
if plate_id is None or plate_id < 1 or len(items) < 2:
return items
# Local import keeps the bytes->ZipFile boundary in this module and
# avoids dragging zipfile into every caller.

View file

@ -9,7 +9,10 @@ under the hood, response body is raw G-code or 3MF with metadata in the
"""
import asyncio
import io
import logging
import time
import zipfile
from collections.abc import Callable
from typing import NamedTuple
@ -38,6 +41,18 @@ class SlicerInputError(SlicerApiError):
"""Sidecar rejected the input as invalid (4xx)."""
class SlicerTimeoutError(SlicerApiError):
"""We gave up waiting on a slice that never finished.
Kept apart from ``SlicerApiUnavailableError`` because they call for
opposite reactions and used to be reported as the same thing: an
``httpx.ReadTimeout`` is a subclass of ``RequestError``, so a slice that
simply took a long time surfaced as "Slicer sidecar unreachable" sending
the reporter of #2730 off to check a sidecar that was reachable throughout
and still slicing when we hung up on it.
"""
class SliceResult(NamedTuple):
"""Result of a slice operation."""
@ -49,6 +64,36 @@ class SliceResult(NamedTuple):
_shared_http_client: httpx.AsyncClient | None = None
# Fallback for callers that don't pass one (tests, and any path that runs
# without a DB session to read the setting from). The user-facing value is
# ``slicer_stall_timeout_minutes`` under Settings -> Workflow -> Slicer.
DEFAULT_SLICE_STALL_TIMEOUT_SECONDS = 15 * 60.0
# How often the progress poller ticks. Also the granularity of the stall check,
# since a missed tick is what the stall clock is counting.
_PROGRESS_POLL_INTERVAL = 1.0
async def get_stall_timeout_seconds(db) -> float:
"""Read ``slicer_stall_timeout_minutes`` (Settings -> Workflow -> Slicer).
Falls back to the default on anything unparseable rather than failing the
slice a bad settings row must not be the reason a print doesn't happen.
"""
from backend.app.api.routes.settings import get_setting
try:
raw = await get_setting(db, "slicer_stall_timeout_minutes")
except Exception:
return DEFAULT_SLICE_STALL_TIMEOUT_SECONDS
try:
minutes = int(str(raw).strip())
except (TypeError, ValueError):
return DEFAULT_SLICE_STALL_TIMEOUT_SECONDS
if minutes < 1:
return DEFAULT_SLICE_STALL_TIMEOUT_SECONDS
return float(minutes) * 60.0
def _format_sidecar_error(response: httpx.Response) -> str:
"""Build a human-readable error string from a sidecar 4xx/5xx response.
@ -74,6 +119,62 @@ def _format_sidecar_error(response: httpx.Response) -> str:
return (message or details or response.text)[:500]
def _handle_slice_response(response: httpx.Response, *, export_3mf: bool) -> SliceResult:
"""Turn a sidecar ``/slice`` HTTP response into a validated ``SliceResult``.
Shared by ``slice_with_profiles`` / ``slice_without_profiles`` so the status
handling and output validation live in one place.
Beyond the status check, this guards against the sidecar (or a reverse proxy
in front of it) returning **HTTP 200 with a body that isn't a real slice**
(#2671): a stock/misconfigured sidecar, a proxy interstitial or truncated
response, or an OrcaSlicer/BambuStudio CLI crash that produces empty output.
Without this check Bambuddy would store that tiny blob as a ``.gcode.3mf``,
let it be queued, and FTP it to the printer a silently-broken print. When
a 3MF export was requested the body must be a valid ZIP (3MF container);
anything else is treated as a sidecar failure.
Raises:
SlicerInputError: 4xx from the sidecar (bad input / proxy body limit).
SlicerApiServerError: 5xx, or a 2xx whose body is not a valid 3MF.
"""
if response.status_code == 413:
# A 413 almost never comes from the slicer itself — it's a reverse proxy
# (nginx/SWAG/Traefik) or a CDN capping the multipart upload (model +
# profiles). Name the real fix so the user doesn't tweak the wrong layer.
raise SlicerInputError(
"The slice request was rejected as too large (HTTP 413). A reverse proxy "
"in front of the slicer sidecar is capping the request body — raise "
"'client_max_body_size' (nginx/SWAG) or the equivalent on the proxy that "
"sits directly in front of the sidecar, then reload it. If the sidecar is "
"behind Cloudflare, note its request-size cap."
)
if response.status_code >= 500:
raise SlicerApiServerError(f"Slicer CLI failed ({response.status_code}): {_format_sidecar_error(response)}")
if response.status_code >= 400:
raise SlicerInputError(f"Slicer rejected input ({response.status_code}): {_format_sidecar_error(response)}")
content = response.content
if export_3mf and not zipfile.is_zipfile(io.BytesIO(content)):
# 200 OK but the body is not a 3MF zip → the sidecar did not produce a
# usable slice. Surface it loudly instead of persisting a corrupt file.
detail = _format_sidecar_error(response) if len(content) <= 500 else ""
raise SlicerApiServerError(
f"Slicer sidecar returned HTTP {response.status_code} but the body is not a valid "
f"3MF ({len(content)} bytes). This usually means a misconfigured sidecar, an "
f"OrcaSlicer/BambuStudio CLI crash producing no output, or a reverse proxy returning "
f"an error page or truncating the response — verify the sidecar URL and any proxy in "
f"front of it." + (f" Body: {detail}" if detail else "")
)
return SliceResult(
content=content,
print_time_seconds=_safe_int(response.headers.get("x-print-time-seconds")),
filament_used_g=_safe_float(response.headers.get("x-filament-used-g")),
filament_used_mm=_safe_float(response.headers.get("x-filament-used-mm")),
)
def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
"""Register an app-scoped client so per-request services can pool transport."""
global _shared_http_client
@ -91,6 +192,65 @@ def _guess_model_content_type(filename: str) -> str:
return "application/octet-stream"
class _Liveness:
"""Tracks when the slicer last showed a sign of life.
``deadline`` is what the slice waits against, and it moves forward on every
genuine progress update. A slice therefore fails only after the configured
window of *silence*, however long the whole thing has been running (#2730).
``progress_supported`` stays False for sidecars that never answer the
progress endpoint. Those give us nothing to judge liveness by, so the caller
treats the same window as a total-elapsed ceiling rather than pretending a
stall can be detected.
"""
def __init__(self, window_seconds: float, poll_interval: float = _PROGRESS_POLL_INTERVAL) -> None:
# Liveness can only be observed as often as the poller ticks, so a
# window shorter than a few ticks would expire in the gap between two
# polls and fail every slice instantly, however healthy. The settings
# schema already floors the user-facing value at a minute; this guards
# the constructor, which tests and any future caller can pass anything.
self.window_seconds = max(window_seconds, poll_interval * 3)
self.progress_supported = False
self.started_at = time.monotonic()
self._last_alive = self.started_at
def saw_progress_endpoint(self) -> None:
self.progress_supported = True
def mark_alive(self) -> None:
self._last_alive = time.monotonic()
@property
def deadline(self) -> float:
"""Monotonic time at which we stop waiting."""
base = self._last_alive if self.progress_supported else self.started_at
return base + self.window_seconds
def silent_for(self) -> float:
return time.monotonic() - self._last_alive
def elapsed(self) -> float:
return time.monotonic() - self.started_at
def timeout_message(self) -> str:
minutes = self.window_seconds / 60
if self.progress_supported:
return (
f"The slicer stopped reporting progress for {minutes:.0f} minutes "
f"(slicing had been running for {self.elapsed() / 60:.0f} minutes). "
"Raise 'Slicer stall timeout' under Settings -> Workflow -> Slicer if this model "
"legitimately needs longer between progress updates."
)
return (
f"Slicing did not finish within {minutes:.0f} minutes, and this sidecar does not "
"report progress, so there was no way to tell a slow model from a stalled one. "
"Raise 'Slicer stall timeout' under Settings -> Workflow -> Slicer, or update the "
"sidecar to a version that reports progress."
)
class SlicerApiService:
"""Talks to an OrcaSlicer / BambuStudio API sidecar."""
@ -99,10 +259,25 @@ class SlicerApiService:
base_url: str,
*,
client: httpx.AsyncClient | None = None,
timeout_seconds: float = 300.0,
timeout_seconds: float = DEFAULT_SLICE_STALL_TIMEOUT_SECONDS,
) -> None:
"""``timeout_seconds`` bounds *silence*, not total slicing time (#2730).
While a slice is running Bambuddy polls the sidecar's progress channel
once a second, so it can tell a model that is merely slow from one that
has stopped: the clock is reset by every progress update, and only runs
out when the slicer has said nothing for this long. A heavy model that
keeps reporting will run to completion however long it takes.
Sidecars too old to report progress have no liveness signal to offer, so
for those the same number bounds total elapsed time the pre-#2730
behaviour, but configurable and no longer five minutes flat.
"""
self.base_url = base_url.rstrip("/")
self.timeout_seconds = timeout_seconds
# Instance-level so tests can compress the timing; production always
# uses the module default.
self.progress_poll_interval = _PROGRESS_POLL_INTERVAL
if client is not None:
self._client = client
self._owns_client = False
@ -159,6 +334,8 @@ class SlicerApiService:
self,
request_id: str,
on_progress: Callable[[dict], None],
*,
liveness: "_Liveness | None" = None,
) -> None:
"""Poll the sidecar's progress endpoint at ~1Hz and forward each
snapshot to ``on_progress``. Runs until cancelled.
@ -174,14 +351,27 @@ class SlicerApiService:
slice grace expiry) just costs a few wasted GETs that the cancel
will stop. Network errors and non-JSON 5xx are swallowed; the
next tick retries.
When ``liveness`` is supplied this doubles as the stall watchdog: every
200 carrying a *changed* payload marks the slicer alive, which is what
keeps the slice's deadline moving (#2730). An unchanged payload
deliberately does not count the sidecar re-serves its last snapshot on
every poll, so treating a repeat as progress would leave the watchdog
unable to detect a stall at all.
"""
url = f"{self.base_url}/slice/progress/{request_id}"
last_payload: dict | None = None
while True:
try:
response = await self._client.get(url, timeout=5.0)
if response.status_code == 200:
payload = response.json()
if isinstance(payload, dict):
if liveness is not None:
liveness.saw_progress_endpoint()
if payload != last_payload:
liveness.mark_alive()
last_payload = payload
on_progress(payload)
# 404 / other 4xx = no progress available (yet, or ever
# for older sidecars). Keep polling — the outer slice
@ -191,10 +381,85 @@ class SlicerApiService:
# returns a non-JSON 5xx. Don't crash the poller.
pass
try:
await asyncio.sleep(1.0)
await asyncio.sleep(self.progress_poll_interval)
except asyncio.CancelledError:
return
async def _post_slice(
self,
*,
files: list | dict,
data: dict,
request_id: str | None,
on_progress: Callable[[dict], None] | None,
) -> httpx.Response:
"""POST /slice, supervised by the progress channel rather than a clock.
Before #2730 this was a plain ``httpx`` call with a flat 300 s timeout on
every phase. A genuinely heavy model the reporter's was a MakerWorld
model that Bambu Studio also took a long time over hit the ceiling
while it was still slicing perfectly happily, and because
``httpx.ReadTimeout`` is a ``RequestError`` it was reported as "Slicer
sidecar unreachable". Meanwhile Bambuddy was polling the sidecar's
progress endpoint once a second and could see the thing working.
So the read timeout comes off the HTTP call and the poller supervises
instead: the deadline is pushed forward by every progress update, and
only a genuine silence ends the wait. Connect and pool keep short
timeouts a sidecar that won't accept the connection at all is
unreachable, and should still say so quickly.
"""
liveness = _Liveness(self.timeout_seconds, self.progress_poll_interval)
# Poll whenever we have a request_id, even if the caller wants no
# progress callbacks: the poll is what makes stall detection possible,
# and one GET per second is cheaper than a wrongly-cancelled slice.
progress_task: asyncio.Task | None = None
if request_id is not None:
progress_task = asyncio.create_task(
self._poll_progress(request_id, on_progress or (lambda _payload: None), liveness=liveness),
name=f"slicer-progress-{request_id}",
)
post_task = asyncio.create_task(
self._client.post(
f"{self.base_url}/slice",
files=files,
data=data,
timeout=httpx.Timeout(connect=30.0, read=None, write=None, pool=30.0),
),
name="slicer-slice-post",
)
try:
while True:
remaining = liveness.deadline - time.monotonic()
if remaining <= 0:
post_task.cancel()
logger.warning(
"Slice abandoned after %.0fs (silent for %.0fs, progress channel %s)",
liveness.elapsed(),
liveness.silent_for(),
"available" if liveness.progress_supported else "unavailable",
)
raise SlicerTimeoutError(liveness.timeout_message())
# Re-check at poll granularity so a progress update that lands
# mid-wait extends the deadline promptly.
done, _pending = await asyncio.wait({post_task}, timeout=min(remaining, self.progress_poll_interval))
if post_task in done:
break
finally:
if progress_task is not None:
progress_task.cancel()
# Await both so neither is left pending — a cancelled POST still
# needs its connection released back to the pool.
await asyncio.gather(post_task, progress_task or asyncio.sleep(0), return_exceptions=True)
try:
return post_task.result()
except httpx.RequestError as exc:
raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
async def slice_with_profiles(
self,
*,
@ -270,41 +535,8 @@ class SlicerApiService:
# and surfaces structured updates via on_progress. Uses a
# short-tick poll (1s) since the slicer emits stage changes
# several times per minute on complex models.
progress_task: asyncio.Task | None = None
if request_id is not None and on_progress is not None:
progress_task = asyncio.create_task(
self._poll_progress(request_id, on_progress),
name=f"slicer-progress-{request_id}",
)
try:
response = await self._client.post(
f"{self.base_url}/slice",
files=files,
data=data,
timeout=self.timeout_seconds,
)
except httpx.RequestError as exc:
raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
finally:
if progress_task is not None:
progress_task.cancel()
try:
await progress_task
except (asyncio.CancelledError, Exception):
pass # Polling errors must not fail the slice.
if response.status_code >= 500:
raise SlicerApiServerError(f"Slicer CLI failed ({response.status_code}): {_format_sidecar_error(response)}")
if response.status_code >= 400:
raise SlicerInputError(f"Slicer rejected input ({response.status_code}): {_format_sidecar_error(response)}")
return SliceResult(
content=response.content,
print_time_seconds=_safe_int(response.headers.get("x-print-time-seconds")),
filament_used_g=_safe_float(response.headers.get("x-filament-used-g")),
filament_used_mm=_safe_float(response.headers.get("x-filament-used-mm")),
)
response = await self._post_slice(files=files, data=data, request_id=request_id, on_progress=on_progress)
return _handle_slice_response(response, export_3mf=export_3mf)
async def slice_without_profiles(
self,
@ -348,41 +580,8 @@ class SlicerApiService:
# embedded-settings fallback path triggered by an Orca/Bambu CLI
# segfault on complex H2D models — both want to keep updating
# the user's toast through the slow operation.
progress_task: asyncio.Task | None = None
if request_id is not None and on_progress is not None:
progress_task = asyncio.create_task(
self._poll_progress(request_id, on_progress),
name=f"slicer-progress-{request_id}",
)
try:
response = await self._client.post(
f"{self.base_url}/slice",
files=files,
data=data,
timeout=self.timeout_seconds,
)
except httpx.RequestError as exc:
raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
finally:
if progress_task is not None:
progress_task.cancel()
try:
await progress_task
except (asyncio.CancelledError, Exception):
pass
if response.status_code >= 500:
raise SlicerApiServerError(f"Slicer CLI failed ({response.status_code}): {_format_sidecar_error(response)}")
if response.status_code >= 400:
raise SlicerInputError(f"Slicer rejected input ({response.status_code}): {_format_sidecar_error(response)}")
return SliceResult(
content=response.content,
print_time_seconds=_safe_int(response.headers.get("x-print-time-seconds")),
filament_used_g=_safe_float(response.headers.get("x-filament-used-g")),
filament_used_mm=_safe_float(response.headers.get("x-filament-used-mm")),
)
response = await self._post_slice(files=files, data=data, request_id=request_id, on_progress=on_progress)
return _handle_slice_response(response, export_3mf=export_3mf)
def _safe_int(value: str | None) -> int:

View file

@ -26,12 +26,32 @@ class TasmotaService:
@staticmethod
def _validate_ip(ip: str) -> bool:
"""Block cloud metadata and link-local IPs."""
"""Block cloud metadata, loopback and link-local destinations.
Deliberately stricter than the shared LAN-service guard, and kept that
way: a Tasmota plug is always a separate device on the LAN, so a bare
IP literal is the only sensible value. Anything that is not one
including a symbolic hostname still fails closed here, which is why
this does not simply delegate to ``assert_safe_lan_service_url``.
What it borrows from the shared guard is the destination set that is
dangerous under any topology: cloud-metadata endpoints beyond the AWS
IPv4 address (Alibaba's 100.100.100.200, AWS's fd00:ec2::254),
multicast and unspecified addresses, and IPv4-mapped IPv6 encodings
used to smuggle any of the above past the per-class checks.
"""
from backend.app.api.routes._url_safety import CLOUD_METADATA_IPS, unwrap_ipv4_mapped
try:
addr = ipaddress.ip_address(ip)
except ValueError:
return False # Not a valid IP
return not addr.is_loopback and not addr.is_link_local
effective = unwrap_ipv4_mapped(addr)
if effective in CLOUD_METADATA_IPS:
return False
if effective.is_multicast or effective.is_unspecified:
return False
return not effective.is_loopback and not effective.is_link_local
async def _send_command(
self,

View file

@ -5,6 +5,7 @@ bound to its dedicated IP address, regardless of mode.
"""
import asyncio
import json
import logging
import time
from collections.abc import Callable
@ -154,6 +155,60 @@ def _tristate_from_slicer(data: dict, bool_field: str, int_field: str) -> str |
return None
def _extract_slicer_ams_mapping_json(data: dict, log_prefix: str) -> str | None:
"""Pull the slicer's own AMS-slot pick out of a captured project_file payload.
BambuStudio/OrcaSlicer resolves the physical AMS tray for each filament
live, right before sending either automatically or via the slicer's
manual per-filament AMS-slot assignment dialog and embeds the result as
``ams_mapping`` (``list[int]``, position = slot_id-1, value = global tray
ID) directly in the MQTT ``project_file`` command. Confirmed by wire
capture: the field is present and already in the exact shape
``PrintQueueItem.ams_mapping`` expects.
The VP-queue path previously never read this every queued print had the
scheduler re-derive a mapping from just the 3MF's static type/color at
dispatch time (`PrintScheduler._compute_ams_mapping_for_printer`), discarding
the slicer's already-correct, live-resolved pick. That re-derivation can
land on the wrong physical spool whenever the file's type+color match
isn't unique (e.g. two spools of the same color) or the file's own
filament-slot color wasn't what the user actually intended for that
particular print. Capturing it here mirroring the existing
``nozzle_mapping`` passthrough for H2C rack-swap models (#1780) — lets the
scheduler's "already resolved, don't touch it" branch in
``_ensure_ams_mapping`` use the slicer's own choice unchanged.
That branch skipping ``_compute_ams_mapping_for_printer`` is also what
makes this a trade rather than a pure win: ``prefer_lowest_filament``, its
AMS-filament-backup gate (#1766), the inventory-remain overrides and the
per-slot force-color overrides all live inside that function. Callers are
responsible for the gating this parser only says what the slicer sent.
Returns ``None`` when the field is absent, unparsable, or the classic
"all -1" unresolved-race sentinel (#2589) — never worth trusting over a
fresh live computation.
"""
raw = data.get("ams_mapping")
if raw is None:
return None
if isinstance(raw, str):
try:
raw = json.loads(raw)
except json.JSONDecodeError:
logger.warning("%s Slicer ams_mapping is unparseable JSON, dropping: %r", log_prefix, raw)
return None
# bool is a subclass of int in Python — isinstance(True, int) is True —
# so it must be excluded explicitly, or [True, False] would pass as a
# valid mapping.
if not isinstance(raw, list) or not raw or not all(isinstance(v, int) and not isinstance(v, bool) for v in raw):
return None
if all(v < 0 for v in raw):
# #2589 sentinel — every slot unresolved. Let the scheduler compute a
# fresh mapping from live AMS state instead of trusting this.
return None
return json.dumps(raw)
def _get_serial_for_model(model: str, serial_suffix: str) -> str:
"""Get serial number for the given model and suffix."""
prefix = MODEL_SERIAL_PREFIXES.get(model, "00M09A")
@ -181,6 +236,7 @@ class VirtualPrinterInstance:
target_printer_id: int | None = None,
auto_dispatch: bool = True,
queue_force_color_match: bool = False,
save_ams_mapping: bool = False,
gcode_injection: bool = False,
bind_ip: str = "",
remote_interface_ip: str = "",
@ -204,6 +260,7 @@ class VirtualPrinterInstance:
self.target_printer_id = target_printer_id
self.auto_dispatch = auto_dispatch
self.queue_force_color_match = queue_force_color_match
self.save_ams_mapping = save_ams_mapping
self.gcode_injection = gcode_injection
self.bind_ip = bind_ip
self.remote_interface_ip = remote_interface_ip
@ -416,8 +473,9 @@ class VirtualPrinterInstance:
row was already written with settings defaults. This method runs
on the late MQTT path: it looks up the most recent queue items
committed for this filename and patches in the slicer's
``nozzle_mapping`` + workflow flags, but only while the items are
still ``pending`` (scheduler hasn't dispatched them yet).
``nozzle_mapping`` + ``ams_mapping`` + workflow flags, but only
while the items are still ``pending`` (scheduler hasn't dispatched
them yet).
"""
if not self._session_factory:
return
@ -469,12 +527,34 @@ class VirtualPrinterInstance:
if raw is not None:
patch["nozzle_mapping"] = json.dumps(raw)
if not patch:
# Same two gates as the immediate path in `_add_to_print_queue`: a
# model-based VP has no live AMS layout for the slicer to have resolved
# tray IDs against, and taking the slicer's pick at all is the per-VP
# `save_ams_mapping` opt-in (it makes the scheduler skip
# `_compute_ams_mapping_for_printer`, and with it prefer-lowest and the
# #1766 backup gate).
ams_mapping_json = (
_extract_slicer_ams_mapping_json(data, f"[VP {self.name}] Late MQTT")
if self.target_printer_id is not None and self.save_ams_mapping
else None
)
# `Force color match` still wins for this dispatch — see the same
# decision in `_add_to_print_queue`. The archive patch below is
# deliberately not gated on it: persisting the pick for later reprints
# is exactly what the toggle promises.
if ams_mapping_json is not None and not self.queue_force_color_match:
patch["ams_mapping"] = ams_mapping_json
# `ams_mapping_json` alone is enough to keep going even when `patch` is
# empty: with `Force color match` on it never reaches the queue item,
# but it still has to be written onto the archive below.
if not patch and ams_mapping_json is None:
self._recent_queue_items.pop(stash_key, None)
return
from sqlalchemy import select, update
from backend.app.models.archive import PrintArchive
from backend.app.models.print_queue import PrintQueueItem
try:
@ -482,23 +562,49 @@ class VirtualPrinterInstance:
# Only stamp items still pending; once the scheduler has
# picked the row up we can't safely race the dispatcher.
result = await db.execute(
select(PrintQueueItem.id).where(
select(PrintQueueItem.id, PrintQueueItem.archive_id).where(
PrintQueueItem.id.in_(queue_item_ids),
PrintQueueItem.status == "pending",
)
)
eligible_ids = [row[0] for row in result.all()]
rows = result.all()
eligible_ids = [row[0] for row in rows]
if not eligible_ids:
self._recent_queue_items.pop(stash_key, None)
return
await db.execute(update(PrintQueueItem).where(PrintQueueItem.id.in_(eligible_ids)).values(**patch))
if patch:
await db.execute(update(PrintQueueItem).where(PrintQueueItem.id.in_(eligible_ids)).values(**patch))
# The archive was already created (with no slicer_ams_mapping)
# before this late MQTT arrived — see
# `_extract_slicer_ams_mapping_json`'s docstring. Patch it here
# too so a reprint later still picks up the slicer's pick, and
# the "AMS mapping from slicer" badge reflects reality instead
# of staying stuck on the archive's initial (empty) snapshot.
# Already gated on `save_ams_mapping` above, and deliberately
# NOT on `queue_force_color_match`: that toggle decides how
# *this* print is matched, not whether the pick is worth
# keeping for a later reprint.
if ams_mapping_json is not None:
archive_ids = {row[1] for row in rows if row[1] is not None}
if archive_ids:
archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id.in_(archive_ids)))
for archive in archive_result.scalars().all():
extra = dict(archive.extra_data or {})
extra["slicer_ams_mapping"] = {
"mapping": json.loads(ams_mapping_json),
"printer_id": self.target_printer_id,
}
archive.extra_data = extra
await db.commit()
logger.info(
"[VP %s] Late slicer MQTT for %s — retroactively stamped %s onto queue item(s) %s",
"[VP %s] Late slicer MQTT for %s — retroactively stamped %s onto queue item(s) %s%s",
self.name,
stash_key,
sorted(patch.keys()),
eligible_ids,
" and saved the slicer's AMS pick onto the archive" if ams_mapping_json is not None else "",
)
except Exception as e:
logger.error(
@ -834,6 +940,60 @@ class VirtualPrinterInstance:
if raw is not None:
nozzle_mapping_json = json.dumps(raw)
# Slicer's own live-resolved AMS-slot pick (see docstring on
# `_extract_slicer_ams_mapping_json`). Stamped onto every plate
# below, same treatment as nozzle_mapping_json above — when
# present it makes `_ensure_ams_mapping` skip its own
# type/color re-derivation entirely and dispatch use exactly
# the tray the slicer/user picked.
#
# Two gates, both required:
#
# 1. This VP must target one fixed printer. A model-based
# ("Any <model>") VP has no MQTT bridge to a real printer,
# so the slicer has no live AMS layout to resolve tray IDs
# against — whatever it sends here is meaningless (or,
# worse, coincidentally valid for the wrong printer once
# the scheduler later picks one).
# 2. The per-VP `save_ams_mapping` opt-in must be on. Taking
# the slicer's pick means `_ensure_ams_mapping` returns
# early and `_compute_ams_mapping_for_printer` never runs —
# and that function is where `prefer_lowest_filament`, its
# AMS-filament-backup gate (#1766) and the inventory-remain
# overrides live. Honouring the slicer unconditionally would
# silently retire all of that for every existing queue-mode
# VP on upgrade, so it's opt-in like every other queue-mode
# behaviour toggle (#2700 review).
#
# Either gate failing leaves it unset, and the scheduler's
# normal type/color re-derivation runs against whichever
# printer actually gets the job.
ams_mapping_json: str | None = None
if slicer_opts is not None and self.target_printer_id is not None and self.save_ams_mapping:
ams_mapping_json = _extract_slicer_ams_mapping_json(slicer_opts, f"[VP {self.name}]")
# `Force color match` is the user asking Bambuddy to do the
# matching strictly, against the printer's live trays. Its only
# effect on a fixed-printer item is via the per-slot
# `filament_overrides` written below, which are consumed inside
# `_compute_ams_mapping_for_printer` — the exact function a
# stored mapping skips. So when both toggles are on, the
# explicit strictness wins for *this* dispatch and the slicer's
# pick is still persisted onto the archive for later reprints,
# which is what `Save AMS mapping` actually promises (#2700
# review).
queue_ams_mapping_json = ams_mapping_json
if queue_ams_mapping_json is not None and self.queue_force_color_match:
logger.info(
"[VP %s] Saved the slicer's AMS pick to the archive but not onto the queue item(s): "
"'Force color match' is on, so the scheduler matches against live trays for this print.",
self.name,
)
queue_ams_mapping_json = None
# Parsed once for the per-plate length check in the loop below.
queue_ams_mapping = json.loads(queue_ams_mapping_json) if queue_ams_mapping_json else None
service = ArchiveService(db)
archive = await service.archive_print(
printer_id=None,
@ -844,6 +1004,14 @@ class VirtualPrinterInstance:
"source_ip": source_ip,
},
prefer_filename_for_name=prefer_filename,
# Slicer's own live AMS-slot pick -- promoted to
# `extra_data.slicer_ams_mapping` by archive_print() so a
# later reprint can reuse it. Already gated on the per-VP
# `save_ams_mapping` opt-in above. Tagged with the printer
# it was resolved against so a later reprint on a
# *different* printer knows not to reuse it (#2700 review).
slicer_ams_mapping=(json.loads(ams_mapping_json) if ams_mapping_json else None),
slicer_ams_mapping_printer_id=self.target_printer_id,
)
if archive:
logger.info("[VP %s] Archived: %s - %s", self.name, archive.id, archive.print_name)
@ -903,11 +1071,20 @@ class VirtualPrinterInstance:
if types:
required_filament_types_json = json.dumps(types)
if self.queue_force_color_match:
# Carry tray_info_idx so force_color_match can
# tell Bambu PLA variants apart (#2650). Bambu
# reports Basic/Matte/Silk all as tray_type
# "PLA"; the variant lives only in tray_info_idx
# (GFA00/GFA01/GFA06/...). A blank idx (custom or
# third-party spool) means "no variant
# constraint" and the scheduler falls back to
# type+colour.
overrides = [
{
"slot_id": r["slot_id"],
"type": r.get("type", ""),
"color": r.get("color", ""),
"tray_info_idx": r.get("tray_info_idx", ""),
"force_color_match": True,
}
for r in requirements
@ -916,6 +1093,31 @@ class VirtualPrinterInstance:
if overrides:
filament_overrides_json = json.dumps(overrides)
# The slicer's mapping is indexed by the 3MF's own
# file-global slot ids (position = slot_id - 1), so one
# array covers every plate of a multi-plate Send All —
# each plate just reads the entries for the slots it
# actually prints. What must be checked is that it
# reaches that far: a mapping shorter than this plate's
# highest slot id can't address the plate's own slots,
# and `_ensure_ams_mapping` would keep it anyway
# because it only rejects an all-unresolved mapping. Fall
# back to a computed mapping for that plate instead
# (#2700 review).
plate_ams_mapping_json = queue_ams_mapping_json
if queue_ams_mapping is not None and requirements:
max_slot_id = max((r.get("slot_id") or 0) for r in requirements)
if max_slot_id > len(queue_ams_mapping):
logger.warning(
"[VP %s] Slicer ams_mapping has %d entries but plate %s needs slot %d; "
"dropping it for this plate so the scheduler computes one from live AMS state.",
self.name,
len(queue_ams_mapping),
plate_id,
max_slot_id,
)
plate_ams_mapping_json = None
queue_item = PrintQueueItem(
printer_id=self.target_printer_id,
target_model=target_model,
@ -941,6 +1143,9 @@ class VirtualPrinterInstance:
# the same nozzle pick across plates rather than only the
# first one (mirrors the #1697 / #1188 per-plate loop fix).
nozzle_mapping=nozzle_mapping_json,
# Slicer's own live AMS-slot pick, when present —
# see `_extract_slicer_ams_mapping_json`.
ams_mapping=plate_ams_mapping_json,
)
db.add(queue_item)
await db.flush() # populate queue_item.id before logging
@ -1538,6 +1743,7 @@ class VirtualPrinterManager:
# instance silently keeps the old value until process
# restart (#1552 follow-up family).
or instance.queue_force_color_match != vp.queue_force_color_match
or instance.save_ams_mapping != vp.save_ams_mapping
or instance.gcode_injection != vp.gcode_injection
or proxy_target_changed
)
@ -1592,6 +1798,7 @@ class VirtualPrinterManager:
target_printer_id=vp.target_printer_id,
auto_dispatch=vp.auto_dispatch,
queue_force_color_match=vp.queue_force_color_match,
save_ams_mapping=vp.save_ams_mapping,
gcode_injection=vp.gcode_injection,
bind_ip=vp.bind_ip or "",
remote_interface_ip=vp.remote_interface_ip or "",

View file

@ -659,6 +659,12 @@ class MQTTBridge:
# paints those empty slots as phantom loaded filaments (#1726).
# Runs whether or not a prev cache existed — fresh pushalls also
# carry tray_exist_bits and benefit from the cleanup.
# These units carry the RAW firmware ids — this cache is what the
# slicer sees, and BambuStudio addresses the A2L's AMS-Lite as the
# physical id 16 (it sends `ams_get_rfid {ams_id: 16}` through the
# VP), so we must not normalise them to 6 the way Bambuddy's
# internal state does. `apply_tray_exist_bits` folds 16 onto the
# same bit base internally instead (#2697).
merged_ams_dict = new_state.get("ams")
if isinstance(merged_ams_dict, dict):
units = merged_ams_dict.get("ams")

View file

@ -116,6 +116,28 @@ LINEAR_RAIL_MODELS = frozenset(
)
# Models sold with a single nozzle flow variant, so a Standard / High Flow
# choice on a K-profile is meaningless there. Derived from the slicer's own
# rule (len(nozzle_volume) // len(nozzle_diameter) > 1 over the bundled Bambu
# machine presets), not from nozzle count — P1P/P1S/P2S/X1/X1C/X1E/H2S are
# single-nozzle and all carry two variants. Only the A-series has one.
SINGLE_NOZZLE_FLOW_MODELS = frozenset(
[
# Display names (uppercase, no spaces)
"A1",
"A1MINI",
"A2L",
# Internal codes
"N1", # A1 Mini
"N2S", # A1
"N9", # A2L
"A04", # A1 Mini (alternate)
"A11", # A1
"A12", # A1 Mini
]
)
# Models without any external storage (MicroSD / SD card slot).
# The A1 and A1 Mini ship with internal storage only — there is no
# firmware-side "Store sent files on external storage" toggle and no
@ -212,6 +234,36 @@ DUAL_NOZZLE_MODELS = frozenset(
)
# Models where Bambu's own firmware/UI names the enclosure fan (big_fan2 /
# airduct part id 3) "Exhaust" rather than "Chamber". On these the printer's
# touchscreen and Bambu Studio both call it the exhaust fan, and on the P2S it
# is an add-on kit rather than built-in hardware. Other enclosed models
# (X1 / P1S / H2 series) keep the "Chamber" naming.
EXHAUST_FAN_LABEL_MODELS = frozenset(
[
# Display names (uppercase, no spaces)
"P2S",
"X2D",
# Internal codes
"N7", # P2S
"N6", # X2D
]
)
def uses_exhaust_fan_label(model: str | None) -> bool:
"""Return True if this model calls the big_fan2 enclosure fan "Exhaust".
P2S/X2D name that fan "Exhaust" in Bambu's firmware/UI; everything else
enclosed calls it the chamber fan. Used so the UI badge and the API
response message agree on what the user sees.
"""
if not model:
return False
normalized = model.strip().upper().replace(" ", "").replace("-", "")
return normalized in EXHAUST_FAN_LABEL_MODELS
def has_ethernet(model: str | None) -> bool:
"""Return True if the printer model has an ethernet port."""
if not model:
@ -260,6 +312,33 @@ def is_dual_nozzle_model(model: str | None) -> bool:
return normalized in DUAL_NOZZLE_MODELS
def supports_nozzle_flow_type(model: str | None) -> bool:
"""Return True if the model offers a Standard / High Flow nozzle choice.
A K-profile is filed under a ``nozzle_id`` of the form ``HS00-0.4``
(Standard) or ``HH00-0.4`` (High Flow), so the flow type is part of the
profile's identity on any printer where both exist — and meaningless noise
on one where only a single variant is sold.
The split is NOT the nozzle count: P1S, P2S, X1C and H2S are single-nozzle
and all offer both flows. BambuStudio/OrcaSlicer derive the same capability
from the machine preset ``support_nozzle_volume()`` is
``len(nozzle_volume) // len(nozzle_diameter) > 1`` and every bundled
Bambu profile evaluated against that formula puts only the A-series on the
"one variant" side (A1 and A1 Mini at 1, A2L at 1; everything from P1P
upward at 2 or more per extruder).
Defaults to True for unknown models: offering the choice on a printer that
turns out to have one flow type costs the user a redundant dropdown, while
hiding it on one that has two makes half its calibration table
unreachable.
"""
if not model:
return True
normalized = model.strip().upper().replace(" ", "").replace("-", "")
return normalized not in SINGLE_NOZZLE_FLOW_MODELS
def get_rod_type(model: str | None) -> str | None:
"""Return the rod/rail type for a printer model.

View file

@ -702,6 +702,68 @@ def _parse_3mf_gcode_header(content: str) -> dict[str, str]:
return header
def _select_plate_gcode_name(names: list[str], plate_id: int | None) -> str | None:
"""Pick a plate's ``.gcode`` member out of a 3MF namelist.
Prefers ``plate_<id>.gcode``, then falls back to the first ``.gcode``
member so single-plate files and files from slicers that don't use the
plate naming convention still resolve.
"""
gcodes = [n for n in names if n.endswith(".gcode")]
if not gcodes:
return None
if plate_id is not None:
suffix = f"plate_{plate_id}.gcode"
for name in gcodes:
if name.endswith(suffix):
return name
return gcodes[0]
# The header block sits at the very top of the plate G-code. Read only that
# much: a sliced plate is routinely tens of megabytes and `ZipFile.read()`
# would inflate all of it to reach ~40 lines.
_HEADER_READ_LIMIT_BYTES = 64 * 1024
def extract_max_z_height_from_3mf(file_path: Path, plate_id: int | None = None) -> float | None:
"""Return the plate's ``max_z_height`` in mm, or None if not knowable.
This is the Z the toolhead sat at for the final layer the same value
Bambu's own end G-code adds its bed-drop offset to (``G1 Z{max_layer_z +
100}``). #2547 uses it to put the plate back into camera framing before the
finish photo, which is only safe because it is a height the printer was
physically at seconds earlier.
None means "don't know" and callers must treat it as such rather than
substituting a default: the file may be unreadable, carry no plate G-code,
or come from a slicer that writes no ``max_z_height`` header. Guessing a
height here would command a Z move to somewhere the nozzle has never been.
"""
try:
with zipfile.ZipFile(file_path, "r") as zf:
target = _select_plate_gcode_name(zf.namelist(), plate_id)
if target is None:
return None
with zf.open(target, "r") as fh:
head = fh.read(_HEADER_READ_LIMIT_BYTES)
except (OSError, zipfile.BadZipFile, KeyError) as e:
logger.debug("max_z_height: cannot read %s: %s", file_path, e)
return None
raw = _parse_3mf_gcode_header(head.decode("utf-8", errors="ignore")).get("max_z_height")
if raw is None:
return None
try:
value = float(raw)
except ValueError:
logger.debug("max_z_height: unusable value %r in %s", raw, file_path)
return None
# Zero or negative means the header key is present but meaningless. Passed
# on as a height it would become a move *toward* the bed, so drop it.
return value if value > 0 else None
def _substitute_placeholders(snippet: str, header: dict[str, str]) -> str:
"""Replace `{var}` placeholders with header values, leaving unknowns intact."""
@ -802,21 +864,10 @@ def inject_gcode_into_3mf(
try:
# Find the target gcode file inside the 3MF
with zipfile.ZipFile(source_path, "r") as zf:
all_gcode = [f for f in zf.namelist() if f.endswith(".gcode")]
if not all_gcode:
return None
# Try plate-specific gcode file first
target_gcode = None
plate_pattern = f"plate_{plate_id}.gcode"
for f in all_gcode:
if f.endswith(plate_pattern):
target_gcode = f
break
# Fall back to first gcode file
# Plate-specific gcode first, else the first one in the file.
target_gcode = _select_plate_gcode_name(zf.namelist(), plate_id)
if target_gcode is None:
target_gcode = all_gcode[0]
return None
# Read and modify gcode content
gcode_content = zf.read(target_gcode).decode("utf-8", errors="ignore")
@ -907,6 +958,51 @@ def extract_project_filaments_from_3mf(zf: zipfile.ZipFile) -> list[dict]:
return out
def expand_to_project_slots(zf: zipfile.ZipFile, used: list[dict]) -> list[dict]:
"""Widen a used-only filament list to one entry per project slot.
``used`` is the slice_info-derived list: only the slots whose G-code
actually consumed filament, each carrying real usage figures. That is the
right answer for print-time AMS matching, and the wrong one for the slice
modal, because the list the modal builds is **positional** index 0 is
slot 1 all the way down to the ``filament_N.json`` parts handed to the CLI.
A source whose only used slot is 4 therefore produced a single dropdown
whose pick the CLI bound to slot 1, leaving slot 4 the one the model
prints with on whatever the source had baked in (#2712).
Returns the project's slots in slot order, each flagged ``used_in_plate``.
Rows present in ``used`` are kept whole, so their usage figures, resolved
type/colour and ``tray_info_idx`` survive; the rest come from the project
configuration with zero usage. A used slot beyond the project's slot count
is appended rather than dropped the caller asked for a superset, and
silently losing the one slot that prints would be the original bug again.
``used`` is returned unchanged when the file carries no project settings
to widen against: a narrower-than-ideal list still prints correctly, an
invented one might not.
"""
project = extract_project_filaments_from_3mf(zf)
if not project:
return used
by_slot = {f["slot_id"]: f for f in used}
out: list[dict] = []
for slot in project:
known = by_slot.pop(slot["slot_id"], None)
if known is not None:
known["used_in_plate"] = True
out.append(known)
else:
slot["used_in_plate"] = False
out.append(slot)
# Anything slice_info reported that the project doesn't declare.
for leftover in by_slot.values():
leftover["used_in_plate"] = True
out.append(leftover)
out.sort(key=lambda f: f["slot_id"])
return out
def extract_support_filament_slots_from_3mf(zf: zipfile.ZipFile) -> set[int]:
"""Slots referenced by the process settings for support material.

View file

@ -203,8 +203,17 @@ async def async_client(test_engine, db_session) -> AsyncGenerator[AsyncClient, N
test_async_session = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
async def override_get_db():
# Mirror production get_db (core/database.py): commit on success,
# rollback on error. Endpoints that rely on the request-scoped
# implicit commit (e.g. create_project, which only flushes) would
# otherwise silently lose their writes in tests (#1897).
async with test_async_session() as session:
yield session
try:
yield session
await session.commit()
except BaseException:
await session.rollback()
raise
app.dependency_overrides[get_db] = override_get_db
@ -217,6 +226,9 @@ async def async_client(test_engine, db_session) -> AsyncGenerator[AsyncClient, N
patch("backend.app.core.database.async_session", test_async_session),
patch("backend.app.core.auth.async_session", test_async_session),
patch("backend.app.main.async_session", test_async_session),
# Obico endpoints load settings through the service's module-level binding;
# without this patch they'd read whatever DB the cwd resolves to (#1546).
patch("backend.app.services.obico_detection.async_session", test_async_session),
patch("backend.app.main.init_printer_connections", mock_init_printer_connections),
):
# Seed default groups for tests that need them

View file

@ -1022,6 +1022,29 @@ class TestArchivesSlimAPI:
assert "duplicates" not in item
assert "duplicate_count" not in item
@pytest.mark.asyncio
@pytest.mark.integration
async def test_slim_includes_energy_fields(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""Per-print smart-plug energy surfaces through /slim so the stats
page can include it in cost records and trends (#1432)."""
printer = await printer_factory()
await archive_factory(
printer.id,
status="completed",
cost=1.50,
energy_kwh=0.421,
energy_cost=0.063,
)
response = await async_client.get("/api/v1/archives/slim")
assert response.status_code == 200
item = response.json()[0]
assert item["energy_kwh"] == 0.421
assert item["energy_cost"] == 0.063
@pytest.mark.asyncio
@pytest.mark.integration
async def test_slim_computes_actual_time(
@ -1720,3 +1743,95 @@ class TestUploadSourceThreeMF:
assert "outside the data directory" in response.json()["detail"]
# Did not write anything under the bogus /tmp/source/ either.
assert not (Path("/tmp") / "source").exists() or not (Path("/tmp") / "source" / "totally_outside.3mf").exists() # nosec B108
class TestSoftDeletedArchivesAreExcluded:
"""Soft-deleted archives (#1343) must not leak into export or analysis (#2731).
The soft delete keeps the row so global Quick Stats can still count it, but
the archive is gone from every listing. Two consumers never got the memo:
the CSV export handed back rows the UI says do not exist, and per-project
failure analysis kept counting prints the user had deleted from the project
disagreeing with the project's own figures.
"""
@staticmethod
async def _soft_delete(db_session, archive) -> int:
from datetime import datetime, timezone
archive_id = archive.id
archive.deleted_at = datetime.now(timezone.utc)
await db_session.commit()
return archive_id
@pytest.mark.asyncio
@pytest.mark.integration
async def test_export_omits_soft_deleted_archives(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
):
printer = await printer_factory()
await archive_factory(printer.id, print_name="Kept Print")
gone = await archive_factory(printer.id, print_name="Deleted Print")
await self._soft_delete(db_session, gone)
response = await async_client.get("/api/v1/archives/export?format=csv")
assert response.status_code == 200
body = response.text
assert "Kept Print" in body
assert "Deleted Print" not in body
@pytest.mark.asyncio
@pytest.mark.integration
async def test_project_failure_analysis_omits_soft_deleted_archives(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
):
from backend.app.models.project import Project
project = Project(name="Analysis Project")
db_session.add(project)
await db_session.commit()
await db_session.refresh(project)
project_id = project.id
printer = await printer_factory()
await archive_factory(
printer.id,
print_name="Kept Failure",
status="failed",
failure_reason="bed_adhesion",
project_id=project_id,
)
gone = await archive_factory(
printer.id,
print_name="Deleted Failure",
status="failed",
failure_reason="filament_runout",
project_id=project_id,
)
await self._soft_delete(db_session, gone)
response = await async_client.get(f"/api/v1/archives/analysis/failures?project_id={project_id}")
assert response.status_code == 200
result = response.json()
assert result["failed_prints"] == 1
assert result["failures_by_reason"] == {"bed_adhesion": 1}
@pytest.mark.asyncio
@pytest.mark.integration
async def test_unscoped_failure_analysis_is_unchanged(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""Only the project-scoped path filters. Global analysis still counts
every run, including orphans, exactly as #1390 established."""
printer = await printer_factory()
gone = await archive_factory(
printer.id, print_name="Deleted Failure", status="failed", failure_reason="filament_runout"
)
await self._soft_delete(db_session, gone)
response = await async_client.get("/api/v1/archives/analysis/failures")
assert response.status_code == 200
assert response.json()["failed_prints"] == 1

View file

@ -501,6 +501,12 @@ class TestCloudRouteRegionPlumbing:
def handler(request: httpx.Request) -> httpx.Response:
captured.append(str(request.url))
# The TOTP path now performs a CSRF handshake first (#2696): it
# fetches /api/csrf and refuses to submit the code unless that call
# yields a bbl_csrf_token cookie. Mint one here so region-routing
# tests reach the TFA POST they are actually asserting on.
if request.url.path == "/api/csrf":
return httpx.Response(204, headers={"set-cookie": "bbl_csrf_token=csrf-test-token; Path=/"})
return httpx.Response(status, json=response_json)
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
@ -574,6 +580,10 @@ class TestCloudRouteRegionPlumbing:
# TOTP endpoint lives on bambulab.cn (without the api. prefix),
# NOT bambulab.com — that's exactly the bug we just fixed.
assert any("bambulab.cn/api/sign-in/tfa" in url for url in captured_urls), captured_urls
# The CSRF handshake (#2696) must follow the same origin —
# fetching a token from the global site would hand the .cn
# endpoint a cookie it never issued.
assert any("bambulab.cn/api/csrf" in url for url in captured_urls), captured_urls
assert not any("bambulab.com" in url for url in captured_urls), captured_urls
finally:
set_shared_http_client(None)

View file

@ -0,0 +1,100 @@
"""The plates endpoints must surface the designer's changed settings (#2622).
Parsing is covered in ``unit/test_design_settings.py``. What is asserted here is
the wiring: SliceModal reads ``design_overrides`` off the plates response, so a
correct parser that never reaches the payload is a feature that silently does
nothing.
"""
import json
import zipfile
from pathlib import Path
import pytest
from httpx import AsyncClient
def _designed_3mf(path: Path, *, with_deviations: bool = True) -> None:
"""A Bambu-style project 3MF, optionally carrying designer deviations."""
config = {
"print_settings_id": "0.20mm Standard @BBL A1",
"printer_settings_id": "Bambu Lab A1 0.4 nozzle",
"filament_settings_id": ["Bambu PLA Basic @BBL A1"],
"wall_loops": "5",
"outer_wall_speed": "200",
"machine_start_gcode": "G28 ; designer printer",
"different_settings_to_system": (
["wall_loops;outer_wall_speed", "", "machine_start_gcode"] if with_deviations else ["", "", ""]
),
}
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("Metadata/plate_1.gcode", "G0\n")
zf.writestr("Metadata/project_settings.config", json.dumps(config))
@pytest.fixture
def _patch_base_dir(monkeypatch, tmp_path):
from backend.app.core.config import settings
monkeypatch.setattr(settings, "base_dir", tmp_path)
return tmp_path
class TestArchivePlatesDesignOverrides:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_returns_the_process_deviations_with_classification(
self, async_client: AsyncClient, archive_factory, printer_factory, _patch_base_dir
):
_designed_3mf(_patch_base_dir / "designed.3mf")
printer = await printer_factory()
archive = await archive_factory(printer.id, filename="designed.3mf", file_path="designed.3mf")
response = await async_client.get(f"/api/v1/archives/{archive.id}/plates")
assert response.status_code == 200
overrides = response.json()["design_overrides"]
assert [o["key"] for o in overrides] == ["outer_wall_speed", "wall_loops"]
by_key = {o["key"]: o for o in overrides}
assert by_key["wall_loops"] == {"key": "wall_loops", "value": "5", "printer_coupled": False}
assert by_key["outer_wall_speed"]["printer_coupled"] is True
# The printer slot must never leak into the process list.
assert "machine_start_gcode" not in by_key
@pytest.mark.asyncio
@pytest.mark.integration
async def test_empty_for_a_file_that_changes_nothing(
self, async_client: AsyncClient, archive_factory, printer_factory, _patch_base_dir
):
_designed_3mf(_patch_base_dir / "stock.3mf", with_deviations=False)
printer = await printer_factory()
archive = await archive_factory(printer.id, filename="stock.3mf", file_path="stock.3mf")
response = await async_client.get(f"/api/v1/archives/{archive.id}/plates")
assert response.status_code == 200
assert response.json()["design_overrides"] == []
class TestLibraryPlatesDesignOverrides:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_returns_the_process_deviations(self, async_client: AsyncClient, db_session, tmp_path):
from backend.app.models.library import LibraryFile
path = tmp_path / "designed.3mf"
_designed_3mf(path)
lib_file = LibraryFile(
filename="designed.3mf",
file_path=str(path),
file_type="3mf",
file_size=path.stat().st_size,
)
db_session.add(lib_file)
await db_session.commit()
await db_session.refresh(lib_file)
response = await async_client.get(f"/api/v1/library/files/{lib_file.id}/plates")
assert response.status_code == 200
assert [o["key"] for o in response.json()["design_overrides"]] == ["outer_wall_speed", "wall_loops"]

View file

@ -542,6 +542,144 @@ class TestExternalFolderScan:
assert subfolder["external_readonly"] is True
class TestExternalFolderModifiedTime:
"""Filesystem mtime capture + recursive activity sort (#2680).
The folder tree's "sort by recent activity" and the file pane's date sort
must track the real on-disk mtime (``ls -t``), not the DB ``updated_at`` (the
scan instant, identical across a bulk scan).
"""
@staticmethod
def _set_mtime(path: Path, epoch: float) -> None:
os.utime(path, (epoch, epoch))
@pytest.fixture
async def make_folder(self, async_client, db_session):
async def _make(ext_dir: Path, name: str = "MTime Test") -> dict:
data = {
"name": name,
"external_path": str(ext_dir),
"readonly": True,
"show_hidden": False,
}
resp = await async_client.post("/api/v1/library/folders/external", json=data)
assert resp.status_code == 200
return resp.json()
return _make
@pytest.mark.asyncio
@pytest.mark.integration
async def test_scan_captures_file_fs_mtime(self, async_client, db_session, tmp_path, make_folder):
"""Each scanned file carries its real on-disk mtime, not the scan time."""
ext = tmp_path / "prints"
ext.mkdir()
old = ext / "old.3mf"
new = ext / "new.3mf"
old.write_bytes(b"a")
new.write_bytes(b"b")
# old.3mf modified 2021-01-01, new.3mf modified 2024-01-01.
self._set_mtime(old, 1609459200.0) # 2021-01-01T00:00:00Z
self._set_mtime(new, 1704067200.0) # 2024-01-01T00:00:00Z
folder = await make_folder(ext)
await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
resp = await async_client.get(f"/api/v1/library/files?folder_id={folder['id']}")
files = {f["filename"]: f for f in resp.json()}
assert files["old.3mf"]["fs_modified_at"] is not None
assert files["new.3mf"]["fs_modified_at"] is not None
# The real mtime, not "now": the 2021 file must predate the 2024 file.
assert files["old.3mf"]["fs_modified_at"] < files["new.3mf"]["fs_modified_at"]
assert files["old.3mf"]["fs_modified_at"].startswith("2021")
assert files["new.3mf"]["fs_modified_at"].startswith("2024")
@pytest.mark.asyncio
@pytest.mark.integration
async def test_rescan_refreshes_changed_file_mtime(self, async_client, db_session, tmp_path, make_folder):
"""A file edited over the mount re-sorts on the next scan (#2680)."""
ext = tmp_path / "prints"
ext.mkdir()
f = ext / "part.3mf"
f.write_bytes(b"a")
self._set_mtime(f, 1609459200.0) # 2021
folder = await make_folder(ext)
await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
# File touched later (samba edit); re-scan must pick up the new mtime.
self._set_mtime(f, 1704067200.0) # 2024
await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
resp = await async_client.get(f"/api/v1/library/files?folder_id={folder['id']}")
got = resp.json()[0]
assert got["fs_modified_at"].startswith("2024")
@pytest.mark.asyncio
@pytest.mark.integration
async def test_recursive_activity_bubbles_deep_file_to_root(self, async_client, db_session, tmp_path, make_folder):
"""A freshly-added deep file lifts every ancestor's activity (#2680).
``a`` holds only an OLD file directly but a NEW file three levels down;
``b`` holds a MIDDLE-aged file directly. Recursive bubble must rank ``a``
(newest descendant) ahead of ``b`` even though a's own direct file and
directory are older.
"""
root = tmp_path / "root"
deep = root / "a" / "x" / "y"
deep.mkdir(parents=True)
(root / "b").mkdir()
a_direct = root / "a" / "shallow.3mf"
deep_file = deep / "deep.3mf"
b_direct = root / "b" / "mid.3mf"
for p, data in ((a_direct, b"1"), (deep_file, b"2"), (b_direct, b"3")):
p.write_bytes(data)
self._set_mtime(a_direct, 1609459200.0) # 2021 (oldest)
self._set_mtime(b_direct, 1656633600.0) # 2022-07 (middle)
self._set_mtime(deep_file, 1704067200.0) # 2024 (newest, deep under a)
# Directory mtimes are all old so only the deep FILE can lift branch a.
for d in (root, root / "a", root / "a" / "x", deep, root / "b"):
self._set_mtime(d, 1609459200.0)
folder = await make_folder(root)
await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
tree = (await async_client.get("/api/v1/library/folders")).json()
top = find_folder_in_tree(tree, folder["name"])
assert top is not None
children = {c["name"]: c for c in top["children"]}
assert "a" in children and "b" in children
# Branch a's activity == the deep 2024 file; b's == its 2022 file.
assert children["a"]["latest_activity_at"] > children["b"]["latest_activity_at"]
assert children["a"]["latest_activity_at"].startswith("2024")
# The root itself bubbles up to the newest descendant anywhere inside it.
assert top["latest_activity_at"].startswith("2024")
@pytest.mark.asyncio
@pytest.mark.integration
async def test_scan_captures_folder_fs_mtime(self, async_client, db_session, tmp_path, make_folder):
"""An empty-but-recently-touched subfolder still carries a real mtime."""
root = tmp_path / "root"
sub = root / "sub"
sub.mkdir(parents=True)
# A file so the subfolder survives the empty-subfolder cleanup.
(sub / "keep.3mf").write_bytes(b"a")
self._set_mtime(sub / "keep.3mf", 1609459200.0) # 2021
self._set_mtime(sub, 1704067200.0) # dir touched 2024
folder = await make_folder(root)
await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
tree = (await async_client.get("/api/v1/library/folders")).json()
subfolder = find_folder_in_tree(tree, "sub")
assert subfolder is not None
# Dir mtime (2024) beats the single 2021 file → folder activity is 2024.
assert subfolder["latest_activity_at"].startswith("2024")
class TestExternalFolderProtections:
"""Tests for read-only protections on external folders."""

View file

@ -69,6 +69,17 @@ def _install_mock_sidecar(handler: Callable[[httpx.Request], httpx.Response]) ->
return client
def _is_slice_post(request: httpx.Request) -> bool:
"""True for the slice call itself, false for the progress polls beside it.
Since #2730 a slice is supervised by a 1 Hz poll of
``GET /slice/progress/{id}``, which shares this mock transport. Tests that
count *slice attempts* primary vs embedded-settings fallback have to
exclude those, or the count becomes a measure of how long the test took.
"""
return request.method == "POST" and request.url.path.endswith("/slice")
async def _wait_for_job(client: AsyncClient, job_id: int, timeout: float = 5.0) -> dict:
"""Poll `/api/v1/slice-jobs/{id}` until the job hits a terminal state.
@ -93,15 +104,24 @@ async def _wait_for_job(client: AsyncClient, job_id: int, timeout: float = 5.0)
@pytest.fixture
async def slice_test_setup(db_session, tmp_path):
"""Source LibraryFile + 3 LocalPresets + preferred_slicer=orcaslicer."""
async def slice_test_setup(db_session, tmp_path, monkeypatch):
"""Source LibraryFile + 3 LocalPresets + preferred_slicer=orcaslicer.
``base_dir`` is patched via ``monkeypatch`` rather than assigned and
restored by hand. ``app_settings`` is a process-wide singleton, and the
hand-rolled version only restored after the ``yield`` so anything raising
during setup (a commit, a refresh) left ``base_dir`` pointing at a
``tmp_path`` that pytest then deleted, and every later test in that xdist
worker which reads it failed. That was the cause of intermittent failures
in ``TestLibraryPathHelpers`` and ``TestArchivePlatesDesignOverrides``,
which share nothing with this module but land in the same worker.
"""
storage_dir = tmp_path / "library" / "files"
storage_dir.mkdir(parents=True, exist_ok=True)
src_path = storage_dir / "Cube.stl"
src_path.write_bytes(b"solid Cube\nendsolid\n")
original_base_dir = app_settings.base_dir
app_settings.base_dir = tmp_path
monkeypatch.setattr(app_settings, "base_dir", tmp_path)
src_file = LibraryFile(
filename="Cube.stl",
@ -137,7 +157,6 @@ async def slice_test_setup(db_session, tmp_path):
"tmp_path": tmp_path,
}
app_settings.base_dir = original_base_dir
slicer_api_module.set_shared_http_client(None)
@ -206,7 +225,7 @@ class TestSliceLibraryFile:
captured["url"] = str(request.url)
return httpx.Response(
status_code=200,
content=b"PK\x03\x04 fake-3mf",
content=_make_3mf_with_settings(), # #2671: real zip; validation rejects non-3MF bodies
headers={
"x-print-time-seconds": "656",
"x-filament-used-g": "0.94",
@ -249,7 +268,7 @@ class TestSliceLibraryFile:
captured["body"] = bytes(request.content)
return httpx.Response(
status_code=200,
content=b"PK\x03\x04 fake",
content=_make_3mf_with_settings(), # #2671: real zip; validation rejects non-3MF bodies
headers={
"x-print-time-seconds": "10",
"x-filament-used-g": "0.1",
@ -291,7 +310,7 @@ class TestSliceLibraryFile:
captured["body"] = bytes(request.content)
return httpx.Response(
status_code=200,
content=b"PK\x03\x04 fake",
content=_make_3mf_with_settings(), # #2671: real zip; validation rejects non-3MF bodies
headers={
"x-print-time-seconds": "10",
"x-filament-used-g": "0.1",
@ -406,6 +425,8 @@ class TestSliceLibraryFile:
call_count = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
if not _is_slice_post(request):
return httpx.Response(404)
call_count["n"] += 1
# First call: profile triplet present → simulate CLI 5xx
if call_count["n"] == 1:
@ -416,7 +437,7 @@ class TestSliceLibraryFile:
# Retry: no profile triplet → succeed with embedded settings
return httpx.Response(
status_code=200,
content=b"PK\x03\x04 fake-3mf",
content=_make_3mf_with_settings(), # #2671: real zip; validation rejects non-3MF bodies
headers={
"x-print-time-seconds": "100",
"x-filament-used-g": "1.0",
@ -446,7 +467,9 @@ class TestSliceLibraryFile:
# STL has no embedded settings — the CLI 5xx is terminal.
call_count = {"n": 0}
def handler(_: httpx.Request) -> httpx.Response:
def handler(request: httpx.Request) -> httpx.Response:
if not _is_slice_post(request):
return httpx.Response(404)
call_count["n"] += 1
return httpx.Response(
status_code=500,
@ -499,7 +522,7 @@ class TestSliceLibraryFile:
captured["body"] = request.content
return httpx.Response(
status_code=200,
content=b"PK\x03\x04 fake-3mf",
content=_make_3mf_with_settings(), # #2671: real zip; validation rejects non-3MF bodies
headers={
"x-print-time-seconds": "1",
"x-filament-used-g": "0",
@ -560,11 +583,13 @@ class TestSliceLibraryFile:
call_count = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
if not _is_slice_post(request):
return httpx.Response(404)
call_count["n"] += 1
captured["body"] = request.content
return httpx.Response(
status_code=200,
content=b"PK\x03\x04 fake-3mf",
content=_make_3mf_with_settings(), # #2671: real zip; validation rejects non-3MF bodies
headers={
"x-print-time-seconds": "100",
"x-filament-used-g": "1.0",
@ -607,7 +632,7 @@ class TestSliceLibraryFile:
captured["body"] = request.content
return httpx.Response(
status_code=200,
content=b"PK\x03\x04 fake-3mf",
content=_make_3mf_with_settings(), # #2671: real zip; validation rejects non-3MF bodies
headers={
"x-print-time-seconds": "1",
"x-filament-used-g": "0",
@ -769,6 +794,8 @@ class TestCrossClassSliceAllLoop:
captured_requests: list[dict] = []
def handler(request: httpx.Request) -> httpx.Response:
if not _is_slice_post(request):
return httpx.Response(404)
# Multipart bodies aren't trivially parseable here; pull
# the plate field by string search since the helper sends
# ``name="plate"`` immediately followed by the value.
@ -1455,6 +1482,8 @@ class TestSliceSlicerRejection:
call_count = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
if not _is_slice_post(request):
return httpx.Response(404)
call_count["n"] += 1
return httpx.Response(
status_code=500,
@ -1618,3 +1647,356 @@ class TestNozzleClassGuard:
if resp.status_code == 400:
detail = resp.json().get("detail", "")
assert "isn't supported" not in detail, f"guard still firing on preset path: {detail!r}"
class TestUnusedSlotSubstitutionOnSinglePlateSource:
"""#2711: a single-plate 3MF must still get its unused slots substituted.
The SliceModal omits ``plate`` entirely for single-plate and STL sources
it skips the plate picker, so ``selectedPlate`` stays null and the field
never reaches the body. The schema documents an absent plate as "plate 1",
but the substitution used to read it as "unknown plate" and skip, so every
single-plate project reached the CLI with the dropdown values of slots the
plate never paints with.
In the reported case that was a MakerWorld project declaring four filaments
while plate 1 paints with one, the other three carrying presets baked into
the source for a different printer. The CLI rejected the whole slice with
"filament preset ... (slot 1) is not compatible with printer ...", and the
modal disables unused rows so there was no way to correct it by hand.
"""
@staticmethod
def _single_plate_using_only_slot_3() -> bytes:
"""One plate, one object, painted with slot 3 — slots 1, 2 and 4 are
declared by the project but unused. Mirrors the reported file."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("3D/3dmodel.model", "<model/>")
zf.writestr(
"Metadata/project_settings.config",
json.dumps({"filament_type": ["PLA", "PLA", "PLA", "TPU"]}),
)
zf.writestr(
"Metadata/model_settings.config",
"<?xml version='1.0'?>\n<config>"
'<object id="1"><metadata key="extruder" value="3"/></object>'
'<plate><metadata key="plater_id" value="1"/>'
'<model_instance><metadata key="object_id" value="1"/>'
'<metadata key="instance_id" value="0"/></model_instance>'
"</plate></config>",
)
return buf.getvalue()
@staticmethod
def _filament_names_sent(body: bytes) -> list[str]:
"""Pull the ``name`` of each ``filamentProfile`` part, in slot order.
``slice_model`` sends one repeated ``filamentProfile`` part per slot as
``filament_N.json``; the parts stay in submission order, so a plain
scan preserves the slot mapping.
"""
names: list[str] = []
marker = b'name="filamentProfile"; filename="filament_'
pos = body.find(marker)
while pos != -1:
start = body.find(b"{", pos)
end = body.find(b"\r\n", start)
names.append(json.loads(body[start:end].decode("utf-8"))["name"])
pos = body.find(marker, end)
return names
@pytest.mark.asyncio
@pytest.mark.integration
async def test_unused_slots_are_substituted_when_the_body_omits_plate(
self, async_client: AsyncClient, db_session, slice_test_setup
):
tmp_path = slice_test_setup["tmp_path"]
src = tmp_path / "library" / "files" / "train.3mf"
src.write_bytes(self._single_plate_using_only_slot_3())
threemf = LibraryFile(
filename="train.3mf",
file_path=str(src.relative_to(tmp_path)),
file_type="3mf",
file_size=src.stat().st_size,
)
db_session.add(threemf)
# Four distinguishable filament presets, one per project slot. Only
# slot 3's is compatible with the target in the reported scenario.
slots = []
for i in range(1, 5):
p = LocalPreset(
name=f"slot{i}",
preset_type="filament",
source="orcaslicer",
setting=json.dumps({"name": f"slot{i}", "type": "filament"}),
)
db_session.add(p)
slots.append(p)
await db_session.commit()
await db_session.refresh(threemf)
for p in slots:
await db_session.refresh(p)
captured: list[list[str]] = []
def handler(request: httpx.Request) -> httpx.Response:
if not _is_slice_post(request):
return httpx.Response(404)
captured.append(self._filament_names_sent(request.content))
return httpx.Response(
status_code=200,
content=_make_3mf_with_settings(),
headers={
"x-print-time-seconds": "100",
"x-filament-used-g": "1.0",
"x-filament-used-mm": "100",
},
)
_install_mock_sidecar(handler)
response = await async_client.post(
f"/api/v1/library/files/{threemf.id}/slice",
json={
"printer_preset": {"source": "local", "id": str(slice_test_setup["printer_id"])},
"process_preset": {"source": "local", "id": str(slice_test_setup["process_id"])},
"filament_presets": [{"source": "local", "id": str(p.id)} for p in slots],
# No "plate" — exactly what the modal sends for a single-plate
# source. This is the whole point of the test.
},
)
assert response.status_code == 202, response.text
final = await _wait_for_job(async_client, response.json()["job_id"])
assert final["status"] == "completed", final
assert captured, "sidecar was never called"
# Every slot carries slot 3's profile: the array length stays intact
# (the source's per-slot references depend on it) while nothing the
# plate doesn't print with can fail the CLI's validators.
assert captured[0] == ["slot3", "slot3", "slot3", "slot3"], captured[0]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_slice_all_keeps_every_slot(self, async_client: AsyncClient, db_session, slice_test_setup):
"""``plate=0`` is the all-plates sentinel, so nothing is unused.
It reaches the same call site, and plate ids are 1-indexed the
geometry lookup for plate 0 matches nothing. Without an explicit
exclusion the project's support-filament slot would be the only
member of the used set and would be copied over every colour.
"""
tmp_path = slice_test_setup["tmp_path"]
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("3D/3dmodel.model", "<model/>")
zf.writestr(
"Metadata/project_settings.config",
json.dumps(
{
"enable_support": "1",
"support_filament": "4",
"support_interface_filament": "4",
"filament_type": ["PLA", "PLA", "PLA", "PVA"],
}
),
)
zf.writestr(
"Metadata/model_settings.config",
"<?xml version='1.0'?>\n<config>"
'<object id="1"><metadata key="extruder" value="1"/></object>'
'<object id="2"><metadata key="extruder" value="2"/></object>'
'<plate><metadata key="plater_id" value="1"/>'
'<model_instance><metadata key="object_id" value="1"/></model_instance></plate>'
'<plate><metadata key="plater_id" value="2"/>'
'<model_instance><metadata key="object_id" value="2"/></model_instance></plate>'
"</config>",
)
src = tmp_path / "library" / "files" / "multi.3mf"
src.write_bytes(buf.getvalue())
threemf = LibraryFile(
filename="multi.3mf",
file_path=str(src.relative_to(tmp_path)),
file_type="3mf",
file_size=src.stat().st_size,
)
db_session.add(threemf)
slots = []
for i in range(1, 5):
p = LocalPreset(
name=f"slot{i}",
preset_type="filament",
source="orcaslicer",
setting=json.dumps({"name": f"slot{i}", "type": "filament"}),
)
db_session.add(p)
slots.append(p)
await db_session.commit()
await db_session.refresh(threemf)
for p in slots:
await db_session.refresh(p)
captured: list[list[str]] = []
def handler(request: httpx.Request) -> httpx.Response:
if not _is_slice_post(request):
return httpx.Response(404)
captured.append(self._filament_names_sent(request.content))
return httpx.Response(
status_code=200,
content=_make_3mf_with_settings(),
headers={
"x-print-time-seconds": "100",
"x-filament-used-g": "1.0",
"x-filament-used-mm": "100",
},
)
_install_mock_sidecar(handler)
response = await async_client.post(
f"/api/v1/library/files/{threemf.id}/slice",
json={
"printer_preset": {"source": "local", "id": str(slice_test_setup["printer_id"])},
"process_preset": {"source": "local", "id": str(slice_test_setup["process_id"])},
"filament_presets": [{"source": "local", "id": str(p.id)} for p in slots],
"plate": 0,
},
)
assert response.status_code == 202, response.text
final = await _wait_for_job(async_client, response.json()["job_id"])
assert final["status"] == "completed", final
assert captured, "sidecar was never called"
assert captured[0] == ["slot1", "slot2", "slot3", "slot4"], captured[0]
class TestFilamentRequirementsFullSlots:
"""#2712: what the slice modal is handed must be positional.
The modal's filament list maps index 0 to slot 1, and the backend forwards
it in that order as ``filament_1.json``..``filament_N.json``. A MakerWorld
source that ships slice_info and paints with slot 4 alone therefore has to
present four rows a one-row list binds the user's pick to slot 1, and
slot 4 slices with whatever the source had baked in. Picking PETG produced
a PLA print, and the print dialog then correctly refused to match PETG.
Print-time AMS matching shares this endpoint and needs the opposite: only
the slots the plate consumes, so it doesn't demand spools for slots the
G-code never touches. Hence the opt-in flag rather than a shape change.
"""
@staticmethod
def _sliced_source_using_only_slot_4() -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("3D/3dmodel.model", "<model/>")
zf.writestr(
"Metadata/project_settings.config",
json.dumps(
{
"filament_type": ["PLA", "PLA", "PLA", "PLA"],
"filament_colour": ["#38CC0A", "#161616", "#898989", "#898989"],
}
),
)
# MakerWorld ships slice_info without plate G-code, which is what
# sends this file down the "already sliced" branch.
zf.writestr(
"Metadata/slice_info.config",
"<?xml version='1.0'?>\n<config><plate>"
"<metadata key='index' value='1'/>"
"<filament id='4' tray_info_idx='GFL99' type='PLA' color='#898989'"
" used_m='35.51' used_g='105.92'/>"
"</plate></config>",
)
return buf.getvalue()
async def _make_file(self, db_session, tmp_path) -> int:
src = tmp_path / "library" / "files" / "tunnel.3mf"
src.write_bytes(self._sliced_source_using_only_slot_4())
lib = LibraryFile(
filename="tunnel.3mf",
file_path=str(src.relative_to(tmp_path)),
file_type="3mf",
file_size=src.stat().st_size,
)
db_session.add(lib)
await db_session.commit()
await db_session.refresh(lib)
return lib.id
@pytest.mark.asyncio
@pytest.mark.integration
async def test_full_slots_returns_one_row_per_project_slot(
self, async_client: AsyncClient, db_session, slice_test_setup
):
file_id = await self._make_file(db_session, slice_test_setup["tmp_path"])
r = await async_client.get(f"/api/v1/library/files/{file_id}/filament-requirements?plate_id=1&full_slots=true")
assert r.status_code == 200, r.text
filaments = r.json()["filaments"]
assert [f["slot_id"] for f in filaments] == [1, 2, 3, 4]
# Only slot 4 prints, so only its row is selectable in the modal.
assert [f["used_in_plate"] for f in filaments] == [False, False, False, True]
# The used row keeps what the slice actually reported.
assert filaments[3]["used_grams"] == 105.9
assert filaments[3]["tray_info_idx"] == "GFL99"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_the_print_path_still_gets_only_the_used_slot(
self, async_client: AsyncClient, db_session, slice_test_setup
):
"""Without the flag the response must be byte-for-byte what it was.
PrintModal drives AMS matching off this; widening it would ask the
user to load three spools the print never touches.
"""
file_id = await self._make_file(db_session, slice_test_setup["tmp_path"])
r = await async_client.get(f"/api/v1/library/files/{file_id}/filament-requirements?plate_id=1")
assert r.status_code == 200, r.text
filaments = r.json()["filaments"]
assert [f["slot_id"] for f in filaments] == [4]
assert filaments[0]["used_in_plate"] is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_unsliced_sources_are_unaffected_by_the_flag(
self, async_client: AsyncClient, db_session, slice_test_setup
):
"""Those already returned the full project list; the flag must not
double-handle them or change what the modal has been getting."""
tmp_path = slice_test_setup["tmp_path"]
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("3D/3dmodel.model", "<model/>")
zf.writestr(
"Metadata/project_settings.config",
json.dumps({"filament_type": ["PLA", "PETG"], "filament_colour": ["#000000", "#FFFFFF"]}),
)
src = tmp_path / "library" / "files" / "raw.3mf"
src.write_bytes(buf.getvalue())
lib = LibraryFile(
filename="raw.3mf",
file_path=str(src.relative_to(tmp_path)),
file_type="3mf",
file_size=src.stat().st_size,
)
db_session.add(lib)
await db_session.commit()
await db_session.refresh(lib)
with_flag = await async_client.get(
f"/api/v1/library/files/{lib.id}/filament-requirements?plate_id=1&full_slots=true"
)
without = await async_client.get(f"/api/v1/library/files/{lib.id}/filament-requirements?plate_id=1")
assert with_flag.status_code == 200 and without.status_code == 200
assert with_flag.json()["filaments"] == without.json()["filaments"]
assert [f["slot_id"] for f in with_flag.json()["filaments"]] == [1, 2]

View file

@ -96,6 +96,35 @@ class TestLocalLoginGate:
)
assert response.status_code == 200, response.text
@pytest.mark.asyncio
@pytest.mark.integration
async def test_unrecognized_env_value_does_not_500_the_login_path(
self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
):
"""The recovery bypass reads BAMBUDDY_LOCAL_LOGIN on the request path, so
an unrecognized value (BAMBUDDY_LOCAL_LOGIN=on) must fall back to "off",
never raise -- env_bool is strict for the startup OIDC reader but lenient
here. A raise would 500 the very endpoint the bypass exists to keep open."""
await _enable_auth(async_client, "gateonval")
await _set_setting(db_session, "local_login_enabled", "false")
monkeypatch.setenv("BAMBUDDY_LOCAL_LOGIN", "on")
response = await async_client.post(
"/api/v1/auth/login",
json={"username": "gateonval", "password": "GatePass1!"},
)
# Bypass stays off (same 401 as no env var), and crucially not a 500.
assert response.status_code == 401, response.text
def test_the_bypass_var_is_registered_in_the_typo_guard(self):
"""config.py logs "possible typo" for any unregistered BAMBUDDY_* var.
Unregistered, this one tells an operator who is locked out and following
the documented recovery that the variable they just set is not real --
while the same line lists every BAMBUDDY_OIDC_* var as legitimate."""
from backend.app.core.config import _INTENTIONAL_UNSETTINGS
assert "BAMBUDDY_LOCAL_LOGIN" in _INTENTIONAL_UNSETTINGS
@pytest.mark.asyncio
@pytest.mark.integration
async def test_forgot_password_rejected_when_local_disabled(

View file

@ -8,7 +8,8 @@ hardcoded 5s read timeout by pre-populating a cache before issuing the ML call.
import pytest
from httpx import AsyncClient
from backend.app.services.obico_detection import _frame_cache, stash_frame
from backend.app.services.obico_detection import _frame_cache, obico_detection_service, stash_frame
from backend.app.services.obico_smoothing import PrintState
FAKE_JPEG = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xff\xd9"
@ -69,3 +70,75 @@ class TestObicoCachedFrame:
response = await async_client.get(f"/api/v1/obico/cached-frame/{nonce}")
assert response.status_code == 200
assert "no-store" in response.headers.get("cache-control", "")
class TestObicoPrinterStatus:
"""The lightweight /obico/printer-status endpoint for printer-card badges (#1546)."""
@pytest.fixture(autouse=True)
def clear_detection_state(self):
obico_detection_service._states.clear()
obico_detection_service._last_class.clear()
obico_detection_service._last_error = None
yield
obico_detection_service._states.clear()
obico_detection_service._last_class.clear()
obico_detection_service._last_error = None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_returns_per_printer_classification(self, async_client: AsyncClient):
state = PrintState()
state.update(0.5)
obico_detection_service._states[1] = state
obico_detection_service._last_class[1] = "warning"
response = await async_client.get("/api/v1/obico/printer-status")
assert response.status_code == 200
data = response.json()
assert "enabled" in data
# None = all printers monitored (no obico_enabled_printers subset configured)
assert data["monitored_printers"] is None
entry = data["per_printer"]["1"]
assert entry["class"] == "warning"
assert entry["frame_count"] == 1
assert isinstance(entry["score"], float)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_empty_when_nothing_monitored(self, async_client: AsyncClient):
response = await async_client.get("/api/v1/obico/printer-status")
assert response.status_code == 200
assert response.json()["per_printer"] == {}
@pytest.mark.asyncio
@pytest.mark.integration
async def test_monitored_subset_is_returned(self, async_client: AsyncClient):
"""A configured obico_enabled_printers subset surfaces (as a sorted list) so
the frontend can show the idle badge only on monitored printers."""
update = await async_client.put("/api/v1/settings/", json={"obico_enabled_printers": "[3, 1]"})
assert update.status_code == 200
try:
response = await async_client.get("/api/v1/obico/printer-status")
assert response.json()["monitored_printers"] == [1, 3]
finally:
await async_client.put("/api/v1/settings/", json={"obico_enabled_printers": ""})
@pytest.mark.asyncio
@pytest.mark.integration
async def test_last_error_is_surfaced(self, async_client: AsyncClient):
"""The badge modal shows the service's last error (auth disabled in the
test env, so the settings:read gate on the field is open)."""
obico_detection_service._last_error = "Failed to capture snapshot for printer 1"
response = await async_client.get("/api/v1/obico/printer-status")
assert response.json()["last_error"] == "Failed to capture snapshot for printer 1"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_does_not_leak_settings(self, async_client: AsyncClient):
"""Unlike /obico/status, this endpoint is readable with printers:read only,
so it must not expose the ML URL or other configuration."""
response = await async_client.get("/api/v1/obico/printer-status")
data = response.json()
for key in ("ml_url", "action", "history", "poll_interval", "external_url_configured"):
assert key not in data

View file

@ -0,0 +1,801 @@
"""Upserting the env-managed OIDC provider (#2593).
Startup applies BAMBUDDY_OIDC_* to the database. The row is updated in place,
never delete-recreated: user_oidc_links.provider_id is FK ON DELETE CASCADE, so
recreating the provider would silently unlink every account bound to it.
"""
from __future__ import annotations
import logging
import os
import pytest
from sqlalchemy import select
from backend.app.core.oidc_env import apply_env_oidc_provider
from backend.app.models.oidc_provider import OIDCProvider
REQUIRED = {
"BAMBUDDY_OIDC_NAME": "Keycloak",
"BAMBUDDY_OIDC_ISSUER_URL": "https://sso.example.com/realms/main",
"BAMBUDDY_OIDC_CLIENT_ID": "bambuddy",
"BAMBUDDY_OIDC_CLIENT_SECRET": "s3cr3t",
}
ALL_VARS = (
*REQUIRED,
"BAMBUDDY_OIDC_SCOPES",
"BAMBUDDY_OIDC_ENABLED",
"BAMBUDDY_OIDC_AUTO_CREATE_USERS",
"BAMBUDDY_OIDC_AUTO_LINK_EXISTING",
"BAMBUDDY_OIDC_EMAIL_CLAIM",
"BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED",
"BAMBUDDY_OIDC_ICON_URL",
"BAMBUDDY_OIDC_AUTOLOGIN",
"BAMBUDDY_OIDC_DEFAULT_GROUP",
)
@pytest.fixture(autouse=True)
def clean_env(monkeypatch):
for key in ALL_VARS:
monkeypatch.delenv(key, raising=False)
def _configure(monkeypatch, **overrides):
for key, value in REQUIRED.items():
monkeypatch.setenv(key, value)
for key, value in overrides.items():
monkeypatch.setenv(key, value)
async def _env_provider(db_session) -> OIDCProvider | None:
result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.is_env_managed.is_(True)))
return result.scalar_one_or_none()
@pytest.mark.asyncio
async def test_creates_the_provider_from_env(db_session, monkeypatch):
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
provider = await _env_provider(db_session)
assert provider is not None
assert provider.name == "Keycloak"
assert provider.client_id == "bambuddy"
assert provider.is_env_managed is True
assert provider.client_secret == "s3cr3t" # property decrypts
@pytest.mark.asyncio
async def test_a_changed_var_updates_the_same_row(db_session, monkeypatch):
"""The id must survive: user_oidc_links references it with ON DELETE
CASCADE, so a delete-recreate would unlink every bound account."""
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
original_id = (await _env_provider(db_session)).id
monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_ID", "rotated")
await apply_env_oidc_provider(db_session)
provider = await _env_provider(db_session)
assert provider.id == original_id
assert provider.client_id == "rotated"
@pytest.mark.asyncio
async def test_removing_the_env_config_disables_but_keeps_the_row(db_session, monkeypatch):
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
original_id = (await _env_provider(db_session)).id
for key in ALL_VARS:
monkeypatch.delenv(key, raising=False)
await apply_env_oidc_provider(db_session)
# Looked up by name, not by the flag: releasing the provider clears the flag,
# and the point of this test is that the ROW survives either way.
result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "Keycloak"))
provider = result.scalar_one_or_none()
assert provider is not None, "deleting would cascade away every account link"
assert provider.id == original_id
assert provider.is_enabled is False
@pytest.mark.asyncio
async def test_env_autologin_clears_it_on_other_providers(db_session, monkeypatch):
"""Only one provider may be the autologin target; the env one wins."""
ui_provider = OIDCProvider(
name="UI provider",
issuer_url="https://other.example.com",
client_id="ui",
is_autologin=True,
)
ui_provider.client_secret = "ui-secret"
db_session.add(ui_provider)
await db_session.commit()
_configure(monkeypatch, BAMBUDDY_OIDC_AUTOLOGIN="true")
await apply_env_oidc_provider(db_session)
await db_session.refresh(ui_provider)
assert (await _env_provider(db_session)).is_autologin is True
assert ui_provider.is_autologin is False
@pytest.mark.asyncio
async def test_a_ui_provider_is_otherwise_left_alone(db_session, monkeypatch):
ui_provider = OIDCProvider(name="UI provider", issuer_url="https://other.example.com", client_id="ui")
ui_provider.client_secret = "ui-secret"
db_session.add(ui_provider)
await db_session.commit()
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
await db_session.refresh(ui_provider)
assert ui_provider.is_env_managed is False
assert ui_provider.is_enabled is True
assert ui_provider.client_id == "ui"
@pytest.mark.asyncio
async def test_an_unsafe_auto_link_config_is_skipped_not_raised(db_session, monkeypatch):
"""auto-link + unverified email is the SEC-1 account-takeover shape. The
schema rejects it for the UI, and env config must not be a way around that
-- but a bad variable must not stop the app from booting either."""
_configure(
monkeypatch,
BAMBUDDY_OIDC_AUTO_LINK_EXISTING="true",
BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED="false",
)
await apply_env_oidc_provider(db_session)
assert await _env_provider(db_session) is None
@pytest.mark.asyncio
async def test_a_rejected_config_never_logs_the_client_secret(db_session, monkeypatch, caplog):
"""client_secret has max_length=512, so an over-long value raises
string_too_long. The rejection must be logged without the value: str(exc)
embeds input_value=..., which would leak the secret (no-secrets-in-logs)."""
secret = "S3CR3T" * 100 # > 512 chars -> ValidationError on client_secret
_configure(monkeypatch, BAMBUDDY_OIDC_CLIENT_SECRET=secret)
with caplog.at_level(logging.ERROR):
await apply_env_oidc_provider(db_session)
assert await _env_provider(db_session) is None # rejected, not booted-through
assert "rejected" in caplog.text # the rejection was actually logged
assert secret not in caplog.text
assert "S3CR3T" not in caplog.text # not even a fragment of the value
# --- an unrecognized boolean is rejected, not guessed --------------------------
# `_env_bool` used to return the default for anything outside {true,1,yes}, so
# BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED=on silently read as OFF and
# BAMBUDDY_OIDC_ENABLED=on silently disabled the provider. Strict parsing
# refuses the config instead -- through the same clean path a bad
# DEFAULT_GROUP or a ValidationError already uses, so a typo never releases a
# provider that was running fine.
@pytest.mark.asyncio
async def test_an_unrecognized_require_email_verified_leaves_a_running_provider_intact(db_session, monkeypatch, caplog):
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
original = await _env_provider(db_session)
original_id, original_enabled = original.id, original.is_enabled
monkeypatch.setenv("BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED", "on")
with caplog.at_level(logging.ERROR):
await apply_env_oidc_provider(db_session)
provider = await _env_provider(db_session)
assert provider is not None, "a typo must not release the provider"
assert provider.id == original_id
assert provider.is_enabled == original_enabled
assert provider.is_env_managed is True
assert "rejected" in caplog.text
assert "BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED" in caplog.text
@pytest.mark.asyncio
async def test_an_unrecognized_enabled_leaves_a_running_provider_intact(db_session, monkeypatch, caplog):
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
original = await _env_provider(db_session)
original_id, original_enabled = original.id, original.is_enabled
monkeypatch.setenv("BAMBUDDY_OIDC_ENABLED", "on")
with caplog.at_level(logging.ERROR):
await apply_env_oidc_provider(db_session)
provider = await _env_provider(db_session)
assert provider is not None, "a typo must not release the provider"
assert provider.id == original_id
assert provider.is_enabled == original_enabled
assert provider.is_env_managed is True
assert "rejected" in caplog.text
assert "BAMBUDDY_OIDC_ENABLED" in caplog.text
@pytest.mark.asyncio
async def test_a_non_validation_error_is_survivable_and_leaks_nothing(db_session, monkeypatch, caplog):
"""The generic except branch handles anything that isn't a ValidationError
(e.g. a library call raising mid-construction). It must not stop boot and,
since such a message could carry a configured value, must log only the
exception class -- never str(exc)."""
# oidc_env imports OIDCProviderCreate inside the function (to avoid an
# import cycle), so patch it at its source module, not on oidc_env.
import backend.app.schemas.auth as auth_schemas
def _raise(**_kwargs):
raise RuntimeError("boom leaked-secret")
monkeypatch.setattr(auth_schemas, "OIDCProviderCreate", _raise)
_configure(monkeypatch, BAMBUDDY_OIDC_CLIENT_SECRET="leaked-secret")
with caplog.at_level(logging.ERROR):
await apply_env_oidc_provider(db_session) # must not raise
assert await _env_provider(db_session) is None
assert "could not be applied" in caplog.text
assert "RuntimeError" in caplog.text # class is logged...
assert "leaked-secret" not in caplog.text # ...but nothing from the message
@pytest.mark.asyncio
async def test_a_commit_failure_is_survivable_and_leaks_nothing(db_session, monkeypatch, caplog):
"""The upsert's db.execute/db.commit calls sit outside the inner
ValidationError guard -- a Postgres blip or a SQLite WAL lock at startup
must not propagate out of the lifespan either. Only the exception class
may be logged, never str(exc), since a DB error message can echo a
configured value."""
async def _raise_on_commit():
raise RuntimeError("database is locked")
monkeypatch.setattr(db_session, "commit", _raise_on_commit)
_configure(monkeypatch, BAMBUDDY_OIDC_CLIENT_SECRET="leaked-secret")
with caplog.at_level(logging.ERROR):
await apply_env_oidc_provider(db_session) # must not raise
assert "could not be applied" in caplog.text
assert "RuntimeError" in caplog.text # class is logged...
assert "leaked-secret" not in caplog.text # ...but nothing from the message
@pytest.mark.asyncio
async def test_a_failing_rollback_is_also_survivable(db_session, monkeypatch, caplog):
"""The handler rolls back after a failed commit -- but rollback on a wedged
connection can raise too, and 'never raises' has to hold for that as well
or the boot dies on the recovery path. The rollback is suppressed."""
async def _raise_on_commit():
raise RuntimeError("database is locked")
async def _raise_on_rollback():
raise RuntimeError("connection is closed")
monkeypatch.setattr(db_session, "commit", _raise_on_commit)
monkeypatch.setattr(db_session, "rollback", _raise_on_rollback)
_configure(monkeypatch, BAMBUDDY_OIDC_CLIENT_SECRET="leaked-secret")
with caplog.at_level(logging.ERROR):
await apply_env_oidc_provider(db_session) # must not raise, even here
assert "could not be applied" in caplog.text
assert "leaked-secret" not in caplog.text
@pytest.mark.asyncio
async def test_applying_twice_without_changes_is_a_no_op(db_session, monkeypatch):
"""Every boot re-applies; the second run must not create a second row."""
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
await apply_env_oidc_provider(db_session)
result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.is_env_managed.is_(True)))
assert len(result.scalars().all()) == 1
# --- identity is the name, not the flag ---------------------------------------
# The provider is looked up by BAMBUDDY_OIDC_NAME, which is unique on the table.
# Matching on is_env_managed instead made three things impossible: adopting a
# provider that already carries the name (the insert hit the unique constraint
# and took startup down with it), releasing the provider when the config goes
# away, and finding it again afterwards.
@pytest.mark.asyncio
async def test_a_name_collision_adopts_the_existing_provider(db_session, monkeypatch):
"""An operator who names the env provider after one they created in the UI
must not end up with an app that refuses to boot."""
ui_provider = OIDCProvider(name="Keycloak", issuer_url="https://old.example.com", client_id="ui-client")
ui_provider.client_secret = "ui-secret"
db_session.add(ui_provider)
await db_session.commit()
original_id = ui_provider.id
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
provider = await _env_provider(db_session)
assert provider is not None
assert provider.id == original_id, "adopted, not duplicated"
assert provider.client_id == "bambuddy"
result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "Keycloak"))
assert len(result.scalars().all()) == 1
@pytest.mark.asyncio
async def test_adopting_a_ui_provider_logs_a_distinct_warning(db_session, monkeypatch, caplog):
"""Overwriting a UI-created provider in place is a bigger deal than a
routine re-apply -- it must not be silent at the same INFO level."""
ui_provider = OIDCProvider(name="Keycloak", issuer_url="https://old.example.com", client_id="ui-client")
ui_provider.client_secret = "ui-secret"
db_session.add(ui_provider)
await db_session.commit()
_configure(monkeypatch)
with caplog.at_level(logging.INFO):
await apply_env_oidc_provider(db_session)
warnings = [r for r in caplog.records if r.levelname == "WARNING"]
assert any("adopted" in r.message for r in warnings)
@pytest.mark.asyncio
async def test_a_routine_reapply_does_not_log_an_adoption_warning(db_session, monkeypatch, caplog):
"""The same provider re-applying on the next boot is not an adoption --
it was already env-managed."""
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
caplog.clear()
with caplog.at_level(logging.INFO):
await apply_env_oidc_provider(db_session)
warnings = [r for r in caplog.records if r.levelname == "WARNING"]
assert not any("adopted" in r.message for r in warnings)
@pytest.mark.asyncio
async def test_removing_the_config_releases_the_provider_to_the_ui(db_session, monkeypatch):
"""Nothing manages it any more, so the API must stop refusing edits and
deletes -- otherwise the row is a dead end only reachable via the database."""
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
for key in ALL_VARS:
monkeypatch.delenv(key, raising=False)
await apply_env_oidc_provider(db_session)
result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "Keycloak"))
provider = result.scalar_one()
assert provider.is_enabled is False
assert provider.is_env_managed is False
@pytest.mark.asyncio
async def test_restoring_the_config_finds_the_same_row_again(db_session, monkeypatch):
"""The account links hang off this row; a second provider would orphan them."""
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
original_id = (await _env_provider(db_session)).id
for key in ALL_VARS:
monkeypatch.delenv(key, raising=False)
await apply_env_oidc_provider(db_session)
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
provider = await _env_provider(db_session)
assert provider.id == original_id
assert provider.is_enabled is True
@pytest.mark.asyncio
async def test_the_issuer_and_client_can_change_under_the_same_name(db_session, monkeypatch):
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
original_id = (await _env_provider(db_session)).id
monkeypatch.setenv("BAMBUDDY_OIDC_ISSUER_URL", "https://sso.example.com/realms/other")
monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_ID", "rotated")
await apply_env_oidc_provider(db_session)
provider = await _env_provider(db_session)
assert provider.id == original_id
assert provider.issuer_url == "https://sso.example.com/realms/other"
assert provider.client_id == "rotated"
# --- a rename must not leave the old row managed -------------------------------
# Identity is the name, so renaming BAMBUDDY_OIDC_NAME matches nothing and
# creates a second row. Leaving the flag on the first one is what makes that
# fatal: it stays enabled with a stale issuer and secret on the login page, the
# API refuses every edit/disable/delete on it (409), and the release path's
# scalar_one_or_none() then raises MultipleResultsFound out of the lifespan --
# the app stops booting. Both states are reachable by ordinary config edits.
async def _env_managed(db_session) -> list[OIDCProvider]:
result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.is_env_managed.is_(True)))
return list(result.scalars().all())
@pytest.mark.asyncio
async def test_renaming_the_provider_releases_the_row_it_managed_before(db_session, monkeypatch):
_configure(monkeypatch, BAMBUDDY_OIDC_AUTOLOGIN="true")
await apply_env_oidc_provider(db_session)
old_id = (await _env_provider(db_session)).id
monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
await apply_env_oidc_provider(db_session)
managed = await _env_managed(db_session)
assert [p.name for p in managed] == ["Authentik"], "exactly one row may carry the flag"
old = (await db_session.execute(select(OIDCProvider).where(OIDCProvider.id == old_id))).scalar_one()
# Released, not deleted -- user_oidc_links.provider_id cascades.
assert old.is_env_managed is False
assert old.is_enabled is False, "a stale issuer must not stay on the login page"
assert old.is_autologin is False
@pytest.mark.asyncio
async def test_boot_survives_removing_the_config_after_a_rename(db_session, monkeypatch):
"""The MultipleResultsFound path: rename, then unset. Must not raise."""
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
await apply_env_oidc_provider(db_session)
for key in ALL_VARS:
monkeypatch.delenv(key, raising=False)
await apply_env_oidc_provider(db_session) # must not raise
assert await _env_managed(db_session) == []
names = (await db_session.execute(select(OIDCProvider.name))).scalars().all()
assert sorted(names) == ["Authentik", "Keycloak"], "both rows survive, both released"
@pytest.mark.asyncio
async def test_every_managed_row_is_released_not_just_one(db_session, monkeypatch):
"""The upsert's sweep should keep this at one row. Should is not enforced by
the schema, and the cost of being wrong is the whole release path raising
MultipleResultsFound out of the lifespan -- so it releases what it finds."""
for name in ("Keycloak", "Authentik"):
stale = OIDCProvider(
name=name,
issuer_url="https://sso.example.com/realms/main",
client_id="bambuddy",
is_env_managed=True,
)
stale.client_secret = "s3cr3t"
db_session.add(stale)
await db_session.commit()
await apply_env_oidc_provider(db_session) # no vars set -> release path
assert await _env_managed(db_session) == []
@pytest.mark.asyncio
async def test_releasing_the_provider_clears_autologin(db_session, monkeypatch):
"""is_enabled and is_env_managed alone leave a UI-editable row carrying a
latent autologin claim: update_oidc_provider only runs the exclusivity
sweep when a request sets is_autologin=True, so merely re-enabling this row
makes it the autologin target again."""
_configure(monkeypatch, BAMBUDDY_OIDC_AUTOLOGIN="true")
await apply_env_oidc_provider(db_session)
assert (await _env_provider(db_session)).is_autologin is True
for key in ALL_VARS:
monkeypatch.delenv(key, raising=False)
await apply_env_oidc_provider(db_session)
released = (await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "Keycloak"))).scalar_one()
assert released.is_autologin is False
# --- default group by name -----------------------------------------------------
# Group ids are not stable across installs, so a declarative deployment cannot
# name one by id. Without this, every auto-created user falls back to Viewers
# (routes/mfa.py) and the env lock means the UI cannot correct the provider.
async def _group(db_session, name: str):
from backend.app.models.group import Group
group = Group(name=name, description=f"Test group {name}")
db_session.add(group)
await db_session.commit()
return group
@pytest.mark.asyncio
async def test_the_default_group_is_resolved_by_name(db_session, monkeypatch):
group = await _group(db_session, "Operators")
_configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="Operators")
await apply_env_oidc_provider(db_session)
assert (await _env_provider(db_session)).default_group_id == group.id
@pytest.mark.asyncio
async def test_an_unknown_group_name_is_rejected_rather_than_defaulted(db_session, monkeypatch, caplog):
"""Silently falling back to Viewers is how a typo mints under-privileged
users for weeks. The API answers 400 for a default_group_id that does not
exist; env config gets the same answer, logged and survivable."""
_configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="Nope")
with caplog.at_level(logging.ERROR):
await apply_env_oidc_provider(db_session)
assert await _env_provider(db_session) is None
assert "BAMBUDDY_OIDC_DEFAULT_GROUP" in caplog.text
assert "Nope" in caplog.text
@pytest.mark.asyncio
async def test_an_unknown_group_name_leaves_the_previous_provider_intact(db_session, monkeypatch):
"""Rejection happens before the upsert, so the running config survives a
bad edit -- the provider keeps working until the operator fixes the name."""
group = await _group(db_session, "Operators")
_configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="Operators")
await apply_env_oidc_provider(db_session)
monkeypatch.setenv("BAMBUDDY_OIDC_DEFAULT_GROUP", "Typo")
await apply_env_oidc_provider(db_session)
provider = await _env_provider(db_session)
assert provider is not None
assert provider.default_group_id == group.id
@pytest.mark.asyncio
async def test_no_group_variable_leaves_the_default_group_unset(db_session, monkeypatch):
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
assert (await _env_provider(db_session)).default_group_id is None
@pytest.mark.asyncio
async def test_removing_the_group_variable_clears_the_default_group(db_session, monkeypatch):
"""The environment is the whole truth for this row; a group that is no
longer declared must not linger, since the lock blocks removing it in the UI."""
await _group(db_session, "Operators")
_configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="Operators")
await apply_env_oidc_provider(db_session)
monkeypatch.delenv("BAMBUDDY_OIDC_DEFAULT_GROUP")
await apply_env_oidc_provider(db_session)
assert (await _env_provider(db_session)).default_group_id is None
@pytest.mark.asyncio
async def test_an_empty_group_variable_counts_as_unset(db_session, monkeypatch):
"""Same rule the required vars follow: an empty value in a compose file is
a forgotten value, not a request to reject the config."""
_configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="")
await apply_env_oidc_provider(db_session)
provider = await _env_provider(db_session)
assert provider is not None
assert provider.default_group_id is None
# --- blank optional strings count as unset, not a refusal ---------------------
# `.env.example` ships `# BAMBUDDY_OIDC_ICON_URL=` commented out, so uncommenting
# it must not take the provider down -- same rule default_group already follows.
@pytest.mark.asyncio
async def test_a_blank_scopes_still_creates_the_provider(db_session, monkeypatch):
_configure(monkeypatch, BAMBUDDY_OIDC_SCOPES="")
await apply_env_oidc_provider(db_session)
provider = await _env_provider(db_session)
assert provider is not None, "a blank optional var must not refuse the whole provider"
assert provider.scopes == "openid email profile"
@pytest.mark.asyncio
async def test_a_blank_email_claim_still_creates_the_provider(db_session, monkeypatch):
_configure(monkeypatch, BAMBUDDY_OIDC_EMAIL_CLAIM="")
await apply_env_oidc_provider(db_session)
provider = await _env_provider(db_session)
assert provider is not None, "a blank optional var must not refuse the whole provider"
assert provider.email_claim == "email"
@pytest.mark.asyncio
async def test_a_blank_icon_url_still_creates_the_provider(db_session, monkeypatch):
_configure(monkeypatch, BAMBUDDY_OIDC_ICON_URL="")
await apply_env_oidc_provider(db_session)
provider = await _env_provider(db_session)
assert provider is not None, "a blank optional var must not refuse the whole provider"
assert provider.icon_url is None
# --- account links and collision behavior ------------------------------------
@pytest.mark.asyncio
async def test_renaming_to_match_a_ui_provider_adopts_it_and_releases_the_old_row(db_session, monkeypatch):
"""New name collides with existing UI provider: env config adopts that row,
old env-managed row is released. Identity is the name, so the collision is
resolved by matching the new name against the table."""
# Start with env-managed "Keycloak"
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
old_id = (await _env_provider(db_session)).id
# Add a UI provider named "Authentik"
ui_provider = OIDCProvider(name="Authentik", issuer_url="https://auth.example.com", client_id="ui-client")
ui_provider.client_secret = "ui-secret"
db_session.add(ui_provider)
await db_session.commit()
ui_id = ui_provider.id
# Rename env provider to "Authentik" — matches the UI provider
monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
await apply_env_oidc_provider(db_session)
# The UI provider is adopted and becomes env-managed
provider = await _env_provider(db_session)
assert provider.id == ui_id, "adopted the UI provider"
assert provider.name == "Authentik"
assert provider.client_id == "bambuddy" # updated from env
assert provider.is_env_managed is True
# The old Keycloak row is released
old = (await db_session.execute(select(OIDCProvider).where(OIDCProvider.id == old_id))).scalar_one()
assert old.name == "Keycloak"
assert old.is_env_managed is False
assert old.is_enabled is False
@pytest.mark.asyncio
async def test_account_links_survive_a_provider_rename(db_session, monkeypatch):
"""The provider row is never deleted, only updated: user_oidc_links FK
ON DELETE CASCADE must not be triggered by a rename."""
from backend.app.models.oidc_provider import UserOIDCLink
from backend.app.models.user import User
# Create a user and link it to the env-managed provider
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
provider_id = (await _env_provider(db_session)).id
user = User(username="testuser", email="test@example.com")
db_session.add(user)
await db_session.flush()
link = UserOIDCLink(
user_id=user.id,
provider_id=provider_id,
provider_user_id="oidc-sub-12345",
provider_email="test@idp.example.com",
)
db_session.add(link)
await db_session.commit()
# Rename the env provider
monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
await apply_env_oidc_provider(db_session)
# The link still exists, pointing to the old row (which is now released)
result = await db_session.execute(select(UserOIDCLink).where(UserOIDCLink.provider_id == provider_id))
links = result.scalars().all()
assert len(links) == 1
assert links[0].provider_user_id == "oidc-sub-12345"
@pytest.mark.asyncio
async def test_renaming_with_autologin_updates_the_exclusivity_sweep(db_session, monkeypatch):
"""When renamed env config has autologin=true, the sweep clears autologin
from other rows. The old row is released (autologin cleared there too)."""
# Setup: env provider "Keycloak" with autologin
_configure(monkeypatch, BAMBUDDY_OIDC_AUTOLOGIN="true")
await apply_env_oidc_provider(db_session)
old_id = (await _env_provider(db_session)).id
assert (await _env_provider(db_session)).is_autologin is True
# Another UI provider also has autologin
ui_provider = OIDCProvider(name="UI", issuer_url="https://ui.example.com", client_id="ui")
ui_provider.client_secret = "secret"
ui_provider.is_autologin = True
db_session.add(ui_provider)
await db_session.commit()
# Rename env provider to "Authentik" with autologin=true
monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
await apply_env_oidc_provider(db_session)
# New row is the autologin target
new_provider = await _env_provider(db_session)
assert new_provider.name == "Authentik"
assert new_provider.is_autologin is True
# Old row is released and autologin cleared
old = (await db_session.execute(select(OIDCProvider).where(OIDCProvider.id == old_id))).scalar_one()
assert old.is_env_managed is False
assert old.is_autologin is False
# UI provider autologin is cleared (only env-managed can be autologin now)
await db_session.refresh(ui_provider)
assert ui_provider.is_autologin is False
@pytest.mark.asyncio
async def test_group_name_matching_is_case_sensitive(db_session, monkeypatch, caplog):
"""Group name is resolved by exact match; 'operators' != 'Operators'."""
await _group(db_session, "Operators") # capital O
_configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="operators") # lowercase
with caplog.at_level(logging.ERROR):
await apply_env_oidc_provider(db_session)
# Config is rejected
assert await _env_provider(db_session) is None
assert "operators" in caplog.text
assert "BAMBUDDY_OIDC_DEFAULT_GROUP" in caplog.text
@pytest.mark.asyncio
async def test_group_name_rejection_does_not_log_the_secret(db_session, monkeypatch, caplog):
"""Group resolution happens before schema validation, so the secret is
not yet in scope, but verify it's not leaked by the error path."""
_configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="NonExistent")
secret = os.environ["BAMBUDDY_OIDC_CLIENT_SECRET"]
with caplog.at_level(logging.ERROR):
await apply_env_oidc_provider(db_session)
# Config is rejected but secret is safe
assert await _env_provider(db_session) is None
assert secret not in caplog.text
@pytest.mark.asyncio
async def test_restoring_env_config_after_rename_then_unset_finds_the_original_row(db_session, monkeypatch):
"""Rename Keycloak → Authentik, unset everything, restore Keycloak.
Must re-enable the original row, not create a new one."""
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
original_id = (await _env_provider(db_session)).id
# Rename to Authentik
monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
await apply_env_oidc_provider(db_session)
assert (await _env_provider(db_session)).name == "Authentik"
# Unset everything
for key in ALL_VARS:
monkeypatch.delenv(key, raising=False)
await apply_env_oidc_provider(db_session)
# Restore the original Keycloak config
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
# Same row, re-enabled
provider = await _env_provider(db_session)
assert provider.id == original_id
assert provider.name == "Keycloak"
assert provider.is_enabled is True
assert provider.is_env_managed is True

View file

@ -0,0 +1,137 @@
"""The env-managed provider is read-only through the API (#2593).
Startup rewrites this row from BAMBUDDY_OIDC_* on every boot, so a UI edit
would silently disappear at the next restart -- the operator would see their
change accepted and then reverted, with nothing explaining why. Refusing the
write is the honest answer.
Locking it out is safe because BAMBUDDY_LOCAL_LOGIN (#1589) is the documented
recovery path if the provider itself becomes unusable.
"""
from __future__ import annotations
import pytest
from httpx import AsyncClient
from backend.app.models.oidc_provider import OIDCProvider
from backend.tests.integration.test_mfa_api import _auth_header, _setup_and_login
async def _env_managed_provider(db_session) -> int:
provider = OIDCProvider(
name="Env Keycloak",
issuer_url="https://sso.example.com/realms/main",
client_id="bambuddy",
icon_url="https://sso.example.com/logo.png",
is_env_managed=True,
)
provider.client_secret = "s3cr3t"
db_session.add(provider)
await db_session.commit()
await db_session.refresh(provider)
return provider.id
@pytest.mark.asyncio
@pytest.mark.integration
async def test_put_is_refused(async_client: AsyncClient, db_session):
provider_id = await _env_managed_provider(db_session)
token = await _setup_and_login(async_client, "envlockput", "envlockput123")
response = await async_client.put(
f"/api/v1/auth/oidc/providers/{provider_id}",
json={"name": "hijacked"},
headers=_auth_header(token),
)
assert response.status_code == 409
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_is_refused(async_client: AsyncClient, db_session):
provider_id = await _env_managed_provider(db_session)
token = await _setup_and_login(async_client, "envlockdel", "envlockdel123")
response = await async_client.delete(
f"/api/v1/auth/oidc/providers/{provider_id}",
headers=_auth_header(token),
)
assert response.status_code == 409
@pytest.mark.asyncio
@pytest.mark.integration
async def test_icon_delete_is_refused(async_client: AsyncClient, db_session):
"""The icon is part of the env config too -- BAMBUDDY_OIDC_ICON_URL."""
provider_id = await _env_managed_provider(db_session)
token = await _setup_and_login(async_client, "envlockicondel", "envlockicondel123")
response = await async_client.delete(
f"/api/v1/auth/oidc/providers/{provider_id}/icon",
headers=_auth_header(token),
)
assert response.status_code == 409
@pytest.mark.asyncio
@pytest.mark.integration
async def test_icon_refresh_is_refused(async_client: AsyncClient, db_session):
provider_id = await _env_managed_provider(db_session)
token = await _setup_and_login(async_client, "envlockiconref", "envlockiconref123")
response = await async_client.post(
f"/api/v1/auth/oidc/providers/{provider_id}/icon/refresh",
headers=_auth_header(token),
)
assert response.status_code == 409
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_ui_provider_is_still_editable(async_client: AsyncClient):
"""The lock must not leak onto providers the operator created themselves --
they coexist with the env one and stay fully editable."""
token = await _setup_and_login(async_client, "envlockui", "envlockui123")
created = await async_client.post(
"/api/v1/auth/oidc/providers",
json={
"name": "UI provider",
"issuer_url": "https://other.example.com",
"client_id": "ui",
"client_secret": "ui-secret",
"scopes": "openid",
"is_enabled": True,
"auto_create_users": False,
},
headers=_auth_header(token),
)
provider_id = created.json()["id"]
response = await async_client.put(
f"/api/v1/auth/oidc/providers/{provider_id}",
json={"name": "Renamed"},
headers=_auth_header(token),
)
assert response.status_code == 200
assert response.json()["name"] == "Renamed"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_the_response_says_which_provider_is_env_managed(async_client: AsyncClient, db_session):
"""The frontend needs this to render the lock; without it the UI would show
editable fields whose writes the API then refuses."""
await _env_managed_provider(db_session)
token = await _setup_and_login(async_client, "envlockflag", "envlockflag123")
response = await async_client.get("/api/v1/auth/oidc/providers/all", headers=_auth_header(token))
assert response.status_code == 200
providers = response.json()
assert any(p["is_env_managed"] for p in providers)

View file

@ -0,0 +1,43 @@
"""The env provider is applied on startup, not merely appliable (#2593).
test_oidc_env_apply.py calls apply_env_oidc_provider() directly, so it stays
green even if nothing ever calls it -- deleting the lifespan call would leave
the feature dead with a fully passing suite. These tests pin the call site.
They read the lifespan's source rather than running it: the function is ~460
lines and starts printer connections, MQTT and schedulers, so executing it
here would test everything except the one line in question. That makes this a
wiring check, not a behavioural one -- it proves the call exists and runs
after migrations, and deliberately proves nothing about what it does. The
behaviour is covered by test_oidc_env_apply.py.
"""
from __future__ import annotations
import inspect
from backend.app.main import lifespan
def _lifespan_source() -> str:
return inspect.getsource(lifespan)
def test_lifespan_applies_the_env_oidc_provider():
assert "apply_env_oidc_provider(" in _lifespan_source()
def test_it_runs_after_the_migrations():
"""is_env_managed does not exist until run_migrations has added it, so an
upsert before init_db() would fail on every existing installation."""
source = _lifespan_source()
assert source.index("await init_db()") < source.index("apply_env_oidc_provider(")
def test_the_apply_call_is_awaited():
"""apply_env_oidc_provider is a coroutine; calling it without await would
return an un-awaited coroutine and silently apply nothing."""
source = _lifespan_source()
call = source.index("apply_env_oidc_provider(")
line_start = source.rindex("\n", 0, call) + 1
assert source[line_start:call].strip().endswith("await")

View file

@ -827,13 +827,51 @@ class TestLibraryOwnershipPermissions(TestOwnershipPermissionsSetup):
assert response.status_code == 403
# ========================================================================
# Folder deletion (#1781): folders have no ownership tracking, so users
# with only library:delete_own may delete empty, non-external, non-linked
# folders. Everything else still requires library:delete_all.
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_folders_require_all_permission(self, async_client: AsyncClient, auth_setup, library_folder_factory):
"""Folders require *_all permission (no ownership tracking on folders)."""
folder = await library_folder_factory(name="TestFolder")
async def test_operator_can_delete_empty_folder(
self, async_client: AsyncClient, auth_setup, library_folder_factory
):
"""A user with library:delete_own can delete an empty folder (#1781)."""
folder = await library_folder_factory(name="EmptyFolder")
response = await async_client.delete(
f"/api/v1/library/folders/{folder.id}",
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
)
assert response.status_code == 200
@pytest.mark.asyncio
@pytest.mark.integration
async def test_viewer_cannot_delete_empty_folder(
self, async_client: AsyncClient, auth_setup, library_folder_factory
):
"""No delete permission at all still means no folder deletion."""
folder = await library_folder_factory(name="EmptyFolder")
response = await async_client.delete(
f"/api/v1/library/folders/{folder.id}",
headers={"Authorization": f"Bearer {auth_setup['viewer_token']}"},
)
assert response.status_code == 403
@pytest.mark.asyncio
@pytest.mark.integration
async def test_operator_cannot_delete_folder_with_files(
self, async_client: AsyncClient, auth_setup, library_folder_factory, library_file_factory
):
"""Non-empty folders still require library:delete_all."""
folder = await library_folder_factory(name="FullFolder")
await library_file_factory(folder_id=folder.id, created_by_id=auth_setup["operator_user"]["id"])
# Operator cannot delete folder (needs *_all)
response = await async_client.delete(
f"/api/v1/library/folders/{folder.id}",
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
@ -841,6 +879,121 @@ class TestLibraryOwnershipPermissions(TestOwnershipPermissionsSetup):
assert response.status_code == 403
@pytest.mark.asyncio
@pytest.mark.integration
async def test_operator_cannot_delete_folder_with_trashed_file(
self, async_client: AsyncClient, auth_setup, library_folder_factory, library_file_factory
):
"""Trashed files count as content: cascade would hard-drop them and
silently break trash restore for their owner."""
from datetime import datetime, timezone
folder = await library_folder_factory(name="TrashedContentFolder")
await library_file_factory(
folder_id=folder.id,
created_by_id=auth_setup["operator2_user"]["id"],
deleted_at=datetime.now(timezone.utc),
)
response = await async_client.delete(
f"/api/v1/library/folders/{folder.id}",
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
)
assert response.status_code == 403
@pytest.mark.asyncio
@pytest.mark.integration
async def test_operator_cannot_delete_folder_with_subfolder(
self, async_client: AsyncClient, auth_setup, library_folder_factory
):
"""A folder containing subfolders (even empty ones) is not empty."""
parent = await library_folder_factory(name="ParentFolder")
await library_folder_factory(name="ChildFolder", parent_id=parent.id)
response = await async_client.delete(
f"/api/v1/library/folders/{parent.id}",
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
)
assert response.status_code == 403
@pytest.mark.asyncio
@pytest.mark.integration
async def test_operator_cannot_delete_external_folder(
self, async_client: AsyncClient, auth_setup, library_folder_factory
):
"""Deleting an external folder unmounts an operator-configured mount
for everyone stays behind library:delete_all even when empty."""
folder = await library_folder_factory(name="ExternalFolder", is_external=True, external_path="/mnt/models")
response = await async_client.delete(
f"/api/v1/library/folders/{folder.id}",
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
)
assert response.status_code == 403
@pytest.mark.asyncio
@pytest.mark.integration
async def test_operator_cannot_delete_linked_folder(
self, async_client: AsyncClient, auth_setup, library_folder_factory, db_session
):
"""Project/archive links are created via update_all, so unlinking by
deletion stays admin-only even for empty folders."""
from backend.app.models.project import Project
project = Project(name="LinkTestProject")
db_session.add(project)
await db_session.commit()
await db_session.refresh(project)
folder = await library_folder_factory(name="LinkedFolder", project_id=project.id)
response = await async_client.delete(
f"/api/v1/library/folders/{folder.id}",
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
)
assert response.status_code == 403
@pytest.mark.asyncio
@pytest.mark.integration
async def test_admin_can_delete_folder_with_contents(
self, async_client: AsyncClient, auth_setup, library_folder_factory, library_file_factory
):
"""library:delete_all keeps full cascade deletion."""
folder = await library_folder_factory(name="AdminFolder")
await library_file_factory(folder_id=folder.id, created_by_id=auth_setup["operator_user"]["id"])
response = await async_client.delete(
f"/api/v1/library/folders/{folder.id}",
headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
)
assert response.status_code == 200
@pytest.mark.asyncio
@pytest.mark.integration
async def test_bulk_delete_operator_folders_empty_only(
self, async_client: AsyncClient, auth_setup, library_folder_factory, library_file_factory
):
"""Bulk delete applies the same rule: empty folders go, non-empty are skipped."""
empty_folder = await library_folder_factory(name="BulkEmpty")
full_folder = await library_folder_factory(name="BulkFull")
await library_file_factory(folder_id=full_folder.id, created_by_id=auth_setup["operator2_user"]["id"])
response = await async_client.post(
"/api/v1/library/bulk-delete",
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
json={"file_ids": [], "folder_ids": [empty_folder.id, full_folder.id]},
)
assert response.status_code == 200
result = response.json()
assert result["deleted_folders"] == 1
assert result["deleted_files"] == 0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_bulk_delete_skips_non_owned_files(self, async_client: AsyncClient, auth_setup, library_file_factory):

View file

@ -0,0 +1,79 @@
"""Integration tests for the plate-clear-required notification (#2525).
The event is opt-in: it fires after every print, at the same moment as the
print-complete alert, so a provider only receives it when the toggle is
explicitly enabled.
"""
from unittest.mock import AsyncMock, patch
import pytest
from httpx import AsyncClient
from backend.app.services.notification_service import notification_service
class TestPlateClearNotificationDispatch:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_sends_to_a_provider_that_opted_in(self, notification_provider_factory, db_session):
await notification_provider_factory(name="Opted In", on_plate_clear_required=True)
send = AsyncMock()
with patch.object(notification_service, "_send_to_providers", send):
await notification_service.on_plate_clear_required(1, "Workshop", db_session)
assert send.await_count == 1
providers = send.await_args.args[0]
assert [p.name for p in providers] == ["Opted In"]
assert send.await_args.args[4] == "plate_clear_required"
# _build_message_from_template folds in app_name/timestamp; the caller's
# own variable is what matters here.
assert send.await_args.kwargs["variables"]["printer"] == "Workshop"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_silent_for_a_provider_that_did_not_opt_in(self, notification_provider_factory, db_session):
await notification_provider_factory(name="Default Off", on_plate_clear_required=False)
send = AsyncMock()
with patch.object(notification_service, "_send_to_providers", send):
await notification_service.on_plate_clear_required(1, "Workshop", db_session)
send.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_skips_a_provider_scoped_to_a_different_printer(self, notification_provider_factory, db_session):
await notification_provider_factory(name="Other Printer", on_plate_clear_required=True, printer_id=99)
send = AsyncMock()
with patch.object(notification_service, "_send_to_providers", send):
await notification_service.on_plate_clear_required(1, "Workshop", db_session)
send.assert_not_awaited()
class TestPlateClearProviderField:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_defaults_to_off_on_create_and_round_trips_on_update(self, async_client: AsyncClient):
create = await async_client.post(
"/api/v1/notifications/",
json={
"name": "Plate Clear Test",
"provider_type": "ntfy",
"enabled": True,
"config": {"server": "https://ntfy.sh", "topic": "test-topic"},
},
)
assert create.status_code in (200, 201), create.text
provider_id = create.json()["id"]
assert create.json()["on_plate_clear_required"] is False
update = await async_client.patch(
f"/api/v1/notifications/{provider_id}",
json={"on_plate_clear_required": True},
)
assert update.status_code == 200, update.text
assert update.json()["on_plate_clear_required"] is True

View file

@ -258,6 +258,211 @@ class TestPrintQueueAPI:
assert result["archive_id"] == archive.id
assert result["ams_mapping"] == [5, -1, 2, -1]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_add_to_queue_falls_back_to_archive_slicer_ams_mapping_when_unset(
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
):
"""When the caller sends no explicit ams_mapping, but the archive
carries the slicer's own saved pick for this exact printer
(extra_data.slicer_ams_mapping, written by a VP with "Save AMS
mapping" on), the queue item should inherit it — the same
exact-physical-spool reuse the "Mapping" button gives you, but
automatic when nothing was hand-edited.
"""
printer = await printer_factory()
archive = await archive_factory(
extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": printer.id}}
)
data = {
"printer_id": printer.id,
"archive_id": archive.id,
}
response = await async_client.post("/api/v1/queue/", json=data)
assert response.status_code == 200
result = response.json()
assert result["ams_mapping"] == [5, -1, 2, -1]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_add_to_queue_ignores_archive_slicer_ams_mapping_for_different_printer(
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
):
"""A saved mapping's tray IDs only mean something relative to the
printer they were resolved against. Reprinting the same archive on a
*different* printer must not inherit it tray 5 on printer A can
hold a completely different spool than tray 5 on printer B.
"""
origin_printer = await printer_factory()
other_printer = await printer_factory()
archive = await archive_factory(
extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": origin_printer.id}}
)
data = {
"printer_id": other_printer.id,
"archive_id": archive.id,
}
response = await async_client.post("/api/v1/queue/", json=data)
assert response.status_code == 200
result = response.json()
assert result["ams_mapping"] is None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_add_to_queue_ignores_archive_slicer_ams_mapping_for_model_based_dispatch(
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
):
"""A model-based item (no fixed printer_id) can't know in advance
which printer the scheduler will pick, so a saved mapping resolved
against one specific printer must never be inherited here either.
"""
origin_printer = await printer_factory()
archive = await archive_factory(
extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": origin_printer.id}}
)
data = {
"target_model": "X1C",
"archive_id": archive.id,
}
response = await async_client.post("/api/v1/queue/", json=data)
assert response.status_code == 200
result = response.json()
assert result["ams_mapping"] is None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_add_to_queue_explicit_ams_mapping_wins_over_archive_fallback(
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
):
"""An explicit ams_mapping in the request (e.g. from the filament
mapping panel) must take priority over the archive's saved slicer
pick the fallback only fires when the caller sent nothing at all.
"""
printer = await printer_factory()
archive = await archive_factory(
extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": printer.id}}
)
data = {
"printer_id": printer.id,
"archive_id": archive.id,
"ams_mapping": [9, -1, 1, -1],
}
response = await async_client.post("/api/v1/queue/", json=data)
assert response.status_code == 200
result = response.json()
assert result["ams_mapping"] == [9, -1, 1, -1]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_add_to_queue_archive_extra_data_without_slicer_mapping_key_not_used(
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
):
"""extra_data present but without a slicer_ams_mapping key (the
common case most archives have other metadata but no saved slicer
mapping) must not accidentally trip the fallback."""
printer = await printer_factory()
archive = await archive_factory(extra_data={"filament_slots": []})
data = {
"printer_id": printer.id,
"archive_id": archive.id,
}
response = await async_client.post("/api/v1/queue/", json=data)
assert response.status_code == 200
result = response.json()
assert result["ams_mapping"] is None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_add_to_queue_force_color_match_overrides_beat_the_archive_fallback(
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
):
"""Force-color-match overrides are the caller asking the scheduler to
match strictly against the printer's live trays, and they are only ever
applied inside `_compute_ams_mapping_for_printer` the function a
stored mapping makes the scheduler skip. Inheriting the saved mapping
here would silently retire the strictness that was just requested
(#2700 review).
"""
printer = await printer_factory()
archive = await archive_factory(
extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": printer.id}}
)
data = {
"printer_id": printer.id,
"archive_id": archive.id,
"filament_overrides": [
{"slot_id": 1, "type": "PLA", "color": "#FF0000", "force_color_match": True},
],
}
response = await async_client.post("/api/v1/queue/", json=data)
assert response.status_code == 200
result = response.json()
assert result["ams_mapping"] is None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_add_to_queue_plain_overrides_still_allow_the_archive_fallback(
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
):
"""Only force_color_match stands the fallback down. A plain preference
override is a filament swap, not a request for live colour matching, so
the saved mapping is still the best starting point.
"""
printer = await printer_factory()
archive = await archive_factory(
extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": printer.id}}
)
data = {
"printer_id": printer.id,
"archive_id": archive.id,
"filament_overrides": [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}],
}
response = await async_client.post("/api/v1/queue/", json=data)
assert response.status_code == 200
result = response.json()
assert result["ams_mapping"] == [5, -1, 2, -1]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_queue_response_flags_saved_mapping_only_for_its_own_printer(
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
):
"""`archive_has_slicer_ams_mapping` drives a badge that claims the
print reuses the slicer's exact trays. Global tray IDs mean nothing on
another printer, so the flag must be false for a row targeting one
otherwise the badge is there while nothing is reused (#2700 review).
"""
origin_printer = await printer_factory()
other_printer = await printer_factory()
archive = await archive_factory(
extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": origin_printer.id}}
)
own = await async_client.post(
"/api/v1/queue/", json={"printer_id": origin_printer.id, "archive_id": archive.id}
)
assert own.status_code == 200
assert own.json()["archive_has_slicer_ams_mapping"] is True
foreign = await async_client.post(
"/api/v1/queue/", json={"printer_id": other_printer.id, "archive_id": archive.id}
)
assert foreign.status_code == 200
assert foreign.json()["archive_has_slicer_ams_mapping"] is False
# Model-based: the scheduler hasn't picked a printer yet, so the
# mapping is not reused there either.
model_based = await async_client.post("/api/v1/queue/", json={"target_model": "X1C", "archive_id": archive.id})
assert model_based.status_code == 200
assert model_based.json()["archive_has_slicer_ams_mapping"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_add_to_queue_with_plate_id(

View file

@ -3855,8 +3855,10 @@ class TestSetChamberTemperatureAPI:
class TestSetFanSpeedAPI:
"""Integration tests for POST /printers/{id}/fan-speed (#1661).
The fan-id mapping (part->1, aux->2, chamber->3) is the critical
correctness gate wrong mapping would target the wrong physical fan.
The fan-id mapping (part->1, aux->2, chamber->3, aux2->10) is the
critical correctness gate wrong mapping would target the wrong
physical fan. "aux2" (M106 P10) is the optional left auxiliary part
cooling fan on P2S/X2D.
"""
@pytest.mark.asyncio
@ -3879,13 +3881,17 @@ class TestSetFanSpeedAPI:
@pytest.mark.integration
@pytest.mark.parametrize(
"fan_name,expected_fan_id",
[("part", 1), ("aux", 2), ("chamber", 3)],
[("part", 1), ("aux", 2), ("chamber", 3), ("aux2", 10)],
)
async def test_fan_id_mapping(self, async_client: AsyncClient, printer_factory, fan_name, expected_fan_id):
"""Verify each fan name maps to the correct hardware fan-id."""
printer = await printer_factory(name="P", model="X1C")
mock_client = MagicMock()
mock_client.set_fan_speed.return_value = True
# aux2 is presence-gated on the printer reporting airduct part 10, so
# give the mock a reported speed. Set explicitly rather than leaning on
# MagicMock's auto-attribute, which would satisfy the gate by accident.
mock_client.state.left_aux_fan_speed = 0
with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
mock_pm.get_client.return_value = mock_client
response = await async_client.post(f"/api/v1/printers/{printer.id}/fan-speed?fan={fan_name}&speed=100")
@ -3893,6 +3899,39 @@ class TestSetFanSpeedAPI:
called_fan_id, called_pwm = mock_client.set_fan_speed.call_args.args
assert called_fan_id == expected_fan_id
@pytest.mark.asyncio
@pytest.mark.integration
async def test_aux2_rejected_when_printer_has_no_left_aux_fan(self, async_client: AsyncClient, printer_factory):
"""A printer that never reports airduct part 10 must not be sent M106 P10.
Without the gate the endpoint accepted aux2 for every model, so a POST
against an A1 would fire a command for hardware that does not exist.
"""
printer = await printer_factory(name="P", model="A1")
mock_client = MagicMock()
mock_client.set_fan_speed.return_value = True
mock_client.state.left_aux_fan_speed = None
with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
mock_pm.get_client.return_value = mock_client
response = await async_client.post(f"/api/v1/printers/{printer.id}/fan-speed?fan=aux2&speed=50")
assert response.status_code == 400
assert "left auxiliary fan" in response.json()["detail"]
mock_client.set_fan_speed.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_other_fans_unaffected_by_the_aux2_gate(self, async_client: AsyncClient, printer_factory):
"""The gate is aux2-only — a base P2S can still drive its built-in fans."""
printer = await printer_factory(name="P", model="P2S")
mock_client = MagicMock()
mock_client.set_fan_speed.return_value = True
mock_client.state.left_aux_fan_speed = None
with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
mock_pm.get_client.return_value = mock_client
for fan_name in ("part", "aux", "chamber"):
response = await async_client.post(f"/api/v1/printers/{printer.id}/fan-speed?fan={fan_name}&speed=50")
assert response.status_code == 200, fan_name
@pytest.mark.asyncio
@pytest.mark.integration
@pytest.mark.parametrize(
@ -3911,6 +3950,36 @@ class TestSetFanSpeedAPI:
_called_fan_id, called_pwm = mock_client.set_fan_speed.call_args.args
assert called_pwm == expected_pwm
@pytest.mark.asyncio
@pytest.mark.integration
@pytest.mark.parametrize(
"model,expected_label",
[
("P2S", "Exhaust fan"),
("X2D", "Exhaust fan"),
("X1C", "Chamber fan"),
("P1S", "Chamber fan"),
("H2D", "Chamber fan"),
],
)
async def test_chamber_fan_message_matches_model_label(
self, async_client: AsyncClient, printer_factory, model, expected_label
):
"""The success toast must use the same name as the printer card badge.
On P2S/X2D the big_fan2 fan is labelled "Exhaust"; everywhere else it
stays "Chamber". A mismatch means the user clicks "Exhaust" and gets
told "Chamber fan set to N%".
"""
printer = await printer_factory(name="P", model=model)
mock_client = MagicMock()
mock_client.set_fan_speed.return_value = True
with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
mock_pm.get_client.return_value = mock_client
response = await async_client.post(f"/api/v1/printers/{printer.id}/fan-speed?fan=chamber&speed=50")
assert response.status_code == 200
assert response.json()["message"] == f"{expected_label} set to 50%"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_speed_out_of_range_rejected(self, async_client: AsyncClient, printer_factory):

View file

@ -1393,3 +1393,430 @@ class TestProjectListEditableFields:
result = response.json()
assert result["tags"] is None
assert result["due_date"] is None
class TestProjectFileProgress:
"""Per-file print progress inside a project (#1897).
Covers GET /projects/{id}/file-progress (attribution: library_file_id
content hash filename, completed runs only, project-scoped), the
target_sets field round-trip, and the add-to-queue project inheritance
that feeds the attribution chain.
"""
@pytest.fixture
async def project_factory(self, db_session):
_counter = [0]
async def _create_project(**kwargs):
from backend.app.models.project import Project
_counter[0] += 1
defaults = {"name": f"Progress Project {_counter[0]}"}
defaults.update(kwargs)
project = Project(**defaults)
db_session.add(project)
await db_session.commit()
await db_session.refresh(project)
return project
return _create_project
@pytest.fixture
async def folder_factory(self, db_session):
_counter = [0]
async def _create_folder(**kwargs):
from backend.app.models.library import LibraryFolder
_counter[0] += 1
defaults = {"name": f"ProgressFolder {_counter[0]}"}
defaults.update(kwargs)
folder = LibraryFolder(**defaults)
db_session.add(folder)
await db_session.commit()
await db_session.refresh(folder)
return folder
return _create_folder
@pytest.fixture
async def file_factory(self, db_session):
_counter = [0]
async def _create_file(**kwargs):
from backend.app.models.library import LibraryFile
_counter[0] += 1
counter = _counter[0]
defaults = {
"filename": f"plate_{counter}.gcode.3mf",
"file_path": f"library/plate_{counter}.gcode.3mf",
"file_size": 1024,
"file_type": "3mf",
}
defaults.update(kwargs)
lib_file = LibraryFile(**defaults)
db_session.add(lib_file)
await db_session.commit()
await db_session.refresh(lib_file)
return lib_file
return _create_file
@pytest.mark.asyncio
@pytest.mark.integration
async def test_counts_by_library_file_id(
self, async_client: AsyncClient, project_factory, folder_factory, file_factory, printer_factory, archive_factory
):
"""Runs stamped with library_file_id count toward that file even when
the archive's filename differs (rename after dispatch)."""
project = await project_factory()
folder = await folder_factory(project_id=project.id)
file_a = await file_factory(folder_id=folder.id)
file_b = await file_factory(folder_id=folder.id)
printer = await printer_factory()
for _ in range(2):
await archive_factory(
printer.id,
project_id=project.id,
library_file_id=file_a.id,
filename="renamed_on_dispatch.gcode.3mf",
)
await archive_factory(printer.id, project_id=project.id, library_file_id=file_b.id)
response = await async_client.get(f"/api/v1/projects/{project.id}/file-progress")
assert response.status_code == 200
counts = {row["file_id"]: row["completed_count"] for row in response.json()}
assert counts == {file_a.id: 2, file_b.id: 1}
@pytest.mark.asyncio
@pytest.mark.integration
async def test_hash_and_filename_fallback(
self, async_client: AsyncClient, project_factory, folder_factory, file_factory, printer_factory, archive_factory
):
"""Historical archives without library_file_id match by content hash,
then by filename."""
project = await project_factory()
folder = await folder_factory(project_id=project.id)
hashed_file = await file_factory(folder_id=folder.id, file_hash="a" * 64)
named_file = await file_factory(folder_id=folder.id, filename="unique_name.gcode.3mf")
printer = await printer_factory()
# Hash match despite a different filename
await archive_factory(
printer.id, project_id=project.id, content_hash="a" * 64, filename="printer_copy.gcode.3mf"
)
# Filename match with no hash on either side
await archive_factory(printer.id, project_id=project.id, filename="unique_name.gcode.3mf")
response = await async_client.get(f"/api/v1/projects/{project.id}/file-progress")
counts = {row["file_id"]: row["completed_count"] for row in response.json()}
assert counts == {hashed_file.id: 1, named_file.id: 1}
@pytest.mark.asyncio
@pytest.mark.integration
async def test_only_completed_runs_count(
self, async_client: AsyncClient, project_factory, folder_factory, file_factory, printer_factory, archive_factory
):
"""Failed runs and never-printed archives do not advance the count."""
project = await project_factory()
folder = await folder_factory(project_id=project.id)
lib_file = await file_factory(folder_id=folder.id)
printer = await printer_factory()
await archive_factory(printer.id, project_id=project.id, library_file_id=lib_file.id)
await archive_factory(
printer.id, project_id=project.id, library_file_id=lib_file.id, status="failed", run_status="failed"
)
await archive_factory(printer.id, project_id=project.id, library_file_id=lib_file.id, with_run=False)
response = await async_client.get(f"/api/v1/projects/{project.id}/file-progress")
counts = {row["file_id"]: row["completed_count"] for row in response.json()}
assert counts == {lib_file.id: 1}
@pytest.mark.asyncio
@pytest.mark.integration
async def test_scoped_to_project(
self, async_client: AsyncClient, project_factory, folder_factory, file_factory, printer_factory, archive_factory
):
"""Runs of the same file outside the project (no project / another
project) are excluded."""
project = await project_factory()
other_project = await project_factory()
folder = await folder_factory(project_id=project.id)
lib_file = await file_factory(folder_id=folder.id)
printer = await printer_factory()
await archive_factory(printer.id, project_id=None, library_file_id=lib_file.id)
await archive_factory(printer.id, project_id=other_project.id, library_file_id=lib_file.id)
response = await async_client.get(f"/api/v1/projects/{project.id}/file-progress")
assert response.json() == []
@pytest.mark.asyncio
@pytest.mark.integration
async def test_unknown_project_404(self, async_client: AsyncClient):
response = await async_client.get("/api/v1/projects/999999/file-progress")
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_target_sets_roundtrip(self, async_client: AsyncClient):
"""target_sets survives create, update, and explicit-null clearing."""
create = await async_client.post("/api/v1/projects/", json={"name": "Sets Project", "target_sets": 10})
assert create.status_code == 200
project = create.json()
assert project["target_sets"] == 10
update = await async_client.patch(f"/api/v1/projects/{project['id']}", json={"target_sets": 4})
assert update.status_code == 200, update.json()
assert update.json()["target_sets"] == 4
cleared = await async_client.patch(f"/api/v1/projects/{project['id']}", json={"target_sets": None})
assert cleared.json()["target_sets"] is None
untouched = await async_client.patch(f"/api/v1/projects/{project['id']}", json={"name": "Renamed"})
assert untouched.json()["target_sets"] is None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_add_to_queue_inherits_folder_project(
self, async_client: AsyncClient, project_factory, folder_factory, file_factory, db_session, tmp_path
):
"""Queueing a file from a project-linked folder attributes the queue
item (and thus the later archive) to that project; a root file stays
unattributed."""
from sqlalchemy import select
from backend.app.models.print_queue import PrintQueueItem
project = await project_factory()
folder = await folder_factory(project_id=project.id)
on_disk = tmp_path / "linked.gcode.3mf"
on_disk.write_bytes(b"fake sliced content")
linked_file = await file_factory(folder_id=folder.id, file_path=str(on_disk))
root_disk = tmp_path / "root.gcode.3mf"
root_disk.write_bytes(b"fake sliced content")
root_file = await file_factory(folder_id=None, file_path=str(root_disk))
response = await async_client.post(
"/api/v1/library/files/add-to-queue", json={"file_ids": [linked_file.id, root_file.id]}
)
assert response.status_code == 200
assert len(response.json()["added"]) == 2
result = await db_session.execute(
select(PrintQueueItem.library_file_id, PrintQueueItem.project_id).where(
PrintQueueItem.library_file_id.in_([linked_file.id, root_file.id])
)
)
projects_by_file = dict(result.all())
assert projects_by_file[linked_file.id] == project.id
assert projects_by_file[root_file.id] is None
class TestSoftDeletedArchivesLeaveTheProject:
"""Deleting a print removes it from its project, everywhere (#2731).
The default archive delete is soft (#1343): the files go, the row stays so
global Quick Stats keeps counting its filament / time / cost. Nothing in the
projects module filtered on that, so a deleted print stayed listed on the
project with a thumbnail pointing at a file that no longer existed and
could not be unassigned, because the only unassign UI lives on the Archives
page, which correctly hides it.
Unlike Quick Stats, project *counts* exclude it too. A project is a piece of
work with a definite membership, not a lifetime total, so a project that
lists one print must not claim two.
"""
@pytest.fixture
async def project_factory(self, db_session):
async def _create_project(**kwargs):
from backend.app.models.project import Project
defaults = {"name": "Deleted Archive Project", "color": "#FF0000"}
defaults.update(kwargs)
project = Project(**defaults)
db_session.add(project)
await db_session.commit()
await db_session.refresh(project)
return project
return _create_project
@pytest.fixture
async def archive_factory(self, db_session):
"""Archive + matching PrintLogEntry, as production always writes both."""
async def _create_archive(**kwargs):
from backend.app.models.archive import PrintArchive
from backend.app.models.print_log import PrintLogEntry
defaults = {
"filename": "test.3mf",
"file_path": "test/test.3mf",
"file_size": 1000,
"print_name": "Test Print",
"status": "completed",
"quantity": 1,
"thumbnail_path": "test/thumb.png",
}
defaults.update(kwargs)
archive = PrintArchive(**defaults)
db_session.add(archive)
await db_session.commit()
await db_session.refresh(archive)
db_session.add(
PrintLogEntry(
archive_id=archive.id,
print_name=archive.print_name,
status=archive.status,
filament_used_grams=10.0,
)
)
await db_session.commit()
return archive
return _create_archive
@staticmethod
async def _soft_delete(db_session, archive) -> int:
"""Soft-delete *archive* and return its id.
The commit expires the instance, so reading an attribute off it
afterwards is lazy IO outside the greenlet context (MissingGreenlet).
Callers take the id from here instead.
"""
from datetime import datetime, timezone
archive_id = archive.id
archive.deleted_at = datetime.now(timezone.utc)
await db_session.commit()
return archive_id
@pytest.mark.asyncio
@pytest.mark.integration
async def test_deleted_archive_is_not_listed_on_the_project(
self, async_client: AsyncClient, project_factory, archive_factory, db_session
):
"""The reported symptom: a card with a broken preview image."""
project = await project_factory()
await archive_factory(project_id=project.id, print_name="Kept")
gone = await archive_factory(project_id=project.id, print_name="Deleted")
await self._soft_delete(db_session, gone)
response = await async_client.get(f"/api/v1/projects/{project.id}/archives")
assert response.status_code == 200
assert [a["print_name"] for a in response.json()] == ["Kept"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_deleted_archive_is_not_a_preview_on_the_project_card(
self, async_client: AsyncClient, project_factory, archive_factory, db_session
):
"""The overview page renders these as thumbnails too, so it broke there
as well not just on the detail page."""
project = await project_factory()
gone = await archive_factory(project_id=project.id, print_name="Deleted")
await self._soft_delete(db_session, gone)
response = await async_client.get("/api/v1/projects/")
assert response.status_code == 200
row = next(p for p in response.json() if p["id"] == project.id)
assert row["archives"] == []
@pytest.mark.asyncio
@pytest.mark.integration
async def test_project_counts_exclude_the_deleted_archive(
self, async_client: AsyncClient, project_factory, archive_factory, db_session
):
"""The list shows one print, so the count must say one."""
project = await project_factory()
await archive_factory(project_id=project.id, print_name="Kept")
gone = await archive_factory(project_id=project.id, print_name="Deleted")
await self._soft_delete(db_session, gone)
response = await async_client.get("/api/v1/projects/")
row = next(p for p in response.json() if p["id"] == project.id)
assert row["archive_count"] == 1
@pytest.mark.asyncio
@pytest.mark.integration
async def test_project_stats_exclude_the_deleted_archive(
self, async_client: AsyncClient, project_factory, archive_factory, db_session
):
"""Deliberate divergence from #1343: the contribution leaves the project
even though it stays in global Quick Stats."""
project = await project_factory()
await archive_factory(project_id=project.id, print_name="Kept")
gone = await archive_factory(project_id=project.id, print_name="Deleted")
await self._soft_delete(db_session, gone)
response = await async_client.get(f"/api/v1/projects/{project.id}")
assert response.status_code == 200
stats = response.json()["stats"]
assert stats["total_archives"] == 1
assert stats["total_filament_grams"] == pytest.approx(10.0)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_deleted_archive_is_not_in_the_project_timeline(
self, async_client: AsyncClient, project_factory, archive_factory, db_session
):
"""A timeline entry for it links to an archive that 404s when clicked."""
project = await project_factory()
gone = await archive_factory(project_id=project.id, print_name="Deleted")
await self._soft_delete(db_session, gone)
response = await async_client.get(f"/api/v1/projects/{project.id}/timeline")
assert response.status_code == 200
assert not any(e.get("description") == "Deleted" for e in response.json())
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_live_archive_is_untouched_by_all_of_this(
self, async_client: AsyncClient, project_factory, archive_factory
):
"""The filter must not cost a project its actual prints."""
project = await project_factory()
await archive_factory(project_id=project.id, print_name="Kept")
listing = await async_client.get(f"/api/v1/projects/{project.id}/archives")
assert [a["print_name"] for a in listing.json()] == ["Kept"]
stats = await async_client.get(f"/api/v1/projects/{project.id}")
assert stats.json()["stats"]["total_archives"] == 1
row = next(p for p in (await async_client.get("/api/v1/projects/")).json() if p["id"] == project.id)
assert row["archive_count"] == 1
assert len(row["archives"]) == 1
@pytest.mark.asyncio
@pytest.mark.integration
async def test_unassigning_an_already_orphaned_link_still_works(
self, async_client: AsyncClient, project_factory, archive_factory, db_session
):
"""The listings hide it, but the API must still be able to clear the
link that is the repair path for rows written before this fix."""
from sqlalchemy import select
from backend.app.models.archive import PrintArchive
project = await project_factory()
gone = await archive_factory(project_id=project.id, print_name="Deleted")
gone_id = await self._soft_delete(db_session, gone)
response = await async_client.post(
f"/api/v1/projects/{project.id}/remove-archives", json={"archive_ids": [gone_id]}
)
assert response.status_code == 200
db_session.expire_all()
result = await db_session.execute(select(PrintArchive.project_id).where(PrintArchive.id == gone_id))
assert result.scalar_one() is None

View file

@ -100,14 +100,17 @@ async def test_scan_timelapse_attaches_and_persists_via_fresh_session(
# base_name = Path("test_print.gcode.3mf").stem = "test_print.gcode", so this
# video matches by name (strategy 1). .mp4 → no background conversion task.
video_bytes = b"fake-timelapse-video-bytes"
matched = {
"name": "test_print.gcode.mp4",
"path": "/timelapse/test_print.gcode.mp4",
"is_directory": False,
"size": 4096,
# Must equal len(video_bytes): the download is checked against the
# listing, and the file is re-listed afterwards to confirm the printer
# has stopped writing it (#2704).
"size": len(video_bytes),
"mtime": None,
}
video_bytes = b"fake-timelapse-video-bytes"
with (
patch("backend.app.services.bambu_ftp.list_files_async", AsyncMock(return_value=[matched])),
@ -119,6 +122,9 @@ async def test_scan_timelapse_attaches_and_persists_via_fresh_session(
"backend.app.services.bambu_ftp.download_file_bytes_async",
AsyncMock(return_value=video_bytes),
) as mock_download,
# A successful attach now removes the printer's copy (#2704); without
# this the endpoint would open a real FTP connection to the fixture IP.
patch("backend.app.services.bambu_ftp.delete_archived_timelapse", AsyncMock()) as mock_delete,
):
response = await async_client.post(f"/api/v1/archives/{archive.id}/timelapse/scan")
@ -127,6 +133,7 @@ async def test_scan_timelapse_attaches_and_persists_via_fresh_session(
assert data["status"] == "attached"
assert data["filename"] == "test_print.gcode.mp4"
mock_download.assert_awaited_once()
mock_delete.assert_awaited_once()
# The write happened in the route's fresh session; confirm it was committed
# by re-reading the row on the separate test session.

View file

@ -7,6 +7,27 @@ import pytest
from backend.app.services.bambu_cloud import BambuCloudService
@pytest.fixture(autouse=True)
def _stub_csrf_handshake():
"""Keep the CSRF pre-flight off the network for every test in this module.
``verify_totp`` fetches a CSRF token from the ``bambulab.com`` web origin
before posting the code (#2696), and returns early without posting when it
cannot get one. The tests below patch only ``post``, so that GET went out
over the real network: it succeeded on any machine that could reach
bambulab.com which is why this file passed locally and returned a
tokenless 403 on a CI runner, where six tests then failed asserting on a
``post`` that never happened.
The handshake itself is covered end to end in
``tests/unit/test_cloud_totp_csrf.py``, including the no-token path, so
stubbing it here removes a network dependency rather than any coverage.
"""
with patch.object(BambuCloudService, "_fetch_csrf_token", new_callable=AsyncMock) as fetch:
fetch.return_value = "csrf-token-for-tests"
yield fetch
class TestBambuCloudLogin:
"""Test login flow detection (email vs TOTP)."""

Some files were not shown because too many files have changed in this diff Show more