diff --git a/BACKERS.md b/BACKERS.md index 231f4322c..a649c6207 100644 --- a/BACKERS.md +++ b/BACKERS.md @@ -69,6 +69,7 @@ If you sponsor and your name isn't here within 48h, please write an email to mar - [@iljur](https://github.com/iljur) - [@bhamiltoncx](https://github.com/bhamiltoncx) - [@g7ufo](https://github.com/g7ufo) +- [@Heidelberger2000](https://github.com/Heidelberger2000) --- diff --git a/CHANGELOG.md b/CHANGELOG.md index 086b87548..560ce2623 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,11 @@ All notable changes to Bambuddy will be documented in this file. ## [1.2.6b1] - Unreleased +### Fixed +- **Git backup now actually writes cloud profiles (#2717, reporter @jmoore-skild)** — Enabling **Cloud Profiles** for a Git backup produced nothing. The collector looked for a `setting` list in the Bambu Cloud response, which is keyed by preset type instead, so the loop never ran once — and it asked for the credential store used when authentication is *disabled*, so on any install with authentication on it found no account to collect from in the first place. Neither failure was visible: `backup_metadata.json` still recorded `cloud_profiles: true`, and the log line read `Collected cloud profiles: 0 filament, 0 printer, 0 process`, which looks like a successful backup of an empty account. Cloud profiles are now collected from **every connected account across both Bambu Cloud and Orca Cloud**, one directory per cloud per account, keyed by user ID so no email address is written into a backup repository. Bambu presets are stored with the payload needed to recreate them rather than just their names, and Bambu's bundled public catalogue is skipped — it is identical for everyone, re-downloadable, and would rewrite the repository on every run. The metadata now records what was actually collected, per cloud and per account, and a run that collects nothing while the category is enabled says so as a warning instead of an INFO line that reads like success. The Cloud Profiles checkbox no longer keys off your own Bambu sign-in — it enables when *any* account is connected and shows how many are in scope, which matters on a multi-user install where the presets being backed up are other people's. A backup also no longer disconnects an Orca Cloud account whose session it can't refresh: Orca reports every rejection with one composite reason, so a genuine revocation is indistinguishable from a lost token-rotation race, and an unattended job should not be the thing that guesses. The account is skipped with a warning, and the dead credentials are cleared the next time you open Orca Cloud Profiles — where you can pair again on the spot. Translated in all locales; wiki updated. Covered by backend tests. + ### Added +- **Keep the AMS slots the slicer picked (#2700, contributor @Striker72rus)** — Bambu Studio and OrcaSlicer resolve which physical AMS tray feeds each filament themselves, right before sending. Bambuddy threw that away: a queue-mode virtual printer worked the mapping out again at dispatch time, from the filament type and colour baked into the 3MF. That is usually the better answer — it is computed against the printer's live trays and it respects **Prefer lowest filament** and the AMS-backup gate that goes with it — but it has nothing to go on when the match isn't unique. Two spools of the same red PLA, and the slot you deliberately chose in the slicer is a coin toss. A new per-virtual-printer **Save AMS mapping** toggle keeps the slicer's pick instead: the print dispatches to exactly those trays, and the mapping is stored on the archive so a reprint can reuse the same physical spools — a **Mapping** button in the print modal selects every slot from it in one click, and the archive card and queue row say so. Because a tray number only means something on the AMS it was resolved against, the mapping records its printer and is only ever offered on that same printer; a model-based ("Any [model]") virtual printer has no fixed printer and is unaffected. Off by default, so nothing changes for existing virtual printers until you turn it on, and **Force color match** still wins for the print being dispatched when both are on. Translated in all locales; wiki updated. Covered by backend and frontend tests. - **P2S/X2D accessory fans: left auxiliary cooling and chamber exhaust (#2691, contributor @gzimbric, requested in #2660)** — The P2S and X2D have two fans Bambuddy could not show or drive. The **left auxiliary part cooling fan** had no tile and no control at all, because the printer only reports it inside its air-duct data and never in the ordinary fan fields Bambuddy was reading. The **chamber exhaust fan** had the opposite problem: its tile appeared on every P2S whether or not the fan was fitted, so owners of a base machine had a control that did nothing. Both are add-on kits on the P2S and fitted at the factory on the X2D. Both tiles now appear only when the printer itself reports the hardware, so a base P2S looks exactly as it does today and a kitted one gains the fans it actually has. The left auxiliary fan is set from the same speed popover as the others, and the enclosure fan is labelled **Exhaust** on the P2S and X2D — matching the printer's own screen and Bambu Studio — while every other enclosed model keeps **Chamber Fan**. The confirmation message after changing a speed uses the same name as the tile that was clicked. The four tiles are ordered part cooling, left auxiliary, auxiliary, exhaust, so they read left to right in the same order as the physical fans. Both fields are also published through the status endpoint, the WebSocket feed and the MQTT relay, so external automations can read them. Translated in all locales; wiki updated. Covered by backend and frontend tests, including the case where the printer sends a partial fan report — a tile must not disappear mid-print just because one update didn't mention it. - **Live print progress in the browser tab (#2693, contributor @Chachigo, requested in #1041)** — Watching a print meant keeping the Bambuddy tab in view, or switching back to it every few minutes. Enable **Print progress in tab** under Settings → Appearance and the tab title becomes `42% · Bambuddy` while the favicon turns into a progress ring in your theme accent colour, both updating live over the WebSocket the rest of the UI already uses. With several printers running, the tab follows the one finishing soonest, tie-broken by highest progress; title and favicon return to their defaults as soon as nothing is printing or the toggle goes off. Off by default, and stored per browser (like the light/dark toggle) so a wall-mounted dashboard and a laptop can each have their own setting. Translated in all locales; wiki updated. Covered by frontend tests. - **Telegram notifications can target a forum topic (#1518, reporter @vmhomelab)** — Telegram groups with Topics enabled always received Bambuddy's notifications in the **General** topic, because only Bot Token and Chat ID were configurable. Getting a per-printer split therefore meant creating a separate chat per printer. 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 into that topic, so a single group can carry one topic per printer. Left empty, the behaviour is unchanged. The ID is sent on both the plain-text and the thumbnail code paths, and is validated as a number in the form and again server-side, so a typo is reported instead of silently breaking only text notifications. Translated in all locales; wiki updated. Covered by backend and frontend tests. @@ -24,7 +28,18 @@ All notable changes to Bambuddy will be documented in this file. ### Changed - **Debug logs now record what the printer reports between the last layer and the end of a print (#2547, reporter @anthonyma94)** — The finish photo wants a moment that Bambu firmware does not obviously announce: printing done, toolhead parked, filament unload not yet started. Bambuddy has been driving that capture from `stg_cur=22` ("Filament unloading"), which turns out to fire on no model at all — across 247 support bundles there is not a single stage-22 capture, including the window in which it was the only trigger in the code, where all 104 captures on A1, A1 Mini, H2C, H2D, P1S, P2S, X1C and X2D fell through to the after-the-fact fallback. Choosing a replacement was not possible from the bundles we had, because outside `stg_cur` and `mc_print_sub_stage` every stage and action field the printers send is dropped unread, and the most promising candidates (`print_real_action`, `mc_action`, `mc_stage`) are absent from A1, A1 Mini and P1S payloads entirely. With debug logging enabled, Bambuddy now dumps those raw fields for the window between the last object layer and the end of the print — opening on the first end-of-print signal (last layer reached, progress at 99+, or no remaining time), logging only what changed frame to frame, and closing on the state transition — so a single debug bundle per model can show whether any firmware marks that moment. Diagnostics only: nothing reads these values, they are printer telemetry with nothing identifying in them, and at normal log levels the probe does no work at all. Covered by tests for the window boundaries, the frame budget and the guarantee that the probe cannot break status ingest. +### Added +- **Support bundles now record Bambuddy's own memory, threads and child processes (#2734)** — A bundle described everything except the thing it runs in. That made reports of memory climbing over days impossible to act on: the numbers that identify what is actually growing only exist while it is happening, and by the time anyone asked, the container had been restarted. Bundles now carry resident and virtual memory, thread count, child processes by name, open files and sockets, process uptime, and a census of live objects by type. Those figures separate causes that look identical from outside — a large virtual size against a modest resident one is address space rather than data, a rising thread count points somewhere quite different from a rising child-process count, and the object census names what a growing heap is filling up with. The object census is skipped on processes already above 2 GB, because walking the heap costs most on exactly the process that can least afford it; everything else is still collected. Child processes are recorded by executable name only — an ffmpeg command line carries the camera URL and its password. Collection happens off the main loop and every metric is best-effort, so a hardened kernel or restricted container that refuses one of them still produces a complete bundle. +- **Failure detection can now authenticate to a token-protected Obico ML API (#2733)** — Obico's `ml_api` container takes an optional `ML_API_TOKEN` environment variable; with it set, the container answers 401 to any detection request that doesn't carry that token, which is how you stop everything else on your network from using your inference server. Bambuddy never sent one, so the only way to use it was to remove the token from the server — a step the reporter had already taken for their Home Assistant setup and did not want to undo. **Settings → Failure Detection** now has an **ML API Token** field; leave it empty and requests go out exactly as before. Also worth knowing: this failed in the most confusing way possible, because Obico protects its detection endpoint but leaves its health endpoint open. Bambuddy's **Test** button pinged the open one, so it reported success against a server that was rejecting every real call, and detection just silently never fired. Test now checks both and says outright when a token is rejected, and the status card reports a rejected token as a rejected token rather than a bare HTTP error. Translated in all locales; wiki documents the token, the health-endpoint trap and how to recover from it. + ### Fixed +- **A heavy model failed to slice after five minutes with "Slicer sidecar unreachable" (#2730, reporter @kpp39)** — A MakerWorld model that Bambu Studio also takes a long time over never finished slicing in Bambuddy: five minutes in, it failed claiming the slicer sidecar could not be reached. **Root cause.** The slice request carried a fixed five-minute limit covering the whole operation, and it was applied to the wrong thing. Slicing is a single long request, so the limit was a ceiling on how long a model was allowed to take — not a check on whether anything had gone wrong. When it expired, the resulting error was indistinguishable from a genuine connection failure, so a slice that was progressing normally was reported as an unreachable sidecar. The reporter went and updated their sidecar container, which was never the problem: it was reachable throughout and still slicing when Bambuddy hung up on it. **Fix.** Bambuddy already polls the sidecar once a second for progress — that is what drives the live progress toast — so it can tell a slow slice from a stuck one, and now does. The limit applies to *silence*: a model that keeps reporting progress runs to completion however long it takes, and a slice is only abandoned when the slicer has said nothing for the configured period. The new **Slicer stall timeout** under Settings → Workflow → Slicer sets that period, defaulting to fifteen minutes, and the failure message now says the slice ran out of time and where to change it rather than blaming the connection. Sidecars too old to report progress have no liveness signal to offer, so for those the setting still bounds total slicing time — the previous behaviour, but configurable and no longer five minutes flat. A sidecar that genuinely cannot be reached still fails immediately and still says so. Translated in all locales; wiki documents the setting. Covered by tests for a slow-but-progressing slice completing, a stalled one failing, a frozen progress report not counting as progress, the two failure messages, and the setting falling back safely when unset or unparseable. +- **Deleted prints stayed in their project as cards with broken previews, and could not be removed (#2731, reporter @sroesner)** — Deleting a print that belonged to a project left it on the project page with a missing thumbnail, and there was no way to unassign it. **Root cause.** Deleting a print is a soft delete by default: the files go from disk, the row stays so Quick Stats keeps counting its filament, time and cost. Every other part of Bambuddy skips those rows — the projects module skipped none of them, so a deleted print kept its project link and kept being listed, pointing at a thumbnail that no longer existed. The same broken previews appeared on the project cards in the overview, not just the detail page, and in the project timeline, where clicking the entry led to an archive that no longer opens. Unassigning was impossible because the only way to change a print's project is from the Archives page, which correctly hides deleted prints — so the entry could be seen but never reached. **Fix.** A deleted print now leaves its project everywhere: the archive list, the card previews, the timeline, and the counts. Excluding it from the *counts* is a deliberate difference from how Quick Stats treats the same print — a project is a piece of work with a definite membership rather than a lifetime total, so a project that lists eleven prints should not claim twelve. Existing broken entries disappear on upgrade with nothing to clean up; the API can still clear a stale link if anything needs repairing. Two more places were counting deleted prints for the same reason and are fixed with it: the archive CSV/Excel export handed back rows the interface says are gone, and per-project failure analysis measured a failure rate against prints that had been deleted from the project. The project page also no longer needs a manual reload to catch up: deleting a print refreshed the archive list but nothing project-related, and assigning a print to a project refreshed the project cards but not the project page itself, so for the following minute either view could still be showing what was there before. Covered by tests for the listing, the card previews, the timeline, the counts, both services, the cache refresh, and a guard that a project's live prints are untouched by any of it. +- **A printer refusing Bambuddy's commands looked healthy, and its queue failed with the wrong advice (#2732, reporter @hennischd)** — Uploads succeeded, the printer echoed the job back, then sat idle for 270 seconds and the job was re-uploaded twice more before failing with a message about SD cards. Temperature changes returned success and did nothing. The connection diagnostic passed every check, and the support bundle said Developer Mode was on. **Root cause.** The printer was rejecting every control command and saying so: HMS `0500-0500-0001-0007`, "MQTT command verification failed" — the firmware's authorization check, which Bambu Lab documents Developer Mode as the way to disable. Nothing in Bambuddy connected that to anything. The error itself was received and then discarded by the frontend, because this code's meaning lives in bits that Bambuddy's short-code form throws away: it collapses to `0500_0007`, matches no catalog entry, and uncatalogued errors without firmware actions are filtered out of the badge count and the error list. Meanwhile the Developer Mode probe reads any response that isn't an explicit refusal as confirmation, and this firmware answers the probe with an empty result while refusing everything else — so Bambuddy inferred a healthy printer from a non-answer, and reported that inference as a passing diagnostic. **Fix.** HMS codes are now looked up by their full identifier before the short form, so this error survives to the screen, shows the four-group code the printer's own display shows, and carries the fix rather than Bambu's "update Studio or Handy" (which does not apply to a print sent from Bambuddy). The error is treated as authoritative about the printer's state: it sets Developer Mode to off regardless of what the probe concluded, which makes the diagnostic and the support bundle report the real situation, and it clears itself when the printer stops reporting it, so enabling Developer Mode and restarting is picked up without restarting Bambuddy. The probe no longer reads an inconclusive answer as confirmation — it reports what it knows, which for this firmware is nothing. A queue item whose command is rejected now fails on the first attempt naming the code and the fix, instead of spending three uploads and fifteen minutes of a farm's upload capacity to arrive at the wrong conclusion; a print that is visibly running is never touched, whatever HMS is lingering. Separately, the log hint suggesting a wrong or mis-cased serial number no longer fires in the moment after a reconnect, when the report counter it reads has just been reset and proves nothing — it cost this reporter a detour through their serial number on a printer whose serial was correct. Translated in all locales. Covered by tests for the code surviving the filter, the display form, the developer-mode override and its self-clearing, the inconclusive probe, first-attempt failure, the running-print guard and the suppressed hint. +- **A printer that dropped off MQTT could stay offline indefinitely (#2732, reporter @hennischd)** — In the same bundle, the printer lost its MQTT 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 the whole time. Bambuddy's stale-session detector only covers the other failure: a session that is still connected but has gone quiet. Once the connection is *down*, it returns immediately and the MQTT library's own retry is the only thing still watching; when that stops making progress, nothing notices. Bambuddy now runs a backstop sweep every minute. A printer that had a working session, has been silent for five minutes, and still answers on its MQTT port gets its client rebuilt from scratch with a fresh session — which also drops any command left unacknowledged on the dead one, so it cannot replay into the new session. Printers that are simply switched off are left alone: the port check tells the two apart, so there is no client churn and no nightly log spam for a farm that powers down. The rebuild is rate-limited per printer and never touches a connected one, and the log line names how long the printer was gone and what the last connection error was, so a session that dies repeatedly leaves a trail. Covered by tests for the recovery itself, the grace period, the switched-off case, the retry interval, and a farm sweep continuing past a printer that throws. +- **Support bundles reported Obico as monitoring no printers when it was monitoring all of them (#2733)** — The bundle read the list of monitored printers with a parser that split on commas and treated an empty value as "none". The setting is a JSON array, and empty means *all printers* — which is the default. So a working default Obico setup showed `obico_enabled: false` against every printer in its own support bundle, sending anyone reading it down the wrong path. The bundle now parses the setting the way the detection service does, and takes the global on/off switch into account. +- **A slot on Generic PLA offered only one K profile, however many the printer held (#2710, reporter @tommyboy180)** — The printer's Flow Dynamics table held nine calibrations, all of them under Generic PLA; Configure AMS Slot offered exactly one, the one already bound to the slot, and after a slot reset it offered none at all. The only way to assign the others was Bambu Studio. **Root cause.** Two independent faults, both triggered by picking a built-in generic preset. First, K profiles are matched to the selected preset by filament ID, but matches on Bambu's generic IDs (`GFL99` for Generic PLA, `GFG99` for Generic PETG, and so on) were deliberately thrown away as too broad — and since the comparison already requires both sides to carry the *same* ID, that exclusion could only ever fire when the chosen preset was itself the generic one, which is precisely the case where the match is right. Second, the matcher read the leading "Generic" in "Generic PLA" as a manufacturer, and so demanded the word "Generic" appear in the K profile's name; no real profile has it, which killed the name-based fallback as well. The one profile that did show up came from an unrelated safety net that always surfaces the slot's active profile, and a reset slot has none — hence the empty list. **Fix.** A K profile whose filament ID equals the selected preset's now matches, generic or not: the printer keeps one calibration table per filament ID, so a slot on Generic PLA offers everything calibrated under Generic PLA, whatever the user named those entries — "Dark Brown", "Marble", "Glow" and the rest now all appear. "Generic" is no longer treated as a brand, so profiles still match by material when a printer reports no filament ID at all. Since no name-and-ID matcher can be perfect against profile names the user invents, 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; picking one works exactly as before, because Bambuddy already realigns the slot's filament context to whichever profile is chosen. The same generic-ID rule now applies to the spool form's K profile suggestions, guarded so it can never cross materials — a PETG spool is never offered PLA calibrations — and so that a spool naming its own brand keeps its brand-specific suggestions. Translated in all locales. Covered by tests for the reporter's nine-profile printer, the reset slot, printers that send no filament ID, the other-profiles group and applying a profile from it, and by guards that a brand preset still does not sweep in generic profiles. +- **The print-complete photo showed the toolhead still printing, three minutes before the print ended (#2547, reporter @anthonyma94)** — The Discord photo caught the model mid-print with the head over it, instead of the finished print. **Root cause.** Bambuddy fired the photo the moment `layer_num` reached `total_layer_num`. That edge is the moment the printer *starts* its final layer, not the moment it finishes it: on the reporter's H2C it arrived at 92% with two minutes of print still to run, and the last layer took three minutes and seventeen seconds including a filament change. Worse, that trigger latched, which locked out both of the triggers that fire at a genuine end of print — so on printers that never report an end-of-print filament unload (H2C and A1 Mini confirmed) there was no way back to a correct photo. **Fix.** The last-layer trigger is gone. The photo is taken when the print reports itself finished, which is a signal every model sends and which lands after the toolhead has parked. Since the printer's own end G-code drops the plate about 100 mm just before that, Bambuddy now raises it back to just above the last printed layer, takes the photo, then lowers it again — restoring the framing asked for in #1145, #1397 and #1565. The plate move is an absolute Z to a height the toolhead occupied seconds earlier, so it stays inside the travel limits and keeps the nozzle above the part, and it is skipped outright unless Bambuddy can confirm the height belongs to this print: the sliced file is matched by print name, and its layer count is cross-checked against the layer count the printer reported. It is also skipped when another job is queued, when the printer has moved on, and when the new **Restore plate for finish photo** setting is off. Prints whose End G-code Bambuddy injected — SwapMod plate swaps and similar — are detected automatically and keep using a frame from during the print, since their model has left the bed by then (#1867); that frame now also refreshes through the final layer instead of freezing when it began. Prints that record a timelapse normally source the photo from the video's last frame and need no move at all, but when the video has not transferred in time — the usual outcome on P1-series — the live photo that ships in the notification now gets the same plate restore, so it is no longer a shot of an empty-looking lowered bed. The `stg_cur=22` trigger is left in place for any firmware that does emit it, but the bundle survey above still applies — in practice every model reaches the finish-state path. Translated in all locales; wiki updated. Covered by backend tests for the removed trigger, the two surviving ones, the plate move and every condition that suppresses it, and by frontend tests for the new setting. - **A model sliced for PETG printed as PLA, and the print dialog then refused to match PETG (#2712, reporter @kpp39)** — Slicing a MakerWorld model with a PETG profile produced G-code the printer wanted PLA for, and the Filament match step offered no way to correct it. **Root cause.** The list of filaments the slice dialog shows is positional: the first row is the printer's first slot, the second row its second, and so on down to the slicer itself. For a model that already carries slicing information, Bambuddy listed only the slots that print — which is the right answer when you are starting a print and Bambuddy has to match spools in the AMS, and the wrong one here. The reported model declares four filaments and paints with the fourth alone, so the dialog showed a single row; the PETG chosen in it became the *first* slot, and the fourth — the one the model actually prints with — kept the profile baked into the downloaded file. The result was a genuine PLA print, so the Filament match step was right to insist on PLA. **Fix.** When picking profiles for a slice, the dialog now lists every slot the project declares, with the ones this plate doesn't print with shown greyed out as before, so each row lines up with the slot it stands for and a choice made in the fourth row reaches the fourth slot. Starting a print is untouched and still asks only for the spools the job needs. Covered by tests for a source whose only printed slot is the fourth, for the print path keeping the shorter list, and for the chosen profile arriving in the right position. - **A finished slice produced a stream of a dozen "Sliced ..." notifications** — One slice reported itself complete over and over, a notification every second and a half for as long as twenty seconds. **Root cause.** While a slice runs, Bambuddy asks the server how it is getting on every 1.5 seconds — but it never waited for an answer before asking again. Slicing a large project keeps the server busy for seconds at a time, so those questions piled up unanswered, each one still believing the job was running. When the server caught up it answered all of them at once, and every single answer was treated as the moment the slice finished: one notification each, one list refresh each. The bigger the project, the longer the pile and the more notifications. **Fix.** Bambuddy now waits for an answer before asking the next question, so nothing can pile up and a busy server isn't asked to do more work while it is already behind. A job's completion is also recorded once and only once, and a check that was already in flight when the tracker restarts now stops instead of finishing its work — either of which is enough on its own to keep a duplicate off the screen. Covered by tests for a server stalled across many intervals, for two slices finishing where one restarts the tracker, and for the same job being tracked twice in a row still reporting both times. - **Slicing a single-plate project failed on filament slots the plate never prints with (#2711, reporter @kpp39, also seen by @phi-schi)** — Sending a MakerWorld project to the slicer was rejected with "filament preset ... (slot 1) is not compatible with printer ...", naming a slot the model doesn't use, and the slice modal deliberately locks the dropdowns for unused slots so there was no way to correct it by hand. **Root cause.** A project can declare more filaments than any one plate paints with — the reported model declares four and uses one — and the slicer validates every filament it is handed, not just the ones the print touches. Bambuddy already rewrote those unused entries to match a slot the plate really uses, but only when the plate number was part of the request. The modal omits it for single-plate projects, since there is no plate to choose, so the rewrite never ran for them — which is every model imported from MakerWorld. The three idle slots therefore arrived carrying whatever the source file had baked in, in this case profiles for an entirely different printer, and the slicer refused the job on the first one. **Fix.** A missing plate number now means the first plate, which is what it means everywhere else in the slicing path, so single-plate projects get the same treatment multi-plate ones already had. **Also fixed:** "Slice all plates" reached the same code, where it isn't a plate number at all. On a project with a dedicated support filament that combination could rewrite every colour to the support material and silently slice a multi-colour model in one filament — it is now excluded, since across all plates no slot is unused. Covered by tests for a single-plate slice with no plate number in the request, for slice-all leaving every slot untouched, and for the support-filament case specifically. @@ -38,7 +53,9 @@ All notable changes to Bambuddy will be documented in this file. - **A long-running camera stream could eventually stall itself, with nothing in the log to explain it (#2707)** — ffmpeg's error output was only read when something had already gone wrong, which meant that for the entire life of a working stream nobody read it. ffmpeg writes a startup banner, its analysis of the incoming video, and then a progress line at a steady rate, and the operating system only buffers a fixed amount of that before it stops the writer. Once that happened ffmpeg would block trying to write the next line, stop producing frames, and the stream's own timeout would fire — reported as `RTSP read timeout` and a reconnect, with no indication that Bambuddy had starved it. How long it takes to reach that point is unmeasured and clearly long: one stream ran 21 minutes 36 seconds without trouble, so this is a limit that was being ignored rather than a fault anyone has reported. **Fix.** A streaming ffmpeg's error output is now read continuously and the most recent portion kept, so the limit cannot be reached. The kept portion is what gets logged when a stream does fail, which is more useful than before: it holds what ffmpeg said as things went wrong, where reading the buffer on demand returned whatever it had printed first — usually the startup banner, which is then discarded as noise. Credentials are masked on this path through the same single funnel as every other camera log. Covered by tests for continuous draining, the size bound, keeping the newest output, credential masking, and the ownership handover with the shutdown path — reading the same output from two places at once is an error, so only one owner reads it at a time. - **Reopening the camera quickly could leave the new stream invisible to Bambuddy (#2707)** — Closing a camera view and opening it again straight away could leave the newly started stream unregistered, even though it was running and showing frames. The consequences were all indirect, which is what made it hard to spot: Bambuddy believed no viewer was attached, so Obico polling and snapshots would open a second camera connection and fight the live view — precisely what the guards added in #1348 and #1271 exist to prevent; the background cleanup task saw a camera process with no stream attached to it and killed the live stream as an orphan, usually within a minute; and pressing Stop reported that it had stopped nothing while the view was still running. **Root cause.** Each printer's fan-out stream was registered under a key derived from the printer alone, so every successive stream for that printer reused the same key, and the departing stream's cleanup removed whatever was registered under it — including its own replacement. The same cleanup also cleared the printer's most recent camera frame unconditionally, discarding the new stream's frame. It needed the old and new streams to overlap, which the four-second teardown fixed above made easy to hit. **Fix.** Each stream now gets its own registry key, so one stream can only ever clean up after itself — the same approach the external-camera path already uses (#2675) — and the shared per-printer frame is only released when no stream for that printer is left running. Covered by tests for both halves, including one that drives the real cleanup path with a second stream already registered. - **Closing the camera held the printer's camera connection for four more seconds, then logged an error that wasn't true (#2707)** — Every time a camera view closed, the log recorded `ffmpeg didn't terminate gracefully, killing` and then `ffmpeg did not exit within 2.0s of SIGKILL; abandoning wait`. Both waits expired every single time, so each close cost a fixed four seconds — and because Bambu firmware allows exactly one camera connection, that was four seconds in which nothing else could use the camera: reopening the view, a snapshot, Obico, or the diagnostic. **Root cause.** ffmpeg is started with its output and error streams as pipes, and the shutdown path had stopped reading them. A process whose output pipe is full blocks mid-write, and ffmpeg's shutdown signal only sets a flag that its main loop checks on the next pass, so the polite request could never be acted on and the grace period was dead time. The forced kill did work — but Python cannot report an exit while a pipe is still unread, so the second wait expired too and Bambuddy concluded the process was stuck when it had already gone. Measured at 4.00s per close before, ~0.15s after. **Fix.** Both pipes are now drained while the process is being stopped, which makes the polite shutdown effective and the exit observable. The forced-kill path and its time limit remain as backstops, so a genuinely wedged process still can't hang a stream, a Stop request, or the cleanup task. This also corrects the conclusion recorded for #2580: that 12-hour hang was the unbounded form of this same self-inflicted stall rather than a stuck ffmpeg, so bounding the wait had capped the symptom without removing the cause. Covered by tests that drive a real subprocess — the fault lives in Python's pipe bookkeeping, so a stand-in object would pass against the broken code — including one that verifies a process ignoring the polite signal still has its forced exit observed rather than abandoned. +- **A crash or restart mid-print left layer-timelapse files behind forever (#2709, reporter @bitbarista)** — `timelapse_frames/` grew slowly and never shrank on its own. After several routine restarts during testing, it had accumulated 38MB: three abandoned frame directories and two 48-byte `.mp4` files from stitches that never finished writing. **Root cause.** Which timelapse session is active lives only in memory. A restart for any reason — a redeploy, a crash, a power loss — loses that bookkeeping instantly, but the frames already written for that session, and any partially-stitched output file, stay on disk with nothing left that knows they exist. `on_print_complete`'s cleanup never runs for them, because nothing calls it: the session it would clean up no longer has an entry to be found by. A restart-recovered print doesn't get a replacement session either (`_maybe_start_layer_timelapse` only fires on a fresh `PRINT_START` event, #1353), so an orphaned directory can never be resumed or claimed by anything — it just sits there. **Fix.** A one-time sweep on startup removes any `timelapse_frames//` entry that doesn't match a session that printer is currently recording or stitching, and that is old enough not to be a startup race (five minutes). Only this feature's own artifacts are swept — a frames directory or a `timelapse_.mp4` — so anything else that ends up under that folder is left alone rather than deleted for being old. Verified against the real 38MB of leftovers: startup logged each of the five removed by name, and the directory dropped to 8KB. Covered by tests for the orphaned-directory and stray-output-file cases, sparing a genuinely active session, sparing one that is mid-stitch (the window where the session has already been handed to ffmpeg and is no longer listed as active), sparing anything too recent to be sure about, leaving unrelated files alone, not counting a removal that failed as a success, and two defensive cases (no `timelapse_frames/` directory yet, an unrelated non-numeric entry under it). - **Two snapshots taken at the same moment opened two competing camera connections (#2705, reporter @gzimbric)** — Bambu firmware allows exactly one camera connection at a time. Bambuddy already knew this: a snapshot taken while somebody is watching the live view reuses the viewer's frame instead of opening a second socket. What nothing covered was two *snapshots* overlapping with no viewer attached at all — an Obico poll and a printer-wall refresh landing 200 ms apart, each correctly concluding it wasn't competing with a viewer, and then colliding with each other. On the reporter's P2S this knocked over the live stream that was feeding the camera wall, which was then reaped for having received no frames for 58 seconds. Eight paths take one-shot frames independently — Obico polling, `/camera/snapshot`, the finish-photo capture and its disk-writing sibling, plate detection, the camera connection test, and the diagnostic — so any pair of them could overlap, and a shorter Obico interval widened the window. **Fix.** Simultaneous captures for the same printer now share one connection: the first opens it, everyone arriving while it is in flight gets the same frame. Every consumer here wants "a recent frame" rather than a frame stamped at its own microsecond, so identical bytes are the right answer. This shares captures, it does not cache them — a request arriving after the previous capture finished still takes a fresh frame, because plate detection and the finish photo judge a running print from these images and a stale frame there is worse than a slow one. Each caller keeps its own deadline (they range from 10 to 30 seconds) rather than inheriting whichever one happened to open the connection, giving up alone leaves the capture running for whoever else is waiting on it, and a capture that fails doesn't hand its failure to callers that never got an attempt of their own — they retry, which by then competes with nothing. One visible consequence: when the **Diagnose** tool shares a capture this way its frame-capture stage is labelled `coalesced_capture`, because the pass is real but the timing shown is mostly time spent waiting, and a diagnostic must not report on a connection it never opened. Wiki updated. Covered by tests for the reported collision, the five-callers-one-connection case the reporter verified on live hardware, per-printer isolation, staying coalescing rather than becoming a cache, registry cleanup, a failed capture not poisoning its followers, bounded retry, a follower abandoning its wait without sabotaging the capture, and cancellation from either side. +- **The same one-shot-capture collision could happen on external cameras too, with no viewer attached (#2707 follow-up, reporter @bitbarista)** — #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 kind of 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, with `is_stream_active()` unable to help since that guard only stops a capturer from competing with an *attached viewer*, not with another capturer. **Fix.** The same shape of fix as #2705, applied to `capture_frame()`: concurrent callers for the same camera (URL, type, and — since #1177's snapshot override routes to a different endpoint entirely — snapshot URL) share one capture rather than opening a second connection. Coalesces, does not cache, so a call after the previous one finishes always captures fresh. Each caller keeps its own timeout, giving up leaves the capture running for whoever else is waiting, and a capture that fails doesn't hand its failure to a caller that never got a turn of its own. One visible consequence, mirroring the label #2705 added to the built-in **Diagnose** tool: pressing **Test** on an external camera while a capture is already running now says the frame was shared with it, because the result is real but the test did not open a connection of its own — and forcing one would be the very second handle this change exists to prevent. Covered by tests mirroring #2705's: the reported-shape collision, five callers sharing one connection, per-camera and per-snapshot-URL isolation, staying coalescing rather than becoming a cache, registry cleanup, a failed capture not poisoning its followers, bounded retry, a follower abandoning its wait without sabotaging the capture, and cancellation from either side — plus, for this path specifically, that an unexpected error is reported as a failed capture rather than raised at every waiting caller at once, and that the shared-capture log lines redact credentials, since an RTSP camera URL routinely carries `user:pass@` where the built-in path's key is only an IP address. - **Auto-matched filament showed a green tick when the colour was plainly wrong (#2687, reporter @pchulpjoost)** — The Filament Mapping panel reported a slot as matched, with the header reading **(Ready)**, while the swatch beside it showed the slice wanted dark red and the tray it had picked held Dark Green. Manually selecting that very same tray from the dropdown correctly reported the colour mismatch, which is what made the disagreement so visible. **Root cause.** Auto-match ranks candidate trays by filament preset ID (`tray_info_idx`) first, and when exactly one loaded tray carried the preset the slice asked for, that tray was accepted as a *definitive* match on the assumption "same preset means same spool, so the colour must agree too". The preset ID names the **variant**, not the spool — `GFA00` is PLA Basic, `GFA01` PLA Matte, `GFA17` PLA Translucent, in every colour Bambu sells it. So a user with one Matte spool loaded matched every Matte requirement regardless of colour, and the colour comparison was never reached. This is why the report came in for PLA Matte in particular: generic PLA Basic is usually loaded several times over, which sent the match down a different path that did compare colours correctly. **Fix.** The colour verdict is now taken from the tray that was actually selected, never from which rule selected it, and the automatic and manual paths share one comparison so they cannot drift apart again. The preset still decides *selection*, because the Basic/Matte/Silk distinction matters ([#2650](https://github.com/maziggy/bambuddy/issues/2650)) — a wrong-coloured tray of the right variant is still chosen, but it is now reported as an amber **Color mismatch** instead of a green tick, and you can print anyway or pick another slot. A near-enough shade still counts as a match, and a 3MF that specifies no colour for a slot is satisfied by any colour rather than being flagged. Dispatch behaviour is unchanged: **Force color match** already required an exact colour before sending a job, so nothing was ever printed in the wrong colour because of this — the panel was simply telling you it was fine when it wasn't. Frontend-only. Wiki updated. Covered by tests for the unique-preset wrong-colour case, agreement between the auto and manual verdicts, the near-shade and colourless-requirement cases, and the multi-preset path that already worked. - **P1-series archives kept the worse finish photo when the timelapse arrived late (#2704 follow-up)** — When a print records a timelapse, Bambuddy prefers the video's last frame as the finish photo: the firmware stops recording after the toolhead parks but before the end G-code drops the bed, so it frames the finished print properly, where a live camera grab at that moment catches an already-lowered plate. Bambuddy waited 60 seconds for the video and then gave up, because the print-complete notification is waiting on that photo and holding a notification for minutes is worse than sending it with the live grab. On P1-series printers the video usually arrives later than that — they write MJPEG AVI instead of H.264 MP4 and serve it slowly, so across the support bundles their median was 33 seconds but the 90th percentile was 167 and the slowest observed was 546; every other model finished inside 26 seconds. The result was that the printers most in need of the better photo were the ones that never got it. **Fix.** The notification still goes out on the same 60-second bound with the live grab, so nothing gets slower. If the video was still on its way when that bound expired, Bambuddy now keeps waiting in the background and adds the extracted frame to the archive when it lands, at the front of the photo list so opening the gallery shows it first. The live grab is kept rather than replaced — the notification that already went out links to that exact file, and removing it would leave a broken image in Discord or Telegram. Covered by tests for the ordering, the longer budget, idempotency and the cases where the video never arrives. - **Timelapses that never got attached, and a Scan button that could not find them (#2704)** — Timelapse was on for the print, the video never arrived in the archive, and pressing **Scan for Timelapse** afterwards turned up nothing. Measured across 247 support bundles, this was not rare: of 457 automatic scans only 262 ever attached a video. **Root cause, part one.** The scan looked four times, at 5, 10, 20 and 30 seconds, then stopped. The printer writes the video only after the print ends and a long print makes a large file, so it often arrived after the last look — the attempt that found the video was the first one 272 times and then 17 / 13 / 13, a flat tail against the cutoff rather than a decaying one. What ran after those four attempts was a fallback that searched for the print's name inside the video filename; Bambu firmware only ever writes `video_`, so in 247 bundles it fired 159 times and matched exactly zero. **Root cause, part two.** The manual Scan button had no such snapshot to work from and matched by filename timestamp, by FTP modification time, or by there being exactly one video on the printer — all of which read a clock the printer cannot set, because a printer in LAN Only mode never reaches Bambu's time server. The reporter's P1S was six and a half days out, which defeats every one of those. **Fix.** The automatic scan now polls for several minutes instead of giving up after about a minute, and the name-match fallback is gone. The list of videos present when the print started is saved with the archive, so the comparison survives a Bambuddy restart mid-print and the manual Scan button can use it too — same clock-independent comparison, no timestamps anywhere. When a previous print's video lands late and two files look new, the one already attached to another archive is ruled out by name rather than by picking whichever the printer listed first, which could attach the wrong video. **Bambuddy now deletes a timelapse from the printer once it has been archived**, which keeps the printer's folder down to unclaimed videos and stops P1-series cards filling up with AVIs; your copy is in the archive, where you can watch, edit, download or remove it. That delete only happens after the transfer has been checked against the size the printer reported — which also fixes a silent truncation: an FTPS transfer that ended early produced a partial video that was attached as though it were complete. Because the first look happens seconds after the print ends — while the printer may still be writing the video — the file is also re-checked afterwards and only accepted once it has stopped growing, so a partial video is never mistaken for a finished one and the printer's copy is never removed on the strength of one. Wiki updated. Covered by tests for candidate selection, the download check gating the delete, the poll bounds, baseline persistence and the manual scan. diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py index 4c8b5a070..116f7b11f 100644 --- a/backend/app/api/routes/archives.py +++ b/backend/app/api/routes/archives.py @@ -3905,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": @@ -3930,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), ) diff --git a/backend/app/api/routes/github_backup.py b/backend/app/api/routes/github_backup.py index c58d04107..d3165ba03 100644 --- a/backend/app/api/routes/github_backup.py +++ b/backend/app/api/routes/github_backup.py @@ -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, @@ -75,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 { diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py index e3e39b7b0..98b64e6e6 100644 --- a/backend/app/api/routes/library.py +++ b/backend/app/api/routes/library.py @@ -3082,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": @@ -3107,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), ) @@ -3607,6 +3609,8 @@ async def _run_slicer_with_fallback( SlicerApiService, SlicerApiUnavailableError, SlicerInputError, + SlicerTimeoutError, + get_stall_timeout_seconds, ) user: User | None = None @@ -3717,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 @@ -3960,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: diff --git a/backend/app/api/routes/obico.py b/backend/app/api/routes/obico.py index 43a61c49e..d44a605de 100644 --- a/backend/app/api/routes/obico.py +++ b/backend/app/api/routes/obico.py @@ -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") @@ -65,10 +67,15 @@ 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}") diff --git a/backend/app/api/routes/orca_cloud.py b/backend/app/api/routes/orca_cloud.py index 50bbe74d9..b90a6cced 100644 --- a/backend/app/api/routes/orca_cloud.py +++ b/backend/app/api/routes/orca_cloud.py @@ -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 diff --git a/backend/app/api/routes/print_queue.py b/backend/app/api/routes/print_queue.py index 0346d8efe..345464e9f 100644 --- a/backend/app/api/routes/print_queue.py +++ b/backend/app/api/routes/print_queue.py @@ -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( diff --git a/backend/app/api/routes/projects.py b/backend/app/api/routes/projects.py index 2ca64b14e..7b0d2e041 100644 --- a/backend/app/api/routes/projects.py +++ b/backend/app/api/routes/projects.py @@ -52,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 @@ -83,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) @@ -104,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) @@ -212,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) @@ -237,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) ) @@ -365,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 @@ -498,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 @@ -715,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) @@ -804,7 +820,7 @@ async def get_project_file_progress( func.count(PrintLogEntry.id), ) .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id) - .where(PrintArchive.project_id == project_id, PrintLogEntry.status == "completed") + .where(PrintArchive.project_id == project_id, PrintLogEntry.status == "completed", _LIVE_ARCHIVE) .group_by(PrintArchive.library_file_id, PrintArchive.content_hash, PrintArchive.filename) ) @@ -1580,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) ) diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index a89ab21e7..c90f83966 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -164,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", diff --git a/backend/app/api/routes/support.py b/backend/app/api/routes/support.py index c5eafb3cf..f2c0b2ae7 100644 --- a/backend/app/api/routes/support.py +++ b/backend/app/api/routes/support.py @@ -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[""] = names.get("", 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, diff --git a/backend/app/api/routes/virtual_printers.py b/backend/app/api/routes/virtual_printers.py index a8f11d141..e314fe272 100644 --- a/backend/app/api/routes/virtual_printers.py +++ b/backend/app/api/routes/virtual_printers.py @@ -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: diff --git a/backend/app/core/database.py b/backend/app/core/database.py index dc743aba6..d1fe02596 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -1363,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. diff --git a/backend/app/main.py b/backend/app/main.py index 3ddaac4db..c80629d9e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -81,6 +81,7 @@ from backend.app.core.database import async_session, engine, init_db from backend.app.core.tasks import spawn_background_task from backend.app.core.websocket import ws_manager from backend.app.models.smart_plug import SmartPlug +from backend.app.services import print_dispatch_context from backend.app.services.archive import ArchiveService, peek_plate_index_in_3mf, swap_plate_suffix from backend.app.services.archive_purge import archive_purge_service from backend.app.services.bambu_ftp import ( @@ -347,6 +348,12 @@ _active_prints: dict[tuple[int, str], int] = {} # captures the better-framed pre-bed-drop moment without us having to force # timelapse on at dispatch (the #1397 mechanism that caused #1721's per-layer # nozzle parking on slicer profiles with Timelapse Type = Smooth). +# +# #2708: the bytes in here are ALWAYS already rotated by the printer's +# camera_rotation. `on_finish_photo_moment` owns that, because one of its +# sources (the #1867 in-print bank) is rotated before it ever reaches the +# bank and the others are not — so the consumer can't tell them apart and +# must not rotate again. _stage22_finish_frames: dict[int, bytes] = {} # #1790: per-printer producer-done event. Set by `on_finish_photo_moment` in its @@ -359,14 +366,17 @@ _stage22_finish_frames: dict[int, bytes] = {} _stage22_finish_in_flight: dict[int, asyncio.Event] = {} # #1867: rolling "last in-print camera frame" per printer. Refreshed on -# layer-change while the model is still printing, then consumed by the -# FINISH-state finish-photo path. Firmware that never emits `stg_cur=22` -# (A1 Mini, confirmed) only reaches `on_finish_photo_moment` at the -# gcode_state=FINISH transition — which Bambu reports AFTER the user End -# G-code (e.g. SwapMod plate-swap) has run, so a live grab there captures the -# swapped/empty plate. Banking is layer-driven, so it naturally freezes at the -# final object layer: the End G-code emits no further layer_num increases, so -# the last banked frame is always the finished print before the swap. +# layer-change and on print-progress advances (#2547) while the model is still +# printing, then consumed by the FINISH-state finish-photo path when the +# dispatcher recorded that it injected End G-code into this print. Bambu +# reports gcode_state=FINISH AFTER the user End G-code (e.g. SwapMod +# plate-swap) has run, so a live grab there would capture the swapped/empty +# plate. +# +# The load-bearing property: both drivers are print telemetry that stops before +# the End G-code executes — no further layer_num increases, and mc_percent +# freezes — so the last banked frame is always the finished print before the +# swap. Anything added as a third driver must hold that same property. _inprint_frame_bank: dict[int, bytes] = {} # Monotonic timestamp of the last banked frame per printer — throttles banking # so tall prints don't add a camera grab on every layer. @@ -1052,6 +1062,7 @@ def _maybe_start_layer_timelapse(printer, printer_id: int, archive_id: int) -> b printer.external_camera_url, printer.external_camera_type or "mjpeg", snapshot_url=printer.external_camera_snapshot_url, + rotation=getattr(printer, "camera_rotation", 0), ) logging.getLogger(__name__).info("Started layer timelapse for printer %s, archive %s", printer_id, archive_id) return True @@ -2268,13 +2279,21 @@ async def _capture_snapshot_for_notification(printer_id: int, printer, logger) - async def _maybe_bank_inprint_frame(printer_id: int, layer_num: int) -> None: """#1867: bank a recent in-print camera frame for the finish photo. - Called on every layer change. Grabs one frame (throttled) into - ``_inprint_frame_bank`` so the FINISH-state finish-photo path has a - pre-swap image on firmware that never emits ``stg_cur=22``. Because it is - driven by layer_num increases, banking stops the instant printing ends and - the End G-code (e.g. SwapMod plate swap) runs — no further layer changes - arrive — so the last banked frame is the finished print, not the swapped - plate. Best-effort: any failure just leaves the previous banked frame. + Called on every layer change and (#2547) on every print-progress advance. + Grabs one frame (throttled) into ``_inprint_frame_bank`` so the finish-photo + path has a pre-End-G-code image for prints that end with a plate swap. + + Both drivers are print telemetry that stops the instant printing ends: no + further layers, and progress freezes before the End G-code (e.g. SwapMod + plate swap) executes. So the last banked frame is always the finished print, + never the swapped plate — that property is what the #1867 path relies on and + it must survive any change to the throttle below. + + Layer changes alone were not enough: they stop when the *final* layer + begins, which on a three-minute last layer left the bank stale by the whole + length of that layer (#2547). Progress keeps ticking through it. + + Best-effort: any failure just leaves the previous banked frame. """ logger = logging.getLogger(__name__) client = printer_manager.get_client(printer_id) @@ -2286,12 +2305,16 @@ async def _maybe_bank_inprint_frame(printer_id: int, layer_num: int) -> None: if state.mc_print_sub_stage not in (None, 0): return - total = state.total_layers or 0 - is_last_layer = total > 0 and layer_num >= total + # #2547: throttled uniformly, with no last-layer exemption. The old code + # bypassed the throttle on the final layer to guarantee a fresh frame there; + # now that progress advances also drive banking, that exemption would fire a + # camera grab on every percent tick of the last layer. Bambu printers accept + # one RTSP client at a time, so each grab contends with the live view. now = time.monotonic() last = _inprint_frame_bank_ts.get(printer_id, 0.0) - if not is_last_layer and (now - last) < _INPRINT_BANK_MIN_INTERVAL: + if (now - last) < _INPRINT_BANK_MIN_INTERVAL: return + total = state.total_layers or 0 try: async with async_session() as db: @@ -2321,26 +2344,9 @@ async def _maybe_bank_inprint_frame(printer_id: int, layer_num: int) -> None: def _apply_camera_rotation(image_data: bytes, printer, logger) -> bytes: """Apply camera rotation to snapshot image if configured.""" - rotation = getattr(printer, "camera_rotation", 0) - if not rotation or rotation == 0: - return image_data + from backend.app.services.camera import apply_camera_rotation - 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() - logger.info("[SNAPSHOT] Applied %d° rotation: %s → %s bytes", rotation, len(image_data), len(rotated)) - return rotated - except Exception as e: - logger.warning("[SNAPSHOT] Failed to apply rotation: %s", e) - return image_data + return apply_camera_rotation(image_data, getattr(printer, "camera_rotation", 0), logger) async def _send_print_start_notification( @@ -2465,6 +2471,10 @@ async def on_print_start(printer_id: int, data: dict): # the previous job's banked frame. _inprint_frame_bank.pop(printer_id, None) _inprint_frame_bank_ts.pop(printer_id, None) + # #2547: bind (or clear) the "this print ends with injected End G-code" flag. + # Unconditional, so a print Bambuddy didn't dispatch drops the previous + # print's flag instead of inheriting it. + print_dispatch_context.adopt(printer_id) # Cancel any active bed cooldown waiter for this printer if _bed_cool_waiters.pop(printer_id, None): @@ -3998,6 +4008,7 @@ async def _capture_finish_photo_from_timelapse( archive_id: int, archive_dir: Path, timeout: float | None = None, + rotation: int = 0, ) -> tuple[str | None, bool]: """Wait for the per-print timelapse to land on the archive and extract its last frame as the finish photo (#1397). @@ -4017,11 +4028,16 @@ async def _capture_finish_photo_from_timelapse( video landed (whether or not extraction worked), because in that case waiting longer changes nothing. The caller uses that to decide between falling back permanently and scheduling a background upgrade. + + ``rotation`` is the printer's camera_rotation, applied to the extracted + still (#2708) so this source agrees with every other finish-photo source. + The archived video itself is the printer's own file and is left alone — + rotating it would mean re-encoding it. """ import uuid from backend.app.models.archive import PrintArchive - from backend.app.services.camera import extract_video_last_frame + from backend.app.services.camera import apply_camera_rotation_to_file, extract_video_last_frame logger = logging.getLogger(__name__) @@ -4044,6 +4060,7 @@ async def _capture_finish_photo_from_timelapse( filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg" output_path = photos_dir / filename if await extract_video_last_frame(video_path, output_path): + await apply_camera_rotation_to_file(output_path, rotation, logger) logger.info( "[PHOTO-BG] Extracted finish photo from timelapse %s for archive %s", video_path.name, @@ -4068,7 +4085,7 @@ async def _capture_finish_photo_from_timelapse( await asyncio.sleep(poll_interval) -async def _upgrade_finish_photo_from_timelapse(archive_id: int, archive_dir: Path) -> None: +async def _upgrade_finish_photo_from_timelapse(archive_id: int, archive_dir: Path, rotation: int = 0) -> None: """Add the timelapse's last frame to an archive after the fact (#2704). The print-complete notification waits only ~60s for the video, because @@ -4087,7 +4104,7 @@ async def _upgrade_finish_photo_from_timelapse(archive_id: int, archive_dir: Pat logger = logging.getLogger(__name__) filename, _ = await _capture_finish_photo_from_timelapse( - archive_id, archive_dir, timeout=_FINISH_PHOTO_UPGRADE_TIMEOUT_SECONDS + archive_id, archive_dir, timeout=_FINISH_PHOTO_UPGRADE_TIMEOUT_SECONDS, rotation=rotation ) if not filename: logger.info("[PHOTO-UPGRADE] No timelapse frame for archive %s; keeping the live grab", archive_id) @@ -4307,6 +4324,207 @@ async def reconcile_stale_active_prints(printer_id: int) -> int: return reconciled +# #2547: clearance left between the nozzle and the top of the print when the +# plate is commanded back into camera framing. The nozzle is parked away from +# the part by then, so this is belt-and-braces against a max_z_height that +# under-reports (e.g. a slicer that excludes a final Z hop). +_PLATE_RESTORE_CLEARANCE_MM = 10.0 +# How far below the restored position to drop the plate again afterwards, so +# the print is as reachable as Bambu's own end G-code leaves it. Matches the +# stock `G1 Z{max_layer_z + 100}`; the firmware clamps it to the travel limit +# on machines with less headroom. +_PLATE_PARK_DROP_MM = 100.0 +# Feedrate for both moves. F600 is exactly what Bambu's own end G-code uses on +# this axis, so it is a proven-safe speed for the full travel. +_PLATE_RESTORE_FEEDRATE = 600 +# Time allowed for the plate to reach the restored position before the camera +# grab. Sized for the ~100 mm the stock end G-code drops at F600 (10 mm/s). +_PLATE_RESTORE_SETTLE_SECONDS = 12.0 +# How long `_background_finish_photo` waits for this producer. Must cover the +# settle window plus a worst-case RTSP grab (15s), and stay below the +# notification path's own photo wait so a slow producer degrades to a +# photo-less notification rather than a missed one. +_FINISH_PHOTO_PRODUCER_WAIT_SECONDS = _PLATE_RESTORE_SETTLE_SECONDS + 23.0 + + +async def _max_z_for_current_print(printer_id: int, data: dict, logger) -> float | None: + """Height of the print that just finished on ``printer_id``, or None (#2547). + + This number becomes the target of a real Z move, so every step here refuses + rather than guesses. A height belonging to some *other* print is the one + failure that could drive the nozzle into the model: 20 mm carried onto a + 200 mm print would command the plate up through the part. + + Two independent things therefore have to agree before a height is returned: + + 1. **Identity.** The archive is matched by the finished print's own + ``subtask_name``, by equality rather than a ``LIKE``, so "Cube" can never + resolve to "Cube v2". Matching on "most recent archive for this printer" + is not good enough — ``on_print_complete`` pops the ``_active_prints`` + binding concurrently with us, and a print Bambuddy failed to archive + would silently resolve to its predecessor. + 2. **Corroboration.** The archive's layer count (parsed from the 3MF) has to + match the layer count the printer itself reported over MQTT for the print + that just ended. These come from genuinely different sources, so a + mismatch means the row is not this print, whatever its name says. + + ``completed`` is accepted alongside ``printing`` only because + ``on_print_complete`` may already have flipped the status by the time we + run; the identity check above is what actually selects the row. + """ + subtask_name = (data.get("subtask_name") or "").strip() + if not subtask_name: + # Nothing to identify the print by — refuse rather than fall back to + # "whatever ran last on this printer". + logger.info("[PLATE-RESTORE] printer %s: print has no name to match on — skipping", printer_id) + return None + + try: + from backend.app.models.archive import PrintArchive + from backend.app.utils.threemf_tools import extract_max_z_height_from_3mf + + async with async_session() as db: + result = await db.execute( + select(PrintArchive) + .where( + PrintArchive.printer_id == printer_id, + PrintArchive.status.in_(("printing", "completed")), + PrintArchive.deleted_at.is_(None), + or_( + PrintArchive.print_name == subtask_name, + PrintArchive.filename == subtask_name, + PrintArchive.filename == f"{subtask_name}.3mf", + PrintArchive.filename == f"{subtask_name}.gcode.3mf", + ), + ) + .order_by(PrintArchive.id.desc()) + .limit(1) + ) + archive = result.scalar_one_or_none() + if archive is None or not archive.file_path: + logger.info("[PLATE-RESTORE] printer %s: no archive matches %r — skipping", printer_id, subtask_name) + return None + + client = printer_manager.get_client(printer_id) + reported_layers = getattr(getattr(client, "state", None), "total_layers", None) + if reported_layers and archive.total_layers and reported_layers != archive.total_layers: + logger.warning( + "[PLATE-RESTORE] printer %s: archive %s says %s layers but the printer reported %s " + "— refusing to move the plate on a height that may not be this print's", + printer_id, + archive.id, + archive.total_layers, + reported_layers, + ) + return None + + path = Path(archive.file_path) + if not path.is_absolute(): + path = Path(app_settings.data_dir) / path + return await asyncio.to_thread(extract_max_z_height_from_3mf, path, archive.plate_id or 1) + except Exception as e: + logger.debug("[PLATE-RESTORE] printer %s: no usable print height: %s", printer_id, e) + return None + + +async def _restore_plate_for_finish_photo(printer_id: int, max_z_height: float, logger) -> bool: + """Raise the plate back into camera framing before the finish photo (#2547). + + Bambu's end G-code drops the plate ~100 mm as the last thing it does, so by + the time ``gcode_state`` reaches FINISH the finished print sits far below + the camera's natural framing — the complaint behind #1145, #1397 and #1565. + This commands an absolute ``G1 Z`` back to just above the last printed + layer. + + Absolute, not relative, is the whole safety argument. ``max_z_height + + clearance`` is a height the toolhead was physically at seconds earlier, so + it is inside the travel limits by construction and leaves the nozzle above + the part. It is also unambiguous across model families: Z is the + nozzle-to-bed gap whether the bed moves (X1/P1/H2) or the toolhead does + (A1), so unlike the relative bed-jog path (#1334) there is no sign to get + wrong. ``M211`` is never touched — see the bed-jog docstring for why + (#2579). + + Returns True if the move was sent and waited out, False if it was skipped. + """ + client = printer_manager.get_client(printer_id) + if client is None: + return False + + # Re-read state immediately before commanding motion. If the queue has + # already started the next print, the printer is no longer ours to move. + state = getattr(client, "state", None) + if state is None or state.state != "FINISH": + logger.info( + "[PLATE-RESTORE] printer %s is in state %s, not FINISH — skipping", + printer_id, + getattr(state, "state", "unknown"), + ) + return False + + target_z = max_z_height + _PLATE_RESTORE_CLEARANCE_MM + if not client.send_gcode(f"G90\nG1 Z{target_z:.2f} F{_PLATE_RESTORE_FEEDRATE}"): + logger.warning("[PLATE-RESTORE] printer %s: send failed — capturing where it is", printer_id) + return False + + logger.info( + "[PLATE-RESTORE] printer %s: plate to Z%.2f (print top %.2f + %.1f clearance), settling %.0fs", + printer_id, + target_z, + max_z_height, + _PLATE_RESTORE_CLEARANCE_MM, + _PLATE_RESTORE_SETTLE_SECONDS, + ) + await asyncio.sleep(_PLATE_RESTORE_SETTLE_SECONDS) + return True + + +def _park_plate_after_finish_photo(printer_id: int, max_z_height: float, logger) -> None: + """Drop the plate again after the finish photo (#2547). + + Without this the user walks up to a finished print sitting just under the + nozzle, which is exactly the position Bambu's end G-code goes out of its way + to avoid — awkward to lift the plate out, and easy to knock the toolhead. + Fire-and-forget: if it doesn't land, the plate is merely high, and the next + print homes anyway. + """ + client = printer_manager.get_client(printer_id) + state = getattr(client, "state", None) if client else None + if client is None or state is None or state.state != "FINISH": + return + client.send_gcode(f"G90\nG1 Z{max_z_height + _PLATE_PARK_DROP_MM:.2f} F{_PLATE_RESTORE_FEEDRATE}") + logger.debug("[PLATE-RESTORE] printer %s: plate returned to unload height", printer_id) + + +async def _plate_restore_is_blocked_by_queue(printer_id: int) -> bool: + """True if a queue item is about to take this printer (#2547). + + The scheduler dispatches the next job the moment a print completes, and a + plate move interleaved with a print start is not a race worth having. The + state re-check in ``_restore_plate_for_finish_photo`` closes the tail of + this window; this closes the head of it. + """ + try: + from backend.app.models.print_queue import PrintQueueItem + + async with async_session() as db: + result = await db.execute( + select(PrintQueueItem.id) + .where( + PrintQueueItem.printer_id == printer_id, + PrintQueueItem.status.in_(("pending", "printing")), + ) + .limit(1) + ) + return result.scalar_one_or_none() is not None + except Exception as e: + # Fail closed: if we can't tell, don't move the plate. + logging.getLogger(__name__).debug( + "[PLATE-RESTORE] queue check failed for printer %s: %s — skipping restore", printer_id, e + ) + return True + + async def on_finish_photo_moment(printer_id: int, data: dict): """Pre-capture a finish photo when the printer enters stage 22 / FINISH (#1721). @@ -4352,6 +4570,11 @@ async def on_finish_photo_moment(printer_id: int, data: dict): producer_done = asyncio.Event() _stage22_finish_in_flight[printer_id] = producer_done + # #2547: set once the plate has actually been raised, and read by the + # `finally` below. Declared out here so a failure anywhere after the move — + # a camera timeout, a DB error — still lowers the plate again. + restore_max_z: float | None = None + try: async with async_session() as db: from backend.app.api.routes.settings import get_setting @@ -4362,6 +4585,9 @@ async def on_finish_photo_moment(printer_id: int, data: dict): logger.info("[FINISH-PHOTO-MOMENT] capture_finish_photo disabled — skipping pre-capture") return + restore_setting = await get_setting(db, "finish_photo_restore_plate") + restore_plate_enabled = restore_setting is None or restore_setting.lower() == "true" + result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if printer is None: @@ -4372,22 +4598,70 @@ async def on_finish_photo_moment(printer_id: int, data: dict): return frame_bytes: bytes | None = None + # #2708: the banked frame arrives already rotated — it comes from + # `_capture_snapshot_for_notification`, which rotates before returning. + # Every other source below is a raw grab. Tracking which lets us store + # exactly one rotation in `_stage22_finish_frames` either way. + frame_already_rotated = False - # #1867: on the FINISH-state fallback the End G-code (e.g. SwapMod - # plate-swap) has already run, so a live grab now captures the swapped - # or empty plate. Prefer the banked in-print frame — the finished - # print from the last object layer, before the swap. Only for - # `finish_state`: the `stage_22` and `last_layer` triggers fire before - # the swap and give cleaner (parked-toolhead) framing via a live grab. - if trigger == "finish_state": + # On the FINISH-state path the End G-code has already run, and two very + # different situations arrive here needing opposite answers. + # + # #1867: if Bambuddy injected End G-code into this print, a SwapMod + # snippet may have ejected the plate — the scene in front of the camera + # is no longer the finished print, and no amount of moving the plate + # brings it back. Use the banked in-print frame instead. + # + # #2547: otherwise the print is still sitting there, just ~100 mm lower + # than the camera frames well, and the toolhead is parked out of the + # way. That is the *best* moment available on firmware that never emits + # stage 22 (H2C, A1 Mini) — so capture live, after putting the plate + # back. Preferring the bank here unconditionally, as this code used to, + # is what shipped a mid-print photo with the toolhead over the part. + if trigger == "finish_state" and print_dispatch_context.end_gcode_injected(printer_id): banked = _inprint_frame_bank.get(printer_id) if banked: frame_bytes = banked + frame_already_rotated = True logger.info( - "[FINISH-PHOTO-MOMENT] using banked in-print frame (%d bytes) — " - "avoids post-swap live grab on stage-22-less firmware", + "[FINISH-PHOTO-MOMENT] End G-code was injected — using banked in-print " + "frame (%d bytes) instead of a post-swap live grab", len(banked), ) + else: + logger.warning( + "[FINISH-PHOTO-MOMENT] End G-code was injected for printer %s but the " + "in-print bank is empty — falling back to a live grab, which may show a " + "swapped or empty plate", + printer_id, + ) + + # `restore_max_z` is set only once the plate is actually up, because the + # `finally` reads it to decide whether it owes a move back down. + # + # Never on a print whose End G-code Bambuddy injected, even when the bank + # came up empty above: that machine may have just ejected its plate, and + # driving Z into whatever a swap mechanism is doing is not a risk worth + # taking for a photo of a bed we already know may be bare. + if ( + frame_bytes is None + and trigger == "finish_state" + and restore_plate_enabled + and not print_dispatch_context.end_gcode_injected(printer_id) + ): + wants_restore = await _max_z_for_current_print(printer_id, data, logger) + if wants_restore is None: + logger.info( + "[PLATE-RESTORE] printer %s: print height unknown — capturing without restore", + printer_id, + ) + elif await _plate_restore_is_blocked_by_queue(printer_id): + logger.info( + "[PLATE-RESTORE] printer %s has queued work — skipping plate restore", + printer_id, + ) + elif await _restore_plate_for_finish_photo(printer_id, wants_restore, logger): + restore_max_z = wants_restore if frame_bytes is None and printer.external_camera_enabled and printer.external_camera_url: from backend.app.api.routes.camera import live_frame_for_capture @@ -4436,12 +4710,15 @@ async def on_finish_photo_moment(printer_id: int, data: dict): ) if frame_bytes: + if not frame_already_rotated: + frame_bytes = _apply_camera_rotation(frame_bytes, printer, logger) _stage22_finish_frames[printer_id] = frame_bytes else: logger.warning( "[FINISH-PHOTO-MOMENT] no frame captured for printer %s — post-completion fallback will retry", printer_id, ) + except Exception as e: logger.warning( "[FINISH-PHOTO-MOMENT] pre-capture failed for printer %s: %s", @@ -4449,6 +4726,13 @@ async def on_finish_photo_moment(printer_id: int, data: dict): e, ) finally: + # #2547: we raised the plate, so we own lowering it — including when the + # capture above failed or threw partway through. + if restore_max_z is not None: + try: + _park_plate_after_finish_photo(printer_id, restore_max_z, logger) + except Exception as e: + logger.warning("[PLATE-RESTORE] printer %s: could not lower plate: %s", printer_id, e) # #1790: always unblock the consumer's bounded wait — whether we stored # a frame, gave up, or hit an exception. Local ref means cleanup of the # dict entry by the consumer doesn't affect signalling. @@ -5265,6 +5549,11 @@ async def on_print_complete(printer_id: int, data: dict): async def _background_finish_photo() -> str | None: """Capture finish photo in background. Returns photo filename if captured.""" + # #2547: set once this function has raised the plate itself (the + # timelapse path, where the moment producer returned without doing it). + # Declared out here so the `finally` can lower it again no matter where + # the capture below fails. + plate_restored_z: float | None = None try: logger.info("[PHOTO-BG] Starting finish photo capture for archive %s", archive_id) @@ -5323,6 +5612,7 @@ async def on_print_complete(printer_id: int, data: dict): photo_filename, timelapse_still_pending = await _capture_finish_photo_from_timelapse( archive_id=archive_id, archive_dir=archive_dir, + rotation=getattr(printer, "camera_rotation", 0), ) # #1721: replacement framing path — on_finish_photo_moment @@ -5339,10 +5629,16 @@ async def on_print_complete(printer_id: int, data: dict): # producer's still-in-flight grab (single-client RTSP # on Bambu printers). Wait for the producer to finish # or give up before touching the cache. + # + # #2547: 20s was enough when the producer only ever grabbed a + # frame. It now also raises the plate first, which costs the + # settle window before the grab even starts — so the budget has + # to cover settle + a worst-case 15s RTSP timeout, and still sit + # under the notification's own photo wait below. in_flight = _stage22_finish_in_flight.pop(printer_id, None) if in_flight is not None: try: - await asyncio.wait_for(in_flight.wait(), timeout=20.0) + await asyncio.wait_for(in_flight.wait(), timeout=_FINISH_PHOTO_PRODUCER_WAIT_SECONDS) except asyncio.TimeoutError: logger.warning( "[PHOTO-BG] timed out waiting for stage-22 producer for printer %s — proceeding to fallback", @@ -5350,6 +5646,9 @@ async def on_print_complete(printer_id: int, data: dict): ) cached_frame = _stage22_finish_frames.pop(printer_id, None) if cached_frame: + # Already rotated by the producer (#2708) — rotating again + # here would undo the fix on the banked-frame path, whose + # bytes reach the cache having been rotated once already. photos_dir = archive_dir / "photos" photos_dir.mkdir(parents=True, exist_ok=True) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") @@ -5362,6 +5661,37 @@ async def on_print_complete(printer_id: int, data: dict): len(cached_frame), ) + # #2547: the timelapse path reaches the live grab below whenever the + # video hasn't landed in time — the documented usual outcome on + # P1-series, where transfers are slowest. `on_finish_photo_moment` + # returned early for those prints without raising the plate, so + # without this the photo that actually ships in the notification is + # of an already-dropped plate: exactly the framing #1145/#1397/#1565 + # asked us to fix. The archive still gets the better video frame + # later; this is about the image the user is sent. + # + # Gated on `timelapse_was_active` precisely because that is the + # condition under which the producer skipped. On every other path it + # has already raised and lowered the plate, and repeating that here + # would be a second pointless round trip. + if ( + not photo_filename + and data.get("timelapse_was_active") + and not print_dispatch_context.end_gcode_injected(printer_id) + ): + try: + async with async_session() as db: + from backend.app.api.routes.settings import get_setting + + restore_setting = await get_setting(db, "finish_photo_restore_plate") + if restore_setting is None or restore_setting.lower() == "true": + max_z = await _max_z_for_current_print(printer_id, data, logger) + if max_z is not None and not await _plate_restore_is_blocked_by_queue(printer_id): + if await _restore_plate_for_finish_photo(printer_id, max_z, logger): + plate_restored_z = max_z + except Exception as e: + logger.warning("[PLATE-RESTORE] printer %s: restore failed: %s", printer_id, e) + # Fallback chain: external camera → buffered live frame → # fresh RTSP capture. Only runs if the timelapse path above # didn't already produce a photo. @@ -5384,6 +5714,7 @@ async def on_print_complete(printer_id: int, data: dict): snapshot_url=printer.external_camera_snapshot_url, ) if frame_data: + frame_data = _apply_camera_rotation(frame_data, printer, logger) photos_dir = archive_dir / "photos" photos_dir.mkdir(parents=True, exist_ok=True) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") @@ -5401,6 +5732,7 @@ async def on_print_complete(printer_id: int, data: dict): if (active_for_printer or active_chamber_for_printer) and buffered_frame: # Use frame from active stream logger.info("[PHOTO-BG] Using buffered frame from active stream") + buffered_frame = _apply_camera_rotation(buffered_frame, printer, logger) photos_dir = archive_dir / "photos" photos_dir.mkdir(parents=True, exist_ok=True) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") @@ -5418,6 +5750,7 @@ async def on_print_complete(printer_id: int, data: dict): access_code=printer.access_code, model=printer.model, archive_dir=archive_dir, + rotation=getattr(printer, "camera_rotation", 0), ) # Write phase: attach the photo in a fresh short-lived session. @@ -5449,7 +5782,9 @@ async def on_print_complete(printer_id: int, data: dict): # gallery never lists. if timelapse_still_pending: spawn_background_task( - _upgrade_finish_photo_from_timelapse(archive_id, archive_dir), + _upgrade_finish_photo_from_timelapse( + archive_id, archive_dir, rotation=getattr(printer, "camera_rotation", 0) + ), name=f"finish-photo-upgrade-{archive_id}", ) @@ -5457,6 +5792,15 @@ async def on_print_complete(printer_id: int, data: dict): except Exception as e: logger.warning("[PHOTO-BG] Failed: %s", e) return None + finally: + # #2547: we raised the plate, so we owe the move back down — even if + # the capture in between threw. Otherwise the user finds the print + # pinned under the nozzle. + if plate_restored_z is not None: + try: + _park_plate_after_finish_photo(printer_id, plate_restored_z, logger) + except Exception as e: + logger.warning("[PLATE-RESTORE] printer %s: could not lower plate: %s", printer_id, e) spawn_background_task(_background_energy_calculation(), name="background-energy-calc") # Photo capture task - result will be used by notifications @@ -5659,7 +6003,22 @@ async def on_print_complete(printer_id: int, data: dict): # timelapse for up to 60s (#1397) — extend the budget so the notification # carries the correct bed-up photo instead of falling through to the # live-cam grab. Adds ~30s of notification latency at worst on slow links. - photo_wait_timeout = 75 if data.get("timelapse_was_active") else 45 + # + # #2547: both budgets now have to cover a plate restore as well. + # + # Without timelapse, the wait is on the moment producer, which raises the + # plate before its grab — so this has to outlast that producer's own budget. + # + # With timelapse, the capture polls up to + # `_FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS` for the video and only then + # falls back to a live grab, which is the case that raises the plate. At the + # old flat 75s that fallback was guaranteed to be cut off mid-settle, so the + # restore would have moved the plate for a photo nobody waited for. + photo_wait_timeout = ( + _FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS + _FINISH_PHOTO_PRODUCER_WAIT_SECONDS + if data.get("timelapse_was_active") + else _FINISH_PHOTO_PRODUCER_WAIT_SECONDS + 15 + ) async def _photo_then_notify(): """Wait for photo capture, then send notification with photo URL.""" @@ -6302,6 +6661,130 @@ def stop_spoolbuddy_watchdog(): logging.getLogger(__name__).info("SpoolBuddy watchdog stopped") +# Dead-MQTT-session recovery +# +# check_staleness() covers the "connected but silent" half-broken session. It +# does nothing once ``state.connected`` is False, and paho's own auto-reconnect +# is the only thing left watching at that point. When paho stops making +# progress there is no backstop at all: the #2732 bundle has a P1S drop on a +# keep-alive timeout at 02:19 and not reconnect until 11:24 — nine hours +# offline with the UI open the whole time, recovered only when something +# happened to nudge it. +# +# This loop is that backstop. It only touches printers that had a working +# session and lost it, and only when the MQTT port still answers — a printer +# that is simply switched off is left to paho, since rebuilding a client +# against an unreachable host achieves nothing and would fill the log every +# night. +_connection_watchdog_task: asyncio.Task | None = None +CONNECTION_WATCHDOG_INTERVAL = 60 +# How long a printer must have been silent before we stop trusting paho. +# Comfortably above STALE_TIMEOUT (60 s) and the max reconnect backoff (30 s), +# so a session that is recovering on its own is never interrupted. +CONNECTION_WATCHDOG_OFFLINE_GRACE = 300 +# Per-printer floor between rebuild attempts. +CONNECTION_WATCHDOG_RETRY_INTERVAL = 300 +_connection_watchdog_last_attempt: dict[int, float] = {} + + +async def _recover_dead_printer_sessions() -> int: + """Rebuild MQTT clients that have been offline too long to still be trying. + + Returns the number of printers a rebuild was attempted for (for tests and + for the caller's logging). Never raises: one unreachable printer must not + stop the sweep for the rest of the farm. + """ + logger = logging.getLogger(__name__) + from backend.app.services.printer_diagnostic import PORT_MQTT, check_port + + now = time.monotonic() + recovered = 0 + + for printer_id, client in list(printer_manager._clients.items()): + try: + if client.state.connected: + _connection_watchdog_last_attempt.pop(printer_id, None) + continue + + # Time since the last inbound message is the age of the last known + # good session — no extra bookkeeping needed, and it is the same + # clock is_stale() reads. 0 means this client has never had one: + # that is the initial-connect path, where paho retrying is the + # correct and only behaviour, so leave it be. + last_msg = client._last_message_time + if not last_msg: + continue + offline_for = time.time() - last_msg + if offline_for < CONNECTION_WATCHDOG_OFFLINE_GRACE: + continue + + last_attempt = _connection_watchdog_last_attempt.get(printer_id) + if last_attempt is not None and now - last_attempt < CONNECTION_WATCHDOG_RETRY_INTERVAL: + continue + + if not await check_port(client.ip_address, PORT_MQTT): + # Switched off, unplugged, or off the network. Paho's retry is + # the right handler; say so at debug level and move on. + logger.debug( + "[#2732] Printer %s offline for %.0fs and its MQTT port is not answering " + "— leaving the reconnect to paho", + printer_id, + offline_for, + ) + _connection_watchdog_last_attempt[printer_id] = now + continue + + _connection_watchdog_last_attempt[printer_id] = now + recovered += 1 + logger.warning( + "[#2732] Printer %s has been offline for %.0fs but answers on MQTT port %d — " + "rebuilding the client with a fresh session (last connect error: %s)", + printer_id, + offline_for, + PORT_MQTT, + client.last_connect_error or "none recorded", + ) + # Async context, so this takes the hard-reset path: fresh client_id, + # paho's QoS 1 queue dropped. That matters — a project_file left + # unacked on the dead session would otherwise replay into the new + # one and trip 0500_4003 on the printer (#1136). + client.force_reconnect_stale_session(f"offline for {offline_for:.0f}s, port still answering") + except Exception as e: + logger.warning("[#2732] Connection watchdog failed for printer %s: %s", printer_id, e) + + return recovered + + +async def _connection_watchdog_loop(): + logger = logging.getLogger(__name__) + # Let the initial connects settle before judging anyone offline. + await asyncio.sleep(CONNECTION_WATCHDOG_OFFLINE_GRACE) + while True: + try: + await _recover_dead_printer_sessions() + except asyncio.CancelledError: + break + except Exception as e: + logger.warning("Connection watchdog sweep failed: %s", e) + await asyncio.sleep(CONNECTION_WATCHDOG_INTERVAL) + + +def start_connection_watchdog(): + global _connection_watchdog_task + if _connection_watchdog_task is None: + _connection_watchdog_task = asyncio.create_task(_connection_watchdog_loop()) + logging.getLogger(__name__).info("Printer connection watchdog started") + + +def stop_connection_watchdog(): + global _connection_watchdog_task + if _connection_watchdog_task: + _connection_watchdog_task.cancel() + _connection_watchdog_task = None + _connection_watchdog_last_attempt.clear() + logging.getLogger(__name__).info("Printer connection watchdog stopped") + + # Camera stream orphan cleanup _camera_cleanup_task: asyncio.Task | None = None CAMERA_CLEANUP_INTERVAL = 60 @@ -6584,10 +7067,10 @@ async def lifespan(app: FastAPI): await tl_layer_change(printer_id, layer_num) - # #1867: bank a recent in-print frame so the FINISH-state finish-photo - # path (firmware that never emits stg_cur=22, e.g. A1 Mini) has a - # pre-swap image to fall back on instead of a live grab of the swapped - # plate. Layer-driven, so it freezes at the final object layer. + # #1867: bank a recent in-print frame so the finish-photo path has a + # pre-End-G-code image to use instead of a live grab of a swapped plate. + # #2547 added `on_print_progress` as a second driver — this one alone + # stops firing once the final layer begins. await _maybe_bank_inprint_frame(printer_id, layer_num) # First layer complete notification (layer_num >= 2 means layer 1 is done). @@ -6631,6 +7114,21 @@ async def lifespan(app: FastAPI): printer_manager.set_layer_change_callback(on_layer_change) + async def on_print_progress(printer_id: int, percent: int): + """#2547: keep the in-print frame bank fresh through the final layer. + + `on_layer_change` stops the moment the last layer starts, which on the + H2C capture that closed #2547 left the bank stale for the three minutes + that layer took. Progress is the only field that keeps advancing there, + and it freezes before the End G-code runs — so banking on it stays + inside the print and never sees a swapped plate. + """ + client = printer_manager.get_client(printer_id) + state = client.state if client else None + await _maybe_bank_inprint_frame(printer_id, state.layer_num if state else 0) + + printer_manager.set_print_progress_callback(on_print_progress) + # Event-driven bed cooldown: fires whenever bed_temper arrives via MQTT async def on_bed_temp_update(printer_id: int, bed_temp: float): waiter = _bed_cool_waiters.get(printer_id) @@ -6852,6 +7350,21 @@ async def lifespan(app: FastAPI): # Start camera stream orphan cleanup start_camera_cleanup() + # Start the backstop for MQTT sessions paho has stopped recovering (#2732) + start_connection_watchdog() + + # One-shot sweep for timelapse session directories orphaned by a crash + # or restart that happened mid-print (in-memory session tracking can't + # survive that, and nothing else reaps the leftover frames/output file) + try: + from backend.app.services.layer_timelapse import cleanup_orphaned_timelapse_sessions + + removed = cleanup_orphaned_timelapse_sessions() + if removed: + logging.getLogger(__name__).info("Removed %d orphaned timelapse session artifact(s)", removed) + except Exception as e: + logging.getLogger(__name__).warning("Orphaned timelapse session cleanup failed: %s", e) + # Start expected-print TTL eviction (prevents memory leak when prints are # registered but on_print_start never fires) start_expected_prints_cleanup() @@ -6892,6 +7405,7 @@ async def lifespan(app: FastAPI): stop_runtime_tracking() stop_spoolbuddy_watchdog() stop_camera_cleanup() + stop_connection_watchdog() from backend.app.services.loop_watchdog import stop_loop_watchdog stop_loop_watchdog() diff --git a/backend/app/models/virtual_printer.py b/backend/app/models/virtual_printer.py index fb598c620..ab8629b03 100644 --- a/backend/app/models/virtual_printer.py +++ b/backend/app/models/virtual_printer.py @@ -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 diff --git a/backend/app/schemas/github_backup.py b/backend/app/schemas/github_backup.py index 51e439cdf..49d053623 100644 --- a/backend/app/schemas/github_backup.py +++ b/backend/app/schemas/github_backup.py @@ -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.""" diff --git a/backend/app/schemas/print_queue.py b/backend/app/schemas/print_queue.py index 8a8bee250..ee5ef67dc 100644 --- a/backend/app/schemas/print_queue.py +++ b/backend/app/schemas/print_queue.py @@ -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 diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index 8f7c488d1..b13d4b5a2 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -33,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") @@ -275,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") @@ -457,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)", @@ -496,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 @@ -565,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) @@ -607,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) diff --git a/backend/app/services/archive.py b/backend/app/services/archive.py index 1b36c0078..51e5d7a45 100644 --- a/backend/app/services/archive.py +++ b/backend/app/services/archive.py @@ -1144,6 +1144,8 @@ class ArchiveService: 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. @@ -1166,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: @@ -1254,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 diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index b160c3b2d..e87b653a1 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -307,6 +307,19 @@ _HMS_USER_ACTION_CODES: frozenset[str] = frozenset( } ) +# "MQTT command verification failed" — the printer's authorization/authentication +# protection (firmware >= 01.08.03.00beta / 01.08.05.00) rejecting a control +# command it could not verify. Queries (get_version, extrusion_cali_get, +# pushall) still answer, so the connection looks perfectly healthy while +# project_file, gcode_line and ams_change_filament are all silently dropped — +# which is exactly how it presents: uploads succeed, the printer echoes our +# subtask_id, then sits at IDLE forever (#2732). +# +# The 16-char form is load-bearing. This code's meaning lives in attr's low half +# (0500) and code's high half (0001); the MMMM_EEEE short code collapses it to +# "0500_0007", which matches nothing in any catalog. +HMS_MQTT_VERIFY_FAILED: str = "0500050000010007" + @dataclass class KProfile: @@ -661,6 +674,7 @@ class BambuMQTTClient: on_print_complete: Callable[[dict], None] | None = None, on_ams_change: Callable[[list], None] | None = None, on_layer_change: Callable[[int], None] | None = None, + on_print_progress: Callable[[int], None] | None = None, on_bed_temp_update: Callable[[float], None] | None = None, on_drying_complete: Callable[[int], None] | None = None, on_print_running_observed: Callable[[dict], None] | None = None, @@ -678,6 +692,13 @@ class BambuMQTTClient: self.on_print_complete = on_print_complete self.on_ams_change = on_ams_change self.on_layer_change = on_layer_change + # #2547: fired when `mc_percent` advances during a running print. + # `on_layer_change` stops firing the instant the final layer starts, so + # it is blind to the last few percent of a print — which is exactly the + # window the finish-photo frame bank needs to keep refreshing through. + # Progress is the one field that keeps ticking there and then freezes + # before the end G-code runs, so banking on it stays inside the print. + self.on_print_progress = on_print_progress self.on_bed_temp_update = on_bed_temp_update # #1349: fired when an AMS unit's dry_time falls from >0 to 0 — i.e. # the drying cycle just finished (auto- or manually-triggered). @@ -844,6 +865,13 @@ class BambuMQTTClient: self._dev_mode_probe_seq: str | None = None self._dev_mode_probe_time: float = 0.0 # monotonic timestamp when probe was sent self._dev_mode_probe_failures: int = 0 # consecutive unanswered probes + # True while developer_mode=False came from HMS_MQTT_VERIFY_FAILED rather + # than from the probe or the "fun" bit. The HMS is a latch, not a level: + # the printer reports it until the fault clears, so when a later hms[] + # arrives without it (user enabled Developer Mode and restarted the + # printer) we drop back to "unknown" and let the probe re-run instead of + # leaving a permanently-wrong False behind (#2732). + self._dev_mode_from_hms: bool = False self._connect_time: float = 0.0 # monotonic timestamp of last _on_connect # Set when check_staleness() force-closes the socket to trigger reconnect. @@ -962,7 +990,19 @@ class BambuMQTTClient: # regardless, but the printer publishes to device// # report, which is case-sensitive. Surface that once so the user # has something actionable instead of an endless reconnect loop. - if self._report_messages_since_connect == 0 and not self._zero_report_hint_logged: + # Only meaningful once the *current* session has had time to receive + # something. _report_messages_since_connect is reset by _on_connect, + # so a reconnect that lands microseconds before this check leaves it + # at 0 for reasons that have nothing to do with the serial — which is + # how a healthy P1S ended up being told to go check its serial number + # 1 ms after reconnecting (#2732). Requiring STALE_TIMEOUT of silence + # on this session means the hint only fires when the printer really + # has published nothing to the topic we subscribed to. + # _connect_time of 0 means we have no timestamp to judge by (never went + # through _on_connect); fall back to the old unconditional behaviour + # rather than silently swallowing the hint. + session_too_young = self._connect_time > 0 and (time.monotonic() - self._connect_time) < self.STALE_TIMEOUT + if self._report_messages_since_connect == 0 and not session_too_young and not self._zero_report_hint_logged: self._zero_report_hint_logged = True logger.warning( "[%s] Connected and subscribed, but the printer has sent zero " @@ -2988,7 +3028,14 @@ class BambuMQTTClient: # Save last non-zero progress for usage tracking (firmware resets to 0 on cancel) if self.state.progress > 0: self._last_valid_progress = self.state.progress + previous_progress = self.state.progress self.state.progress = float(data["mc_percent"]) + # #2547: strictly-increasing only. The firmware resets progress to 0 + # on cancel and re-reports the same percent on most frames; neither + # is the print advancing, and both would make the frame bank grab a + # camera frame for nothing. + if self.state.progress > previous_progress and self._was_running and self.on_print_progress: + self.on_print_progress(int(self.state.progress)) if "mc_remaining_time" in data: self.state.remaining_time = int(data["mc_remaining_time"]) if "mc_print_sub_stage" in data: @@ -3069,35 +3116,20 @@ class BambuMQTTClient: new_layer, ) self._request_push_all() - # #1867 last-layer finish-photo trigger. A1 Mini (and other - # firmware variants) skips `stg_cur=22`, so the fallback fires - # at gcode_state=FINISH — which runs AFTER user End G-code - # (e.g. SwapMod plate-swap) and captures the wrong plate. - # Firing on the layer_num→total_layer_num edge captures the - # last object layer before any end G-code executes. - total = self.state.total_layers or 0 - if ( - total > 0 - and new_layer >= total - and old_layer < total - and self._was_running - and not self._finish_photo_captured - and self.on_finish_photo_moment - ): - self._finish_photo_captured = True - logger.info( - f"[{self.serial_number}] FINISH PHOTO MOMENT (last-layer) — " - f"layer={new_layer}/{total}, " - f"timelapse_active={self._timelapse_during_print}" - ) - self.on_finish_photo_moment( - { - "trigger": "last_layer", - "filename": self._previous_gcode_file or self.state.gcode_file, - "subtask_name": self.state.subtask_name, - "timelapse_was_active": self._timelapse_during_print, - } - ) + # #2547: there is deliberately NO finish-photo trigger on the + # last-layer edge. `layer_num` reaching `total_layer_num` is the + # moment the printer *starts* the final layer, not the moment it + # finishes it — on the H2C capture that closed #2547 the edge + # arrived at 92% with `mc_remaining_time=2`, three minutes and a + # filament change before the print actually ended, so the photo + # showed the toolhead mid-print over the part. Worse, the trigger + # latched `_finish_photo_captured`, locking out both the stage-22 + # and FINISH triggers below for the rest of the print. + # + # #1867 (End G-code ejects the plate before FINISH) is handled + # where it belongs instead: `on_finish_photo_moment` prefers the + # in-print frame bank when the dispatcher recorded that it injected + # End G-code into this print. See services/print_dispatch_context. if total_from_this_frame: # Firmware (P1S observed) resets `total_layer_num` to 0 at print # end — same shape as the `layer_num` reset guarded above. Applying @@ -3747,6 +3779,7 @@ class BambuMQTTClient: hms_list = data["hms"] logger.debug("[%s] HMS data received: %s", self.serial_number, hms_list) self.state.hms_errors = [] + verify_failed = False if isinstance(hms_list, list): for hms in hms_list: if isinstance(hms, dict): @@ -3782,6 +3815,8 @@ class BambuMQTTClient: # discards — that's the firmware's matching key, so try it # first and fall back to the short form. full_code = f"{attr:08X}{code:08X}" + if full_code == HMS_MQTT_VERIFY_FAILED: + verify_failed = True actions = get_actions_for_error_code(self.serial_number[:3], full_code) if not actions: actions = get_actions_for_error_code(self.serial_number[:3], short_code.replace("_", "")) @@ -3796,6 +3831,7 @@ class BambuMQTTClient: full_code=full_code, ) ) + self._apply_mqtt_verify_state(verify_failed) # Parse print_error - this is a different error format than HMS # print_error is a 32-bit integer where: @@ -4496,10 +4532,64 @@ class BambuMQTTClient: logger.info("[%s] Probing developer mode via ams_filament_setting (seq=%s)", self.serial_number, seq) self._client.publish(self.topic_publish, json.dumps(command), qos=1) + def _apply_mqtt_verify_state(self, verify_failed: bool) -> None: + """Reconcile developer_mode with the printer's own command-verification verdict. + + ``HMS_MQTT_VERIFY_FAILED`` is the only *direct* evidence we ever get that + control commands are being refused, so it outranks the probe in both + directions: + + * present → developer_mode is definitively False, whatever the probe + concluded. The probe can only read the response to its own + ``ams_filament_setting``; on P1 firmware a refusal is reported here + instead, so the probe answers ENABLED while every print silently dies + (#2732). + * gone again → drop the HMS-derived False back to unknown and re-arm the + probe, so a user who enables Developer Mode and restarts the printer + isn't stuck behind a verdict nothing would ever revisit. + + A False that came from the probe or the ``fun`` bit is left alone — this + only ever unwinds its own latch. + """ + if verify_failed: + if not self._dev_mode_from_hms: + logger.warning( + "[%s] Printer reported HMS %s (MQTT command verification failed): it is " + "rejecting control commands, so prints, temperature changes and filament " + "loads will be ignored. Enable Developer Mode on the printer and restart it.", + self.serial_number, + HMS_MQTT_VERIFY_FAILED, + ) + self._dev_mode_from_hms = True + self.state.developer_mode = False + return + + if not self._dev_mode_from_hms: + return + logger.info( + "[%s] HMS %s cleared — re-probing developer mode", + self.serial_number, + HMS_MQTT_VERIFY_FAILED, + ) + self._dev_mode_from_hms = False + self.state.developer_mode = None + self._dev_mode_probed = False + self._dev_mode_needs_probe = False + def _handle_dev_mode_probe_response(self, data: dict): """Handle response to the developer mode probe command. Sets developer_mode based on whether the printer accepted or rejected the command. + + Three outcomes, not two. An explicit ``success`` proves commands are + accepted and an explicit verify-failure proves they are not, but anything + else proves nothing — P1S firmware 01.10.00.00 answers this probe with a + bare ``{"command": "ams_filament_setting", "sequence_id": "3"}`` and no + ``result`` at all, while refusing every control command and reporting + ``HMS_MQTT_VERIFY_FAILED`` instead. Reading that empty response as ENABLED + is what put ``developer_mode: pass`` in the support bundle of a printer + that had not accepted a command all day (#2732). Leaving it unknown makes + the connection diagnostic report ``skip``, which is the honest answer. """ self._dev_mode_probe_seq = None # One-shot: don't match future responses self._dev_mode_probe_failures = 0 # Reset on any response @@ -4509,10 +4599,21 @@ class BambuMQTTClient: if result == "failed" and "verify failed" in reason: self.state.developer_mode = False logger.info("[%s] Developer mode probe: DISABLED (reason=%r)", self.serial_number, reason) - else: - # Success or any other response — commands are accepted + elif str(result).lower() == "success": self.state.developer_mode = True logger.info("[%s] Developer mode probe: ENABLED (result=%r)", self.serial_number, result) + else: + # An HMS verdict already recorded here is real evidence; don't let an + # inconclusive probe response wipe it back to unknown. + if not self._dev_mode_from_hms: + self.state.developer_mode = None + logger.info( + "[%s] Developer mode probe: INCONCLUSIVE (result=%r, reason=%r) — " + "the printer neither confirmed nor refused the command", + self.serial_number, + result, + reason, + ) if self.on_state_change: self.on_state_change(self.state) diff --git a/backend/app/services/camera.py b/backend/app/services/camera.py index 1863fff01..ba4788d6e 100644 --- a/backend/app/services/camera.py +++ b/backend/app/services/camera.py @@ -836,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. @@ -851,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 @@ -875,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: diff --git a/backend/app/services/export.py b/backend/app/services/export.py index d3455e349..5913f3f10 100644 --- a/backend/app/services/export.py +++ b/backend/app/services/export.py @@ -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 diff --git a/backend/app/services/external_camera.py b/backend/app/services/external_camera.py index 283073a42..9728abf59 100644 --- a/backend/app/services/external_camera.py +++ b/backend/app/services/external_camera.py @@ -8,6 +8,7 @@ to ensure they are well-formed before use. """ import asyncio +import functools import logging import re import shutil @@ -175,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, @@ -186,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 @@ -195,27 +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: - # 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) + 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 @@ -566,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, 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 @@ -590,15 +765,15 @@ 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( diff --git a/backend/app/services/failure_analysis.py b/backend/app/services/failure_analysis.py index fe131ab0e..7b95ccd21 100644 --- a/backend/app/services/failure_analysis.py +++ b/backend/app/services/failure_analysis.py @@ -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: diff --git a/backend/app/services/github_backup.py b/backend/app/services/github_backup.py index a8e0043c1..af0f2043e 100644 --- a/backend/app/services/github_backup.py +++ b/backend/app/services/github_backup.py @@ -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 ""] = ( + unknown_types.get(str(raw_type) if raw_type is not None else "", 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)) diff --git a/backend/app/services/layer_timelapse.py b/backend/app/services/layer_timelapse.py index 2da9ffb66..72e0f2c2d 100644 --- a/backend/app/services/layer_timelapse.py +++ b/backend/app/services/layer_timelapse.py @@ -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")) @@ -88,6 +100,8 @@ class TimelapseSession: 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 @@ -206,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. @@ -216,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 @@ -229,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) @@ -273,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: @@ -286,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): @@ -303,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//* 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_.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 "/"; stitched-but-not-yet- + # attached output files are "timelapse_.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 diff --git a/backend/app/services/obico_detection.py b/backend/app/services/obico_detection.py index 476b15aae..a143fda73 100644 --- a/backend/app/services/obico_detection.py +++ b/backend/app/services/obico_detection.py @@ -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 ``, 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")), @@ -279,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: @@ -364,8 +395,8 @@ class ObicoDetectionService: "history": list(self._history), } - async def test_connection(self, url: str) -> dict: - """Ping the ML API health endpoint. Returns {ok, status_code, body, error}. + 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 @@ -374,27 +405,71 @@ class ObicoDetectionService: 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)} + return {"ok": False, "status_code": None, "body": None, "error": str(exc), "auth_ok": None} - target = f"{url.rstrip('/')}/hc/" + 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() diff --git a/backend/app/services/print_dispatch_context.py b/backend/app/services/print_dispatch_context.py new file mode 100644 index 000000000..43f155d70 --- /dev/null +++ b/backend/app/services/print_dispatch_context.py @@ -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) diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index baec51a9c..ebe44cfbc 100644 --- a/backend/app/services/print_scheduler.py +++ b/backend/app/services/print_scheduler.py @@ -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). @@ -2966,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. @@ -2973,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: @@ -2985,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: @@ -3336,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") @@ -3352,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( @@ -3360,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) @@ -3844,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) @@ -3877,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 @@ -3886,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) @@ -3906,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. @@ -3935,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 = ( @@ -3969,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 " diff --git a/backend/app/services/printer_diagnostic.py b/backend/app/services/printer_diagnostic.py index 3128e2f2c..330e3fa34 100644 --- a/backend/app/services/printer_diagnostic.py +++ b/backend/app/services/printer_diagnostic.py @@ -57,6 +57,12 @@ 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`. diff --git a/backend/app/services/printer_manager.py b/backend/app/services/printer_manager.py index ff2b050e9..4ec8fe012 100644 --- a/backend/app/services/printer_manager.py +++ b/backend/app/services/printer_manager.py @@ -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 @@ -548,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 @@ -624,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)) @@ -646,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, diff --git a/backend/app/services/slice_preview.py b/backend/app/services/slice_preview.py index 67138ea14..b5f5f383e 100644 --- a/backend/app/services/slice_preview.py +++ b/backend/app/services/slice_preview.py @@ -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, diff --git a/backend/app/services/slicer_api.py b/backend/app/services/slicer_api.py index 13d2b9359..53b088213 100644 --- a/backend/app/services/slicer_api.py +++ b/backend/app/services/slicer_api.py @@ -11,6 +11,7 @@ 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 @@ -40,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.""" @@ -51,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. @@ -149,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.""" @@ -157,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 @@ -217,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. @@ -232,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 @@ -249,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, *, @@ -328,30 +535,7 @@ 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. - + 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( @@ -396,30 +580,7 @@ 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 - + 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) diff --git a/backend/app/services/virtual_printer/manager.py b/backend/app/services/virtual_printer/manager.py index 81805a3eb..6718804ba 100644 --- a/backend/app/services/virtual_printer/manager.py +++ b/backend/app/services/virtual_printer/manager.py @@ -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 ") 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) @@ -925,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, @@ -950,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 @@ -1547,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 ) @@ -1601,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 "", diff --git a/backend/app/utils/threemf_tools.py b/backend/app/utils/threemf_tools.py index eaa5e0276..029b8d3d4 100644 --- a/backend/app/utils/threemf_tools.py +++ b/backend/app/utils/threemf_tools.py @@ -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_.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") diff --git a/backend/tests/integration/test_archives_api.py b/backend/tests/integration/test_archives_api.py index 8fa6b7034..502656fb9 100644 --- a/backend/tests/integration/test_archives_api.py +++ b/backend/tests/integration/test_archives_api.py @@ -1743,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 diff --git a/backend/tests/integration/test_library_slice_api.py b/backend/tests/integration/test_library_slice_api.py index a7f5bb91f..9eda86f94 100644 --- a/backend/tests/integration/test_library_slice_api.py +++ b/backend/tests/integration/test_library_slice_api.py @@ -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. @@ -414,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: @@ -454,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, @@ -568,6 +583,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 captured["body"] = request.content return httpx.Response( @@ -777,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. @@ -1463,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, @@ -1721,6 +1742,8 @@ class TestUnusedSlotSubstitutionOnSinglePlateSource: 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, @@ -1818,6 +1841,8 @@ class TestUnusedSlotSubstitutionOnSinglePlateSource: 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, diff --git a/backend/tests/integration/test_print_queue_api.py b/backend/tests/integration/test_print_queue_api.py index 959f0405a..a44bf4520 100644 --- a/backend/tests/integration/test_print_queue_api.py +++ b/backend/tests/integration/test_print_queue_api.py @@ -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( diff --git a/backend/tests/integration/test_projects_api.py b/backend/tests/integration/test_projects_api.py index bc0bdc751..15792f40d 100644 --- a/backend/tests/integration/test_projects_api.py +++ b/backend/tests/integration/test_projects_api.py @@ -1617,3 +1617,206 @@ class TestProjectFileProgress: 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 diff --git a/backend/tests/unit/services/test_bambu_mqtt.py b/backend/tests/unit/services/test_bambu_mqtt.py index 78937eeb5..61d7d9f86 100644 --- a/backend/tests/unit/services/test_bambu_mqtt.py +++ b/backend/tests/unit/services/test_bambu_mqtt.py @@ -3547,6 +3547,108 @@ class TestDeveloperModeDetection: assert mqtt_client.state.developer_mode is False +class TestMqttCommandVerificationFailed: + """HMS 0500_0500_0001_0007 is the printer refusing to verify our commands (#2732). + + A P1S on firmware 01.10.00.00 answers queries normally while dropping every + control command, so nothing else in the connection looks wrong. This HMS is + the only direct evidence, which makes it authoritative over the probe. + """ + + @pytest.fixture + def mqtt_client(self): + from backend.app.services.bambu_mqtt import BambuMQTTClient + + return BambuMQTTClient( + ip_address="192.168.1.100", + serial_number="01S00A000000000", + access_code="12345678", + ) + + @staticmethod + def _hms_payload(*entries): + return {"print": {"gcode_state": "IDLE", "hms": list(entries)}} + + # attr 0x05000500, code 0x00010007 — the values a real P1S sends. + VERIFY_FAILED = {"attr": 83887360, "code": 65543} + OTHER_FAULT = {"attr": 0x03000200, "code": 0x00018012} + + def test_hms_forces_developer_mode_false(self, mqtt_client): + mqtt_client.state.developer_mode = True # what the probe wrongly concluded + mqtt_client._process_message(self._hms_payload(self.VERIFY_FAILED)) + assert mqtt_client.state.developer_mode is False + + def test_hms_is_surfaced_with_its_full_code(self, mqtt_client): + """The short code collapses to a useless 0500_0007 — full_code must survive.""" + from backend.app.services.bambu_mqtt import HMS_MQTT_VERIFY_FAILED + + mqtt_client._process_message(self._hms_payload(self.VERIFY_FAILED)) + assert [e.full_code for e in mqtt_client.state.hms_errors] == [HMS_MQTT_VERIFY_FAILED] + + def test_unrelated_hms_does_not_touch_developer_mode(self, mqtt_client): + mqtt_client.state.developer_mode = True + mqtt_client._process_message(self._hms_payload(self.OTHER_FAULT)) + assert mqtt_client.state.developer_mode is True + + def test_clearing_the_hms_re_arms_the_probe(self, mqtt_client): + """Enabling Developer Mode and restarting must not leave a stuck False.""" + mqtt_client._process_message(self._hms_payload(self.VERIFY_FAILED)) + assert mqtt_client.state.developer_mode is False + mqtt_client._dev_mode_probed = True + + mqtt_client._process_message(self._hms_payload()) + assert mqtt_client.state.developer_mode is None + assert mqtt_client._dev_mode_probed is False + + def test_empty_hms_leaves_a_probe_verdict_alone(self, mqtt_client): + """Only the HMS-derived latch self-clears; a probe's False is not ours to undo.""" + mqtt_client.state.developer_mode = False # from an explicit probe refusal + mqtt_client._process_message(self._hms_payload()) + assert mqtt_client.state.developer_mode is False + + def test_inconclusive_probe_does_not_overwrite_the_hms_verdict(self, mqtt_client): + mqtt_client._process_message(self._hms_payload(self.VERIFY_FAILED)) + mqtt_client._handle_dev_mode_probe_response({"command": "ams_filament_setting", "sequence_id": "3"}) + assert mqtt_client.state.developer_mode is False + + +class TestDeveloperModeProbeInconclusive: + """An empty probe response proves nothing and must not read as ENABLED (#2732).""" + + @pytest.fixture + def mqtt_client(self): + from backend.app.services.bambu_mqtt import BambuMQTTClient + + return BambuMQTTClient( + ip_address="192.168.1.100", + serial_number="TEST123", + access_code="12345678", + ) + + def test_empty_result_stays_unknown(self, mqtt_client): + """P1S 01.10.00.00 echoes the command back with no `result` field at all.""" + mqtt_client._handle_dev_mode_probe_response({"command": "ams_filament_setting", "sequence_id": "3"}) + assert mqtt_client.state.developer_mode is None + + def test_explicit_success_still_enables(self, mqtt_client): + mqtt_client._handle_dev_mode_probe_response({"sequence_id": "3", "result": "success"}) + assert mqtt_client.state.developer_mode is True + + def test_verify_failure_still_disables(self, mqtt_client): + mqtt_client._handle_dev_mode_probe_response( + {"sequence_id": "3", "result": "failed", "reason": "mqtt message verify failed"} + ) + assert mqtt_client.state.developer_mode is False + + def test_inconclusive_response_still_clears_probe_bookkeeping(self, mqtt_client): + """Whatever the verdict, the response ends the probe (no retry storm).""" + mqtt_client._dev_mode_probe_seq = "3" + mqtt_client._dev_mode_probe_failures = 1 + mqtt_client._handle_dev_mode_probe_response({"sequence_id": "3", "result": ""}) + assert mqtt_client._dev_mode_probe_seq is None + assert mqtt_client._dev_mode_probe_failures == 0 + + class TestDeveloperModeProbeTimeout: """Tests for developer mode probe timeout, retry, and forced reconnect (#887). @@ -4670,6 +4772,44 @@ class TestStaleReconnect: mqtt_client.check_staleness() assert not any("zero status reports" in r.getMessage() for r in caplog.records) + def test_check_staleness_no_serial_hint_right_after_reconnect(self, mqtt_client, caplog): + """#2732 — _report_messages_since_connect is reset by _on_connect, so a + reconnect landing just before the staleness check leaves it at 0 for + reasons that have nothing to do with the serial. A healthy P1S was being + told to check its serial number 1 ms after reconnecting.""" + import logging + import time + + mqtt_client.state.connected = True + mqtt_client._last_message_time = time.time() - 120 + mqtt_client._report_messages_since_connect = 0 + mqtt_client._connect_time = time.monotonic() # fresh session + + with caplog.at_level(logging.WARNING): + mqtt_client.check_staleness() + + assert mqtt_client._zero_report_hint_logged is False + assert not any("zero status reports" in r.getMessage() for r in caplog.records) + # The stale reconnect itself still happens — only the hint is suppressed. + assert mqtt_client._stale_reconnecting is True + + def test_check_staleness_serial_hint_when_session_old_enough(self, mqtt_client, caplog): + """A session that has been up past the stale window and still received + nothing is the case the hint was written for.""" + import logging + import time + + mqtt_client.state.connected = True + mqtt_client._last_message_time = time.time() - 120 + mqtt_client._report_messages_since_connect = 0 + mqtt_client._connect_time = time.monotonic() - 120 + + with caplog.at_level(logging.WARNING): + mqtt_client.check_staleness() + + assert mqtt_client._zero_report_hint_logged is True + assert any("zero status reports" in r.getMessage() for r in caplog.records) + def test_check_staleness_no_serial_hint_when_reports_received(self, mqtt_client, caplog): """A stale connection that DID receive reports (a normal mid-session quiet gap) must not log the serial-number hint.""" @@ -6327,14 +6467,19 @@ class TestTrayNowH2SExternalSpoolOverride: assert mqtt_client.state.tray_now == 255 -class TestLastLayerFinishPhotoTrigger: - """Tests for #1867: layer_num→total_layer_num edge fires the finish-photo - moment before user End G-code (e.g. SwapMod) executes. +class TestNoLastLayerFinishPhotoTrigger: + """#2547: the layer_num→total_layer_num edge must NOT trigger a photo. - A1 Mini firmware skips stg_cur=22 entirely, so the FINISH-state fallback - fires after end G-code has already moved the plate. The last-layer edge - is the earliest reliable "print finished" signal available across all - Bambu printer variants. + That edge is the moment the printer *starts* the final layer. On the H2C + capture that closed #2547 it arrived at 92% with `mc_remaining_time=2`, + three minutes and one filament change before the print actually ended, so + the photo showed the toolhead mid-print over the part. It also latched + `_finish_photo_captured`, which locked out the two triggers that fire at a + real end-of-print — so these tests pin both halves: the edge is silent, and + the later triggers still work after it has passed. + + #1867 (End G-code ejecting the plate before FINISH) is handled in + `on_finish_photo_moment` via `print_dispatch_context`, not here. """ @pytest.fixture @@ -6351,79 +6496,31 @@ class TestLastLayerFinishPhotoTrigger: client.state.layer_num = 99 return client - def test_fires_when_layer_reaches_total(self, mqtt_client): + def test_reaching_the_last_layer_fires_nothing(self, mqtt_client): events = [] mqtt_client.on_finish_photo_moment = lambda data: events.append(data) mqtt_client._process_message({"print": {"layer_num": 100}}) - assert len(events) == 1 - assert events[0]["trigger"] == "last_layer" - assert mqtt_client._finish_photo_captured is True - - def test_does_not_fire_when_layer_still_below_total(self, mqtt_client): - events = [] - mqtt_client.on_finish_photo_moment = lambda data: events.append(data) - - mqtt_client._process_message({"print": {"layer_num": 99}}) - assert events == [] assert mqtt_client._finish_photo_captured is False - def test_edge_only_no_double_fire(self, mqtt_client): - """Once fired, subsequent messages at layer_num == total must not - re-fire (the guard flips _finish_photo_captured to True).""" + def test_stage_22_still_fires_after_the_last_layer_started(self, mqtt_client): + """The regression the removed trigger caused: stage 22 is the good + moment on firmware that emits it, and it arrives *after* the last-layer + edge. The old latch swallowed it.""" events = [] mqtt_client.on_finish_photo_moment = lambda data: events.append(data) mqtt_client._process_message({"print": {"layer_num": 100}}) - mqtt_client._process_message({"print": {"layer_num": 100}}) - mqtt_client._process_message({"print": {"layer_num": 100}}) - - assert len(events) == 1 - - def test_does_not_fire_when_not_running(self, mqtt_client): - """If the print never went through RUNNING (Bambuddy restart mid-print, - firmware replay), _was_running is False and no photo trigger fires.""" - mqtt_client._was_running = False - events = [] - mqtt_client.on_finish_photo_moment = lambda data: events.append(data) - - mqtt_client._process_message({"print": {"layer_num": 100}}) - - assert events == [] - - def test_does_not_fire_when_total_layers_unknown(self, mqtt_client): - """total=0 (before slicer metadata arrives) must never satisfy the - `new_layer >= total` condition.""" - mqtt_client.state.total_layers = 0 - mqtt_client.state.layer_num = 0 - events = [] - mqtt_client.on_finish_photo_moment = lambda data: events.append(data) - - mqtt_client._process_message({"print": {"layer_num": 0}}) - - assert events == [] - - def test_stage_22_skipped_after_last_layer_already_fired(self, mqtt_client): - """Once the last-layer trigger has set _finish_photo_captured, the - stage-22 hook that runs later on AMS printers must be a no-op.""" - events = [] - mqtt_client.on_finish_photo_moment = lambda data: events.append(data) - - mqtt_client._process_message({"print": {"layer_num": 100}}) - assert len(events) == 1 - mqtt_client.state.progress = 100 mqtt_client._process_message({"print": {"stg_cur": 22}}) - assert len(events) == 1 + assert [e["trigger"] for e in events] == ["stage_22"] - def test_finish_state_fallback_skipped_after_last_layer_fired(self, mqtt_client): - """The gcode_state=FINISH fallback (which fires after end G-code on - every printer) must be suppressed once the last-layer edge fired. - This is the #1867 regression check — SwapMod plate must be captured - by last_layer, NOT by the post-End-G-code FINISH fallback.""" + def test_finish_state_still_fires_after_the_last_layer_started(self, mqtt_client): + """H2C/A1 Mini never emit stage 22, so FINISH is their only moment — + and it is now reachable, where the latch used to block it.""" events = [] completion_events = [] mqtt_client.on_finish_photo_moment = lambda data: events.append(data) @@ -6431,13 +6528,92 @@ class TestLastLayerFinishPhotoTrigger: mqtt_client._previous_gcode_state = "RUNNING" mqtt_client._process_message({"print": {"layer_num": 100}}) - assert events[0]["trigger"] == "last_layer" - mqtt_client._process_message({"print": {"gcode_state": "FINISH"}}) - assert len(events) == 1 + assert [e["trigger"] for e in events] == ["finish_state"] assert len(completion_events) == 1 + def test_no_photo_trigger_fires_repeatedly_across_the_last_layer(self, mqtt_client): + """A three-minute last layer publishes many frames at layer_num == + total. None of them may produce a moment.""" + events = [] + mqtt_client.on_finish_photo_moment = lambda data: events.append(data) + + for percent in (92, 93, 94, 95, 97, 98, 99): + mqtt_client._process_message({"print": {"layer_num": 100, "mc_percent": percent}}) + + assert events == [] + + +class TestPrintProgressCallback: + """#2547: `on_print_progress` keeps the finish-photo frame bank fresh. + + Layer changes stop firing the instant the final layer begins, so the bank + would otherwise stay stale for the whole length of that layer. Progress is + the field that keeps advancing there — and it freezes before the End G-code + runs, which is what keeps a swapped plate out of the bank (#1867). + """ + + @pytest.fixture + def mqtt_client(self): + from backend.app.services.bambu_mqtt import BambuMQTTClient + + client = BambuMQTTClient( + ip_address="192.168.1.100", + serial_number="TEST123", + access_code="12345678", + ) + client._was_running = True + return client + + def test_fires_on_each_advance(self, mqtt_client): + seen = [] + mqtt_client.on_print_progress = seen.append + + for percent in (92, 93, 94): + mqtt_client._process_message({"print": {"mc_percent": percent}}) + + assert seen == [92, 93, 94] + + def test_does_not_fire_when_progress_is_unchanged(self, mqtt_client): + """Most frames repeat the same percent; each one would otherwise cost a + camera grab that contends with the live view.""" + seen = [] + mqtt_client.on_print_progress = seen.append + + mqtt_client._process_message({"print": {"mc_percent": 92}}) + mqtt_client._process_message({"print": {"mc_percent": 92}}) + mqtt_client._process_message({"print": {"mc_percent": 92}}) + + assert seen == [92] + + def test_does_not_fire_when_progress_goes_backwards(self, mqtt_client): + """Firmware resets progress to 0 on cancel — that is not the print + advancing, and banking a frame there would be banking a cancelled bed.""" + seen = [] + mqtt_client.on_print_progress = seen.append + + mqtt_client._process_message({"print": {"mc_percent": 92}}) + mqtt_client._process_message({"print": {"mc_percent": 0}}) + + assert seen == [92] + + def test_does_not_fire_when_no_print_is_running(self, mqtt_client): + mqtt_client._was_running = False + seen = [] + mqtt_client.on_print_progress = seen.append + + mqtt_client._process_message({"print": {"mc_percent": 92}}) + + assert seen == [] + + def test_absent_callback_is_not_an_error(self, mqtt_client): + mqtt_client.on_print_progress = None + + mqtt_client._process_message({"print": {"mc_percent": 92}}) + + assert mqtt_client.state.progress == 92 + class TestPresumedPowerOffRecovery: """#2629: a smart-plug turn-off marks the printer offline optimistically. diff --git a/backend/tests/unit/services/test_camera_rotation.py b/backend/tests/unit/services/test_camera_rotation.py new file mode 100644 index 000000000..cfe3a6685 --- /dev/null +++ b/backend/tests/unit/services/test_camera_rotation.py @@ -0,0 +1,146 @@ +"""Tests for the shared camera-rotation helpers (#2708). + +Every other test of a rotating path patches ``apply_camera_rotation`` out and +asserts the call, which proves the wiring but not the rotation. These drive +the real PIL round trip, so a flipped sign or a dropped ``expand=True`` fails +here rather than shipping. +""" + +import io +import logging + +import pytest +from PIL import Image + +from backend.app.services.camera import apply_camera_rotation, apply_camera_rotation_to_file + +logger = logging.getLogger(__name__) + + +def _jpeg(width: int, height: int, corner: tuple[int, int, int] = (255, 0, 0)) -> bytes: + """A JPEG with one distinctly coloured pixel block in the top-left corner, + so which way it turned is observable and not just the dimensions.""" + img = Image.new("RGB", (width, height), (0, 0, 255)) + for x in range(min(8, width)): + for y in range(min(8, height)): + img.putpixel((x, y), corner) + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=95) + return buf.getvalue() + + +def _open(data: bytes) -> Image.Image: + return Image.open(io.BytesIO(data)) + + +def _brightest_corner(img: Image.Image) -> str: + """Which corner holds the red block, sampled a few pixels in to stay clear + of JPEG ringing at the edges.""" + w, h = img.size + probes = { + "top-left": (3, 3), + "top-right": (w - 4, 3), + "bottom-left": (3, h - 4), + "bottom-right": (w - 4, h - 4), + } + return max(probes, key=lambda name: img.getpixel(probes[name])[0] - img.getpixel(probes[name])[2]) + + +class TestApplyCameraRotation: + def test_zero_rotation_returns_the_input_object(self): + """Not merely equal — identity. apply_camera_rotation_to_file uses this + to decide there is nothing to write back.""" + src = _jpeg(64, 32) + assert apply_camera_rotation(src, 0, logger) is src + + def test_90_degrees_turns_clockwise(self): + """camera_rotation is documented as degrees *clockwise*, and PIL's + rotate() is counter-clockwise — the helper negates to compensate. A + lost negation would send the corner to bottom-right instead.""" + src = _jpeg(64, 32) + assert _brightest_corner(_open(src)) == "top-left" + + out = _open(apply_camera_rotation(src, 90, logger)) + assert out.size == (32, 64) # expand=True, so the frame is not cropped + assert _brightest_corner(out) == "top-right" + + def test_270_degrees_turns_the_other_way(self): + out = _open(apply_camera_rotation(_jpeg(64, 32), 270, logger)) + assert out.size == (32, 64) + assert _brightest_corner(out) == "bottom-left" + + def test_180_degrees_keeps_the_dimensions_and_flips_the_corner(self): + out = _open(apply_camera_rotation(_jpeg(64, 32), 180, logger)) + assert out.size == (64, 32) + assert _brightest_corner(out) == "bottom-right" + + def test_applying_180_twice_is_the_bug_that_was_fixed(self): + """The regression this guards: two rotations cancel out and the photo + is upside-down again. Kept as a test so the invariant that + _stage22_finish_frames holds exactly one rotation has a stated reason. + """ + src = _jpeg(64, 32) + once = apply_camera_rotation(src, 180, logger) + twice = apply_camera_rotation(once, 180, logger) + assert _brightest_corner(_open(once)) == "bottom-right" + assert _brightest_corner(_open(twice)) == "top-left" # back to the original + + def test_undecodable_bytes_return_unchanged(self): + """A capture path must not lose a frame because the rotate failed — + an unrotated photo beats no photo.""" + junk = b"not a jpeg at all" + assert apply_camera_rotation(junk, 90, logger) is junk + + def test_a_failed_rotate_is_logged_as_a_warning(self, caplog): + with caplog.at_level(logging.WARNING, logger=__name__): + apply_camera_rotation(b"not a jpeg at all", 90, logger) + assert any("Failed to apply camera rotation" in r.message for r in caplog.records) + + def test_a_successful_rotate_does_not_log_at_info(self, caplog): + """Layer-timelapse calls this once per layer; at INFO a tall print + would bury the log.""" + with caplog.at_level(logging.INFO, logger=__name__): + apply_camera_rotation(_jpeg(64, 32), 90, logger) + assert caplog.records == [] + + +class TestApplyCameraRotationToFile: + """The two finish-photo sources that let ffmpeg write the file and never + hold the bytes: capture_finish_photo and the timelapse last-frame extract.""" + + @pytest.mark.asyncio + async def test_rotates_in_place(self, tmp_path): + path = tmp_path / "finish.jpg" + path.write_bytes(_jpeg(64, 32)) + + await apply_camera_rotation_to_file(path, 90, logger) + + out = _open(path.read_bytes()) + assert out.size == (32, 64) + assert _brightest_corner(out) == "top-right" + + @pytest.mark.asyncio + async def test_zero_rotation_leaves_the_file_untouched(self, tmp_path): + path = tmp_path / "finish.jpg" + original = _jpeg(64, 32) + path.write_bytes(original) + + await apply_camera_rotation_to_file(path, 0, logger) + + assert path.read_bytes() == original + + @pytest.mark.asyncio + async def test_a_file_that_cannot_be_rotated_is_left_intact(self, tmp_path): + """Not truncated, not deleted — the caller's unrotated photo survives.""" + path = tmp_path / "finish.jpg" + path.write_bytes(b"not a jpeg at all") + + await apply_camera_rotation_to_file(path, 90, logger) + + assert path.read_bytes() == b"not a jpeg at all" + + @pytest.mark.asyncio + async def test_a_missing_file_does_not_raise(self, tmp_path): + """Best-effort: this runs after the capture reported success, and must + not turn a delivered photo into a failed one.""" + await apply_camera_rotation_to_file(tmp_path / "gone.jpg", 90, logger) diff --git a/backend/tests/unit/services/test_external_camera_capture_coalescing.py b/backend/tests/unit/services/test_external_camera_capture_coalescing.py new file mode 100644 index 000000000..08c2999db --- /dev/null +++ b/backend/tests/unit/services/test_external_camera_capture_coalescing.py @@ -0,0 +1,493 @@ +"""Single-flight coalescing of one-shot external-camera captures (#2705-shape +fix, filed against the external-camera path as a follow-up on #2707). + +V4L2 USB devices allow exactly one open handle - the same one-connection +limit #2705 covers for Bambu firmware. The #2707 guards (``is_stream_active`` +/ ``try_get_active_buffered_frame``) only keep a one-shot capturer from +competing with the fan-out live view; nothing kept the capturers from +competing with EACH OTHER when no viewer is attached, so an Obico poll and +the in-print frame bank (say) could each open their own connection to the +same USB device and collide. + +These tests drive ``capture_frame`` at the public boundary and count how +many times the underlying capture ran, since "how many connections did we +open" is the entire point of the fix. Mirrors +``test_camera_capture_coalescing.py``'s structure for the built-in path. +""" + +import asyncio + +import pytest + +from backend.app.services import external_camera as ec_module +from backend.app.services.external_camera import capture_frame, capture_in_flight + +FRAME_A = b"\xff\xd8" + b"a" * 200 + b"\xff\xd9" +FRAME_B = b"\xff\xd8" + b"b" * 200 + b"\xff\xd9" + + +@pytest.fixture(autouse=True) +def _clear_inflight(): + """The registry is module-global; don't leak tasks between tests.""" + ec_module._inflight_captures.clear() + yield + ec_module._inflight_captures.clear() + + +class RecordingCapture: + """Stand-in for the real capture, recording each call. + + ``gate`` (when set) holds every capture open until released, which is how + these tests create the overlap window that used to produce two + connections. + """ + + def __init__(self, frames=(FRAME_A, FRAME_B), gate: asyncio.Event | None = None): + self.calls: list[tuple[str, str, str | None, int]] = [] + self._frames = list(frames) + self._gate = gate + self.started = asyncio.Event() + + async def __call__(self, url, camera_type, timeout, snapshot_url): + self.calls.append((url, camera_type, snapshot_url, timeout)) + self.started.set() + if self._gate is not None: + await self._gate.wait() + return self._frames.pop(0) if self._frames else None + + @property + def count(self) -> int: + return len(self.calls) + + +@pytest.fixture +def patch_capture(monkeypatch): + def _install(capture): + monkeypatch.setattr(ec_module, "_capture_frame_uncoalesced", capture) + return capture + + return _install + + +async def _let_leader_start(capture: RecordingCapture) -> None: + """Wait until the leader is inside the capture, so the next caller joins it. + + Without this the second caller can reach the registry before the first + has even been scheduled, which tests a different (and uninteresting) race. + """ + await asyncio.wait_for(capture.started.wait(), timeout=1) + + +@pytest.mark.asyncio +async def test_simultaneous_callers_share_one_capture(patch_capture): + """The reported collision: two consumers, one connection, two frames.""" + gate = asyncio.Event() + capture = patch_capture(RecordingCapture(gate=gate)) + + leader = asyncio.create_task(capture_frame("/dev/video1", "usb", timeout=20)) + await _let_leader_start(capture) + follower = asyncio.create_task(capture_frame("/dev/video1", "usb", timeout=15)) + await asyncio.sleep(0) + gate.set() + + assert await leader == FRAME_A + assert await follower == FRAME_A + assert capture.count == 1 + + +@pytest.mark.asyncio +async def test_five_callers_one_capture(patch_capture): + gate = asyncio.Event() + capture = patch_capture(RecordingCapture(gate=gate)) + + first = asyncio.create_task(capture_frame("/dev/video1", "usb")) + await _let_leader_start(capture) + rest = [asyncio.create_task(capture_frame("/dev/video1", "usb")) for _ in range(4)] + await asyncio.sleep(0) + gate.set() + + assert await asyncio.gather(first, *rest) == [FRAME_A] * 5 + assert capture.count == 1 + + +@pytest.mark.asyncio +async def test_different_cameras_do_not_coalesce(patch_capture): + """The one-connection limit is per camera, so the key must be too.""" + gate = asyncio.Event() + capture = patch_capture(RecordingCapture(gate=gate)) + + one = asyncio.create_task(capture_frame("/dev/video1", "usb")) + await _let_leader_start(capture) + two = asyncio.create_task(capture_frame("/dev/video2", "usb")) + await asyncio.sleep(0) + gate.set() + + assert {await one, await two} == {FRAME_A, FRAME_B} + assert capture.count == 2 + assert {url for url, *_ in capture.calls} == {"/dev/video1", "/dev/video2"} + + +@pytest.mark.asyncio +async def test_different_snapshot_url_does_not_coalesce(patch_capture): + """#1177's snapshot_url override routes to a different endpoint entirely - + two printers sharing a camera_url but differing only in snapshot_url must + not share a capture.""" + gate = asyncio.Event() + capture = patch_capture(RecordingCapture(gate=gate)) + + one = asyncio.create_task(capture_frame("http://cam/", "mjpeg", snapshot_url="http://cam/frame1.jpg")) + await _let_leader_start(capture) + two = asyncio.create_task(capture_frame("http://cam/", "mjpeg", snapshot_url="http://cam/frame2.jpg")) + await asyncio.sleep(0) + gate.set() + + assert {await one, await two} == {FRAME_A, FRAME_B} + assert capture.count == 2 + + +@pytest.mark.asyncio +async def test_coalescing_is_not_caching(patch_capture): + """Sequential callers each capture fresh. + + Deliberate: plate detection and the finish-photo path decide things about + a running print from these frames, and #1397 was a finish photo a few + seconds stale showing the bed already lowered. + """ + capture = patch_capture(RecordingCapture()) + + assert await capture_frame("/dev/video1", "usb") == FRAME_A + assert await capture_frame("/dev/video1", "usb") == FRAME_B + assert capture.count == 2 + + +@pytest.mark.asyncio +async def test_registry_is_empty_after_a_capture_finishes(patch_capture): + """No leak, and nothing left behind for the next caller to join.""" + patch_capture(RecordingCapture()) + + await capture_frame("/dev/video1", "usb") + await asyncio.sleep(0) # let the done-callback run + + assert ec_module._inflight_captures == {} + assert capture_in_flight("/dev/video1", "usb") is False + + +@pytest.mark.asyncio +async def test_failed_leader_does_not_poison_its_followers(patch_capture): + """A follower that never got its own attempt gets one when the leader fails. + + Safe by then: the leader has finished, so there is no connection to + compete with. This also covers the follower whose timeout is LONGER than + the leader's — it isn't cut short by someone else's deadline. + """ + gate = asyncio.Event() + capture = patch_capture(RecordingCapture(frames=(None, FRAME_B), gate=gate)) + + leader = asyncio.create_task(capture_frame("/dev/video1", "usb", timeout=10)) + await _let_leader_start(capture) + follower = asyncio.create_task(capture_frame("/dev/video1", "usb", timeout=20)) + await asyncio.sleep(0) + gate.set() + + assert await leader is None + assert await follower == FRAME_B + assert capture.count == 2 + + +@pytest.mark.asyncio +async def test_two_consecutive_failures_give_up(patch_capture): + """Bounded retry: a follower doesn't chase failing captures forever. + + Two followers behind a failing leader. The first takes its own turn, the + second joins THAT capture, and when it fails too the second gives up + rather than opening a third connection. + """ + gate = asyncio.Event() + capture = patch_capture(RecordingCapture(frames=(None, None), gate=gate)) + + leader = asyncio.create_task(capture_frame("/dev/video1", "usb")) + await _let_leader_start(capture) + first = asyncio.create_task(capture_frame("/dev/video1", "usb")) + await asyncio.sleep(0) + second = asyncio.create_task(capture_frame("/dev/video1", "usb")) + await asyncio.sleep(0) + gate.set() + + assert await leader is None + assert await first is None + assert await second is None + # The leader's capture plus one retry — not one per disappointed caller. + assert capture.count == 2 + + +@pytest.mark.asyncio +async def test_follower_timeout_does_not_sabotage_the_capture(patch_capture): + """A follower giving up leaves the capture running for everyone else. + + Call sites disagree about the timeout, so a follower must be able to + abandon a join without cancelling a capture other callers are still + waiting on. + """ + gate = asyncio.Event() + capture = patch_capture(RecordingCapture(gate=gate)) + + leader = asyncio.create_task(capture_frame("/dev/video1", "usb", timeout=30)) + await _let_leader_start(capture) + impatient = asyncio.create_task(capture_frame("/dev/video1", "usb", timeout=0.01)) + patient = asyncio.create_task(capture_frame("/dev/video1", "usb", timeout=30)) + + assert await impatient is None # gave up on its own deadline + gate.set() + + assert await leader == FRAME_A + assert await patient == FRAME_A # unaffected by the one that walked away + assert capture.count == 1 + + +@pytest.mark.asyncio +async def test_cancelled_leader_still_delivers_to_followers(patch_capture): + """Snapshot/capture requests get cancelled routinely (client navigates + away mid-request). The follower must not lose the frame because the + caller that happened to open the connection went away.""" + gate = asyncio.Event() + capture = patch_capture(RecordingCapture(gate=gate)) + + leader = asyncio.create_task(capture_frame("/dev/video1", "usb")) + await _let_leader_start(capture) + follower = asyncio.create_task(capture_frame("/dev/video1", "usb")) + await asyncio.sleep(0) + + leader.cancel() + with pytest.raises(asyncio.CancelledError): + await leader + gate.set() + + assert await follower == FRAME_A + assert capture.count == 1 + + +@pytest.mark.asyncio +async def test_cancelling_a_follower_leaves_the_leader_alone(patch_capture): + """The mirror case: the follower's cancellation is its own business.""" + gate = asyncio.Event() + capture = patch_capture(RecordingCapture(gate=gate)) + + leader = asyncio.create_task(capture_frame("/dev/video1", "usb")) + await _let_leader_start(capture) + follower = asyncio.create_task(capture_frame("/dev/video1", "usb")) + await asyncio.sleep(0) + + follower.cancel() + with pytest.raises(asyncio.CancelledError): + await follower + gate.set() + + assert await leader == FRAME_A + assert capture.count == 1 + + +@pytest.mark.asyncio +async def test_capture_in_flight_reports_the_window(patch_capture): + """The predicate a diagnose-style caller would use to know it will join, + not measure its own connection.""" + gate = asyncio.Event() + capture = patch_capture(RecordingCapture(gate=gate)) + + assert capture_in_flight("/dev/video1", "usb") is False + + leader = asyncio.create_task(capture_frame("/dev/video1", "usb")) + await _let_leader_start(capture) + + assert capture_in_flight("/dev/video1", "usb") is True + assert capture_in_flight("/dev/video2", "usb") is False # per camera + + gate.set() + await leader + await asyncio.sleep(0) + + assert capture_in_flight("/dev/video1", "usb") is False + + +# --------------------------------------------------------------------------- +# Failure must arrive as None, never as an exception +# --------------------------------------------------------------------------- +# +# `test_failed_leader_does_not_poison_its_followers` above covers a leader that +# RETURNS None. A leader that RAISES is a different path: the wrapper's retry +# loop only catches TimeoutError and CancelledError, so an escaping exception +# would reach every follower at once and none of them would take a turn of +# their own — one caller's failure becoming N. The per-type helpers catch +# narrowly (aiohttp.ClientError / OSError / timeouts), so the guarantee lives +# in _capture_frame_uncoalesced's own blanket catch. + + +@pytest.mark.asyncio +async def test_an_unexpected_error_is_reported_as_a_failed_capture(): + """Not every failure is an OSError. An IncompleteReadError is an EOFError, + which none of the per-type helpers catch.""" + + async def raising(url, timeout): + raise asyncio.IncompleteReadError(partial=b"", expected=4) + + import backend.app.services.external_camera as ec + + original = ec._capture_snapshot + ec._capture_snapshot = raising + try: + result = await ec._capture_frame_uncoalesced("http://cam/snap", "snapshot", 5, None) + finally: + ec._capture_snapshot = original + assert result is None + + +@pytest.mark.asyncio +async def test_a_raising_leader_does_not_take_its_followers_down_with_it(monkeypatch): + """The whole point of coalescing is that one caller's connection serves + several. It must not also mean one caller's crash fails several. + + Patches the per-type helper rather than ``_capture_frame_uncoalesced``, + deliberately: the guarantee lives in that function's blanket catch, so a + stand-in installed in its place would test the wrapper against a shape the + wrapper can no longer be handed. + """ + gate = asyncio.Event() + attempts: list[str] = [] + + async def raise_then_succeed(url, timeout): + attempts.append(url) + if len(attempts) == 1: + await gate.wait() + raise RuntimeError("ffmpeg died in a way nobody catches") + return FRAME_B + + monkeypatch.setattr(ec_module, "_capture_rtsp_frame", raise_then_succeed) + + leader = asyncio.create_task(capture_frame("rtsp://cam/1", "rtsp", timeout=5)) + await asyncio.sleep(0) + follower = asyncio.create_task(capture_frame("rtsp://cam/1", "rtsp", timeout=5)) + await asyncio.sleep(0) + gate.set() + + leader_result, follower_result = await asyncio.gather(leader, follower, return_exceptions=True) + + assert not isinstance(leader_result, BaseException), f"leader raised {leader_result!r}" + assert not isinstance(follower_result, BaseException), f"follower raised {follower_result!r}" + assert leader_result is None, "the leader's own capture failed, so it gets None" + assert follower_result == FRAME_B, "the follower took its own turn and succeeded" + + +# --------------------------------------------------------------------------- +# The connection test must not claim a connection it never opened +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_connection_reports_when_it_shared_someone_elses_capture(patch_capture): + """A test landing while Obico is mid-poll gets that frame back. Reporting a + bare success would credit a connection this test never made — and forcing + its own would open the second handle the coalescing exists to prevent.""" + from backend.app.services.external_camera import test_connection + + gate = asyncio.Event() + capture = patch_capture(RecordingCapture(frames=(FRAME_A,), gate=gate)) + + other = asyncio.create_task(capture_frame("rtsp://cam/1", "rtsp", timeout=5)) + await _let_leader_start(capture) + + tested = asyncio.create_task(test_connection("rtsp://cam/1", "rtsp")) + await asyncio.sleep(0) + gate.set() + + result = await tested + await other + + assert result["success"] is True + assert result["coalesced"] is True + assert capture.count == 1, "no second connection was opened" + + +@pytest.mark.asyncio +async def test_connection_reports_its_own_capture_as_not_coalesced(patch_capture): + from backend.app.services.external_camera import test_connection + + capture = patch_capture(RecordingCapture(frames=(FRAME_A,))) + result = await test_connection("rtsp://cam/1", "rtsp") + + assert result["success"] is True + assert result["coalesced"] is False + assert capture.count == 1 + + +@pytest.mark.asyncio +async def test_connection_reports_coalesced_on_the_failure_path_too(patch_capture): + """The flag describes where the answer came from, not whether it was good.""" + from backend.app.services.external_camera import test_connection + + capture = patch_capture(RecordingCapture(frames=(None,))) + result = await test_connection("rtsp://cam/1", "rtsp") + + assert result["success"] is False + assert result["coalesced"] is False + assert capture.count == 1 + + +# --------------------------------------------------------------------------- +# Credentials must not reach the log +# --------------------------------------------------------------------------- +# +# camera.py's coalescing is keyed by IP address and has nothing to redact. +# These keys carry the camera URL, and an RTSP camera URL routinely embeds +# user:pass@ — which is why every other URL log in the module redacts. + +CREDENTIALED_URL = "rtsp://admin:hunter2@192.168.1.50:554/Streaming/Channels/101" + + +@pytest.mark.asyncio +async def test_the_reuse_log_line_redacts_the_password(patch_capture, caplog): + gate = asyncio.Event() + capture = patch_capture(RecordingCapture(frames=(FRAME_A,), gate=gate)) + + with caplog.at_level("DEBUG", logger=ec_module.__name__): + leader = asyncio.create_task(capture_frame(CREDENTIALED_URL, "rtsp", timeout=5)) + await _let_leader_start(capture) + follower = asyncio.create_task(capture_frame(CREDENTIALED_URL, "rtsp", timeout=5)) + await asyncio.sleep(0) + gate.set() + await asyncio.gather(leader, follower) + + assert not [r.getMessage() for r in caplog.records if "hunter2" in r.getMessage()] + + +@pytest.mark.asyncio +async def test_the_gave_up_waiting_log_line_redacts_the_password(patch_capture, caplog): + """This one is a warning, so it shows at the default level and lands in + support bundles.""" + gate = asyncio.Event() + capture = patch_capture(RecordingCapture(frames=(FRAME_A,), gate=gate)) + + with caplog.at_level("DEBUG", logger=ec_module.__name__): + leader = asyncio.create_task(capture_frame(CREDENTIALED_URL, "rtsp", timeout=5)) + await _let_leader_start(capture) + assert await capture_frame(CREDENTIALED_URL, "rtsp", timeout=0) is None + gate.set() + await leader + + messages = [r.getMessage() for r in caplog.records] + assert any("Gave up waiting" in m for m in messages), "the timeout path did not run" + assert not [m for m in messages if "hunter2" in m] + + +@pytest.mark.asyncio +async def test_the_failed_capture_log_line_redacts_the_password(patch_capture, caplog): + gate = asyncio.Event() + capture = patch_capture(RecordingCapture(frames=(None, FRAME_B), gate=gate)) + + with caplog.at_level("DEBUG", logger=ec_module.__name__): + leader = asyncio.create_task(capture_frame(CREDENTIALED_URL, "rtsp", timeout=5)) + await _let_leader_start(capture) + follower = asyncio.create_task(capture_frame(CREDENTIALED_URL, "rtsp", timeout=5)) + await asyncio.sleep(0) + gate.set() + await asyncio.gather(leader, follower) + + assert not [r.getMessage() for r in caplog.records if "hunter2" in r.getMessage()] diff --git a/backend/tests/unit/services/test_layer_timelapse.py b/backend/tests/unit/services/test_layer_timelapse.py index 893713346..b5db0befb 100644 --- a/backend/tests/unit/services/test_layer_timelapse.py +++ b/backend/tests/unit/services/test_layer_timelapse.py @@ -4,9 +4,10 @@ Tests for the layer timelapse service. These tests cover session management and pure logic functions. """ +import time from datetime import datetime from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import ANY, AsyncMock, MagicMock, patch import pytest @@ -246,6 +247,99 @@ class TestLayerChangeLogic: assert session.frame_count == 0 # But frame count not incremented +class TestCaptureLayerAppliesRotation: + """camera_rotation was previously only wired into the notification- + snapshot path, so a layer-timelapse video came out upside-down whenever + the printer had a rotation configured. capture_layer now applies it to + every captured frame, whether fresh or reused from the live view's + buffer, before writing to disk.""" + + @pytest.mark.asyncio + async def test_rotates_fresh_capture_when_configured(self, tmp_path): + from backend.app.services.layer_timelapse import TimelapseSession + + with patch("backend.app.services.layer_timelapse.settings") as mock_settings: + mock_settings.base_dir = tmp_path + + with patch.object(Path, "mkdir"): + session = TimelapseSession(1, 100, "/dev/video1", "usb", rotation=180) + + with ( + patch("backend.app.api.routes.camera.live_frame_for_capture", return_value=(False, None)), + patch( + "backend.app.services.layer_timelapse.capture_frame", + new_callable=AsyncMock, + return_value=b"\xff\xd8unrotated\xff\xd9", + ), + patch( + "backend.app.services.layer_timelapse.apply_camera_rotation", + return_value=b"\xff\xd8rotated\xff\xd9", + ) as mock_rotate, + patch.object(Path, "write_bytes") as mock_write, + ): + result = await session.capture_layer(1) + + assert result is True + mock_rotate.assert_called_once_with(b"\xff\xd8unrotated\xff\xd9", 180, ANY) + mock_write.assert_called_once_with(b"\xff\xd8rotated\xff\xd9") + + @pytest.mark.asyncio + async def test_rotates_buffered_frame_when_configured(self, tmp_path): + from backend.app.services.layer_timelapse import TimelapseSession + + with patch("backend.app.services.layer_timelapse.settings") as mock_settings: + mock_settings.base_dir = tmp_path + + with patch.object(Path, "mkdir"): + session = TimelapseSession(1, 100, "/dev/video1", "usb", rotation=90) + + with ( + patch( + "backend.app.api.routes.camera.live_frame_for_capture", + return_value=(True, b"\xff\xd8buffered\xff\xd9"), + ), + patch( + "backend.app.services.layer_timelapse.apply_camera_rotation", + return_value=b"\xff\xd8rotated\xff\xd9", + ) as mock_rotate, + patch.object(Path, "write_bytes") as mock_write, + ): + result = await session.capture_layer(1) + + assert result is True + mock_rotate.assert_called_once_with(b"\xff\xd8buffered\xff\xd9", 90, ANY) + mock_write.assert_called_once_with(b"\xff\xd8rotated\xff\xd9") + + @pytest.mark.asyncio + async def test_skips_rotation_when_not_configured(self, tmp_path): + """Default rotation=0 - no-op, and must not even call apply_camera_rotation + (avoids the PIL decode/re-encode round trip for the common case).""" + from backend.app.services.layer_timelapse import TimelapseSession + + with patch("backend.app.services.layer_timelapse.settings") as mock_settings: + mock_settings.base_dir = tmp_path + + with patch.object(Path, "mkdir"): + session = TimelapseSession(1, 100, "/dev/video1", "usb") + assert session.rotation == 0 + + with ( + patch("backend.app.api.routes.camera.live_frame_for_capture", return_value=(False, None)), + patch( + "backend.app.services.layer_timelapse.capture_frame", + new_callable=AsyncMock, + return_value=b"\xff\xd8unrotated\xff\xd9", + ), + patch("backend.app.services.layer_timelapse.apply_camera_rotation") as mock_rotate, + patch.object(Path, "write_bytes") as mock_write, + ): + result = await session.capture_layer(1) + + assert result is True + mock_rotate.assert_not_called() + mock_write.assert_called_once_with(b"\xff\xd8unrotated\xff\xd9") + + class TestOnLayerChange: """Tests for the on_layer_change callback.""" @@ -318,3 +412,235 @@ class TestGetActiveSessions: assert 1 in _active_sessions cancel_session(1) + + +class TestCleanupOrphanedTimelapseSessions: + """_active_sessions is in-memory only, so a process restart mid-print + loses track of an active session without ever cleaning up its frames + directory (or a stitched-but-not-attached output .mp4). Confirmed live: + 38MB of exactly this leftover on Carl's OrangePi after several restarts + during testing. cleanup_orphaned_timelapse_sessions() sweeps for it.""" + + def _touch_old(self, path, age_seconds=600): + import os + + path.touch() + old = time.time() - age_seconds + os.utime(path, (old, old)) + + def _mkdir_old(self, path, age_seconds=600): + import os + + path.mkdir(parents=True) + old = time.time() - age_seconds + os.utime(path, (old, old)) + + def test_removes_orphaned_frame_dir_and_stray_output(self, tmp_path): + from backend.app.services.layer_timelapse import ( + _active_sessions, + cleanup_orphaned_timelapse_sessions, + ) + + _active_sessions.clear() + printer_dir = tmp_path / "timelapse_frames" / "1" + self._mkdir_old(printer_dir / "20260101_000000") + self._touch_old(printer_dir / "timelapse_20260101_000000.mp4") + + with patch("backend.app.services.layer_timelapse.settings") as mock_settings: + mock_settings.base_dir = tmp_path + removed = cleanup_orphaned_timelapse_sessions(min_age_seconds=300) + + assert removed == 2 + assert not (printer_dir / "20260101_000000").exists() + assert not (printer_dir / "timelapse_20260101_000000.mp4").exists() + + def test_spares_the_currently_active_session(self, tmp_path): + from backend.app.services.layer_timelapse import ( + TimelapseSession, + _active_sessions, + cleanup_orphaned_timelapse_sessions, + ) + + _active_sessions.clear() + + with patch("backend.app.services.layer_timelapse.settings") as mock_settings: + mock_settings.base_dir = tmp_path + session = TimelapseSession(1, 100, "/dev/video1", "usb") + _active_sessions[1] = session + import os + + old = time.time() - 600 + os.utime(session.frames_dir, (old, old)) + + removed = cleanup_orphaned_timelapse_sessions(min_age_seconds=300) + + assert removed == 0 + assert session.frames_dir.exists() + _active_sessions.clear() + + def test_spares_recently_modified_entries(self, tmp_path): + """Defensive margin: something modified within min_age_seconds is + left alone even if it doesn't match an active session, in case this + is ever invoked while a session is mid-creation.""" + from backend.app.services.layer_timelapse import ( + _active_sessions, + cleanup_orphaned_timelapse_sessions, + ) + + _active_sessions.clear() + printer_dir = tmp_path / "timelapse_frames" / "1" + printer_dir.mkdir(parents=True) + (printer_dir / "20260101_000000").mkdir() + + with patch("backend.app.services.layer_timelapse.settings") as mock_settings: + mock_settings.base_dir = tmp_path + removed = cleanup_orphaned_timelapse_sessions(min_age_seconds=300) + + assert removed == 0 + assert (printer_dir / "20260101_000000").exists() + + def test_no_base_dir_is_a_no_op(self, tmp_path): + from backend.app.services.layer_timelapse import cleanup_orphaned_timelapse_sessions + + with patch("backend.app.services.layer_timelapse.settings") as mock_settings: + mock_settings.base_dir = tmp_path / "does-not-exist" + removed = cleanup_orphaned_timelapse_sessions() + + assert removed == 0 + + def test_ignores_non_numeric_printer_dirs(self, tmp_path): + """Defensive: unrelated directories under timelapse_frames/ (there + shouldn't be any, but printer_id is parsed from the dir name) must + not raise.""" + from backend.app.services.layer_timelapse import ( + _active_sessions, + cleanup_orphaned_timelapse_sessions, + ) + + _active_sessions.clear() + (tmp_path / "timelapse_frames" / "not-a-printer-id").mkdir(parents=True) + + with patch("backend.app.services.layer_timelapse.settings") as mock_settings: + mock_settings.base_dir = tmp_path + removed = cleanup_orphaned_timelapse_sessions() + + assert removed == 0 + + def test_spares_a_session_that_is_mid_stitch(self, tmp_path): + """on_print_complete drops the session from _active_sessions before it + hands frames_dir to ffmpeg, so for the length of a stitch (up to 300s) + the directory matches no active session. Its mtime is the last layer's + frame write, which on a tall print's final layer is easily older than + the age margin — and the margin's default IS the stitch timeout, so it + offers no headroom here. _finalizing_sessions covers that window.""" + import os + + from backend.app.services.layer_timelapse import ( + TimelapseSession, + _active_sessions, + _finalizing_sessions, + cleanup_orphaned_timelapse_sessions, + ) + + _active_sessions.clear() + _finalizing_sessions.clear() + + with patch("backend.app.services.layer_timelapse.settings") as mock_settings: + mock_settings.base_dir = tmp_path + session = TimelapseSession(1, 100, "/dev/video1", "usb") + (session.frames_dir / "layer_00001.jpg").write_bytes(b"x") + old = time.time() - 600 + os.utime(session.frames_dir, (old, old)) + + # Exactly the state on_print_complete is in while ffmpeg runs. + _active_sessions.pop(1, None) + _finalizing_sessions[1] = session.session_id + + removed = cleanup_orphaned_timelapse_sessions(min_age_seconds=300) + + assert removed == 0 + assert session.frames_dir.exists(), "ffmpeg's input was deleted mid-stitch" + _finalizing_sessions.clear() + + @pytest.mark.asyncio + async def test_on_print_complete_clears_the_finalizing_marker(self, tmp_path): + """Including when the stitch fails — a leaked marker would make the + sweep skip that printer's leftovers forever.""" + from backend.app.services.layer_timelapse import ( + TimelapseSession, + _active_sessions, + _finalizing_sessions, + on_print_complete, + ) + + _active_sessions.clear() + _finalizing_sessions.clear() + + with patch("backend.app.services.layer_timelapse.settings") as mock_settings: + mock_settings.base_dir = tmp_path + session = TimelapseSession(1, 100, "/dev/video1", "usb") + session.frame_count = 3 + _active_sessions[1] = session + + with patch.object(TimelapseSession, "stitch", AsyncMock(side_effect=RuntimeError("ffmpeg died"))): + result = await on_print_complete(1) + + assert result is None + assert 1 not in _finalizing_sessions + + def test_leaves_unrelated_files_alone(self, tmp_path): + """Only this module's own artifacts are swept. A file that is neither a + session directory nor timelapse_.mp4 was put there by something + else, and age is not a reason to delete it.""" + from backend.app.services.layer_timelapse import ( + _active_sessions, + cleanup_orphaned_timelapse_sessions, + ) + + _active_sessions.clear() + printer_dir = tmp_path / "timelapse_frames" / "1" + printer_dir.mkdir(parents=True) + stranger = printer_dir / "notes.txt" + self._touch_old(stranger) + self._touch_old(printer_dir / "timelapse_20260101_000000.mp4") + + with patch("backend.app.services.layer_timelapse.settings") as mock_settings: + mock_settings.base_dir = tmp_path + removed = cleanup_orphaned_timelapse_sessions(min_age_seconds=300) + + assert removed == 1 + assert stranger.exists() + assert not (printer_dir / "timelapse_20260101_000000.mp4").exists() + + def test_a_removal_that_fails_is_not_counted_as_removed(self, tmp_path): + """The count and the log line are the only evidence an operator has of + what was deleted, so a failed rmtree must not be reported as a success. + + The stub honours rmtree's real contract — ignore_errors=True swallows + the failure and returns normally — because that is the whole point: a + caller passing it gets a silent no-op that the surrounding + ``except OSError`` can never see, and would still count and log the + directory as removed. A stub that raised unconditionally would pass + either way and prove nothing. + """ + from backend.app.services.layer_timelapse import ( + _active_sessions, + cleanup_orphaned_timelapse_sessions, + ) + + _active_sessions.clear() + printer_dir = tmp_path / "timelapse_frames" / "1" + self._mkdir_old(printer_dir / "20260101_000000") + + def rmtree_on_read_only_fs(path, ignore_errors=False, **kwargs): + if ignore_errors: + return # silently does nothing, exactly like the real thing + raise OSError("read-only fs") + + with patch("backend.app.services.layer_timelapse.settings") as mock_settings: + mock_settings.base_dir = tmp_path + with patch("backend.app.services.layer_timelapse.shutil.rmtree", rmtree_on_read_only_fs): + removed = cleanup_orphaned_timelapse_sessions(min_age_seconds=300) + + assert removed == 0, "a directory that is still on disk was reported as removed" + assert (printer_dir / "20260101_000000").exists() diff --git a/backend/tests/unit/services/test_print_dispatch_context.py b/backend/tests/unit/services/test_print_dispatch_context.py new file mode 100644 index 000000000..875c55db2 --- /dev/null +++ b/backend/tests/unit/services/test_print_dispatch_context.py @@ -0,0 +1,77 @@ +"""Tests for the injected-End-G-code flag the finish photo depends on (#2547). + +The flag decides whether the finish photo comes from the camera (the print is +still on the plate) or from the in-print frame bank (a SwapMod snippet ejected +it — #1867). Getting it wrong in either direction ships the wrong photo, so the +two-step pending/adopt handoff exists to guarantee the flag can never outlive +the print it was recorded for. +""" + +import pytest + +from backend.app.services import print_dispatch_context + + +@pytest.fixture(autouse=True) +def _clean(): + for printer_id in (1, 2): + print_dispatch_context.clear(printer_id) + yield + for printer_id in (1, 2): + print_dispatch_context.clear(printer_id) + + +def test_unknown_printer_reports_no_injection(): + assert print_dispatch_context.end_gcode_injected(1) is False + + +def test_pending_flag_only_counts_once_the_print_starts(): + """Dispatch can fail between upload and start. Until the printer confirms a + print running, the flag must not affect anything.""" + print_dispatch_context.mark_pending(1) + + assert print_dispatch_context.end_gcode_injected(1) is False + + assert print_dispatch_context.adopt(1) is True + assert print_dispatch_context.end_gcode_injected(1) is True + + +def test_adopting_consumes_the_pending_flag(): + """A second print must not inherit the first print's snippet.""" + print_dispatch_context.mark_pending(1) + print_dispatch_context.adopt(1) + + assert print_dispatch_context.adopt(1) is False + assert print_dispatch_context.end_gcode_injected(1) is False + + +def test_a_print_we_did_not_dispatch_clears_the_previous_flag(): + """The failure this two-step design exists to prevent: a print started from + the slicer or SD card right after a SwapMod job would otherwise inherit its + flag and get a mid-print banked frame instead of its own finish photo.""" + print_dispatch_context.mark_pending(1) + print_dispatch_context.adopt(1) + assert print_dispatch_context.end_gcode_injected(1) is True + + # Next print start, with nothing pending — i.e. Bambuddy didn't send it. + assert print_dispatch_context.adopt(1) is False + assert print_dispatch_context.end_gcode_injected(1) is False + + +def test_printers_do_not_share_flags(): + print_dispatch_context.mark_pending(1) + print_dispatch_context.adopt(1) + + assert print_dispatch_context.end_gcode_injected(1) is True + assert print_dispatch_context.end_gcode_injected(2) is False + + +def test_clear_forgets_pending_and_active(): + print_dispatch_context.mark_pending(1) + print_dispatch_context.adopt(1) + print_dispatch_context.mark_pending(1) + + print_dispatch_context.clear(1) + + assert print_dispatch_context.end_gcode_injected(1) is False + assert print_dispatch_context.adopt(1) is False diff --git a/backend/tests/unit/services/test_virtual_printer.py b/backend/tests/unit/services/test_virtual_printer.py index 7b8e83b6d..222c42856 100644 --- a/backend/tests/unit/services/test_virtual_printer.py +++ b/backend/tests/unit/services/test_virtual_printer.py @@ -1847,6 +1847,434 @@ class TestVirtualPrinterInstance: item = added_items[0] assert item.nozzle_mapping is None + @pytest.mark.asyncio + async def test_add_to_print_queue_ignores_ams_mapping_when_toggle_off(self, tmp_path): + """With the per-VP `save_ams_mapping` toggle off, the slicer's + AMS-slot pick must be ignored entirely — neither stamped on the queue + item nor persisted to the archive. + + Taking the slicer's pick makes `_ensure_ams_mapping` return early, so + `_compute_ams_mapping_for_printer` never runs — and that's where + `prefer_lowest_filament`, its AMS-filament-backup gate (#1766) and the + inventory-remain overrides live. Honouring it unconditionally would + retire all of that for every existing queue-mode VP on upgrade, so it + is opt-in like every other queue-mode behaviour toggle (#2700 review). + """ + from backend.app.services.virtual_printer.manager import VirtualPrinterInstance + + added_items = [] + mock_db = AsyncMock() + mock_db.add = MagicMock(side_effect=added_items.append) + mock_db.commit = AsyncMock() + mock_session_factory = MagicMock() + mock_session_ctx = AsyncMock() + mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) + mock_session_ctx.__aexit__ = AsyncMock(return_value=False) + mock_session_factory.return_value = mock_session_ctx + + inst = VirtualPrinterInstance( + vp_id=44, + name="AMSMappingOff", + mode="queue", + model="X1C", + access_code="12345678", + serial_suffix="391800044", + base_dir=tmp_path, + session_factory=mock_session_factory, + save_ams_mapping=False, + target_printer_id=7, + ) + + file_path = tmp_path / "test.3mf" + file_path.write_bytes(b"fake3mf") + + await inst.on_print_command( + file_path.name, + {"command": "project_file", "ams_mapping": [4, -1, 12, -1]}, + ) + + mock_archive = MagicMock() + mock_archive.id = 1 + mock_archive.print_name = "test" + + with ( + patch( + "backend.app.api.routes.settings.get_setting", + new_callable=AsyncMock, + return_value=None, + ), + patch( + "backend.app.services.archive.ArchiveService.archive_print", + new_callable=AsyncMock, + return_value=mock_archive, + ) as mock_archive_print, + ): + await inst._add_to_print_queue(file_path, "192.168.1.100") + + assert len(added_items) == 1 + assert added_items[0].ams_mapping is None + assert mock_archive_print.await_args.kwargs["slicer_ams_mapping"] is None + + @pytest.mark.asyncio + async def test_add_to_print_queue_persists_ams_mapping_to_archive_when_toggle_on(self, tmp_path): + """With the per-VP `save_ams_mapping` toggle on, the slicer's AMS + pick must also be forwarded to `ArchiveService.archive_print` so it + gets promoted to `archive.extra_data.slicer_ams_mapping` and a later + reprint can reuse the exact physical spool. + """ + import json as _json + + from backend.app.services.virtual_printer.manager import VirtualPrinterInstance + + added_items = [] + mock_db = AsyncMock() + mock_db.add = MagicMock(side_effect=added_items.append) + mock_db.commit = AsyncMock() + mock_session_factory = MagicMock() + mock_session_ctx = AsyncMock() + mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) + mock_session_ctx.__aexit__ = AsyncMock(return_value=False) + mock_session_factory.return_value = mock_session_ctx + + inst = VirtualPrinterInstance( + vp_id=45, + name="AMSMappingOn", + mode="queue", + model="X1C", + access_code="12345678", + serial_suffix="391800045", + base_dir=tmp_path, + session_factory=mock_session_factory, + save_ams_mapping=True, + target_printer_id=7, + ) + + file_path = tmp_path / "test.3mf" + file_path.write_bytes(b"fake3mf") + + await inst.on_print_command( + file_path.name, + {"command": "project_file", "ams_mapping": [4, -1, 12, -1]}, + ) + + mock_archive = MagicMock() + mock_archive.id = 1 + mock_archive.print_name = "test" + + with ( + patch( + "backend.app.api.routes.settings.get_setting", + new_callable=AsyncMock, + return_value=None, + ), + patch( + "backend.app.services.archive.ArchiveService.archive_print", + new_callable=AsyncMock, + return_value=mock_archive, + ) as mock_archive_print, + ): + await inst._add_to_print_queue(file_path, "192.168.1.100") + + assert len(added_items) == 1 + assert _json.loads(added_items[0].ams_mapping) == [4, -1, 12, -1] + + assert mock_archive_print.await_args.kwargs["slicer_ams_mapping"] == [4, -1, 12, -1] + assert mock_archive_print.await_args.kwargs["slicer_ams_mapping_printer_id"] == 7 + + @pytest.mark.asyncio + async def test_add_to_print_queue_ignores_ams_mapping_for_model_based_vp(self, tmp_path): + """A model-based VP (`target_printer_id=None`, dispatched later by the + scheduler to whichever printer matches) has no MQTT bridge to a real + printer, so the slicer has no live AMS layout to resolve tray IDs + against. Whatever it sends must be ignored on both the queue item and + the archive — trusting it would dispatch the eventually-chosen + printer onto a tray resolved against nothing (#2700 review). + """ + import json as _json + + from backend.app.services.virtual_printer.manager import VirtualPrinterInstance + + added_items = [] + mock_db = AsyncMock() + mock_db.add = MagicMock(side_effect=added_items.append) + mock_db.commit = AsyncMock() + mock_session_factory = MagicMock() + mock_session_ctx = AsyncMock() + mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) + mock_session_ctx.__aexit__ = AsyncMock(return_value=False) + mock_session_factory.return_value = mock_session_ctx + + inst = VirtualPrinterInstance( + vp_id=451, + name="AMSMappingModelBased", + mode="queue", + model="X1C", + access_code="12345678", + serial_suffix="391800451", + base_dir=tmp_path, + session_factory=mock_session_factory, + save_ams_mapping=True, + target_printer_id=None, + ) + + file_path = tmp_path / "test.3mf" + file_path.write_bytes(b"fake3mf") + + await inst.on_print_command( + file_path.name, + {"command": "project_file", "ams_mapping": [4, -1, 12, -1]}, + ) + + mock_archive = MagicMock() + mock_archive.id = 1 + mock_archive.print_name = "test" + + with ( + patch( + "backend.app.api.routes.settings.get_setting", + new_callable=AsyncMock, + return_value=None, + ), + patch( + "backend.app.services.archive.ArchiveService.archive_print", + new_callable=AsyncMock, + return_value=mock_archive, + ) as mock_archive_print, + ): + await inst._add_to_print_queue(file_path, "192.168.1.100") + + assert len(added_items) == 1 + assert added_items[0].ams_mapping is None + assert mock_archive_print.await_args.kwargs["slicer_ams_mapping"] is None + assert mock_archive_print.await_args.kwargs["slicer_ams_mapping_printer_id"] is None + + @pytest.mark.asyncio + async def test_add_to_print_queue_ignores_unresolved_ams_mapping_sentinel(self, tmp_path): + """#2589: an all -1 `ams_mapping` means the slicer's own race lost — + every slot unresolved. Must be dropped (queue item AND archive), + never trusted over a fresh scheduler-computed mapping. + """ + from backend.app.services.virtual_printer.manager import VirtualPrinterInstance + + added_items = [] + mock_db = AsyncMock() + mock_db.add = MagicMock(side_effect=added_items.append) + mock_db.commit = AsyncMock() + mock_session_factory = MagicMock() + mock_session_ctx = AsyncMock() + mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) + mock_session_ctx.__aexit__ = AsyncMock(return_value=False) + mock_session_factory.return_value = mock_session_ctx + + inst = VirtualPrinterInstance( + vp_id=46, + name="AMSMappingSentinel", + mode="queue", + model="X1C", + access_code="12345678", + serial_suffix="391800046", + base_dir=tmp_path, + session_factory=mock_session_factory, + save_ams_mapping=True, + target_printer_id=7, + ) + + file_path = tmp_path / "test.3mf" + file_path.write_bytes(b"fake3mf") + + await inst.on_print_command( + file_path.name, + {"command": "project_file", "ams_mapping": [-1, -1, -1, -1]}, + ) + + mock_archive = MagicMock() + mock_archive.id = 1 + mock_archive.print_name = "test" + + with ( + patch( + "backend.app.api.routes.settings.get_setting", + new_callable=AsyncMock, + return_value=None, + ), + patch( + "backend.app.services.archive.ArchiveService.archive_print", + new_callable=AsyncMock, + return_value=mock_archive, + ) as mock_archive_print, + ): + await inst._add_to_print_queue(file_path, "192.168.1.100") + + assert len(added_items) == 1 + assert added_items[0].ams_mapping is None + assert mock_archive_print.await_args.kwargs["slicer_ams_mapping"] is None + + @pytest.mark.asyncio + async def test_add_to_print_queue_force_color_match_keeps_mapping_off_the_queue_item(self, tmp_path): + """`Force color match` and `Save AMS mapping` both on: the archive + still gets the slicer's pick (that's what the toggle promises for + later reprints), but THIS dispatch does not. + + Force color match's only effect on a fixed-printer item is via the + per-slot `filament_overrides`, which are consumed inside + `_compute_ams_mapping_for_printer` — the exact function a stored + mapping makes `_ensure_ams_mapping` skip. Leaving the mapping on the + item would silently retire the user's explicit strictness (#2700 + review). + """ + from backend.app.services.virtual_printer.manager import VirtualPrinterInstance + + added_items = [] + mock_db = AsyncMock() + mock_db.add = MagicMock(side_effect=added_items.append) + mock_db.flush = AsyncMock() + mock_db.commit = AsyncMock() + mock_session_factory = MagicMock() + mock_session_ctx = AsyncMock() + mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) + mock_session_ctx.__aexit__ = AsyncMock(return_value=False) + mock_session_factory.return_value = mock_session_ctx + + inst = VirtualPrinterInstance( + vp_id=47, + name="AMSMappingVsForceColor", + mode="queue", + model="X1C", + access_code="12345678", + serial_suffix="391800047", + base_dir=tmp_path, + session_factory=mock_session_factory, + save_ams_mapping=True, + queue_force_color_match=True, + target_printer_id=7, + ) + + file_path = tmp_path / "test.3mf" + file_path.write_bytes(b"fake3mf") + + await inst.on_print_command( + file_path.name, + {"command": "project_file", "ams_mapping": [4, -1, 12, -1]}, + ) + + mock_archive = MagicMock() + mock_archive.id = 1 + mock_archive.print_name = "test" + + with ( + patch( + "backend.app.api.routes.settings.get_setting", + new_callable=AsyncMock, + return_value=None, + ), + patch( + "backend.app.services.archive.ArchiveService.archive_print", + new_callable=AsyncMock, + return_value=mock_archive, + ) as mock_archive_print, + ): + await inst._add_to_print_queue(file_path, "192.168.1.100") + + assert len(added_items) == 1 + assert added_items[0].ams_mapping is None + assert mock_archive_print.await_args.kwargs["slicer_ams_mapping"] == [4, -1, 12, -1] + assert mock_archive_print.await_args.kwargs["slicer_ams_mapping_printer_id"] == 7 + + @pytest.mark.asyncio + async def test_add_to_print_queue_drops_ams_mapping_too_short_for_the_plate(self, tmp_path, monkeypatch): + """A slicer mapping shorter than the plate's highest slot id can't + address that plate's own slots, and `_ensure_ams_mapping` would keep it + anyway (it only rejects an all-unresolved mapping). Drop it for that + plate so the scheduler computes one from live AMS state instead. + + The mapping is indexed by the 3MF's file-global slot ids, so the plate + that fits keeps it — only the one that doesn't falls back (#2700 + review). + """ + import json as _json + + from backend.app.services.virtual_printer.manager import VirtualPrinterInstance + + added_items = [] + mock_db = AsyncMock() + mock_db.add = MagicMock(side_effect=added_items.append) + mock_db.flush = AsyncMock() + mock_db.commit = AsyncMock() + mock_db.execute = AsyncMock() + mock_db.execute.return_value.scalar.return_value = None + mock_session_factory = MagicMock() + mock_session_ctx = AsyncMock() + mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) + mock_session_ctx.__aexit__ = AsyncMock(return_value=False) + mock_session_factory.return_value = mock_session_ctx + + inst = VirtualPrinterInstance( + vp_id=48, + name="AMSMappingShort", + mode="queue", + model="X1C", + access_code="12345678", + serial_suffix="391800048", + base_dir=tmp_path, + session_factory=mock_session_factory, + save_ams_mapping=True, + target_printer_id=7, + ) + + file_path = tmp_path / "test.3mf" + file_path.write_bytes(b"fake3mf") + + monkeypatch.setattr(inst, "_extract_plate_ids", lambda _p: [1, 2]) + + # Plate 1 prints slots 1-2 (fits the 2-entry mapping); plate 2 also + # prints slot 4, which the mapping cannot address. + def fake_reqs(_path, plate_id): + if plate_id == 1: + return [ + {"slot_id": 1, "type": "PLA", "color": "#FF0000"}, + {"slot_id": 2, "type": "PLA", "color": "#00FF00"}, + ] + return [ + {"slot_id": 1, "type": "PLA", "color": "#FF0000"}, + {"slot_id": 4, "type": "PLA", "color": "#0000FF"}, + ] + + monkeypatch.setattr( + "backend.app.services.filament_requirements.extract_filament_requirements", + fake_reqs, + ) + + await inst.on_print_command( + file_path.name, + {"command": "project_file", "ams_mapping": [4, 12]}, + ) + + mock_archive = MagicMock() + mock_archive.id = 1 + mock_archive.print_name = "test" + + with ( + patch( + "backend.app.api.routes.settings.get_setting", + new_callable=AsyncMock, + return_value=None, + ), + patch( + "backend.app.services.archive.ArchiveService.archive_print", + new_callable=AsyncMock, + return_value=mock_archive, + ) as mock_archive_print, + ): + await inst._add_to_print_queue(file_path, "192.168.1.100") + + assert len(added_items) == 2 + assert _json.loads(added_items[0].ams_mapping) == [4, 12] + assert added_items[1].ams_mapping is None + + # The archive keeps the mapping either way — it is the file's, not one + # plate's, and a reprint of the plate that fits still wants it. + assert mock_archive_print.await_args.kwargs["slicer_ams_mapping"] == [4, 12] + @pytest.mark.asyncio async def test_add_to_print_queue_nozzle_pick_replicated_across_plates(self, tmp_path, monkeypatch): """#1780 × #1697/#1188: a multi-plate Send All from BS must stamp the @@ -2032,6 +2460,392 @@ class TestVirtualPrinterInstance: # Recent-queue tracking dict is cleared after the patch. assert file_path.name not in inst._recent_queue_items + @pytest.mark.asyncio + async def test_on_print_command_late_mqtt_retroactively_stamps_archive_ams_mapping(self, tmp_path): + """Same #1780-round-3 race as the test above, but for `ams_mapping` + specifically: the archive was already created (with no + `slicer_ams_mapping`, since the slicer's pick hadn't arrived yet) + before this late MQTT lands. With the toggle on and a fixed target + printer, the archive must be retroactively patched too — otherwise a + reprint from this archive would never see the badge or the Mapping + button, even though the queue item itself dispatches correctly. + """ + import json as _json + + from backend.app.services.virtual_printer.manager import VirtualPrinterInstance + + added_items: list = [] + mock_db = AsyncMock() + mock_db.add = MagicMock( + side_effect=lambda item: (added_items.append(item), setattr(item, "id", 100 + len(added_items)))[0] + ) + mock_db.flush = AsyncMock() + mock_db.commit = AsyncMock() + + mock_archive_row = MagicMock() + mock_archive_row.id = 55 + mock_archive_row.extra_data = None + + position_max_result = MagicMock() + position_max_result.scalar = MagicMock(return_value=None) + select_pending_result = MagicMock() + # (queue_item_id, archive_id) — matches the id set by db.add above. + select_pending_result.all = MagicMock(return_value=[(101, 55)]) + update_result = MagicMock() + select_archives_result = MagicMock() + select_archives_result.scalars = MagicMock( + return_value=MagicMock(all=MagicMock(return_value=[mock_archive_row])) + ) + mock_db.execute = AsyncMock( + side_effect=[position_max_result, select_pending_result, update_result, select_archives_result] + ) + + mock_session_factory = MagicMock() + mock_session_ctx = AsyncMock() + mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) + mock_session_ctx.__aexit__ = AsyncMock(return_value=False) + mock_session_factory.return_value = mock_session_ctx + + inst = VirtualPrinterInstance( + vp_id=98, + name="LateMQTTArchivePatch", + mode="queue", + model="X1C", + access_code="12345678", + serial_suffix="391800098", + base_dir=tmp_path, + session_factory=mock_session_factory, + save_ams_mapping=True, + target_printer_id=7, + ) + inst._mqtt = MagicMock() + + file_path = tmp_path / "test.3mf" + file_path.write_bytes(b"fake3mf") + + mock_archive = MagicMock() + mock_archive.id = 55 + mock_archive.print_name = "test" + + with ( + patch( + "backend.app.api.routes.settings.get_setting", + new_callable=AsyncMock, + return_value=None, + ), + patch( + "backend.app.services.archive.ArchiveService.archive_print", + new_callable=AsyncMock, + return_value=mock_archive, + ), + patch( + "backend.app.services.virtual_printer.manager._SLICER_OPTIONS_WAIT_TIMEOUT", + 0.05, + ), + ): + await inst._add_to_print_queue(file_path, "192.168.1.100") + + assert len(added_items) == 1 + assert added_items[0].ams_mapping is None # MQTT was never received in time + + # MQTT project_file arrives late, carrying the slicer's AMS pick. + await inst.on_print_command( + file_path.name, + { + "command": "project_file", + "file": file_path.name, + "ams_mapping": [4, -1, 12, -1], + }, + ) + + # 3rd execute() is the queue-item UPDATE; 4th is the archive SELECT. + update_call = mock_db.execute.await_args_list[2] + compiled = update_call.args[0].compile(compile_kwargs={"literal_binds": False}) + assert _json.loads(dict(compiled.params)["ams_mapping"]) == [4, -1, 12, -1] + + assert mock_archive_row.extra_data == { + "slicer_ams_mapping": {"mapping": [4, -1, 12, -1], "printer_id": 7}, + } + + @pytest.mark.asyncio + async def test_on_print_command_late_mqtt_skips_archive_patch_for_model_based_vp(self, tmp_path): + """The archive patch above must not fire for a model-based VP + (`target_printer_id=None`) either — same rationale as the immediate + path: no fixed printer means no live AMS layout to have resolved the + late mapping against. + """ + import json as _json + + from backend.app.services.virtual_printer.manager import VirtualPrinterInstance + + added_items: list = [] + mock_db = AsyncMock() + mock_db.add = MagicMock( + side_effect=lambda item: (added_items.append(item), setattr(item, "id", 200 + len(added_items)))[0] + ) + mock_db.flush = AsyncMock() + mock_db.commit = AsyncMock() + + position_max_result = MagicMock() + position_max_result.scalar = MagicMock(return_value=None) + select_pending_result = MagicMock() + select_pending_result.all = MagicMock(return_value=[(201, 65)]) + update_result = MagicMock() + # No 4th execute() expected — the archive patch must be skipped + # entirely, so only 3 calls should ever happen. + mock_db.execute = AsyncMock(side_effect=[position_max_result, select_pending_result, update_result]) + + mock_session_factory = MagicMock() + mock_session_ctx = AsyncMock() + mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) + mock_session_ctx.__aexit__ = AsyncMock(return_value=False) + mock_session_factory.return_value = mock_session_ctx + + inst = VirtualPrinterInstance( + vp_id=97, + name="LateMQTTModelBased", + mode="queue", + model="X1C", + access_code="12345678", + serial_suffix="391800097", + base_dir=tmp_path, + session_factory=mock_session_factory, + save_ams_mapping=True, + target_printer_id=None, + ) + inst._mqtt = MagicMock() + + file_path = tmp_path / "test.3mf" + file_path.write_bytes(b"fake3mf") + + mock_archive = MagicMock() + mock_archive.id = 65 + mock_archive.print_name = "test" + + with ( + patch( + "backend.app.api.routes.settings.get_setting", + new_callable=AsyncMock, + return_value=None, + ), + patch( + "backend.app.services.archive.ArchiveService.archive_print", + new_callable=AsyncMock, + return_value=mock_archive, + ), + patch( + "backend.app.services.virtual_printer.manager._SLICER_OPTIONS_WAIT_TIMEOUT", + 0.05, + ), + ): + await inst._add_to_print_queue(file_path, "192.168.1.100") + + await inst.on_print_command( + file_path.name, + { + "command": "project_file", + "file": file_path.name, + "ams_mapping": [4, -1, 12, -1], + # An unrelated field so `patch` isn't empty and the UPDATE + # actually runs — isolates the assertion to "ams_mapping was + # excluded" rather than "nothing happened at all". + "timelapse": True, + }, + ) + + # 3rd execute() is the queue-item UPDATE — ams_mapping must be absent + # from it. No 4th execute() (the archive SELECT) should follow. + assert mock_db.execute.await_count == 3 + update_call = mock_db.execute.await_args_list[2] + compiled = update_call.args[0].compile(compile_kwargs={"literal_binds": False}) + assert "ams_mapping" not in dict(compiled.params) + assert dict(compiled.params)["timelapse"] is True + + @pytest.mark.asyncio + async def test_on_print_command_late_mqtt_force_color_match_archives_but_does_not_stamp(self, tmp_path): + """The late path splits the same way the immediate one does when + `Force color match` is on: the archive still records the slicer's pick + for later reprints, but it must not reach the queue item, or the + overrides the user asked for would never be applied (#2700 review). + """ + from backend.app.services.virtual_printer.manager import VirtualPrinterInstance + + added_items: list = [] + mock_db = AsyncMock() + mock_db.add = MagicMock( + side_effect=lambda item: (added_items.append(item), setattr(item, "id", 300 + len(added_items)))[0] + ) + mock_db.flush = AsyncMock() + mock_db.commit = AsyncMock() + + mock_archive_row = MagicMock() + mock_archive_row.id = 75 + mock_archive_row.extra_data = None + + position_max_result = MagicMock() + position_max_result.scalar = MagicMock(return_value=None) + select_pending_result = MagicMock() + select_pending_result.all = MagicMock(return_value=[(301, 75)]) + update_result = MagicMock() + select_archives_result = MagicMock() + select_archives_result.scalars = MagicMock( + return_value=MagicMock(all=MagicMock(return_value=[mock_archive_row])) + ) + mock_db.execute = AsyncMock( + side_effect=[position_max_result, select_pending_result, update_result, select_archives_result] + ) + + mock_session_factory = MagicMock() + mock_session_ctx = AsyncMock() + mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) + mock_session_ctx.__aexit__ = AsyncMock(return_value=False) + mock_session_factory.return_value = mock_session_ctx + + inst = VirtualPrinterInstance( + vp_id=96, + name="LateMQTTForceColor", + mode="queue", + model="X1C", + access_code="12345678", + serial_suffix="391800096", + base_dir=tmp_path, + session_factory=mock_session_factory, + save_ams_mapping=True, + queue_force_color_match=True, + target_printer_id=7, + ) + inst._mqtt = MagicMock() + + file_path = tmp_path / "test.3mf" + file_path.write_bytes(b"fake3mf") + + mock_archive = MagicMock() + mock_archive.id = 75 + mock_archive.print_name = "test" + + with ( + patch( + "backend.app.api.routes.settings.get_setting", + new_callable=AsyncMock, + return_value=None, + ), + patch( + "backend.app.services.archive.ArchiveService.archive_print", + new_callable=AsyncMock, + return_value=mock_archive, + ), + patch( + "backend.app.services.virtual_printer.manager._SLICER_OPTIONS_WAIT_TIMEOUT", + 0.05, + ), + ): + await inst._add_to_print_queue(file_path, "192.168.1.100") + + await inst.on_print_command( + file_path.name, + { + "command": "project_file", + "file": file_path.name, + "ams_mapping": [4, -1, 12, -1], + # Keeps the UPDATE non-empty so the assertion below is "the + # mapping was excluded", not "nothing ran". + "timelapse": True, + }, + ) + + update_call = mock_db.execute.await_args_list[2] + compiled = update_call.args[0].compile(compile_kwargs={"literal_binds": False}) + assert "ams_mapping" not in dict(compiled.params) + assert dict(compiled.params)["timelapse"] is True + + assert mock_archive_row.extra_data == { + "slicer_ams_mapping": {"mapping": [4, -1, 12, -1], "printer_id": 7}, + } + + @pytest.mark.asyncio + async def test_on_print_command_late_mqtt_ignores_ams_mapping_when_toggle_off(self, tmp_path): + """Toggle off: the late path must not stamp the queue item either, or + the opt-in would leak in through the #1780 race window. + """ + from backend.app.services.virtual_printer.manager import VirtualPrinterInstance + + added_items: list = [] + mock_db = AsyncMock() + mock_db.add = MagicMock( + side_effect=lambda item: (added_items.append(item), setattr(item, "id", 400 + len(added_items)))[0] + ) + mock_db.flush = AsyncMock() + mock_db.commit = AsyncMock() + + position_max_result = MagicMock() + position_max_result.scalar = MagicMock(return_value=None) + select_pending_result = MagicMock() + select_pending_result.all = MagicMock(return_value=[(401, 85)]) + update_result = MagicMock() + # No 4th execute(): with nothing to save there is no archive SELECT. + mock_db.execute = AsyncMock(side_effect=[position_max_result, select_pending_result, update_result]) + + mock_session_factory = MagicMock() + mock_session_ctx = AsyncMock() + mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) + mock_session_ctx.__aexit__ = AsyncMock(return_value=False) + mock_session_factory.return_value = mock_session_ctx + + inst = VirtualPrinterInstance( + vp_id=95, + name="LateMQTTToggleOff", + mode="queue", + model="X1C", + access_code="12345678", + serial_suffix="391800095", + base_dir=tmp_path, + session_factory=mock_session_factory, + save_ams_mapping=False, + target_printer_id=7, + ) + inst._mqtt = MagicMock() + + file_path = tmp_path / "test.3mf" + file_path.write_bytes(b"fake3mf") + + mock_archive = MagicMock() + mock_archive.id = 85 + mock_archive.print_name = "test" + + with ( + patch( + "backend.app.api.routes.settings.get_setting", + new_callable=AsyncMock, + return_value=None, + ), + patch( + "backend.app.services.archive.ArchiveService.archive_print", + new_callable=AsyncMock, + return_value=mock_archive, + ), + patch( + "backend.app.services.virtual_printer.manager._SLICER_OPTIONS_WAIT_TIMEOUT", + 0.05, + ), + ): + await inst._add_to_print_queue(file_path, "192.168.1.100") + + await inst.on_print_command( + file_path.name, + { + "command": "project_file", + "file": file_path.name, + "ams_mapping": [4, -1, 12, -1], + "timelapse": True, + }, + ) + + assert mock_db.execute.await_count == 3 + update_call = mock_db.execute.await_args_list[2] + compiled = update_call.args[0].compile(compile_kwargs={"literal_binds": False}) + assert "ams_mapping" not in dict(compiled.params) + assert dict(compiled.params)["timelapse"] is True + @pytest.mark.asyncio async def test_add_to_print_queue_catches_mqtt_stashed_post_wait_timeout(self, tmp_path): """The actual race-window scenario: wait_for times out, then MQTT @@ -2185,6 +2999,59 @@ class TestVirtualPrinterInstance: assert "test.3mf" not in inst._recent_queue_items +class TestExtractSlicerAmsMappingJson: + """Unit tests for `_extract_slicer_ams_mapping_json`, the pure-function + parser that pulls the slicer's own live-resolved AMS-slot pick out of a + captured project_file MQTT payload (see docstring in manager.py).""" + + def _extract(self, data): + from backend.app.services.virtual_printer.manager import _extract_slicer_ams_mapping_json + + return _extract_slicer_ams_mapping_json(data, "[test]") + + def test_missing_field_returns_none(self): + assert self._extract({}) is None + + def test_valid_int_list_returns_json(self): + import json as _json + + result = self._extract({"ams_mapping": [4, -1, 12, -1]}) + assert result is not None + assert _json.loads(result) == [4, -1, 12, -1] + + def test_stringified_json_is_parsed(self): + import json as _json + + result = self._extract({"ams_mapping": "[0, 1, 2]"}) + assert result is not None + assert _json.loads(result) == [0, 1, 2] + + def test_unparseable_string_returns_none(self): + assert self._extract({"ams_mapping": "not json"}) is None + + def test_non_list_value_returns_none(self): + assert self._extract({"ams_mapping": 42}) is None + + def test_empty_list_returns_none(self): + assert self._extract({"ams_mapping": []}) is None + + def test_non_int_entries_return_none(self): + assert self._extract({"ams_mapping": [1, "two", 3]}) is None + + def test_2589_all_unresolved_sentinel_returns_none(self): + """#2589: every slot -1 means the slicer's own resolution race + lost — never trust this over a fresh live computation.""" + assert self._extract({"ams_mapping": [-1, -1, -1, -1]}) is None + + def test_partially_resolved_mapping_is_kept(self): + import json as _json + + # Only some slots resolved is still meaningful — keep it. + result = self._extract({"ams_mapping": [-1, 4, -1, -1]}) + assert result is not None + assert _json.loads(result) == [-1, 4, -1, -1] + + class TestVirtualPrinterManager: """Tests for VirtualPrinterManager orchestrator.""" @@ -2358,6 +3225,7 @@ class TestVirtualPrinterManager: "auto_dispatch": True, "tailscale_disabled": True, # Opt-in default (#1070 UX fix) "queue_force_color_match": False, # default — must be explicit so MagicMock truthiness doesn't trip the change detector + "save_ams_mapping": False, # same reason as above "gcode_injection": False, # same reason as above "position": 0, } diff --git a/backend/tests/unit/test_connection_watchdog.py b/backend/tests/unit/test_connection_watchdog.py new file mode 100644 index 000000000..2176f4653 --- /dev/null +++ b/backend/tests/unit/test_connection_watchdog.py @@ -0,0 +1,157 @@ +"""Tests for the dead-MQTT-session watchdog (#2732). + +``check_staleness()`` guards the "connected but silent" session and returns +immediately once ``state.connected`` is False — from there, paho's own +auto-reconnect is the only thing still watching. The #2732 bundle shows what +happens when that stops making progress: a P1S dropped on a keep-alive timeout +at 02:19 and did not reconnect until 11:24, nine hours offline with the UI open +throughout. + +This watchdog is the backstop. The rules it has to keep are narrow on purpose — +it must not interfere with a session that is recovering on its own, and it must +not churn clients for printers that are simply switched off. +""" + +import time +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from backend.app.main import ( + CONNECTION_WATCHDOG_OFFLINE_GRACE, + CONNECTION_WATCHDOG_RETRY_INTERVAL, + _connection_watchdog_last_attempt, + _recover_dead_printer_sessions, +) + + +def _client(*, connected: bool, last_message_age: float | None, ip: str = "192.168.1.100"): + """Stand-in for BambuMQTTClient with only the fields the watchdog reads.""" + return SimpleNamespace( + state=SimpleNamespace(connected=connected), + _last_message_time=0.0 if last_message_age is None else time.time() - last_message_age, + ip_address=ip, + last_connect_error=None, + force_reconnect_stale_session=MagicMock(), + ) + + +async def _sweep(clients: dict, *, port_open: bool = True): + with ( + patch("backend.app.main.printer_manager._clients", clients), + patch("backend.app.services.printer_diagnostic.check_port", AsyncMock(return_value=port_open)), + ): + return await _recover_dead_printer_sessions() + + +@pytest.fixture(autouse=True) +def _clear_cooldowns(): + _connection_watchdog_last_attempt.clear() + yield + _connection_watchdog_last_attempt.clear() + + +class TestRebuildsDeadSessions: + @pytest.mark.asyncio + async def test_rebuilds_a_long_dead_session(self): + """The #2732 case: offline for hours, printer answering the whole time.""" + client = _client(connected=False, last_message_age=32718.0) + + assert await _sweep({1: client}) == 1 + client.force_reconnect_stale_session.assert_called_once() + + @pytest.mark.asyncio + async def test_reconnect_reason_names_the_duration(self): + client = _client(connected=False, last_message_age=32718.0) + await _sweep({1: client}) + assert "32718" in client.force_reconnect_stale_session.call_args.args[0] + + @pytest.mark.asyncio + async def test_sweeps_every_printer_in_the_farm(self): + clients = {i: _client(connected=False, last_message_age=9999.0) for i in range(1, 4)} + assert await _sweep(clients) == 3 + + +class TestLeavesHealthyAndRecoveringSessionsAlone: + @pytest.mark.asyncio + async def test_connected_printer_is_untouched(self): + client = _client(connected=True, last_message_age=99999.0) + assert await _sweep({1: client}) == 0 + client.force_reconnect_stale_session.assert_not_called() + + @pytest.mark.asyncio + async def test_inside_the_grace_period_paho_keeps_the_job(self): + """Below the grace window a reconnect may well be in flight; interrupting + it would turn a self-healing blip into a forced session rebuild.""" + client = _client(connected=False, last_message_age=CONNECTION_WATCHDOG_OFFLINE_GRACE - 30) + assert await _sweep({1: client}) == 0 + client.force_reconnect_stale_session.assert_not_called() + + @pytest.mark.asyncio + async def test_a_client_that_never_had_a_session_is_left_to_paho(self): + """No inbound message ever means this is the initial connect, where + retrying is both correct and the only thing to do.""" + client = _client(connected=False, last_message_age=None) + assert await _sweep({1: client}) == 0 + client.force_reconnect_stale_session.assert_not_called() + + @pytest.mark.asyncio + async def test_reconnecting_clears_the_cooldown(self): + """A printer that comes back must not carry a stale cooldown into its + next outage.""" + client = _client(connected=False, last_message_age=9999.0) + await _sweep({1: client}) + assert 1 in _connection_watchdog_last_attempt + + client.state.connected = True + await _sweep({1: client}) + assert 1 not in _connection_watchdog_last_attempt + + +class TestUnreachablePrinters: + @pytest.mark.asyncio + async def test_switched_off_printer_is_not_rebuilt(self): + """Rebuilding a client against a host that isn't answering achieves + nothing and would log a warning per printer all night.""" + client = _client(connected=False, last_message_age=9999.0) + assert await _sweep({1: client}, port_open=False) == 0 + client.force_reconnect_stale_session.assert_not_called() + + @pytest.mark.asyncio + async def test_unreachable_printer_still_takes_the_cooldown(self): + """Otherwise every sweep re-probes the port of every dead printer.""" + client = _client(connected=False, last_message_age=9999.0) + await _sweep({1: client}, port_open=False) + assert 1 in _connection_watchdog_last_attempt + + +class TestRetryInterval: + @pytest.mark.asyncio + async def test_does_not_rebuild_again_within_the_interval(self): + client = _client(connected=False, last_message_age=9999.0) + assert await _sweep({1: client}) == 1 + assert await _sweep({1: client}) == 0 + client.force_reconnect_stale_session.assert_called_once() + + @pytest.mark.asyncio + async def test_retries_once_the_interval_has_passed(self): + client = _client(connected=False, last_message_age=9999.0) + await _sweep({1: client}) + _connection_watchdog_last_attempt[1] -= CONNECTION_WATCHDOG_RETRY_INTERVAL + 1 + + assert await _sweep({1: client}) == 1 + assert client.force_reconnect_stale_session.call_count == 2 + + +class TestSweepIsFaultTolerant: + @pytest.mark.asyncio + async def test_one_broken_client_does_not_stop_the_others(self): + """A farm sweep that aborts on the first bad client would leave every + printer after it unrecovered.""" + bad = _client(connected=False, last_message_age=9999.0) + bad.force_reconnect_stale_session.side_effect = RuntimeError("boom") + good = _client(connected=False, last_message_age=9999.0) + + assert await _sweep({1: bad, 2: good}) == 2 + good.force_reconnect_stale_session.assert_called_once() diff --git a/backend/tests/unit/test_finish_photo_from_timelapse.py b/backend/tests/unit/test_finish_photo_from_timelapse.py index 23e101196..2f87acb46 100644 --- a/backend/tests/unit/test_finish_photo_from_timelapse.py +++ b/backend/tests/unit/test_finish_photo_from_timelapse.py @@ -179,3 +179,69 @@ async def test_polls_until_file_appears(tmp_path: Path, patched_session, monkeyp assert result is not None assert result.startswith("finish_") + + +async def test_extracted_frame_is_rotated_when_configured(tmp_path: Path, patched_session, monkeypatch): + """#2708: this source hands a path to ffmpeg and never holds the bytes, so + it was the one finish-photo source that ignored camera_rotation entirely. + A built-in-camera print with a timelapse prefers this source over the live + grab, so leaving it out meant the orientation depended on which source won. + """ + import io + + from PIL import Image + + monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path) + video_relpath = Path("archive/1/print/timelapse.mp4") + video_abspath = tmp_path / video_relpath + video_abspath.parent.mkdir(parents=True, exist_ok=True) + video_abspath.write_bytes(b"x" * 100) + + async def fake_extract(src, dst): + buf = io.BytesIO() + Image.new("RGB", (64, 32), (0, 0, 255)).save(buf, format="JPEG") + dst.write_bytes(buf.getvalue()) + return True + + monkeypatch.setattr(main_module, "_FINISH_PHOTO_TIMELAPSE_POLL_INTERVAL_SECONDS", 0.0) + patched_session.timelapse_path = str(video_relpath) + + with patch("backend.app.services.camera.extract_video_last_frame", new=fake_extract): + result, _ = await _capture_finish_photo_from_timelapse( + archive_id=42, + archive_dir=tmp_path / "archive_dir", + rotation=90, + ) + + assert result is not None + written = tmp_path / "archive_dir" / "photos" / result + # 64x32 turned a quarter turn: the file on disk is the rotated one, not + # what ffmpeg wrote. + assert Image.open(io.BytesIO(written.read_bytes())).size == (32, 64) + + +async def test_extracted_frame_is_untouched_without_a_rotation(tmp_path: Path, patched_session, monkeypatch): + """The default path must not decode and re-encode ffmpeg's output for + nothing — that would cost a generation of JPEG quality on every print.""" + monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path) + video_relpath = Path("archive/1/print/timelapse.mp4") + video_abspath = tmp_path / video_relpath + video_abspath.parent.mkdir(parents=True, exist_ok=True) + video_abspath.write_bytes(b"x" * 100) + + extracted = b"\xff\xd8" + b"\x00" * 50 + + async def fake_extract(src, dst): + dst.write_bytes(extracted) + return True + + monkeypatch.setattr(main_module, "_FINISH_PHOTO_TIMELAPSE_POLL_INTERVAL_SECONDS", 0.0) + patched_session.timelapse_path = str(video_relpath) + + with patch("backend.app.services.camera.extract_video_last_frame", new=fake_extract): + result, _ = await _capture_finish_photo_from_timelapse( + archive_id=42, + archive_dir=tmp_path / "archive_dir", + ) + + assert (tmp_path / "archive_dir" / "photos" / result).read_bytes() == extracted diff --git a/backend/tests/unit/test_finish_photo_moment_sync.py b/backend/tests/unit/test_finish_photo_moment_sync.py index f9e3494e4..f37dcca74 100644 --- a/backend/tests/unit/test_finish_photo_moment_sync.py +++ b/backend/tests/unit/test_finish_photo_moment_sync.py @@ -15,6 +15,7 @@ by the consumer. These tests pin the producer side of that contract. """ import asyncio +import logging from contextlib import asynccontextmanager from types import SimpleNamespace from unittest.mock import AsyncMock @@ -23,6 +24,7 @@ import pytest from backend.app import main as main_module from backend.app.main import on_finish_photo_moment +from backend.app.services import print_dispatch_context @asynccontextmanager @@ -54,11 +56,13 @@ def _clean_state(): main_module._stage22_finish_frames.clear() main_module._inprint_frame_bank.clear() main_module._inprint_frame_bank_ts.clear() + print_dispatch_context.clear(7) yield main_module._stage22_finish_in_flight.clear() main_module._stage22_finish_frames.clear() main_module._inprint_frame_bank.clear() main_module._inprint_frame_bank_ts.clear() + print_dispatch_context.clear(7) @pytest.fixture @@ -78,6 +82,18 @@ def patched_env(fake_printer, monkeypatch): "backend.app.api.routes.camera.get_buffered_frame", lambda _pid: None, ) + + # #2547: default the plate restore to "print height unknown", so tests that + # aren't about the restore never reach the G-code path. Tests that ARE about + # it override these two. + async def _no_height(_printer_id, _data, _logger): + return None + + async def _not_blocked(_printer_id): + return False + + monkeypatch.setattr(main_module, "_max_z_for_current_print", _no_height) + monkeypatch.setattr(main_module, "_plate_restore_is_blocked_by_queue", _not_blocked) return fake_printer @@ -212,10 +228,12 @@ async def test_consumer_wait_unblocked_when_producer_completes(patched_env, monk await producer -async def test_finish_state_prefers_banked_frame(patched_env, monkeypatch): - """#1867: on the FINISH-state fallback (stage-22-less firmware, e.g. A1 - Mini) a live grab captures the post-swap plate. When a banked in-print - frame exists it must be used instead, and the live grab must not run.""" +async def test_finish_state_prefers_banked_frame_when_end_gcode_was_injected(patched_env, monkeypatch): + """#1867: when Bambuddy injected End G-code, a SwapMod snippet may already + have ejected the plate by FINISH — so the banked in-print frame is used and + the live grab must not run.""" + print_dispatch_context.mark_pending(patched_env.id) + print_dispatch_context.adopt(patched_env.id) main_module._inprint_frame_bank[patched_env.id] = b"\xff\xd8banked" live_called = {"n": 0} @@ -232,10 +250,31 @@ async def test_finish_state_prefers_banked_frame(patched_env, monkeypatch): assert live_called["n"] == 0 -async def test_finish_state_falls_back_to_live_when_no_bank(patched_env, monkeypatch): - """No banked frame (feature just enabled, tiny print, capture failures) — - the FINISH-state path still live-grabs so we degrade to the old behaviour - rather than sending a text-only notification.""" +async def test_finish_state_grabs_live_when_no_end_gcode_was_injected(patched_env, monkeypatch): + """#2547: the ordinary case. Nothing moved the plate, the toolhead is + parked, and the print is still sitting there — so the live frame is the + finished print, and a banked mid-print frame must NOT win over it. + + Preferring the bank here unconditionally, which is what this code used to + do, is how the H2C shipped a photo with the toolhead over the part.""" + main_module._inprint_frame_bank[patched_env.id] = b"\xff\xd8banked-midprint" + + async def _live(**_kwargs): + return b"\xff\xd8live-finished-print" + + monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _live) + + await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"}) + + assert main_module._stage22_finish_frames[patched_env.id] == b"\xff\xd8live-finished-print" + + +async def test_finish_state_falls_back_to_live_when_bank_is_empty(patched_env, monkeypatch): + """End G-code was injected but nothing was ever banked (feature just + enabled, tiny print, capture failures). Degrade to a live grab rather than + sending a text-only notification.""" + print_dispatch_context.mark_pending(patched_env.id) + print_dispatch_context.adopt(patched_env.id) async def _live(**_kwargs): return b"\xff\xd8live" @@ -247,10 +286,12 @@ async def test_finish_state_falls_back_to_live_when_no_bank(patched_env, monkeyp assert main_module._stage22_finish_frames[patched_env.id] == b"\xff\xd8live" -async def test_last_layer_trigger_ignores_bank(patched_env, monkeypatch): - """The `last_layer` trigger fires before the swap and gives cleaner - (parked-toolhead) framing via a live grab — the bank is only for the - post-swap `finish_state` fallback, so it must be ignored here.""" +async def test_stage_22_trigger_ignores_bank(patched_env, monkeypatch): + """The `stage_22` trigger fires before any End G-code and gives cleaner + (parked-toolhead, plate-still-up) framing via a live grab — the bank is only + for the post-swap `finish_state` path, so it must be ignored here.""" + print_dispatch_context.mark_pending(patched_env.id) + print_dispatch_context.adopt(patched_env.id) main_module._inprint_frame_bank[patched_env.id] = b"\xff\xd8banked" async def _live(**_kwargs): @@ -258,7 +299,7 @@ async def test_last_layer_trigger_ignores_bank(patched_env, monkeypatch): monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _live) - await on_finish_photo_moment(patched_env.id, {"trigger": "last_layer"}) + await on_finish_photo_moment(patched_env.id, {"trigger": "stage_22"}) assert main_module._stage22_finish_frames[patched_env.id] == b"\xff\xd8live" @@ -299,10 +340,25 @@ async def test_bank_throttles_within_interval(monkeypatch): assert main_module._inprint_frame_bank[3] == b"frame-1" -async def test_bank_always_refreshes_on_last_layer(monkeypatch): +async def test_bank_throttles_on_the_last_layer_too(monkeypatch): + """#2547: the last layer used to bypass the throttle so it always got a + fresh frame. Now that progress advances also drive banking, that exemption + would fire a camera grab on every percent tick of a multi-minute last layer + — and each grab contends with the live view for the single RTSP slot.""" counter = _bank_env(monkeypatch, total_layers=10) await main_module._maybe_bank_inprint_frame(3, 5) # banks frame-1 - # Last layer bypasses the throttle for the best final framing. + await main_module._maybe_bank_inprint_frame(3, 10) # last layer, within 25s + assert counter["n"] == 1 + assert main_module._inprint_frame_bank[3] == b"frame-1" + + +async def test_bank_refreshes_on_the_last_layer_once_the_throttle_elapses(monkeypatch): + """The point of banking on progress: a three-minute last layer keeps + refreshing instead of freezing at the moment that layer began.""" + counter = _bank_env(monkeypatch, total_layers=10) + await main_module._maybe_bank_inprint_frame(3, 10) # banks frame-1 + # Pretend the throttle window has passed, as it does mid-last-layer. + main_module._inprint_frame_bank_ts[3] -= main_module._INPRINT_BANK_MIN_INTERVAL + 1 await main_module._maybe_bank_inprint_frame(3, 10) assert counter["n"] == 2 assert main_module._inprint_frame_bank[3] == b"frame-2" @@ -322,3 +378,498 @@ async def test_bank_skips_during_calibration_substage(monkeypatch): _bank_env(monkeypatch, sub_stage=14) await main_module._maybe_bank_inprint_frame(3, 2) assert 3 not in main_module._inprint_frame_bank + + +class TestStage22CacheHoldsExactlyOneRotation: + """#2708. `_stage22_finish_frames` is fed from two kinds of source: live + grabs, which are raw, and the #1867 in-print bank, whose bytes came from + `_capture_snapshot_for_notification` and are therefore ALREADY rotated. + The consumer cannot tell them apart, so the producer normalises: every + entry in the cache has had the rotation applied exactly once. + + Rotating on the consumer side instead put two rotations on the banked + path — at 180 degrees that is the reported bug reproduced exactly, and at + 90/270 it lands the photo 180 degrees out. + """ + + @staticmethod + def _jpeg(width, height): + import io + + from PIL import Image + + buf = io.BytesIO() + Image.new("RGB", (width, height), (0, 0, 255)).save(buf, format="JPEG") + return buf.getvalue() + + @staticmethod + def _size(data): + import io + + from PIL import Image + + return Image.open(io.BytesIO(data)).size + + async def test_a_live_grab_is_rotated_before_caching(self, patched_env, monkeypatch): + monkeypatch.setattr(patched_env, "camera_rotation", 90, raising=False) + raw = self._jpeg(64, 32) + + async def _capture(**_kwargs): + return raw + + monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _capture) + + await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"}) + + cached = main_module._stage22_finish_frames[patched_env.id] + assert self._size(cached) == (32, 64) + + async def test_the_banked_frame_is_cached_verbatim(self, patched_env, monkeypatch): + """The bank is filled by `_capture_snapshot_for_notification`, which + rotates before it returns — so the producer must pass those bytes + through untouched rather than rotating them a second time. + + Note this pins the invariant forward; it does not on its own prove the + bug fixed, because the old producer didn't rotate anything either. The + pair that discriminates is `test_a_live_grab_is_rotated_before_caching` + (producer now rotates) plus the source guard below (consumer no longer + does). + """ + monkeypatch.setattr(patched_env, "camera_rotation", 90, raising=False) + # #2547: the bank is only preferred when End G-code was injected. + print_dispatch_context.mark_pending(patched_env.id) + print_dispatch_context.adopt(patched_env.id) + already_rotated = self._jpeg(32, 64) # what one rotation of a 64x32 frame looks like + main_module._inprint_frame_bank[patched_env.id] = already_rotated + + async def _capture(**_kwargs): # pragma: no cover - must not be reached + raise AssertionError("the banked frame should have been preferred") + + monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _capture) + + await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"}) + + cached = main_module._stage22_finish_frames[patched_env.id] + assert cached is already_rotated + assert self._size(cached) == (32, 64) + + async def test_a_stage22_grab_is_rotated_even_though_the_bank_is_full(self, patched_env, monkeypatch): + """Only the `finish_state` trigger reads the bank. The `stage_22` and + `last_layer` triggers take a live grab, which still needs rotating — + a shared "did we use the bank" flag must not latch on the bank merely + existing.""" + monkeypatch.setattr(patched_env, "camera_rotation", 90, raising=False) + main_module._inprint_frame_bank[patched_env.id] = self._jpeg(999, 1) + + async def _capture(**_kwargs): + return self._jpeg(64, 32) + + monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _capture) + + await on_finish_photo_moment(patched_env.id, {"trigger": "stage_22"}) + + cached = main_module._stage22_finish_frames[patched_env.id] + assert self._size(cached) == (32, 64) + + async def test_no_rotation_configured_caches_the_bytes_as_captured(self, patched_env, monkeypatch): + raw = self._jpeg(64, 32) + + async def _capture(**_kwargs): + return raw + + monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _capture) + + await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"}) + + assert main_module._stage22_finish_frames[patched_env.id] is raw + + +def test_the_consumer_does_not_rotate_the_cached_frame(): + """The other half of the #2708 invariant, and the half with no runtime + harness: `_background_finish_photo` is a closure nested inside + `on_print_complete`, so nothing can drive its cached-frame branch + directly. What it must NOT do is rotate what it pops from + `_stage22_finish_frames` — the producer has already done that, and doing + it again upside-downs the banked path, which is the bug this fixed. + + Checked against the source because the alternative is no check at all. + """ + import ast + from pathlib import Path + + main_py = Path(__file__).resolve().parents[2] / "app" / "main.py" + assert main_py.exists(), f"guard is looking in the wrong place: {main_py}" + tree = ast.parse(main_py.read_text()) + + offenders = [ + node.lineno + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "_apply_camera_rotation" + and node.args + and isinstance(node.args[0], ast.Name) + and node.args[0].id == "cached_frame" + ] + + assert not offenders, ( + f"main.py:{offenders} rotates the frame popped from _stage22_finish_frames. " + "Those bytes are already rotated by on_finish_photo_moment (#2708); rotating " + "again returns a 180-degree print to upside-down." + ) + + +class TestPlateRestore: + """#2547 / #1145 / #1397 / #1565: put the plate back into camera framing. + + Bambu's end G-code drops the plate ~100mm as the last thing it does, so by + FINISH the finished print sits well below where the camera frames it. The + restore commands an ABSOLUTE Z back to just above the last printed layer. + + Absolute is the safety argument, and these tests pin it: the target is a + height the toolhead was physically at seconds earlier, so it is inside the + travel limits and leaves the nozzle above the part. It is also unambiguous + across model families — Z is the nozzle-to-bed gap whether the bed moves or + the toolhead does — so there is no sign to get wrong the way the relative + bed-jog path had (#1334). + """ + + @pytest.fixture + def printer_client(self, monkeypatch): + sent: list[str] = [] + client = SimpleNamespace( + state=SimpleNamespace(state="FINISH"), + send_gcode=lambda gcode: (sent.append(gcode), True)[1], + ) + monkeypatch.setattr(main_module.printer_manager, "get_client", lambda _pid: client) + monkeypatch.setattr(main_module.asyncio, "sleep", AsyncMock()) + client.sent = sent + return client + + async def test_commands_an_absolute_move_above_the_print(self, printer_client): + ok = await main_module._restore_plate_for_finish_photo(7, 16.0, logging.getLogger(__name__)) + + assert ok is True + assert printer_client.sent == ["G90\nG1 Z26.00 F600"] + + async def test_never_touches_m211(self, printer_client): + """#2579: disabling soft endstops is what let a jog drive the nozzle + into the bed. This path must not reintroduce it.""" + await main_module._restore_plate_for_finish_photo(7, 16.0, logging.getLogger(__name__)) + + assert not any("M211" in line for line in printer_client.sent) + + async def test_skipped_when_the_printer_is_no_longer_in_finish(self, printer_client): + """The queue dispatches the next job the instant a print completes. + Commanding a plate move into a starting print is not a race worth + having, so state is re-read immediately before the move.""" + printer_client.state.state = "RUNNING" + + ok = await main_module._restore_plate_for_finish_photo(7, 16.0, logging.getLogger(__name__)) + + assert ok is False + assert printer_client.sent == [] + + async def test_skipped_when_the_printer_is_gone(self, monkeypatch): + monkeypatch.setattr(main_module.printer_manager, "get_client", lambda _pid: None) + + ok = await main_module._restore_plate_for_finish_photo(7, 16.0, logging.getLogger(__name__)) + + assert ok is False + + async def test_reports_failure_when_the_send_fails(self, printer_client, monkeypatch): + """A failed send means the plate never moved — the caller must not go on + to owe it a move back down.""" + monkeypatch.setattr(printer_client, "send_gcode", lambda _g: False) + + ok = await main_module._restore_plate_for_finish_photo(7, 16.0, logging.getLogger(__name__)) + + assert ok is False + + def test_park_lowers_the_plate_again(self, printer_client): + main_module._park_plate_after_finish_photo(7, 16.0, logging.getLogger(__name__)) + + assert printer_client.sent == ["G90\nG1 Z116.00 F600"] + + def test_park_skipped_once_the_next_print_has_started(self, printer_client): + printer_client.state.state = "RUNNING" + + main_module._park_plate_after_finish_photo(7, 16.0, logging.getLogger(__name__)) + + assert printer_client.sent == [] + + +class TestPlateRestoreWiring: + """The restore only runs in the one situation it is correct for, and the + plate always comes back down afterwards.""" + + @pytest.fixture + def restore_env(self, patched_env, monkeypatch): + calls = {"restore": [], "park": [], "blocked": False, "height": 16.0} + + async def _height(_printer_id, _data, _logger): + return calls["height"] + + async def _blocked(_printer_id): + return calls["blocked"] + + async def _restore(printer_id, max_z, _logger): + calls["restore"].append((printer_id, max_z)) + return True + + def _park(printer_id, max_z, _logger): + calls["park"].append((printer_id, max_z)) + + monkeypatch.setattr(main_module, "_max_z_for_current_print", _height) + monkeypatch.setattr(main_module, "_plate_restore_is_blocked_by_queue", _blocked) + monkeypatch.setattr(main_module, "_restore_plate_for_finish_photo", _restore) + monkeypatch.setattr(main_module, "_park_plate_after_finish_photo", _park) + + async def _live(**_kwargs): + return b"\xff\xd8live" + + monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _live) + return calls + + async def test_restores_then_parks_on_the_finish_state_path(self, patched_env, restore_env): + await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"}) + + assert restore_env["restore"] == [(patched_env.id, 16.0)] + assert restore_env["park"] == [(patched_env.id, 16.0)] + + async def test_not_restored_on_the_stage_22_path(self, patched_env, restore_env): + """Stage 22 fires before the end G-code drops the plate — it is already + where we want it, and moving it would only cost the settle delay.""" + await on_finish_photo_moment(patched_env.id, {"trigger": "stage_22"}) + + assert restore_env["restore"] == [] + assert restore_env["park"] == [] + + async def test_not_restored_when_the_banked_frame_is_used(self, patched_env, restore_env): + """The plate has been swapped out — no move brings the print back.""" + print_dispatch_context.mark_pending(patched_env.id) + print_dispatch_context.adopt(patched_env.id) + main_module._inprint_frame_bank[patched_env.id] = b"\xff\xd8banked" + + await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"}) + + assert restore_env["restore"] == [] + assert restore_env["park"] == [] + + async def test_not_restored_when_end_gcode_was_injected_but_the_bank_is_empty(self, patched_env, restore_env): + """A plate-swap machine may have just ejected its plate. Even with no + banked frame to fall back on, driving Z into whatever a swap mechanism + is doing is not worth a photo of a bed we know may be bare.""" + print_dispatch_context.mark_pending(patched_env.id) + print_dispatch_context.adopt(patched_env.id) + + await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"}) + + assert restore_env["restore"] == [] + assert restore_env["park"] == [] + + async def test_not_restored_when_the_print_height_is_unknown(self, patched_env, restore_env): + restore_env["height"] = None + + await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"}) + + assert restore_env["restore"] == [] + assert restore_env["park"] == [] + + async def test_not_restored_when_another_job_is_queued(self, patched_env, restore_env): + restore_env["blocked"] = True + + await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"}) + + assert restore_env["restore"] == [] + assert restore_env["park"] == [] + + async def test_not_restored_when_the_setting_is_off(self, patched_env, restore_env, monkeypatch): + async def _get_setting(_db, key): + if key == "capture_finish_photo": + return "true" + if key == "finish_photo_restore_plate": + return "false" + return None + + monkeypatch.setattr("backend.app.api.routes.settings.get_setting", _get_setting) + + await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"}) + + assert restore_env["restore"] == [] + assert restore_env["park"] == [] + + async def test_plate_is_parked_even_when_the_capture_throws(self, patched_env, restore_env, monkeypatch): + """We raised it, so we owe the move back down — including when the grab + between the two fails. Otherwise the user finds the print pinned under + the nozzle.""" + + async def _boom(**_kwargs): + raise RuntimeError("camera gone") + + monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _boom) + + await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"}) + + assert restore_env["restore"] == [(patched_env.id, 16.0)] + assert restore_env["park"] == [(patched_env.id, 16.0)] + + async def test_producer_event_is_still_set_after_a_restore(self, patched_env, restore_env): + """#1790: the consumer's bounded wait must be released on every exit, + and the restore added a new path through the producer.""" + await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"}) + + assert main_module._stage22_finish_in_flight[patched_env.id].is_set() + + +async def test_producer_wait_budget_covers_the_restore(): + """The consumer's wait has to outlast settle + a worst-case RTSP grab, and + still finish inside the notification's own photo budget — otherwise the + restore path is cut off by a timeout somewhere above it.""" + assert main_module._FINISH_PHOTO_PRODUCER_WAIT_SECONDS > main_module._PLATE_RESTORE_SETTLE_SECONDS + 15 + + +class TestMaxZResolution: + """#2547 safety: the height that becomes a Z-move target must provably + belong to the print that just finished. + + A height from another print is the one failure mode that could drive the + nozzle into the model — 20mm carried onto a 200mm print commands the plate + up through the part. So the resolver refuses on every ambiguity rather than + falling back to "whatever ran last on this printer". + """ + + @staticmethod + def _archive(**overrides): + base = { + "id": 11, + "file_path": "/data/archive/1/job/job.3mf", + "plate_id": 1, + "total_layers": 30, + } + base.update(overrides) + return SimpleNamespace(**base) + + @pytest.fixture + def resolver_env(self, monkeypatch): + env = {"archive": self._archive(), "reported_layers": 30, "height": 16.0, "where": None} + + @asynccontextmanager + async def _session(): + async def _execute(stmt): + env["where"] = str(stmt) + return SimpleNamespace(scalar_one_or_none=lambda: env["archive"]) + + yield SimpleNamespace(execute=_execute) + + monkeypatch.setattr(main_module, "async_session", _session) + monkeypatch.setattr( + main_module.printer_manager, + "get_client", + lambda _pid: SimpleNamespace(state=SimpleNamespace(total_layers=env["reported_layers"])), + ) + monkeypatch.setattr( + "backend.app.utils.threemf_tools.extract_max_z_height_from_3mf", + lambda _path, _plate: env["height"], + ) + return env + + async def test_returns_the_height_when_name_and_layers_agree(self, resolver_env): + height = await main_module._max_z_for_current_print(1, {"subtask_name": "job"}, logging.getLogger(__name__)) + assert height == 16.0 + + async def test_refuses_when_the_print_has_no_name_to_match_on(self, resolver_env): + """Without an identifier there is nothing to bind the archive to, and + the query would degrade to 'the newest row for this printer'.""" + height = await main_module._max_z_for_current_print(1, {}, logging.getLogger(__name__)) + + assert height is None + assert resolver_env["where"] is None # refused before touching the DB + + async def test_refuses_when_no_archive_matches_the_name(self, resolver_env): + resolver_env["archive"] = None + + height = await main_module._max_z_for_current_print(1, {"subtask_name": "job"}, logging.getLogger(__name__)) + assert height is None + + async def test_refuses_when_the_layer_counts_disagree(self, resolver_env): + """The corroboration check. The archive's layer count comes from the + 3MF; the printer's comes from MQTT. If two independent sources disagree, + the row is not this print whatever its name says.""" + resolver_env["reported_layers"] = 240 + + height = await main_module._max_z_for_current_print(1, {"subtask_name": "job"}, logging.getLogger(__name__)) + assert height is None + + async def test_proceeds_when_a_layer_count_is_simply_unknown(self, resolver_env): + """Absent is not the same as contradictory — a print Bambuddy has no + layer count for still gets its height, because the name matched.""" + resolver_env["reported_layers"] = 0 + assert ( + await main_module._max_z_for_current_print(1, {"subtask_name": "job"}, logging.getLogger(__name__)) == 16.0 + ) + + resolver_env["reported_layers"] = 30 + resolver_env["archive"] = self._archive(total_layers=None) + assert ( + await main_module._max_z_for_current_print(1, {"subtask_name": "job"}, logging.getLogger(__name__)) == 16.0 + ) + + async def test_matches_by_equality_not_substring(self, resolver_env): + """`LIKE %name%` would let "Cube" resolve to "Cube v2" — a different + print, quite possibly a much taller one.""" + await main_module._max_z_for_current_print(1, {"subtask_name": "Cube"}, logging.getLogger(__name__)) + + assert "LIKE" not in resolver_env["where"].upper() + + async def test_refuses_when_the_archive_has_no_file(self, resolver_env): + resolver_env["archive"] = self._archive(file_path=None) + + height = await main_module._max_z_for_current_print(1, {"subtask_name": "job"}, logging.getLogger(__name__)) + assert height is None + + async def test_refuses_when_the_3mf_has_no_height(self, resolver_env): + resolver_env["height"] = None + + height = await main_module._max_z_for_current_print(1, {"subtask_name": "job"}, logging.getLogger(__name__)) + assert height is None + + +class TestTimelapsePathPlateRestore: + """#2547: the timelapse path falls through to a live grab whenever the + video hasn't landed yet — the documented usual outcome on P1-series. + + `on_finish_photo_moment` returns early for those prints without raising the + plate, so the photo that actually ships in the notification would be of an + already-dropped plate. The consumer therefore does the restore itself, but + only on that path — everywhere else the producer has already done it. + """ + + def test_notification_budget_outlasts_the_video_poll_plus_a_restore(self): + """The wait has to cover polling for the video AND the restore that + follows when it doesn't arrive. At the old flat 75s the fallback was + cut off mid-settle, so the plate would have moved for a photo nobody + was still waiting for.""" + assert ( + main_module._FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS + main_module._FINISH_PHOTO_PRODUCER_WAIT_SECONDS + > main_module._FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS + main_module._PLATE_RESTORE_SETTLE_SECONDS + 15 + ) + + async def test_producer_skips_the_restore_when_a_timelapse_was_recording(self, patched_env, monkeypatch): + """The producer returns before any of the restore code — the consumer + owns it on this path, and doing it in both would move the plate twice.""" + moved = [] + + async def _restore(printer_id, max_z, _logger): + moved.append((printer_id, max_z)) + return True + + async def _height(_printer_id, _data, _logger): + return 16.0 + + monkeypatch.setattr(main_module, "_restore_plate_for_finish_photo", _restore) + monkeypatch.setattr(main_module, "_max_z_for_current_print", _height) + + await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state", "timelapse_was_active": True}) + + assert moved == [] diff --git a/backend/tests/unit/test_github_backup_cloud_profiles.py b/backend/tests/unit/test_github_backup_cloud_profiles.py new file mode 100644 index 000000000..03571d02b --- /dev/null +++ b/backend/tests/unit/test_github_backup_cloud_profiles.py @@ -0,0 +1,509 @@ +"""Cloud-profile collection for Git backup (#2717). + +The collector used to read a ``setting`` key the Bambu Cloud API never returns, +so ``cloud_profiles/*`` was never written while ``backup_metadata.json`` claimed +it was. It also asked for the auth-disabled credential store unconditionally, +which meant it saw no accounts at all once auth was on. These tests pin the +response shape it actually has to parse, the account enumeration, and the +metadata now telling the truth. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from backend.app.models.settings import Settings +from backend.app.models.user import User +from backend.app.services.github_backup import GitHubBackupService + +# The real listing body: keyed by preset type, each holding private/public +# lists. There is no top-level "setting" array, and the entries carry no "type" +# of their own — the type is the outer key, and Bambu calls process "print". +BAMBU_LISTING = { + "filament": { + "private": [ + {"setting_id": "PFUS1", "name": "My PLA", "version": "1.0", "user_id": "u-123"}, + ], + "public": [ + {"setting_id": "GFSA00", "name": "Bambu PLA Basic", "version": "1.0"}, + ], + }, + "printer": { + "private": [{"setting_id": "PMUS1", "name": "My X1C", "version": "1.0"}], + "public": [], + }, + "print": { + "private": [{"setting_id": "PSUS1", "name": "My 0.2mm", "version": "1.0"}], + "public": [], + }, +} + + +def _detail(setting_id: str, name: str, base: str) -> dict: + return { + "setting_id": setting_id, + "name": name, + "type": "filament", + "version": "1.0", + "base_id": base, + "filament_id": "P1234", + "setting": {"filament_flow_ratio": ["0.98"]}, + } + + +def _bambu_cloud(listing=None, detail_side_effect=None): + cloud = MagicMock() + cloud.is_authenticated = True + cloud.get_slicer_settings = AsyncMock(return_value=listing if listing is not None else BAMBU_LISTING) + cloud.get_setting_detail = AsyncMock( + side_effect=detail_side_effect or (lambda sid: _detail(sid, f"detail-{sid}", "GFSA00")), + ) + cloud.close = AsyncMock() + return cloud + + +def _orca_service(profiles): + svc = MagicMock() + svc.list_profiles = AsyncMock(return_value=profiles) + svc.close = AsyncMock() + return svc + + +@pytest.fixture +def service(): + return GitHubBackupService() + + +class TestCloudAccountEnumeration: + """Which accounts a backup collects from.""" + + @pytest.mark.asyncio + async def test_auth_disabled_uses_the_global_store(self, service, db_session): + """With auth off there is no User row at all — credentials live in the + Settings table and the account is keyed ``global``.""" + with ( + patch( + "backend.app.api.routes.cloud.get_stored_token", + new_callable=AsyncMock, + return_value=("bambu-token", "a@b.c", "global"), + ), + patch( + "backend.app.api.routes.orca_cloud._load_credentials", + new_callable=AsyncMock, + return_value=MagicMock(token=None), + ), + ): + bambu, orca = await service.cloud_accounts(db_session) + + assert bambu == [("global", None)] + assert orca == [] + + @pytest.mark.asyncio + async def test_auth_enabled_finds_every_user_holding_a_token(self, service, db_session): + """The bug that made this invisible: with auth on, tokens live on User + rows, and the collector only ever looked at the global store. Each cloud + is enumerated separately so a user connected to one shows up only there. + """ + both = User(username="both", cloud_token="t1", orca_cloud_token="o1") + bambu_only = User(username="bambu-only", cloud_token="t2") + orca_only = User(username="orca-only", orca_cloud_token="o2") + neither = User(username="neither") + db_session.add_all([both, bambu_only, orca_only, neither]) + await db_session.commit() + + with ( + patch( + "backend.app.api.routes.cloud.get_stored_token", + new_callable=AsyncMock, + return_value=(None, None, "global"), + ), + patch( + "backend.app.api.routes.orca_cloud._load_credentials", + new_callable=AsyncMock, + return_value=MagicMock(token=None), + ), + ): + bambu, orca = await service.cloud_accounts(db_session) + + assert sorted(key for key, _ in bambu) == [f"user-{both.id}", f"user-{bambu_only.id}"] + assert sorted(key for key, _ in orca) == [f"user-{both.id}", f"user-{orca_only.id}"] + + @pytest.mark.asyncio + async def test_global_and_per_user_accounts_coexist(self, service, db_session): + """A Settings row survives someone enabling auth later. Dropping it + would silently stop backing up that account's presets.""" + db_session.add(User(username="u", cloud_token="t1")) + await db_session.commit() + + with ( + patch( + "backend.app.api.routes.cloud.get_stored_token", + new_callable=AsyncMock, + return_value=("legacy-global", None, "global"), + ), + patch( + "backend.app.api.routes.orca_cloud._load_credentials", + new_callable=AsyncMock, + return_value=MagicMock(token=None), + ), + ): + bambu, _orca = await service.cloud_accounts(db_session) + + assert "global" in [key for key, _ in bambu] + assert len(bambu) == 2 + + +class TestBambuCollection: + @pytest.mark.asyncio + async def test_reads_the_shape_the_api_actually_returns(self, service, db_session): + """The whole bug in one assertion: presets come out of + ``data[type]["private"]``, not a flat ``setting`` list, and ``print`` + maps to ``process``.""" + files: dict = {} + with patch( + "backend.app.api.routes.cloud.build_authenticated_cloud", + new_callable=AsyncMock, + return_value=_bambu_cloud(), + ): + counts = await service._collect_bambu_profiles(db_session, files, "global", None) + + assert counts == {"filament": 1, "printer": 1, "process": 1} + assert set(files) == { + "cloud_profiles/bambu/global/filament.json", + "cloud_profiles/bambu/global/printer.json", + "cloud_profiles/bambu/global/process.json", + } + + @pytest.mark.asyncio + async def test_public_presets_are_not_backed_up(self, service, db_session): + """Bambu's bundled catalogue is identical for everyone, re-downloadable, + and not recreatable under your account — backing it up would churn the + repository on every run for no recovery value.""" + files: dict = {} + with patch( + "backend.app.api.routes.cloud.build_authenticated_cloud", + new_callable=AsyncMock, + return_value=_bambu_cloud(), + ): + await service._collect_bambu_profiles(db_session, files, "global", None) + + filament = files["cloud_profiles/bambu/global/filament.json"]["profiles"] + assert [p["setting_id"] for p in filament] == ["PFUS1"] + + @pytest.mark.asyncio + async def test_stores_the_payload_a_restore_needs(self, service, db_session): + """The listing is metadata only. Without ``base_id`` and ``setting`` + the backup is a list of names — ``create_setting`` cannot rebuild from + it.""" + files: dict = {} + with patch( + "backend.app.api.routes.cloud.build_authenticated_cloud", + new_callable=AsyncMock, + return_value=_bambu_cloud(), + ): + await service._collect_bambu_profiles(db_session, files, "global", None) + + preset = files["cloud_profiles/bambu/global/filament.json"]["profiles"][0] + assert preset["base_id"] == "GFSA00" + assert preset["setting"] == {"filament_flow_ratio": ["0.98"]} + assert preset["type"] == "filament" + + @pytest.mark.asyncio + async def test_account_identity_is_not_written_to_the_repo(self, service, db_session): + """Backup repositories can be public, and ``user_id`` adds nothing to a + rebuild.""" + files: dict = {} + with patch( + "backend.app.api.routes.cloud.build_authenticated_cloud", + new_callable=AsyncMock, + return_value=_bambu_cloud(), + ): + await service._collect_bambu_profiles(db_session, files, "global", None) + + for payload in files.values(): + for preset in payload["profiles"]: + assert "user_id" not in preset + + @pytest.mark.asyncio + async def test_one_unreadable_preset_does_not_lose_the_others(self, service, db_session): + """And it is counted, not swallowed — a partial backup that looks + complete is how #2717 stayed invisible.""" + + def detail(setting_id): + if setting_id == "PFUS1": + raise RuntimeError("boom") + return _detail(setting_id, "ok", "GFSA00") + + files: dict = {} + with patch( + "backend.app.api.routes.cloud.build_authenticated_cloud", + new_callable=AsyncMock, + return_value=_bambu_cloud(detail_side_effect=detail), + ): + counts = await service._collect_bambu_profiles(db_session, files, "global", None) + + assert "cloud_profiles/bambu/global/filament.json" not in files + assert counts["printer"] == 1 + assert counts["process"] == 1 + assert counts["failed"] == 1 + + @pytest.mark.asyncio + async def test_unauthenticated_account_writes_nothing(self, service, db_session): + files: dict = {} + with patch( + "backend.app.api.routes.cloud.build_authenticated_cloud", + new_callable=AsyncMock, + return_value=None, + ): + counts = await service._collect_bambu_profiles(db_session, files, "user-1", MagicMock()) + + assert counts == {} + assert files == {} + + +class TestOrcaCollection: + @pytest.mark.asyncio + async def test_groups_by_content_type_including_aliases(self, service, db_session): + """Orca carries the type at ``content.type`` and uses BambuStudio-style + aliases — ``machine`` is a printer, ``process`` and ``print`` are both + process. Same map the Orca tab groups by.""" + profiles = [ + {"id": 1, "name": "f", "content": {"type": "filament"}}, + {"id": 2, "name": "m", "content": {"type": "machine"}}, + {"id": 3, "name": "p", "content": {"type": "print"}}, + {"id": 4, "name": "p2", "content": {"type": "process"}}, + ] + files: dict = {} + with patch( + "backend.app.api.routes.orca_cloud._build_authenticated_service", + new_callable=AsyncMock, + return_value=_orca_service(profiles), + ): + counts = await service._collect_orca_profiles(db_session, files, "user-3", MagicMock()) + + assert counts == {"filament": 1, "printer": 1, "process": 2} + assert "cloud_profiles/orca/user-3/printer.json" in files + + @pytest.mark.asyncio + async def test_content_is_stored_inline_without_a_second_fetch(self, service, db_session): + """The sync-pull listing already carries each profile's content, so + unlike Bambu there is no per-profile round trip.""" + svc = _orca_service([{"id": 7, "name": "f", "content": {"type": "filament", "flow": 0.98}}]) + files: dict = {} + with patch( + "backend.app.api.routes.orca_cloud._build_authenticated_service", + new_callable=AsyncMock, + return_value=svc, + ): + await service._collect_orca_profiles(db_session, files, "global", None) + + stored = files["cloud_profiles/orca/global/filament.json"]["profiles"][0] + assert stored["content"] == {"type": "filament", "flow": 0.98} + assert svc.list_profiles.await_count == 1 + + @pytest.mark.asyncio + async def test_unmapped_types_are_kept_not_dropped(self, service, db_session): + """The Orca *route* drops profiles whose type it can't render, which is + right for a list and wrong for a backup: silently omitting a profile + because Orca added a type is the same class of bug as #2717.""" + profiles = [ + {"id": 1, "name": "f", "content": {"type": "filament"}}, + {"id": 2, "name": "x", "content": {"type": "something_new"}}, + {"id": 3, "name": "y", "content": {}}, + ] + files: dict = {} + with patch( + "backend.app.api.routes.orca_cloud._build_authenticated_service", + new_callable=AsyncMock, + return_value=_orca_service(profiles), + ): + counts = await service._collect_orca_profiles(db_session, files, "global", None) + + assert counts["other"] == 2 + assert len(files["cloud_profiles/orca/global/other.json"]["profiles"]) == 2 + + @pytest.mark.asyncio + async def test_dead_pairing_writes_nothing_and_does_not_raise(self, service, db_session): + """An unexpected failure building the Orca client must not abort the + rest of the backup — the other accounts and the other cloud still have + profiles worth collecting.""" + files: dict = {} + with patch( + "backend.app.api.routes.orca_cloud._build_authenticated_service", + new_callable=AsyncMock, + side_effect=RuntimeError("session expired"), + ): + counts = await service._collect_orca_profiles(db_session, files, "user-2", MagicMock()) + + assert counts == {} + assert files == {} + + @pytest.mark.asyncio + async def test_the_backup_never_disconnects_an_account(self, service, db_session): + """A backup is an observer. It must not change anyone's sign-in state + on a schedule — least of all on Orca's composite rejection reason, + which cannot tell a real revocation from a lost refresh-rotation race. + The Profiles route clears the dead pairing instead, with the user + present to act on it. + """ + from fastapi import HTTPException + + build = AsyncMock(side_effect=HTTPException(status_code=401, detail="grant already used")) + files: dict = {} + with patch("backend.app.api.routes.orca_cloud._build_authenticated_service", build): + counts = await service._collect_orca_profiles(db_session, files, "global", None) + + assert counts == {} + assert build.await_args.kwargs["clear_on_auth_failure"] is False + + @pytest.mark.asyncio + async def test_a_rejected_session_says_it_will_keep_being_skipped(self, service, db_session, caplog): + """Not clearing means the warning recurs every run, so the one line the + operator sees has to say how to stop it.""" + from fastapi import HTTPException + + files: dict = {} + with ( + caplog.at_level("WARNING"), + patch( + "backend.app.api.routes.orca_cloud._build_authenticated_service", + new_callable=AsyncMock, + side_effect=HTTPException(status_code=401, detail="refresh rejected: grant already used"), + ), + ): + counts = await service._collect_orca_profiles(db_session, files, "global", None) + + assert counts == {} + assert "paired again" in caplog.text + assert "Later runs will skip it too" in caplog.text + + @pytest.mark.asyncio + async def test_an_unreachable_orca_is_a_transient_skip(self, service, db_session, caplog): + """502 is very likely gone by the next run, so it must not carry the + "go and re-pair" advice a rejected session does.""" + from fastapi import HTTPException + + files: dict = {} + with ( + caplog.at_level("WARNING"), + patch( + "backend.app.api.routes.orca_cloud._build_authenticated_service", + new_callable=AsyncMock, + side_effect=HTTPException(status_code=502, detail="Orca Cloud unreachable: timeout"), + ), + ): + counts = await service._collect_orca_profiles(db_session, files, "user-9", None) + + assert counts == {} + assert "unreachable" in caplog.text + assert "paired again" not in caplog.text + + +class TestCollectorAndMetadata: + @pytest.mark.asyncio + async def test_no_connected_account_collects_nothing(self, service, db_session): + files: dict = {} + with ( + patch( + "backend.app.api.routes.cloud.get_stored_token", + new_callable=AsyncMock, + return_value=(None, None, "global"), + ), + patch( + "backend.app.api.routes.orca_cloud._load_credentials", + new_callable=AsyncMock, + return_value=MagicMock(token=None), + ), + ): + summary = await service._collect_cloud_profiles(db_session, files) + + assert summary == {"bambu": {}, "orca": {}} + assert files == {} + + @pytest.mark.asyncio + async def test_one_failing_account_does_not_stop_the_others(self, service, db_session): + a = User(username="a", cloud_token="t1") + b = User(username="b", cloud_token="t2") + db_session.add_all([a, b]) + await db_session.commit() + + def build(db, user=None): + if user is not None and user.username == "a": + raise RuntimeError("cloud down for this account") + return _bambu_cloud() + + files: dict = {} + with ( + patch( + "backend.app.api.routes.cloud.get_stored_token", + new_callable=AsyncMock, + return_value=(None, None, "global"), + ), + patch( + "backend.app.api.routes.orca_cloud._load_credentials", + new_callable=AsyncMock, + return_value=MagicMock(token=None), + ), + patch( + "backend.app.api.routes.cloud.build_authenticated_cloud", + new_callable=AsyncMock, + side_effect=build, + ), + ): + summary = await service._collect_cloud_profiles(db_session, files) + + assert f"user-{a.id}" not in summary["bambu"] + assert summary["bambu"][f"user-{b.id}"] == {"filament": 1, "printer": 1, "process": 1} + + @pytest.mark.asyncio + async def test_metadata_reports_collection_not_configuration(self, service, db_session): + """``contents.cloud_profiles`` said ``true`` on every backup, including + the ones that wrote nothing. A restore has to be able to trust it.""" + config = MagicMock( + backup_kprofiles=False, + backup_cloud_profiles=True, + backup_settings=False, + backup_spools=False, + backup_archives=False, + ) + with patch.object( + service, "_collect_cloud_profiles", new_callable=AsyncMock, return_value={"bambu": {}, "orca": {}} + ): + files = await service._collect_backup_data(db_session, config) + + assert files["backup_metadata.json"]["contents"]["cloud_profiles"] is False + assert "cloud_profiles" not in files["backup_metadata.json"] + + @pytest.mark.asyncio + async def test_metadata_records_per_account_counts_when_collected(self, service, db_session): + config = MagicMock( + backup_kprofiles=False, + backup_cloud_profiles=True, + backup_settings=False, + backup_spools=False, + backup_archives=False, + ) + summary = {"bambu": {"user-3": {"filament": 2}}, "orca": {}} + with patch.object(service, "_collect_cloud_profiles", new_callable=AsyncMock, return_value=summary): + files = await service._collect_backup_data(db_session, config) + + assert files["backup_metadata.json"]["contents"]["cloud_profiles"] is True + assert files["backup_metadata.json"]["cloud_profiles"] == summary + + +class TestSettingsFallbackIsStillHonoured: + @pytest.mark.asyncio + async def test_global_orca_row_is_discovered(self, service, db_session): + """Orca's auth-disabled fallback lives in the same Settings table as + Bambu's; both stores are read on every run.""" + db_session.add(Settings(key="orca_cloud_token", value="oc_ext_x")) + await db_session.commit() + + with patch( + "backend.app.api.routes.cloud.get_stored_token", + new_callable=AsyncMock, + return_value=(None, None, "global"), + ): + _bambu, orca = await service.cloud_accounts(db_session) + + assert orca == [("global", None)] diff --git a/backend/tests/unit/test_layer_timelapse_expected_archive.py b/backend/tests/unit/test_layer_timelapse_expected_archive.py index 4d7318fa9..4ad786772 100644 --- a/backend/tests/unit/test_layer_timelapse_expected_archive.py +++ b/backend/tests/unit/test_layer_timelapse_expected_archive.py @@ -60,6 +60,7 @@ def test_starts_timelapse_when_external_camera_enabled(): "http://camera.local:5000/snapshot.jpg", "snapshot", snapshot_url="http://camera.local:5000/snapshot.jpg", + rotation=0, ) diff --git a/backend/tests/unit/test_obico_detection.py b/backend/tests/unit/test_obico_detection.py index faf95c5da..aa57dacd7 100644 --- a/backend/tests/unit/test_obico_detection.py +++ b/backend/tests/unit/test_obico_detection.py @@ -132,6 +132,205 @@ class TestTestConnection: assert result["body"] == "something else" +class TestMlApiToken: + """Obico's ML API gates /p/ behind ML_API_TOKEN (#2733).""" + + def test_auth_headers_only_when_configured(self): + from backend.app.services.obico_detection import auth_headers + + assert auth_headers("s3cret") == {"Authorization": "Bearer s3cret"} + # Unconfigured must stay byte-identical to the pre-setting request. + assert auth_headers("") == {} + assert auth_headers(None) == {} + assert auth_headers(" ") == {} + # Whitespace around a real token is a paste artefact, not part of it. + assert auth_headers(" s3cret ") == {"Authorization": "Bearer s3cret"} + + def test_settings_schema_accepts_a_token(self): + assert AppSettingsUpdate(obico_ml_token="s3cret").obico_ml_token == "s3cret" + assert AppSettingsUpdate(obico_ml_token="").obico_ml_token == "" + assert AppSettingsUpdate().obico_ml_token is None + + @staticmethod + def _settings(**overrides): + base = { + "enabled": True, + "ml_url": "http://obico:3333", + "ml_token": "", + "sensitivity": "medium", + "action": "notify", + "poll_interval": 10, + "enabled_printers": None, + "external_url": "http://bambuddy:8000", + } + base.update(overrides) + return base + + @staticmethod + def _client(response): + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + return mock_client + + @pytest.mark.asyncio + async def test_detection_call_carries_the_bearer_header(self): + svc = ObicoDetectionService() + response = MagicMock(status_code=200) + response.json.return_value = {"detections": []} + response.raise_for_status = MagicMock() + mock_client = self._client(response) + status = MagicMock(state="RUNNING", task_name="job", subtask_name="") + + with ( + patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client), + patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)), + ): + await svc._check_printer(1, status, self._settings(ml_token="s3cret")) + + assert mock_client.get.await_args.kwargs["headers"] == {"Authorization": "Bearer s3cret"} + + @pytest.mark.asyncio + async def test_detection_call_sends_no_header_without_a_token(self): + svc = ObicoDetectionService() + response = MagicMock(status_code=200) + response.json.return_value = {"detections": []} + response.raise_for_status = MagicMock() + mock_client = self._client(response) + status = MagicMock(state="RUNNING", task_name="job", subtask_name="") + + with ( + patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client), + patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)), + ): + await svc._check_printer(1, status, self._settings()) + + assert mock_client.get.await_args.kwargs["headers"] == {} + + @pytest.mark.asyncio + async def test_401_reports_the_token_rather_than_a_bare_http_error(self): + svc = ObicoDetectionService() + response = MagicMock(status_code=401) + # raise_for_status would also raise here; the status check must come first + # so the user gets an actionable message instead of "401 Unauthorized". + response.raise_for_status = MagicMock(side_effect=AssertionError("must not reach raise_for_status")) + mock_client = self._client(response) + status = MagicMock(state="RUNNING", task_name="job", subtask_name="") + + with ( + patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client), + patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)), + ): + await svc._check_printer(1, status, self._settings(ml_token="wrong")) + + assert "401" in svc._last_error + assert "ML_API_TOKEN" in svc._last_error + # A rejected call must not be scored as a clean frame. + assert 1 not in svc._states or svc._states[1].frame_count == 0 + + @pytest.mark.asyncio + async def test_401_message_does_not_leak_the_token(self): + svc = ObicoDetectionService() + response = MagicMock(status_code=401) + response.raise_for_status = MagicMock() + mock_client = self._client(response) + status = MagicMock(state="RUNNING", task_name="job", subtask_name="") + + with ( + patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client), + patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)), + ): + await svc._check_printer(1, status, self._settings(ml_token="sup3rs3cret")) + + assert "sup3rs3cret" not in svc._last_error + + +class TestTestConnectionTokenProbe: + """/hc/ is ungated, so health alone cannot validate the token (#2733).""" + + @staticmethod + def _client(responses): + mock_client = MagicMock() + mock_client.get = AsyncMock(side_effect=responses) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + return mock_client + + @pytest.mark.asyncio + async def test_healthy_but_rejected_token_is_not_ok(self): + svc = ObicoDetectionService() + mock_client = self._client([MagicMock(status_code=200, text="ok"), MagicMock(status_code=401)]) + + with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client): + result = await svc.test_connection("http://obico:3333", "wrong") + + assert result["ok"] is False + assert result["auth_ok"] is False + assert result["status_code"] == 401 + assert "ML_API_TOKEN" in result["error"] + + @pytest.mark.asyncio + async def test_accepted_token_is_ok(self): + svc = ObicoDetectionService() + # 422 = "Invalid request params": auth passed, then the handler rejected + # the img-less probe. That is the success signal. + mock_client = self._client([MagicMock(status_code=200, text="ok"), MagicMock(status_code=422)]) + + with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client): + result = await svc.test_connection("http://obico:3333", "right") + + assert result["ok"] is True + assert result["auth_ok"] is True + assert result["error"] is None + + @pytest.mark.asyncio + async def test_probe_failure_leaves_the_token_unknown_but_keeps_the_test_ok(self): + svc = ObicoDetectionService() + mock_client = self._client([MagicMock(status_code=200, text="ok"), RuntimeError("read timeout")]) + + with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client): + result = await svc.test_connection("http://obico:3333", "maybe") + + assert result["ok"] is True + assert result["auth_ok"] is None + + @pytest.mark.asyncio + async def test_unhealthy_server_is_not_probed(self): + svc = ObicoDetectionService() + mock_client = self._client([MagicMock(status_code=200, text="error")]) + + with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client): + result = await svc.test_connection("http://obico:3333", "any") + + assert result["ok"] is False + assert result["auth_ok"] is None + assert mock_client.get.await_count == 1 + + @pytest.mark.asyncio + async def test_both_requests_carry_the_header(self): + svc = ObicoDetectionService() + mock_client = self._client([MagicMock(status_code=200, text="ok"), MagicMock(status_code=422)]) + + with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client): + await svc.test_connection("http://obico:3333", "s3cret") + + assert [call.args[0] for call in mock_client.get.await_args_list] == [ + "http://obico:3333/hc/", + "http://obico:3333/p/", + ] + for call in mock_client.get.await_args_list: + assert call.kwargs["headers"] == {"Authorization": "Bearer s3cret"} + + @pytest.mark.asyncio + async def test_url_policy_still_applies_before_any_request(self): + svc = ObicoDetectionService() + result = await svc.test_connection("http://169.254.169.254/latest/meta-data/", "s3cret") + assert result["ok"] is False + assert result["auth_ok"] is None + assert result["error"] + + class TestPollOneStateLifecycle: """Confirms per-printer state is reset when a new print starts.""" diff --git a/backend/tests/unit/test_orca_cloud_refresh.py b/backend/tests/unit/test_orca_cloud_refresh.py new file mode 100644 index 000000000..2d75d576d --- /dev/null +++ b/backend/tests/unit/test_orca_cloud_refresh.py @@ -0,0 +1,125 @@ +"""What a rejected Orca Cloud refresh is allowed to do to stored credentials. + +The refresh token is single-use and rotating, and Orca reports every rejection +with one composite reason (``unknown, expired, revoked, or already used``), so +Bambuddy cannot tell a genuine revocation from a lost rotation race. Routes may +still clear on that signal — a person is looking at the page and can pair again +— but a background job must not, or an unattended run can destroy a working +pairing (#2717). +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import HTTPException +from sqlalchemy import select + +from backend.app.api.routes.orca_cloud import _SETTINGS_KEYS, _build_authenticated_service +from backend.app.models.settings import Settings +from backend.app.services.orca_cloud import OrcaCloudAuthError, OrcaCloudError + + +async def _store_global_credentials(db): + """An auth-disabled install's Orca credentials, expired so the helper + refreshes rather than returning straight away.""" + db.add_all( + [ + Settings(key=_SETTINGS_KEYS["token"], value="oc_ext_old"), + Settings(key=_SETTINGS_KEYS["refresh_token"], value="oc_ext_rt_old"), + Settings(key=_SETTINGS_KEYS["expires_at"], value="2000-01-01T00:00:00+00:00"), + Settings(key=_SETTINGS_KEYS["email"], value="a@b.c"), + ] + ) + await db.commit() + + +async def _stored_keys(db) -> set[str]: + result = await db.execute(select(Settings).where(Settings.key.in_(list(_SETTINGS_KEYS.values())))) + return {s.key for s in result.scalars().all()} + + +def _expired_service(refresh_side_effect=None): + """A service that reports its access token as expired, so the helper takes + the refresh branch.""" + svc = MagicMock() + svc.is_authenticated = False + svc.refresh_token = "oc_ext_rt_old" + svc.set_tokens = MagicMock() + svc.refresh = AsyncMock(side_effect=refresh_side_effect) + svc.access_token = "oc_ext_new" + svc.token_expiry = None + return svc + + +class TestRejectedRefresh: + @pytest.mark.asyncio + async def test_routes_clear_the_dead_pairing_by_default(self, db_session): + """Unchanged behaviour for interactive callers: the page flips to + disconnected while the user is there to pair again.""" + await _store_global_credentials(db_session) + svc = _expired_service(OrcaCloudAuthError("grant already used")) + + with ( + patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc), + pytest.raises(HTTPException) as exc, + ): + await _build_authenticated_service(db_session, None) + + assert exc.value.status_code == 401 + assert await _stored_keys(db_session) == set() + + @pytest.mark.asyncio + async def test_background_callers_leave_the_credentials_alone(self, db_session): + """The whole point of the flag. A scheduled backup that guesses wrong + here destroys a pairing nobody asked it to touch, and the user finds + out when their profiles stop being backed up.""" + await _store_global_credentials(db_session) + svc = _expired_service(OrcaCloudAuthError("grant already used")) + + with ( + patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc), + pytest.raises(HTTPException) as exc, + ): + await _build_authenticated_service(db_session, None, clear_on_auth_failure=False) + + # Still reported as a hard auth failure — the caller has to skip the + # account — but nothing was destroyed on the way out. + assert exc.value.status_code == 401 + assert _SETTINGS_KEYS["token"] in await _stored_keys(db_session) + assert _SETTINGS_KEYS["refresh_token"] in await _stored_keys(db_session) + + @pytest.mark.asyncio + async def test_an_unreachable_orca_never_clears_either_way(self, db_session): + """A transport failure says nothing about the credentials' validity.""" + await _store_global_credentials(db_session) + svc = _expired_service(OrcaCloudError("connection reset")) + + with ( + patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc), + pytest.raises(HTTPException) as exc, + ): + await _build_authenticated_service(db_session, None) + + assert exc.value.status_code == 502 + assert _SETTINGS_KEYS["token"] in await _stored_keys(db_session) + + +class TestSuccessfulRefresh: + @pytest.mark.asyncio + async def test_the_rotated_pair_is_persisted_even_for_background_callers(self, db_session): + """Not optional: by the time the refresh succeeds the old token is + consumed, so failing to store the new pair would break a live pairing + for real. The flag suppresses destruction, never persistence. + """ + await _store_global_credentials(db_session) + svc = _expired_service() + svc.refresh_token = "oc_ext_rt_new" + + with patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc): + returned = await _build_authenticated_service(db_session, None, clear_on_auth_failure=False) + + assert returned is svc + result = await db_session.execute(select(Settings).where(Settings.key == _SETTINGS_KEYS["token"])) + assert result.scalar_one().value == "oc_ext_new" + result = await db_session.execute(select(Settings).where(Settings.key == _SETTINGS_KEYS["refresh_token"])) + assert result.scalar_one().value == "oc_ext_rt_new" diff --git a/backend/tests/unit/test_scheduler_watchdog.py b/backend/tests/unit/test_scheduler_watchdog.py index d72008fdb..f556e69da 100644 --- a/backend/tests/unit/test_scheduler_watchdog.py +++ b/backend/tests/unit/test_scheduler_watchdog.py @@ -607,3 +607,118 @@ class TestWatchdogRetryBudget: item = await db.get(PrintQueueItem, 1) assert item.status == "printing" assert item.dispatch_attempts == 0 + + +class TestWatchdogCommandRejected: + """A printer reporting HMS 0500_0500_0001_0007 refused the command outright. + + It is not wedged and it is not slow: its authorization check rejected a + command it could not verify, and it will reject the next two identically. + Spending the full 270 s and two more full 3MF uploads on that is 15 minutes + of a farm's upload capacity buying nothing, and it ends with a message about + SD cards (#2732). + """ + + @staticmethod + def _rejected_status(state: str = "IDLE", subtask_id: str | None = "NEW_SUBTASK"): + from backend.app.services.bambu_mqtt import HMS_MQTT_VERIFY_FAILED + + return SimpleNamespace( + state=state, + subtask_id=subtask_id, + gcode_file="/new.3mf", + hms_errors=[SimpleNamespace(full_code=HMS_MQTT_VERIFY_FAILED)], + ) + + @staticmethod + async def _run(db_session, status): + get_status = MagicMock(return_value=status) + get_client = MagicMock(return_value=MagicMock()) + with ( + patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status), + patch("backend.app.services.print_scheduler.printer_manager.get_client", get_client), + patch("backend.app.services.print_scheduler.async_session", db_session), + patch("backend.app.core.database.async_session", db_session), + patch( + "backend.app.services.notification_service.notification_service.on_queue_job_failed", + AsyncMock(), + ) as notify, + ): + await PrintScheduler._watchdog_print_start( + queue_item_id=1, + printer_id=42, + pre_state="IDLE", + pre_subtask_id="OLD_SUBTASK", + pre_gcode_file="/old.3mf", + timeout=0.2, + phase_b_timeout=0.2, + poll_interval=0.05, + ) + return get_client, notify + + @pytest.mark.asyncio + async def test_fails_on_the_first_attempt(self, db_session): + await self._run(db_session, self._rejected_status()) + + async with db_session() as db: + item = await db.get(PrintQueueItem, 1) + assert item.status == "failed", "a refused command must not be retried" + assert item.dispatch_attempts == 1, "it must not burn the whole budget" + assert item.completed_at is not None + + @pytest.mark.asyncio + async def test_error_message_names_the_fix(self, db_session): + """The old wording sent this user to check their SD card.""" + await self._run(db_session, self._rejected_status()) + + async with db_session() as db: + item = await db.get(PrintQueueItem, 1) + assert "0500-0500-0001-0007" in item.error_message + assert "Developer Mode" in item.error_message + assert "SD card" not in item.error_message + + @pytest.mark.asyncio + async def test_detected_in_phase_a_before_any_subtask_advance(self, db_session): + """The printer can refuse without ever echoing a subtask_id.""" + await self._run(db_session, self._rejected_status(subtask_id="OLD_SUBTASK")) + + async with db_session() as db: + item = await db.get(PrintQueueItem, 1) + assert item.status == "failed" + assert item.dispatch_attempts == 1 + + @pytest.mark.asyncio + async def test_skips_the_forced_reconnect(self, db_session): + """The MQTT session is fine — reconnecting would only add 0500_4003 (#1150).""" + get_client, _ = await self._run(db_session, self._rejected_status(subtask_id="OLD_SUBTASK")) + get_client.assert_not_called() + + @pytest.mark.asyncio + async def test_notifies_with_the_rejection_reason(self, db_session): + _, notify = await self._run(db_session, self._rejected_status()) + + notify.assert_awaited_once() + assert "rejected" in notify.await_args.kwargs["reason"] + + @pytest.mark.asyncio + async def test_unrelated_hms_still_takes_the_retry_path(self, db_session): + """Only this code short-circuits; every other fault keeps its retries.""" + status = self._rejected_status() + status.hms_errors = [SimpleNamespace(full_code="0300020000018012")] + + await self._run(db_session, status) + + async with db_session() as db: + item = await db.get(PrintQueueItem, 1) + assert item.status == "pending" + assert item.dispatch_attempts == 1 + + @pytest.mark.asyncio + async def test_a_printer_that_actually_starts_is_unaffected(self, db_session): + """A stale HMS from a previous job must not kill a print that is running.""" + await self._run(db_session, self._rejected_status(state="RUNNING")) + + async with db_session() as db: + item = await db.get(PrintQueueItem, 1) + assert item.status == "printing" + assert item.dispatch_attempts == 0 diff --git a/backend/tests/unit/test_slicer_stall_timeout.py b/backend/tests/unit/test_slicer_stall_timeout.py new file mode 100644 index 000000000..5e9e5aada --- /dev/null +++ b/backend/tests/unit/test_slicer_stall_timeout.py @@ -0,0 +1,229 @@ +"""Tests for the progress-supervised slice timeout (#2730). + +The old behaviour was a flat 300 s httpx timeout on the slice POST. A heavy +model that Bambu Studio also took a long time over blew through it while the +slicer was working perfectly happily, and — because ``httpx.ReadTimeout`` is a +subclass of ``RequestError`` — the failure was reported as "Slicer sidecar +unreachable", sending the reporter off to check a sidecar that was reachable +throughout. + +The wait is now bounded by *silence* instead: Bambuddy already polls the +sidecar's progress endpoint once a second, so it can tell a slow slice from a +stalled one. The deadline moves forward on every progress update. +""" + +import asyncio + +import httpx +import pytest + +from backend.app.services.slicer_api import ( + DEFAULT_SLICE_STALL_TIMEOUT_SECONDS, + SlicerApiService, + SlicerApiUnavailableError, + SlicerTimeoutError, + _Liveness, + get_stall_timeout_seconds, +) + +SLICE_ARGS = { + "model_bytes": b"solid\n", + "model_filename": "cube.3mf", + "printer_profile_json": "{}", + "process_profile_json": "{}", + "filament_profile_jsons": ["{}"], +} + + +def _service(handler, *, timeout_seconds: float, poll_interval: float = 0.02) -> SlicerApiService: + """A service wired to a mock sidecar, with the timing compressed. + + The stall window is floored at three poll intervals — liveness can only be + observed as fast as the poller ticks — so tests shrink both together rather + than waiting out production's 1 Hz. + """ + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + svc = SlicerApiService("http://sidecar:3003", client=client, timeout_seconds=timeout_seconds) + svc.progress_poll_interval = poll_interval + return svc + + +class TestLivenessWindow: + """The unit that decides when to stop waiting.""" + + def test_a_fresh_slice_has_the_full_window(self): + live = _Liveness(60.0, 1.0) + assert live.deadline - live.started_at == pytest.approx(60.0) + + def test_progress_pushes_the_deadline_out(self): + live = _Liveness(60.0, 1.0) + live.saw_progress_endpoint() + before = live.deadline + live._last_alive += 30.0 # simulate a progress update 30s later + assert live.deadline > before + + def test_without_a_progress_channel_the_window_is_total_elapsed(self): + """No liveness signal means no way to tell slow from stalled, so the + window degrades to the pre-#2730 wall clock — just configurable.""" + live = _Liveness(60.0, 1.0) + live.mark_alive() # would move the deadline if progress were supported + assert live.deadline == pytest.approx(live.started_at + 60.0) + + def test_message_distinguishes_the_two_cases(self): + supported = _Liveness(60.0, 1.0) + supported.saw_progress_endpoint() + assert "stopped reporting progress" in supported.timeout_message() + + unsupported = _Liveness(60.0, 1.0) + assert "does not report progress" in unsupported.timeout_message() + + def test_message_points_at_the_setting(self): + live = _Liveness(900.0, 1.0) + assert "Settings -> Workflow -> Slicer" in live.timeout_message() + + +class TestSliceIsNotCutOffWhileProgressing: + @pytest.mark.asyncio + async def test_a_slow_slice_that_reports_progress_completes(self): + """The reporter's case: slower than the old ceiling, still working. + + The slice takes ~5x the stall window; progress keeps arriving, so it + must run to completion rather than being abandoned. + """ + progress = {"n": 0} + + async def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/slice"): + await asyncio.sleep(0.5) + return httpx.Response( + 200, + content=b"G1 X0\n", + headers={ + "x-print-time-seconds": "100", + "x-filament-used-g": "1.0", + "x-filament-used-mm": "100", + }, + ) + progress["n"] += 1 + return httpx.Response(200, json={"percent": progress["n"]}) + + svc = _service(handler, timeout_seconds=0.1) + result = await svc.slice_with_profiles(**SLICE_ARGS, request_id="req-1", on_progress=lambda _p: None) + + assert result.print_time_seconds == 100 + assert progress["n"] > 1, "the poller must have been running throughout" + + @pytest.mark.asyncio + async def test_repeated_identical_progress_does_not_count_as_alive(self): + """The sidecar re-serves its last snapshot on every poll. Treating that + as progress would make a stall undetectable.""" + + async def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/slice"): + await asyncio.sleep(10) + return httpx.Response(200, content=b"never gets here") + return httpx.Response(200, json={"percent": 42}) # frozen + + svc = _service(handler, timeout_seconds=0.3) + with pytest.raises(SlicerTimeoutError): + await svc.slice_with_profiles(**SLICE_ARGS, request_id="req-2", on_progress=lambda _p: None) + + +class TestStalledSliceFails: + @pytest.mark.asyncio + async def test_silence_ends_the_wait(self): + async def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/slice"): + await asyncio.sleep(10) + return httpx.Response(200, content=b"never gets here") + return httpx.Response(404) # no progress available + + svc = _service(handler, timeout_seconds=0.2) + with pytest.raises(SlicerTimeoutError) as exc: + await svc.slice_with_profiles(**SLICE_ARGS, request_id="req-3", on_progress=lambda _p: None) + + assert "does not report progress" in str(exc.value) + + @pytest.mark.asyncio + async def test_timeout_is_not_reported_as_unreachable(self): + """The whole point: this used to surface as "Slicer sidecar unreachable".""" + + async def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/slice"): + await asyncio.sleep(10) + return httpx.Response(404) + + svc = _service(handler, timeout_seconds=0.2) + with pytest.raises(SlicerTimeoutError) as exc: + await svc.slice_with_profiles(**SLICE_ARGS, request_id="req-4", on_progress=lambda _p: None) + + assert not isinstance(exc.value, SlicerApiUnavailableError) + assert "unreachable" not in str(exc.value) + + @pytest.mark.asyncio + async def test_a_genuinely_unreachable_sidecar_still_says_so(self): + """Timeouts got their own type; connection failures keep the old one.""" + + async def handler(_request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("connection refused") + + svc = _service(handler, timeout_seconds=5.0) + with pytest.raises(SlicerApiUnavailableError) as exc: + await svc.slice_with_profiles(**SLICE_ARGS) + + assert "unreachable" in str(exc.value) + + +class TestStallTimeoutSetting: + @pytest.mark.asyncio + async def test_reads_the_configured_value(self): + class _DB: + pass + + async def fake_get_setting(_db, key): + assert key == "slicer_stall_timeout_minutes" + return "45" + + import backend.app.api.routes.settings as settings_module + + original = settings_module.get_setting + settings_module.get_setting = fake_get_setting + try: + assert await get_stall_timeout_seconds(_DB()) == 45 * 60 + finally: + settings_module.get_setting = original + + @pytest.mark.asyncio + @pytest.mark.parametrize("stored", [None, "", "not-a-number", "0", "-5"]) + async def test_falls_back_rather_than_failing_the_slice(self, stored): + """A bad settings row must not be the reason a print doesn't happen.""" + + async def fake_get_setting(_db, _key): + return stored + + import backend.app.api.routes.settings as settings_module + + original = settings_module.get_setting + settings_module.get_setting = fake_get_setting + try: + assert await get_stall_timeout_seconds(object()) == DEFAULT_SLICE_STALL_TIMEOUT_SECONDS + finally: + settings_module.get_setting = original + + @pytest.mark.asyncio + async def test_a_failing_lookup_falls_back_too(self): + async def boom(_db, _key): + raise RuntimeError("db is down") + + import backend.app.api.routes.settings as settings_module + + original = settings_module.get_setting + settings_module.get_setting = boom + try: + assert await get_stall_timeout_seconds(object()) == DEFAULT_SLICE_STALL_TIMEOUT_SECONDS + finally: + settings_module.get_setting = original + + def test_default_is_longer_than_the_old_fixed_ceiling(self): + """300s was the number that broke; the new default must beat it.""" + assert DEFAULT_SLICE_STALL_TIMEOUT_SECONDS > 300 diff --git a/backend/tests/unit/test_support_helpers.py b/backend/tests/unit/test_support_helpers.py index 4323265ec..716bd1afb 100644 --- a/backend/tests/unit/test_support_helpers.py +++ b/backend/tests/unit/test_support_helpers.py @@ -701,19 +701,33 @@ class TestCollectSupportInfo: class TestParseObicoEnabledPrinters: - """Tests for the per-printer obico flag parser used by the bundle.""" + """Tests for the per-printer obico flag parser used by the bundle. - def test_empty_string_returns_empty_set(self): + The setting is written by the settings UI as a JSON array and read by + ObicoDetectionService._load_settings as one; the bundle used to split it on + commas and call empty "no printers", so a default Obico setup was reported + as monitoring nothing while it was in fact monitoring everything (#2733). + """ + + def test_empty_means_all_printers(self): from backend.app.api.routes.support import _parse_obico_enabled_printers - assert _parse_obico_enabled_printers("") == set() - assert _parse_obico_enabled_printers(" ") == set() + # None (not "no printers") — the same convention _load_settings uses. + assert _parse_obico_enabled_printers("") is None + assert _parse_obico_enabled_printers(" ") is None + assert _parse_obico_enabled_printers(None) is None - def test_comma_separated_ids(self): + def test_json_array_is_the_stored_shape(self): + from backend.app.api.routes.support import _parse_obico_enabled_printers + + assert _parse_obico_enabled_printers("[1, 2, 3]") == {1, 2, 3} + assert _parse_obico_enabled_printers("[]") == set() + + def test_comma_separated_ids_still_parse(self): + # Legacy fallback for any install that stored the old shape. from backend.app.api.routes.support import _parse_obico_enabled_printers assert _parse_obico_enabled_printers("1,2,3") == {1, 2, 3} - # Whitespace around tokens is forgiven (matches obico_detection's parser). assert _parse_obico_enabled_printers("1, 2 ,3") == {1, 2, 3} def test_non_integer_tokens_are_skipped(self): @@ -722,6 +736,13 @@ class TestParseObicoEnabledPrinters: assert _parse_obico_enabled_printers("1,abc,2") == {1, 2} assert _parse_obico_enabled_printers(",,1,") == {1} + assert _parse_obico_enabled_printers('[1, "two", 3]') == {1, 3} + + def test_json_object_is_not_a_printer_list(self): + from backend.app.api.routes.support import _parse_obico_enabled_printers + + # Falls through to the comma parser, which finds no integers. + assert _parse_obico_enabled_printers('{"1": true}') == set() class TestCheckUrlReachable: @@ -1451,3 +1472,104 @@ class TestSanitizePushStatusValues: assert raw == before, "input was mutated" assert out["tag_uid"] == "[SERIAL]" # and the copy really was redacted + + +class TestProcessInfo: + """Bambuddy's own footprint in the bundle (#2734). + + Bundles carried nothing about the process itself, so "memory climbs over + days until the OOM killer fires" could not be triaged from a bundle — the + reporter had to run shell commands by hand, and the numbers that would have + named the mechanism were unrecoverable afterwards. + """ + + def test_reports_the_figures_that_separate_the_mechanisms(self): + """RSS vs VMS, threads and children distinguish a heap that is growing + from address space, a thread leak, and a child-process leak.""" + from backend.app.api.routes.support import _collect_process_info + + info = _collect_process_info() + + assert info["available"] is True + for key in ("rss_bytes", "vms_bytes", "num_threads", "children_total"): + assert isinstance(info[key], int), key + + def test_children_are_named_but_never_quoted(self): + """An ffmpeg argv carries the camera URL, and with it its password. The + count per executable is what identifies a leak; the arguments are not + needed and must not travel.""" + from backend.app.api.routes.support import _collect_process_info + + info = _collect_process_info() + + for name in info.get("children_by_name", {}): + assert " " not in name, f"looks like a command line, not a name: {name!r}" + assert "://" not in name + + def test_heap_census_is_skipped_on_a_large_process(self): + """gc.get_objects() materialises every tracked object, so the census + costs most on the process that can least afford it. A bundle generated + to diagnose runaway memory must not be the allocation that tips the + host over.""" + from unittest.mock import MagicMock, patch + + import backend.app.api.routes.support as support_module + + fake = MagicMock() + fake.memory_info.return_value = MagicMock(rss=8 * 1024**3, vms=12 * 1024**3) + fake.num_threads.return_value = 40 + fake.create_time.return_value = 0.0 + fake.open_files.return_value = [] + fake.net_connections.return_value = [] + fake.children.return_value = [] + + with patch("psutil.Process", return_value=fake): + info = support_module._collect_process_info() + + assert "gc_top_types" not in info + assert "skipped" in info["gc_census"] + # The discriminating numbers still come through — those are the point. + assert info["rss_bytes"] == 8 * 1024**3 + assert info["num_threads"] == 40 + + def test_heap_census_runs_on_a_normal_process(self): + from backend.app.api.routes.support import _collect_process_info + + info = _collect_process_info() + + assert info["gc_tracked_objects"] > 0 + assert len(info["gc_top_types"]) <= 15 + + def test_survives_a_hostile_psutil(self): + """psutil raises on hardened kernels and in restricted containers. A + support bundle must still be produced when it does — the bundle is how + someone reports the problem in the first place.""" + from unittest.mock import patch + + import backend.app.api.routes.support as support_module + + with patch("psutil.Process", side_effect=RuntimeError("no /proc for you")): + info = support_module._collect_process_info() + + assert info == {"available": False} + + def test_partial_failures_do_not_lose_the_rest(self): + """One inaccessible metric must not cost the others.""" + from unittest.mock import MagicMock, patch + + import backend.app.api.routes.support as support_module + + fake = MagicMock() + fake.memory_info.return_value = MagicMock(rss=100, vms=200) + fake.num_threads.side_effect = PermissionError("denied") + fake.create_time.return_value = 0.0 + fake.open_files.side_effect = PermissionError("denied") + fake.net_connections.side_effect = PermissionError("denied") + fake.children.return_value = [] + + with patch("psutil.Process", return_value=fake): + info = support_module._collect_process_info() + + assert info["rss_bytes"] == 100 + assert "num_threads" not in info + assert info["children_total"] == 0 diff --git a/backend/tests/unit/test_threemf_tools.py b/backend/tests/unit/test_threemf_tools.py index 915dc1a4d..1b89ad132 100644 --- a/backend/tests/unit/test_threemf_tools.py +++ b/backend/tests/unit/test_threemf_tools.py @@ -14,6 +14,7 @@ from backend.app.utils.threemf_tools import ( extract_bed_type_from_3mf, extract_embedded_presets_from_3mf, extract_filament_usage_from_3mf, + extract_max_z_height_from_3mf, extract_plate_extruder_set_from_3mf, extract_print_time_from_3mf, extract_project_filaments_from_3mf, @@ -1304,3 +1305,90 @@ class TestExtractPlateMetadataFrom3mf: assert meta.filament_usage == [] # Missing file must not create a sticky cache entry (it may appear later). assert spy.call_count == 2 + + +def _make_plate_3mf(tmp_path, gcode_by_name: dict[str, str], name: str = "print.3mf"): + """Write a 3MF containing the given plate G-code members.""" + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as zf: + for member, content in gcode_by_name.items(): + zf.writestr(member, content) + buffer.seek(0) + path = tmp_path / name + path.write_bytes(buffer.read()) + return path + + +def _header(**values: str) -> str: + lines = ["; HEADER_BLOCK_START"] + lines += [f"; {key.replace('_', ' ')}: {value}" for key, value in values.items()] + lines.append("; HEADER_BLOCK_END") + lines.append("G1 X0 Y0") + return "\n".join(lines) + + +class TestExtractMaxZHeightFrom3mf: + """#2547: the print's top Z, used to command the plate back into camera + framing before the finish photo. + + This value becomes the target of a real Z move, so "don't know" has to be + reported as None rather than defaulted — a wrong height would drive the + nozzle into the part. + """ + + def test_reads_max_z_height_from_the_plate_header(self, tmp_path): + path = _make_plate_3mf( + tmp_path, + {"Metadata/plate_1.gcode": _header(max_z_height="16.00", total_layer_number="80")}, + ) + assert extract_max_z_height_from_3mf(path, 1) == 16.0 + + def test_picks_the_requested_plate(self, tmp_path): + path = _make_plate_3mf( + tmp_path, + { + "Metadata/plate_1.gcode": _header(max_z_height="16.00"), + "Metadata/plate_2.gcode": _header(max_z_height="42.50"), + }, + ) + assert extract_max_z_height_from_3mf(path, 2) == 42.5 + + def test_falls_back_to_the_only_gcode_when_the_plate_name_does_not_match(self, tmp_path): + """Files from slicers that don't use Bambu's plate naming still resolve.""" + path = _make_plate_3mf(tmp_path, {"whatever.gcode": _header(max_z_height="7.25")}) + assert extract_max_z_height_from_3mf(path, 3) == 7.25 + + def test_missing_header_key_returns_none(self, tmp_path): + path = _make_plate_3mf(tmp_path, {"Metadata/plate_1.gcode": _header(total_layer_number="80")}) + assert extract_max_z_height_from_3mf(path, 1) is None + + def test_non_numeric_value_returns_none(self, tmp_path): + path = _make_plate_3mf(tmp_path, {"Metadata/plate_1.gcode": _header(max_z_height="tall")}) + assert extract_max_z_height_from_3mf(path, 1) is None + + def test_zero_and_negative_are_treated_as_unknown(self, tmp_path): + """Passed through, either would become a Z move toward the bed.""" + zero = _make_plate_3mf(tmp_path, {"Metadata/plate_1.gcode": _header(max_z_height="0")}, "z.3mf") + negative = _make_plate_3mf(tmp_path, {"Metadata/plate_1.gcode": _header(max_z_height="-3")}, "n.3mf") + assert extract_max_z_height_from_3mf(zero, 1) is None + assert extract_max_z_height_from_3mf(negative, 1) is None + + def test_no_gcode_member_returns_none(self, tmp_path): + path = _make_plate_3mf(tmp_path, {"Metadata/slice_info.config": ""}) + assert extract_max_z_height_from_3mf(path, 1) is None + + def test_unreadable_file_returns_none(self, tmp_path): + path = tmp_path / "broken.3mf" + path.write_text("not a zip") + assert extract_max_z_height_from_3mf(path, 1) is None + + def test_missing_file_returns_none(self, tmp_path): + assert extract_max_z_height_from_3mf(tmp_path / "nope.3mf", 1) is None + + def test_only_the_header_is_inflated(self, tmp_path): + """A sliced plate is routinely tens of MB; reading it whole to reach ~40 + header lines would stall the finish-photo path. The header is read from + a bounded prefix, so a huge body must not change the answer.""" + gcode = _header(max_z_height="99.9") + "\n" + ("G1 X1 Y1 E0.1\n" * 400_000) + path = _make_plate_3mf(tmp_path, {"Metadata/plate_1.gcode": gcode}) + assert extract_max_z_height_from_3mf(path, 1) == 99.9 diff --git a/frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx b/frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx index b6eacfd8a..7b96943e2 100644 --- a/frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx +++ b/frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx @@ -536,6 +536,198 @@ describe('ConfigureAmsSlotModal', () => { }); }); + describe('Generic presets and the K-profile picker (#2710)', () => { + // Reporter's printer: nine Flow-Dynamics entries, every one of them + // calibrated against Generic PLA (filament_id GFL99) and named after the + // spool's colour rather than its material. Bambu Studio lists all nine for + // a Generic PLA slot; Bambuddy showed only the one already bound to the + // slot via cali_idx. + const genericPlaProfiles = [ + 'Black PLA+', 'Dark Brown', 'Glow', 'Gray', 'Lt Brown', + 'Marble', 'Orange PLA', 'Sunlu White PLA+', 'White PLA+ Duramic', + ].map((name, i) => ({ + slot_id: i + 1, + extruder_id: 0, + nozzle_id: 'HH00-0.4', + nozzle_diameter: '0.4', + filament_id: 'GFL99', + name, + k_value: `0.0${30 + i}`, + n_coef: '0', + ams_id: 0, + tray_id: 0, + setting_id: '', + })); + + const genericPlaSlot = { + ...defaultProps.slotInfo, + savedPresetId: 'builtin_GFL99', + extruderId: 0, + }; + + beforeEach(() => { + (api.getBuiltinFilaments as ReturnType).mockResolvedValue([ + { filament_id: 'GFL99', name: 'Generic PLA', filament_type: 'PLA' }, + ]); + (api.getKProfiles as ReturnType).mockResolvedValue({ + profiles: genericPlaProfiles, + }); + }); + + it('offers every Generic PLA profile when the slot preset is Generic PLA', async () => { + render(); + + await waitFor(() => { + expect(screen.getByRole('option', { name: /Dark Brown/ })).toBeInTheDocument(); + }); + // All nine, not just the one bound via cali_idx — matching the printer's + // own calibration table for GFL99. + for (const profile of genericPlaProfiles) { + expect( + screen.getByRole('option', { name: `${profile.name} (K=${profile.k_value})` }), + ).toBeInTheDocument(); + } + }); + + it('offers them on a freshly reset slot with no active cali_idx', async () => { + // "When I reset the AMS slot, the generic PLA shows no k-values at all." + // With no cali_idx the #1689 safety net has nothing to surface, so the + // list came back empty; the id match has to stand on its own. + render( + + ); + + await waitFor(() => { + expect(screen.getByRole('option', { name: /Marble/ })).toBeInTheDocument(); + }); + expect(screen.getByRole('option', { name: /Glow/ })).toBeInTheDocument(); + }); + + it('matches on name when the profile carries no filament_id at all', async () => { + // Not every firmware fills filament_id in extrusion_cali_get. "Generic" + // is not a brand, so the name path must fall back to the material + // instead of demanding "GENERIC" in the profile name. + (api.getKProfiles as ReturnType).mockResolvedValue({ + profiles: [ + { ...genericPlaProfiles[0], filament_id: '', name: 'Orange PLA' }, + { ...genericPlaProfiles[1], filament_id: '', name: 'Dark Brown' }, + ], + }); + render(); + + await waitFor(() => { + expect(screen.getByRole('option', { name: /Orange PLA/ })).toBeInTheDocument(); + }); + }); + + it('does not sweep generic profiles into a brand preset', async () => { + // Guard against the id path widening: "Bambu PLA Basic" is GFL05, so + // GFL99 profiles must still be filtered out of the matching group and + // only reachable through the explicit "other profiles" group. + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Bambu PLA Basic @BBL X1C')).toBeInTheDocument(); + }); + // Every GFL99 profile is demoted to the other group: the brand gate on + // "Bambu" still applies, and none of these names carry it. + for (const profile of genericPlaProfiles) { + const option = screen.getByRole('option', { name: `${profile.name} (K=${profile.k_value})` }); + expect(option.closest('optgroup')).toHaveAttribute( + 'label', + 'Other K profiles on this printer', + ); + } + }); + + it("offers the printer's other profiles even when nothing matches the preset", async () => { + // The escape hatch: a PETG preset matches none of the PLA profiles, but + // the user can still reach every profile the printer holds instead of + // being sent to the slicer. + (api.getBuiltinFilaments as ReturnType).mockResolvedValue([ + { filament_id: 'GFG99', name: 'Generic PETG', filament_type: 'PETG' }, + ]); + render( + + ); + + await waitFor(() => { + expect(screen.getByRole('option', { name: /Dark Brown/ })).toBeInTheDocument(); + }); + expect(screen.getByRole('option', { name: /Dark Brown/ }).closest('optgroup')).toHaveAttribute( + 'label', + 'Other K profiles on this printer', + ); + }); + + it('sends the cali_idx of a profile picked from the other-profiles group', async () => { + (api.getBuiltinFilaments as ReturnType).mockResolvedValue([ + { filament_id: 'GFG99', name: 'Generic PETG', filament_type: 'PETG' }, + ]); + render( + + ); + + await waitFor(() => { + expect(screen.getByRole('option', { name: /Marble/ })).toBeInTheDocument(); + }); + const marble = genericPlaProfiles.find(p => p.name === 'Marble')!; + fireEvent.change(screen.getByRole('combobox'), { + target: { value: `${marble.name}|${marble.k_value}` }, + }); + fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i })); + + await waitFor(() => { + expect(api.configureAmsSlot).toHaveBeenCalled(); + }); + const payload = (api.configureAmsSlot as ReturnType).mock.calls[0][3]; + expect(payload.cali_idx).toBe(marble.slot_id); + expect(payload.kprofile_filament_id).toBe('GFL99'); + }); + + it('distinguishes two profiles that share a name but differ in K', async () => { + // The picker used to key options by name alone, so same-named profiles + // were indistinguishable and the first always won. + (api.getKProfiles as ReturnType).mockResolvedValue({ + profiles: [ + { ...genericPlaProfiles[0], slot_id: 1, name: 'PLA', k_value: '0.020' }, + { ...genericPlaProfiles[1], slot_id: 2, name: 'PLA', k_value: '0.045' }, + ], + }); + render( + + ); + + await waitFor(() => { + expect(screen.getByRole('option', { name: /K=0.045/ })).toBeInTheDocument(); + }); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'PLA|0.045' } }); + fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i })); + + await waitFor(() => { + expect(api.configureAmsSlot).toHaveBeenCalled(); + }); + expect((api.configureAmsSlot as ReturnType).mock.calls[0][3].cali_idx).toBe(2); + }); + }); + it('does not include the active K-profile when caliIdx is 0 or null (#1689 guard)', async () => { // cali_idx == 0 / null means no profile is active (printer default 0.020). // The safety net only triggers for activeIdx > 0 — otherwise unrelated diff --git a/frontend/src/__tests__/components/FailureDetectionSettings.test.tsx b/frontend/src/__tests__/components/FailureDetectionSettings.test.tsx index cf6cab359..4ba8bfaf6 100644 --- a/frontend/src/__tests__/components/FailureDetectionSettings.test.tsx +++ b/frontend/src/__tests__/components/FailureDetectionSettings.test.tsx @@ -23,6 +23,7 @@ const baseSettings = { include_beta_updates: false, obico_enabled: false, obico_ml_url: '', + obico_ml_token: '', obico_sensitivity: 'medium', obico_action: 'notify', obico_poll_interval: 10, @@ -83,6 +84,102 @@ describe('FailureDetectionSettings', () => { expect(await screen.findByText(/ML API reachable/i)).toBeInTheDocument(); }); + describe('ML API token (#2733)', () => { + const enabledWithToken = { + ...baseSettings, + obico_enabled: true, + obico_ml_url: 'http://obico:3333', + obico_ml_token: 's3cret', + }; + + it('renders the token as a masked field populated from settings', async () => { + server.use(http.get('/api/v1/settings/', () => HttpResponse.json(enabledWithToken))); + render(); + + const input = await screen.findByDisplayValue('s3cret'); + expect(input).toHaveAttribute('type', 'password'); + expect(screen.getByText(/ML API Token/i)).toBeInTheDocument(); + }); + + it('sends the token with the test-connection request', async () => { + let sent: { url: string; token?: string } | null = null; + server.use( + http.get('/api/v1/settings/', () => HttpResponse.json(enabledWithToken)), + http.post('/api/v1/obico/test-connection', async ({ request }) => { + sent = (await request.json()) as { url: string; token?: string }; + return HttpResponse.json({ + ok: true, + status_code: 200, + body: 'ok', + error: null, + auth_ok: true, + }); + }), + ); + render(); + await screen.findByDisplayValue('http://obico:3333'); + await userEvent.click(screen.getByRole('button', { name: /test/i })); + + await waitFor(() => expect(sent).not.toBeNull()); + // The value in the box, not the saved one — so a token can be checked + // before it is committed. + expect(sent!.token).toBe('s3cret'); + }); + + it('reports a rejected token instead of a bare success', async () => { + server.use( + http.get('/api/v1/settings/', () => HttpResponse.json(enabledWithToken)), + http.post('/api/v1/obico/test-connection', () => + HttpResponse.json({ + ok: false, + status_code: 401, + body: 'ok', + error: 'The ML API is reachable but rejected the token.', + auth_ok: false, + }), + ), + ); + render(); + await screen.findByDisplayValue('http://obico:3333'); + await userEvent.click(screen.getByRole('button', { name: /test/i })); + + expect(await screen.findByText(/rejected the token/i)).toBeInTheDocument(); + }); + + it('does not claim the token works when it could not be checked', async () => { + server.use( + http.get('/api/v1/settings/', () => HttpResponse.json(enabledWithToken)), + http.post('/api/v1/obico/test-connection', () => + HttpResponse.json({ ok: true, status_code: 200, body: 'ok', error: null, auth_ok: null }), + ), + ); + render(); + await screen.findByDisplayValue('http://obico:3333'); + await userEvent.click(screen.getByRole('button', { name: /test/i })); + + expect(await screen.findByText(/token could not be checked/i)).toBeInTheDocument(); + }); + + it('auto-saves the token', async () => { + let saved: Record | null = null; + server.use( + http.get('/api/v1/settings/', () => HttpResponse.json({ ...enabledWithToken, obico_ml_token: '' })), + http.put('/api/v1/settings/', async ({ request }) => { + saved = (await request.json()) as Record; + return HttpResponse.json({ ...enabledWithToken, obico_ml_token: 'typed' }); + }), + ); + render(); + const input = await screen.findByPlaceholderText(/ML_API_TOKEN/i); + // Every field stays disabled until the settings query lands. + await waitFor(() => expect(input).not.toBeDisabled()); + await userEvent.type(input, 'typed'); + + await waitFor(() => expect(saved).not.toBeNull(), { timeout: 3000 }); + expect(saved!.obico_ml_token).toBe('typed'); + }); + }); + it('shows failure class history entries with red styling', async () => { server.use( http.get('/api/v1/obico/status', () => diff --git a/frontend/src/__tests__/components/FilamentMappingArchivePick.test.tsx b/frontend/src/__tests__/components/FilamentMappingArchivePick.test.tsx new file mode 100644 index 000000000..2b66e657e --- /dev/null +++ b/frontend/src/__tests__/components/FilamentMappingArchivePick.test.tsx @@ -0,0 +1,195 @@ +/** + * Tests for the FilamentMapping "Mapping" toggle (#2700). + * + * When the archive carries the slicer's own saved AMS-slot pick, a toggle next + * to "Re-read" selects every slot straight from it, bypassing the type/color + * auto-match. Turning it back off has to undo exactly what it did and leave + * hand-made picks alone — that bookkeeping is what these tests pin. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { useState } from 'react'; +import { screen, waitFor, cleanup, fireEvent } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { render } from '../utils'; +import { server } from '../mocks/server'; +import { FilamentMapping } from '../../components/PrintModal/FilamentMapping'; +import type { PrinterStatus } from '../../api/client'; + +// Two-slot print. Slot 1 wants red PLA, slot 2 wants green PETG. +const TWO_SLOT_REQS = { + filaments: [ + { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, used_meters: 3 }, + { slot_id: 2, type: 'PETG', color: '#00FF00', used_grams: 10, used_meters: 3 }, + ], +}; + +// One AMS, four trays -> global tray IDs 0..3. Trays 0 and 2 both hold red PLA, +// which is exactly the ambiguity the saved slicer pick exists to resolve: the +// auto-match has no way to tell which red spool the user meant. +function createStatus(): PrinterStatus { + return { + id: 1, + name: 'X1C', + connected: true, + state: 'IDLE', + ams: [ + { + id: 0, + tray: [ + { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', tray_sub_brands: 'Red A' }, + { id: 1, tray_type: 'PETG', tray_color: '00FF00', tray_info_idx: 'GFG00', tray_sub_brands: 'Green' }, + { id: 2, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', tray_sub_brands: 'Red B' }, + { id: 3, tray_type: 'PLA', tray_color: '0000FF', tray_info_idx: 'GFA00', tray_sub_brands: 'Blue' }, + ], + }, + ], + vt_tray: [], + ams_extruder_map: {}, + fila_switch: null, + } as unknown as PrinterStatus; +} + +/** Holds `manualMappings` the way PrintModal does, so an OFF click sees the + * state the ON click produced rather than the initial prop. */ +function Harness({ + archiveAmsMapping, + initialManualMappings = {}, + onChange, +}: { + archiveAmsMapping?: number[]; + initialManualMappings?: Record; + onChange?: (m: Record) => void; +}) { + const [manualMappings, setManualMappings] = useState>(initialManualMappings); + return ( + { + setManualMappings(m); + onChange?.(m); + }} + currencySymbol="$" + defaultCostPerKg={0} + defaultExpanded + archiveAmsMapping={archiveAmsMapping} + /> + ); +} + +/** The panel only finishes mounting once printer status has loaded. */ +async function waitForPanel() { + await waitFor(() => { + expect(screen.getByText(/Re-read/i)).toBeInTheDocument(); + }); +} + +beforeEach(() => { + server.use( + http.get('/api/v1/printers/:id/status', () => HttpResponse.json(createStatus())), + http.get('/api/v1/printers/:id/spool-assignments', () => HttpResponse.json([])), + ); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe('FilamentMapping — saved slicer AMS pick', () => { + it('hides the toggle when the archive has no saved mapping', async () => { + // Every archive predating the feature, every library file, and every + // reprint aimed at a printer other than the one the mapping came from. + render(); + await waitForPanel(); + expect(screen.queryByRole('button', { name: 'Mapping' })).not.toBeInTheDocument(); + }); + + it('selects every slot from the saved mapping when switched on', async () => { + // Saved pick says slot 1 -> tray 2 (the *second* red spool) and slot 2 -> + // tray 1. Auto-match would have taken tray 0 for slot 1, so this is a + // visible, load-bearing difference. + const onChange = vi.fn(); + render(); + await waitForPanel(); + + fireEvent.click(screen.getByRole('button', { name: 'Mapping' })); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith({ 1: 2, 2: 1 }); + }); + + it('skips slots the slicer left unresolved', async () => { + // -1 is the slicer saying "no AMS tray for this filament" (external spool, + // or it simply didn't resolve). Overriding that slot with -1 would be + // worse than leaving it to the auto-match. + const onChange = vi.fn(); + render(); + await waitForPanel(); + + fireEvent.click(screen.getByRole('button', { name: 'Mapping' })); + + expect(onChange).toHaveBeenCalledWith({ 2: 1 }); + }); + + it('skips slots the saved mapping is too short to address', async () => { + // A mapping with fewer entries than the plate has slots can't say anything + // about the missing ones; reading past the end would write `undefined`. + const onChange = vi.fn(); + render(); + await waitForPanel(); + + fireEvent.click(screen.getByRole('button', { name: 'Mapping' })); + + expect(onChange).toHaveBeenCalledWith({ 1: 2 }); + }); + + it('undoes exactly its own picks when switched off', async () => { + const onChange = vi.fn(); + render(); + await waitForPanel(); + + const toggle = screen.getByRole('button', { name: 'Mapping' }); + fireEvent.click(toggle); + expect(onChange).toHaveBeenLastCalledWith({ 1: 2, 2: 1 }); + + fireEvent.click(toggle); + expect(onChange).toHaveBeenLastCalledWith({}); + }); + + it('leaves a hand-made pick untouched when switched off', async () => { + // The user picked slot 2 by hand first, then switched the toggle on, which + // overwrote it as part of applying the whole saved mapping. Switching off + // removes both, since both are now the toggle's own picks — the earlier + // hand pick is not restored, and slot 2 falls back to the auto-match. That + // is the documented behaviour, not an accident; the next test covers the + // case where the hand pick does survive. + const onChange = vi.fn(); + render(); + await waitForPanel(); + + const toggle = screen.getByRole('button', { name: 'Mapping' }); + fireEvent.click(toggle); + expect(onChange).toHaveBeenLastCalledWith({ 1: 2, 2: 1 }); + + fireEvent.click(toggle); + expect(onChange).toHaveBeenLastCalledWith({}); + }); + + it('keeps hand-made picks for slots the saved mapping never touched', async () => { + // Saved mapping only resolves slot 1, so slot 2's hand-made pick was never + // one of "its own" and has to survive the round trip. + const onChange = vi.fn(); + render(); + await waitForPanel(); + + const toggle = screen.getByRole('button', { name: 'Mapping' }); + fireEvent.click(toggle); + expect(onChange).toHaveBeenLastCalledWith({ 1: 2, 2: 3 }); + + fireEvent.click(toggle); + expect(onChange).toHaveBeenLastCalledWith({ 2: 3 }); + }); +}); diff --git a/frontend/src/__tests__/components/HMSErrorModal.test.tsx b/frontend/src/__tests__/components/HMSErrorModal.test.tsx index 55edce6b5..3139f0f7c 100644 --- a/frontend/src/__tests__/components/HMSErrorModal.test.tsx +++ b/frontend/src/__tests__/components/HMSErrorModal.test.tsx @@ -6,7 +6,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { render } from '../utils'; -import { HMSErrorModal } from '../../components/HMSErrorModal'; +import { HMSErrorModal, filterKnownHMSErrors } from '../../components/HMSErrorModal'; import { http, HttpResponse } from 'msw'; import { server } from '../mocks/server'; import type { HMSError } from '../../api/client'; @@ -189,6 +189,50 @@ describe('HMSErrorModal', () => { }); }); + describe('MQTT command verification failed (#2732)', () => { + // attr 0x05000500, code 0x00010007 — a real P1S on firmware 01.10.00.00. + // getShortCode() collapses this to "0500_0007", which matches nothing, so + // before #2732 filterKnownHMSErrors dropped the one error that explained + // why the printer accepted every job and started none of them. + const verifyFailedError: HMSError = { + attr: 0x05000500, + code: '0x10007', + severity: 1, + full_code: '0500050000010007', + }; + + it('surfaces the error instead of filtering it out', () => { + render(); + expect(screen.queryByText('No errors')).not.toBeInTheDocument(); + expect(screen.getByText(/could not verify it/i)).toBeInTheDocument(); + }); + + it('counts towards the known-error filter', () => { + expect(filterKnownHMSErrors([verifyFailedError])).toHaveLength(1); + }); + + it('shows the remedy, not just the fault', () => { + render(); + expect(screen.getByText(/Enable Developer Mode on the printer/i)).toBeInTheDocument(); + }); + + it('displays the code the printer screen shows, not the truncated form', () => { + render(); + expect(screen.getByText('[0500-0500-0001-0007]')).toBeInTheDocument(); + expect(screen.queryByText('[0500-0007]')).not.toBeInTheDocument(); + }); + + it('leaves short-code errors on the two-group display', () => { + render(); + expect(screen.getByText('[0300-400C]')).toBeInTheDocument(); + }); + + it('does not add the remedy line to other errors', () => { + render(); + expect(screen.queryByText(/Enable Developer Mode/i)).not.toBeInTheDocument(); + }); + }); + describe('interactions', () => { it('calls onClose when X button is clicked', async () => { const user = userEvent.setup(); diff --git a/frontend/src/__tests__/components/archiveAmsMapping.test.ts b/frontend/src/__tests__/components/archiveAmsMapping.test.ts new file mode 100644 index 000000000..af91f4b8b --- /dev/null +++ b/frontend/src/__tests__/components/archiveAmsMapping.test.ts @@ -0,0 +1,72 @@ +/** + * Tests for `resolveArchiveSlicerAmsMapping` (#2700). + * + * This is the gate between an archive's saved slicer AMS pick and the print + * modal offering it. The saved tray IDs are *global tray IDs*, which only mean + * something against the AMS layout of the one printer they were resolved + * against — tray 5 on printer A can hold a completely different spool than + * tray 5 on printer B. Everything here exists to make sure the mapping is only + * ever offered for its own printer. + */ + +import { describe, it, expect } from 'vitest'; +import { resolveArchiveSlicerAmsMapping } from '../../components/PrintModal/archiveAmsMapping'; + +const SAVED = { slicer_ams_mapping: { mapping: [4, -1, 12, -1], printer_id: 7 } }; + +describe('resolveArchiveSlicerAmsMapping', () => { + it('returns the mapping when the selected printer is the one it was saved for', () => { + expect(resolveArchiveSlicerAmsMapping(SAVED, 7)).toEqual([4, -1, 12, -1]); + }); + + it('refuses the mapping on a different printer', () => { + // The whole point of storing printer_id. Tray 4 on printer 9 is not the + // spool the slicer picked on printer 7. + expect(resolveArchiveSlicerAmsMapping(SAVED, 9)).toBeUndefined(); + }); + + it('refuses the mapping when no printer is selected yet', () => { + // Guards the `undefined === undefined` reading as a match: with no printer + // chosen there is nothing to compare against, so nothing may be offered. + expect(resolveArchiveSlicerAmsMapping(SAVED, null)).toBeUndefined(); + expect(resolveArchiveSlicerAmsMapping(SAVED, undefined)).toBeUndefined(); + }); + + it('returns undefined for archives with no extra_data at all', () => { + expect(resolveArchiveSlicerAmsMapping(null, 7)).toBeUndefined(); + expect(resolveArchiveSlicerAmsMapping(undefined, 7)).toBeUndefined(); + expect(resolveArchiveSlicerAmsMapping({}, 7)).toBeUndefined(); + }); + + it('ignores unrelated extra_data keys', () => { + // The common case: archives carry plenty of metadata but no saved mapping. + expect(resolveArchiveSlicerAmsMapping({ printable_objects: { '1': 'part' } }, 7)).toBeUndefined(); + }); + + it('rejects a stored value that is missing its printer_id', () => { + // A mapping saved without knowing which printer it came from can't be + // safely reused on any printer, including the one it actually came from — + // there'd be no way to tell. + expect(resolveArchiveSlicerAmsMapping({ slicer_ams_mapping: { mapping: [4, 12] } }, 7)).toBeUndefined(); + }); + + it('rejects malformed stored values instead of throwing', () => { + // extra_data is free-form JSON off the wire; a bad shape must degrade to + // "no saved mapping", never crash the modal. + expect(resolveArchiveSlicerAmsMapping({ slicer_ams_mapping: 'nope' }, 7)).toBeUndefined(); + expect(resolveArchiveSlicerAmsMapping({ slicer_ams_mapping: null }, 7)).toBeUndefined(); + expect( + resolveArchiveSlicerAmsMapping({ slicer_ams_mapping: { mapping: 'nope', printer_id: 7 } }, 7), + ).toBeUndefined(); + expect( + resolveArchiveSlicerAmsMapping({ slicer_ams_mapping: { mapping: [], printer_id: 7 } }, 7), + ).toBeUndefined(); + }); + + it('does not coerce printer ids', () => { + // A string "7" from a hand-edited record is not printer 7. + expect( + resolveArchiveSlicerAmsMapping({ slicer_ams_mapping: { mapping: [4], printer_id: '7' } }, 7), + ).toBeUndefined(); + }); +}); diff --git a/frontend/src/__tests__/components/spool-form/isMatchingCalibration.test.ts b/frontend/src/__tests__/components/spool-form/isMatchingCalibration.test.ts index 4688b2779..d9e7b6254 100644 --- a/frontend/src/__tests__/components/spool-form/isMatchingCalibration.test.ts +++ b/frontend/src/__tests__/components/spool-form/isMatchingCalibration.test.ts @@ -12,6 +12,8 @@ import { isMatchingCalibration, toFilamentId, isGenericFilamentId, + materialForGenericFilamentId, + genericFilamentIdMatchesMaterial, } from '../../../components/spool-form/utils'; describe('toFilamentId', () => { @@ -70,6 +72,37 @@ describe('isGenericFilamentId', () => { }); }); +describe('materialForGenericFilamentId (#2710)', () => { + it('resolves the material each generic id stands for', () => { + expect(materialForGenericFilamentId('GFL99')).toBe('PLA'); + expect(materialForGenericFilamentId('GFG99')).toBe('PETG'); + expect(materialForGenericFilamentId('gfu99')).toBe('TPU'); + }); + + it('returns empty for specific ids and for nothing', () => { + expect(materialForGenericFilamentId('GFL05')).toBe(''); + expect(materialForGenericFilamentId(null)).toBe(''); + expect(materialForGenericFilamentId('')).toBe(''); + }); +}); + +describe('genericFilamentIdMatchesMaterial (#2710)', () => { + it('agrees when the generic id describes that material', () => { + expect(genericFilamentIdMatchesMaterial('GFL99', 'PLA')).toBe(true); + expect(genericFilamentIdMatchesMaterial('GFL99', 'pla')).toBe(true); + }); + + it('treats Nylon and PA as the same material', () => { + expect(genericFilamentIdMatchesMaterial('GFN99', 'Nylon')).toBe(true); + }); + + it('rejects a mismatched material, a specific id, or a missing material', () => { + expect(genericFilamentIdMatchesMaterial('GFL99', 'PETG')).toBe(false); + expect(genericFilamentIdMatchesMaterial('GFL05', 'PLA')).toBe(false); + expect(genericFilamentIdMatchesMaterial('GFL99', '')).toBe(false); + }); +}); + describe('isMatchingCalibration (#1688)', () => { const formData = { material: 'PETG', @@ -98,9 +131,10 @@ describe('isMatchingCalibration (#1688)', () => { ).toBe(true); }); - it('skips id-match for generic GFx99 ids and falls through to name match', () => { - // GFL99 = generic PLA, shared across many real filaments. Even if the - // spool stored GFL99, name parsing must drive the decision. + it('skips id-match when a generic id contradicts the spool material', () => { + // GFL99 is generic *PLA* but the spool says PETG — the ids agreeing is not + // enough, the material has to agree too. Name parsing then drives the + // decision and rejects it. const result = isMatchingCalibration( { name: 'Random thing with no PETG in it', filament_id: 'GFL99' }, { ...formData, slicer_filament: 'GFL99' }, @@ -108,6 +142,47 @@ describe('isMatchingCalibration (#1688)', () => { expect(result).toBe(false); }); + it('matches a generic id when the material agrees and the spool claims no brand (#2710)', () => { + // Reporter's printer: every K-profile calibrated under Generic PLA and + // named after the colour. No name parsing can tie "Dark Brown" to PLA, so + // the shared GFL99 is the only signal there is. + expect( + isMatchingCalibration( + { name: 'Dark Brown', filament_id: 'GFL99' }, + { material: 'PLA', brand: '', subtype: '', slicer_filament: 'GFL99' }, + ), + ).toBe(true); + }); + + it('treats "Generic" as no brand on the generic id path', () => { + expect( + isMatchingCalibration( + { name: 'Marble', filament_id: 'GFL99' }, + { material: 'PLA', brand: 'Generic', subtype: '', slicer_filament: 'GFL99' }, + ), + ).toBe(true); + }); + + it('keeps generic id matches brand-specific when the spool names a brand', () => { + // A spool that says "Sunlu" should still get Sunlu-specific suggestions + // rather than the printer's whole generic-PLA table. + expect( + isMatchingCalibration( + { name: 'Dark Brown', filament_id: 'GFL99' }, + { material: 'PLA', brand: 'Sunlu', subtype: '', slicer_filament: 'GFL99' }, + ), + ).toBe(false); + }); + + it('accepts Nylon/PA as the same material on the generic id path', () => { + expect( + isMatchingCalibration( + { name: 'spool-of-doom', filament_id: 'GFN99' }, + { material: 'Nylon', brand: '', subtype: '', slicer_filament: 'GFN99' }, + ), + ).toBe(true); + }); + it('falls through to name match when spool has no slicer_filament', () => { expect( isMatchingCalibration( diff --git a/frontend/src/__tests__/pages/SettingsPage.test.tsx b/frontend/src/__tests__/pages/SettingsPage.test.tsx index c98f62a0d..8bedd25ee 100644 --- a/frontend/src/__tests__/pages/SettingsPage.test.tsx +++ b/frontend/src/__tests__/pages/SettingsPage.test.tsx @@ -110,6 +110,66 @@ describe('SettingsPage', () => { }); }); + describe('finish photo plate restore (#2547)', () => { + const restoreLabel = 'Restore plate for finish photo'; + + it('offers the toggle while finish photos are enabled', async () => { + render(); + + expect(await screen.findByText(restoreLabel)).toBeInTheDocument(); + }); + + it('hides the toggle when finish photos are switched off', async () => { + // It only describes how the finish photo is framed, so it is meaningless + // when no finish photo is taken at all. + server.use( + http.get('/api/v1/settings/', () => + HttpResponse.json({ ...mockSettings, capture_finish_photo: false }) + ) + ); + render(); + + await screen.findByRole('heading', { name: 'Settings' }); + await waitFor(() => { + expect(screen.queryByText(restoreLabel)).not.toBeInTheDocument(); + }); + }); + + it('defaults to on when the backend has never stored the setting', async () => { + // Existing installs have no row for it; the UI must not read that as off. + render(); + + const label = await screen.findByText(restoreLabel); + const row = label.closest('div')!.parentElement!; + expect(within(row).getByRole('checkbox')).toBeChecked(); + }); + + it('sends the new value on save', async () => { + let saved: Record | null = null; + server.use( + http.put('/api/v1/settings/', async ({ request }) => { + saved = (await request.json()) as Record; + return HttpResponse.json({ ...mockSettings, ...saved }); + }) + ); + render(); + + const label = await screen.findByText(restoreLabel); + // The page suppresses auto-save for 100ms after the settings load, so a + // click landing inside that window is swallowed with no re-trigger. + await new Promise((resolve) => setTimeout(resolve, 200)); + const row = label.closest('div')!.parentElement!; + await userEvent.click(within(row).getByRole('checkbox')); + + // The page auto-saves on a 500ms debounce, so the default 1s waitFor + // window is only just wide enough — give the request room to land. + await waitFor(() => { + expect(saved).not.toBeNull(); + }, { timeout: 3000 }); + expect(saved!.finish_photo_restore_plate).toBe(false); + }); + }); + describe('general settings', () => { it('shows date format setting', async () => { render(); diff --git a/frontend/src/__tests__/utils/projectQueries.test.ts b/frontend/src/__tests__/utils/projectQueries.test.ts new file mode 100644 index 000000000..57db8d0f4 --- /dev/null +++ b/frontend/src/__tests__/utils/projectQueries.test.ts @@ -0,0 +1,75 @@ +/** + * Tests for the project-view cache invalidation helper (#2731). + * + * The default staleTime is 60s, so a project page revisited within a minute of + * deleting one of its prints serves the cached answer and keeps showing the + * print. The user had to reload the page by hand. Deletes invalidated only + * `['archives']`; the project-assign mutations only `['projects']`, which + * refreshed the overview cards but never the detail page. + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { QueryClient } from '@tanstack/react-query'; +import { invalidateProjectViews, invalidateArchiveAndProjectViews } from '../../utils/projectQueries'; + +function mockClient() { + const invalidateQueries = vi.fn().mockResolvedValue(undefined); + return { client: { invalidateQueries } as unknown as QueryClient, invalidateQueries }; +} + +const keysFrom = (fn: ReturnType) => fn.mock.calls.map((c) => c[0].queryKey.join('/')); + +describe('invalidateProjectViews', () => { + it('refreshes every view whose contents depend on project membership', async () => { + const { client, invalidateQueries } = mockClient(); + + await invalidateProjectViews(client); + + expect(keysFrom(invalidateQueries).sort()).toEqual( + ['project', 'project-archives', 'project-file-progress', 'project-timeline', 'projects'].sort(), + ); + }); + + it('uses bare prefixes so every cached project id is covered', async () => { + const { client, invalidateQueries } = mockClient(); + + await invalidateProjectViews(client); + + // ['project'] matches ['project', 42]; ['project', 42] would not match 43. + for (const call of invalidateQueries.mock.calls) { + expect(call[0].queryKey).toHaveLength(1); + } + }); + + it('does not touch the archive list on its own', async () => { + const { client, invalidateQueries } = mockClient(); + + await invalidateProjectViews(client); + + expect(keysFrom(invalidateQueries)).not.toContain('archives'); + }); +}); + +describe('invalidateArchiveAndProjectViews', () => { + it('adds the archive list to the project views', async () => { + const { client, invalidateQueries } = mockClient(); + + await invalidateArchiveAndProjectViews(client); + + const keys = keysFrom(invalidateQueries); + expect(keys).toContain('archives'); + expect(keys).toContain('project-archives'); + expect(keys).toContain('projects'); + }); + + it('resolves only once every invalidation has settled', async () => { + let settled = 0; + const invalidateQueries = vi.fn().mockImplementation( + () => new Promise((resolve) => setTimeout(() => { settled += 1; resolve(); }, 0)), + ); + + await invalidateArchiveAndProjectViews({ invalidateQueries } as unknown as QueryClient); + + expect(settled).toBe(6); + }); +}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index deaca882f..cf63b79f9 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1185,6 +1185,7 @@ export interface AppSettings { auto_archive: boolean; save_thumbnails: boolean; capture_finish_photo: boolean; + finish_photo_restore_plate: boolean; default_filament_cost: number; currency: string; energy_cost_per_kwh: number; @@ -1277,6 +1278,10 @@ export interface AppSettings { // Per-install sidecar URLs. Empty string falls back to the env defaults. orcaslicer_api_url: string; bambu_studio_api_url: string; + // Minutes of silence from the sidecar before a slice is abandoned. Bounds + // stalls, not total slicing time — a model that keeps reporting progress + // runs to completion however long it takes. + slicer_stall_timeout_minutes: number; // Prometheus metrics prometheus_enabled: boolean; prometheus_token: string; @@ -1336,6 +1341,7 @@ export interface AppSettings { ldap_default_group: string; obico_enabled: boolean; obico_ml_url: string; + obico_ml_token: string; obico_sensitivity: 'low' | 'medium' | 'high'; obico_action: 'notify' | 'pause' | 'pause_and_off'; obico_poll_interval: number; @@ -2210,6 +2216,10 @@ export interface PrintQueueItem { // start route when skip_filament_check=true, or at queue creation if // PrintModal's deficit warning was acknowledged. skip_filament_check: boolean; + // True when the source archive carries the slicer's own live-resolved + // AMS-slot pick (extra_data.slicer_ams_mapping) — a reprint reuses that + // exact physical spool instead of re-deriving one from type/color. + archive_has_slicer_ams_mapping: boolean; ams_mapping: number[] | null; // AMS slot mapping for multi-color prints filament_overrides: Array<{ slot_id: number; type: string; color: string; color_name?: string; tray_info_idx?: string; force_color_match?: boolean }> | null; // Filament overrides for model-based assignment plate_id: number | null; // Plate ID for multi-plate 3MF files @@ -2642,6 +2652,14 @@ export interface NotificationProviderUpdate { export type ScheduleType = 'hourly' | 'daily' | 'weekly'; export type GitProviderType = 'github' | 'gitea' | 'forgejo' | 'gitlab'; +/** How many cloud accounts a backup would collect presets from. Counts only — + * with auth enabled the accounts belong to individual users, so the backup + * administrator sees how many, never whose. */ +export interface CloudAccountCounts { + bambu: number; + orca: number; +} + export interface GitHubBackupConfig { id: number; repository_url: string; @@ -2779,6 +2797,9 @@ export interface ObicoTestConnection { status_code: number | null; body: string | null; error: string | null; + // Whether the ML API accepted the token. null = not determined (the health + // check failed first, or the token probe itself errored). + auth_ok: boolean | null; } export interface GitHubTestConnectionResponse { @@ -3895,7 +3916,11 @@ export const api = { method: 'POST', }), testExternalCamera: (printerId: number, url: string, cameraType: string) => - request<{ success: boolean; error?: string; resolution?: string }>( + // `coalesced` is true when the frame came from a capture that was already + // running (Obico polling, a snapshot) rather than a connection this test + // opened — a single-reader camera is shared rather than opened twice, so + // the result is real but says nothing about reaching the camera just now. + request<{ success: boolean; error?: string; resolution?: string; coalesced?: boolean }>( `/printers/${printerId}/camera/external/test?url=${encodeURIComponent(url)}&camera_type=${encodeURIComponent(cameraType)}`, { method: 'POST' } ), @@ -6452,6 +6477,9 @@ export const api = { getGitHubBackupConfig: () => request('/github-backup/config'), + getGitHubBackupCloudAccounts: () => + request('/github-backup/cloud-accounts'), + saveGitHubBackupConfig: (config: GitHubBackupConfigCreate) => request('/github-backup/config', { method: 'POST', @@ -6523,10 +6551,12 @@ export const api = { getObicoPrinterStatus: () => request('/obico/printer-status'), - testObicoConnection: (url: string) => + // `token` is sent as-is, so an empty string tests with no token at all. + // Omitting the argument makes the backend fall back to the saved token. + testObicoConnection: (url: string, token?: string) => request('/obico/test-connection', { method: 'POST', - body: JSON.stringify({ url }), + body: JSON.stringify(token === undefined ? { url } : { url, token }), }), // Slicer API — slice in the background. Both endpoints return 202 + a @@ -7273,6 +7303,7 @@ export interface VirtualPrinterConfig { target_printer_id: number | null; auto_dispatch: boolean; queue_force_color_match: boolean; + save_ams_mapping: boolean; gcode_injection: boolean; tailscale_disabled: boolean; bind_ip: string | null; @@ -7300,6 +7331,7 @@ export const multiVirtualPrinterApi = { target_printer_id?: number; auto_dispatch?: boolean; queue_force_color_match?: boolean; + save_ams_mapping?: boolean; gcode_injection?: boolean; bind_ip?: string; remote_interface_ip?: string; @@ -7318,6 +7350,7 @@ export const multiVirtualPrinterApi = { target_printer_id?: number; auto_dispatch?: boolean; queue_force_color_match?: boolean; + save_ams_mapping?: boolean; gcode_injection?: boolean; tailscale_disabled?: boolean; bind_ip?: string; diff --git a/frontend/src/components/BatchProjectModal.tsx b/frontend/src/components/BatchProjectModal.tsx index fbaf21e37..fbdc95a7f 100644 --- a/frontend/src/components/BatchProjectModal.tsx +++ b/frontend/src/components/BatchProjectModal.tsx @@ -6,6 +6,7 @@ import { api } from '../api/client'; import { Card, CardContent } from './Card'; import { Button } from './Button'; import { useToast } from '../contexts/ToastContext'; +import { invalidateArchiveAndProjectViews } from '../utils/projectQueries'; interface BatchProjectModalProps { selectedIds: number[]; @@ -43,14 +44,9 @@ export function BatchProjectModal({ selectedIds, onClose }: BatchProjectModalPro return () => window.removeEventListener('keydown', handleKeyDown); }, [onClose]); - // Helper to invalidate all project-related queries - const invalidateProjectQueries = () => { - queryClient.invalidateQueries({ queryKey: ['archives'] }); - queryClient.invalidateQueries({ queryKey: ['projects'] }); - // Invalidate project detail pages (partial match catches all project IDs) - queryClient.invalidateQueries({ queryKey: ['project'] }); - queryClient.invalidateQueries({ queryKey: ['project-archives'] }); - }; + // Helper to invalidate all project-related queries. The shared version also + // covers the timeline and file-progress views, which this list was missing. + const invalidateProjectQueries = () => invalidateArchiveAndProjectViews(queryClient); // Assign to project mutation (uses bulk API) const assignMutation = useMutation({ diff --git a/frontend/src/components/ConfigureAmsSlotModal.tsx b/frontend/src/components/ConfigureAmsSlotModal.tsx index 1c13a21c0..a652edc21 100644 --- a/frontend/src/components/ConfigureAmsSlotModal.tsx +++ b/frontend/src/components/ConfigureAmsSlotModal.tsx @@ -5,7 +5,7 @@ import { X, Loader2, Settings2, ChevronDown, CheckCircle2, RotateCcw } from 'luc import { api } from '../api/client'; import type { KProfile } from '../api/client'; import { matchesPrinterModelSuffix, presetCompatibility, buildCompatibilityIndex } from '../utils/slicerPrinterMatch'; -import { toFilamentId, isGenericFilamentId } from './spool-form/utils'; +import { toFilamentId } from './spool-form/utils'; import { Button } from './Button'; import { getAmsLabel } from '../utils/amsHelpers'; @@ -99,6 +99,13 @@ function parsePresetName(name: string): { material: string; brand: string; varia return { material: withoutSuffix, brand: '', variant: '' }; } +// Identity of a K-profile inside the picker. Both profile lists are +// deduplicated on name+k_value, so this is unique across the whole option set +// — unlike the bare name, which two profiles can share (#2710). +function kProfileOptionValue(profile: KProfile): string { + return `${profile.name}|${profile.k_value}`; +} + // Check if a preset is a user preset (not built-in) function isUserPreset(settingId: string): boolean { // Built-in presets have specific patterns, user presets are UUIDs @@ -870,7 +877,12 @@ export function ConfigureAmsSlotModal({ const { fullName, material, brand, filamentId } = selectedPresetInfo; const upperFullName = fullName.toUpperCase(); const upperMaterial = material.toUpperCase(); - const upperBrand = brand.toUpperCase(); + // "Generic" leads every built-in Bambu preset name ("Generic PLA", + // "Generic PETG") but is not a manufacturer (#2710). Treating it as one + // put the filter into brand-gated mode and demanded "GENERIC" in the + // K-profile name, which no real profile has — so selecting a built-in + // generic preset matched nothing at all. + const upperBrand = brand.toUpperCase() === 'GENERIC' ? '' : brand.toUpperCase(); const presetFid = filamentId; // already normalised via toFilamentId // Material must be at least 2 chars to avoid false positives @@ -880,11 +892,18 @@ export function ConfigureAmsSlotModal({ const filtered = kprofilesData.profiles.filter(p => { // Preferred: exact filament_id match (#1688). A user's custom K-profile // whose name doesn't agree with the slicer preset still surfaces when - // both sides agree on filament_id. Generic GFx99 IDs are excluded — - // they're shared across many filaments and over-match if id-compared. + // both sides agree on filament_id. + // + // Generic GFx99 ids count here too (#2710). The equality test already + // means both sides carry the *same* id, so the old "generic ids + // over-match" 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 every profile calibrated under + // Generic PLA, whatever the user named them. if (presetFid) { const calFid = toFilamentId(p.filament_id); - if (calFid && calFid === presetFid && !isGenericFilamentId(calFid)) { + if (calFid && calFid === presetFid) { return true; } } @@ -966,6 +985,46 @@ export function ConfigureAmsSlotModal({ return result; }, [kprofilesData?.profiles, selectedPresetInfo, slotInfo.extruderId, slotInfo.caliIdx]); + // Every remaining K-profile the printer holds, offered under a separate group + // after the matching ones (#2710). The matcher works off preset names and + // filament ids, neither of which the user controls when they name a profile + // after its colour — so there is always a residual chance it filters out a + // profile the user wants. This makes that recoverable in the UI instead of + // sending them to the slicer: the printer's own calibration table is the + // authority on what can be selected, and the backend realigns the slot's + // filament context to whichever profile is picked. + const otherKProfiles = useMemo(() => { + if (!kprofilesData?.profiles) return []; + const matched = new Set(matchingKProfiles.map(p => kProfileOptionValue(p))); + // Same name+k_value dedup as the matching list, so a multi-nozzle printer's + // duplicate rows don't show up twice here either. + const seen = new Map(); + for (const profile of kprofilesData.profiles) { + const key = kProfileOptionValue(profile); + if (matched.has(key)) continue; + const existing = seen.get(key); + if (!existing) { + seen.set(key, profile); + } else if (slotInfo.extruderId !== undefined && profile.extruder_id === slotInfo.extruderId && existing.extruder_id !== slotInfo.extruderId) { + seen.set(key, profile); + } + } + return Array.from(seen.values()).sort((a, b) => a.name.localeCompare(b.name)); + }, [kprofilesData?.profiles, matchingKProfiles, slotInfo.extruderId]); + + const hasAnyKProfile = matchingKProfiles.length > 0 || otherKProfiles.length > 0; + + const selectKProfileByValue = useCallback((value: string) => { + if (!value) { + setSelectedKProfile(null); + return; + } + const profile = matchingKProfiles.find(p => kProfileOptionValue(p) === value) + || otherKProfiles.find(p => kProfileOptionValue(p) === value) + || null; + setSelectedKProfile(profile); + }, [matchingKProfiles, otherKProfiles]); + // Pre-select current profile when modal opens, reset when closes useEffect(() => { if (isOpen) { @@ -1234,22 +1293,28 @@ export function ConfigureAmsSlotModal({ )} - {matchingKProfiles.length > 0 ? ( + {hasAnyKProfile ? (
@@ -1473,22 +1538,28 @@ export function ConfigureAmsSlotModal({ )} - {matchingKProfiles.length > 0 ? ( + {hasAnyKProfile ? (
diff --git a/frontend/src/components/EditArchiveModal.tsx b/frontend/src/components/EditArchiveModal.tsx index ca067fe77..aaf5f077f 100644 --- a/frontend/src/components/EditArchiveModal.tsx +++ b/frontend/src/components/EditArchiveModal.tsx @@ -6,6 +6,7 @@ import { api } from '../api/client'; import type { Archive } from '../api/client'; import { Button } from './Button'; import { PrintLogTable } from './PrintLogTable'; +import { invalidateArchiveAndProjectViews } from '../utils/projectQueries'; // Keys for failure reasons - translated at render time. // Exported so the Print Log per-row classification editor (#1687 part 4) @@ -147,8 +148,9 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr mutationFn: (data: Parameters[1]) => api.updateArchive(archive.id, data), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['archives'] }); - queryClient.invalidateQueries({ queryKey: ['projects'] }); + // This form can change the archive's project, so the project detail + // views need refreshing too — not just the overview cards (#2731). + invalidateArchiveAndProjectViews(queryClient); onClose(); }, }); diff --git a/frontend/src/components/FailureDetectionSettings.tsx b/frontend/src/components/FailureDetectionSettings.tsx index 2d80b26a5..eea874456 100644 --- a/frontend/src/components/FailureDetectionSettings.tsx +++ b/frontend/src/components/FailureDetectionSettings.tsx @@ -17,6 +17,7 @@ export function FailureDetectionSettings() { const [enabled, setEnabled] = useState(false); const [mlUrl, setMlUrl] = useState(''); + const [mlToken, setMlToken] = useState(''); const [sensitivity, setSensitivity] = useState<'low' | 'medium' | 'high'>('medium'); const [action, setAction] = useState<'notify' | 'pause' | 'pause_and_off'>('notify'); const [pollInterval, setPollInterval] = useState(10); @@ -44,6 +45,7 @@ export function FailureDetectionSettings() { if (!settings) return; setEnabled(settings.obico_enabled ?? false); setMlUrl(settings.obico_ml_url ?? ''); + setMlToken(settings.obico_ml_token ?? ''); setSensitivity(settings.obico_sensitivity ?? 'medium'); setAction(settings.obico_action ?? 'notify'); setPollInterval(settings.obico_poll_interval ?? 10); @@ -63,6 +65,7 @@ export function FailureDetectionSettings() { api.updateSettings({ obico_enabled: enabled, obico_ml_url: mlUrl, + obico_ml_token: mlToken, obico_sensitivity: sensitivity, obico_action: action, obico_poll_interval: pollInterval, @@ -84,6 +87,7 @@ export function FailureDetectionSettings() { const changed = settings.obico_enabled !== enabled || settings.obico_ml_url !== mlUrl || + (settings.obico_ml_token ?? '') !== mlToken || settings.obico_sensitivity !== sensitivity || settings.obico_action !== action || settings.obico_poll_interval !== pollInterval || @@ -92,14 +96,23 @@ export function FailureDetectionSettings() { const id = setTimeout(() => saveMutation.mutate(), 500); return () => clearTimeout(id); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [enabled, mlUrl, sensitivity, action, pollInterval, enabledPrinters, initialized]); + }, [enabled, mlUrl, mlToken, sensitivity, action, pollInterval, enabledPrinters, initialized]); const handleTest = async () => { setTestResult(null); try { - const res = await api.testObicoConnection(mlUrl); + const res = await api.testObicoConnection(mlUrl, mlToken); if (res.ok) { - setTestResult({ ok: true, message: t('failureDetection.testSuccess') }); + // auth_ok is null when the token could not be checked — don't claim it + // works. It is true both for an accepted token and for a server that + // requires none, which is the same outcome for the user. + setTestResult({ + ok: true, + message: + res.auth_ok === null + ? t('failureDetection.testSuccessTokenUnknown') + : t('failureDetection.testSuccess'), + }); } else { setTestResult({ ok: false, @@ -163,6 +176,19 @@ export function FailureDetectionSettings() {

{t('failureDetection.mlUrlHint')}

+ + setMlToken(e.target.value)} + autoComplete="off" + placeholder={t('failureDetection.mlTokenPlaceholder')} + className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm" + disabled={!enabled} + /> +

{t('failureDetection.mlTokenHint')}

{testResult && (
({ + queryKey: ['github-backup-cloud-accounts'], + queryFn: api.getGitHubBackupCloudAccounts, + staleTime: 60_000, + }); + const connectedCloudAccounts = (cloudAccounts?.bambu ?? 0) + (cloudAccounts?.orca ?? 0); + // Until the count arrives, fall back to the viewer's own Bambu status rather + // than rendering the toggle as unavailable and making it flicker enabled. + const anyCloudConnected = cloudAccounts + ? connectedCloudAccounts > 0 + : !!cloudStatus?.is_authenticated; + // Fetch printers and their statuses for K-profile availability const { data: printers } = useQuery({ queryKey: ['printers'], @@ -751,18 +770,18 @@ export function GitHubBackupSettings() {

{t('backup.kProfilesDescription')}

-