Commit graph

3628 commits

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